04/24 Migrated info which stored in .ini to database and keyring
This commit is contained in:
+74
-125
@@ -5,24 +5,26 @@ Runs a background daemon thread that wakes every minute, checks whether
|
||||
the configured send_time (HH:MM) has been reached today, and sends the
|
||||
summary report via SMTP if it hasn't been sent yet.
|
||||
|
||||
Configuration (config.ini [email] section):
|
||||
enabled = true/false
|
||||
smtp_host = smtp.example.com
|
||||
smtp_port = 587
|
||||
smtp_user = sender@example.com
|
||||
smtp_password= secret
|
||||
use_tls = true
|
||||
recipients = admin@example.com, manager@example.com
|
||||
send_time = 18:00 (24-hour HH:MM, local time)
|
||||
Configuration is stored in the app_settings database table (not config.ini):
|
||||
|
||||
Key Description
|
||||
─────────────────────── ────────────────────────────────────────────
|
||||
email.enabled 'true' or 'false'
|
||||
email.smtp_host SMTP server hostname
|
||||
email.smtp_port SMTP port number (string)
|
||||
email.smtp_user Sender email address / SMTP login
|
||||
email.smtp_password SMTP password (Fernet-encrypted)
|
||||
email.security 'starttls' | 'ssl' | 'none'
|
||||
email.recipients Comma-separated recipient addresses
|
||||
email.send_time HH:MM (24-hour local time)
|
||||
email.last_sent_date ISO date of last successful send (YYYY-MM-DD)
|
||||
|
||||
Call start() once after login succeeds (admin only).
|
||||
Call stop() on logout/shutdown.
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import threading
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
@@ -31,7 +33,6 @@ from email.utils import formatdate, make_msgid, formataddr
|
||||
|
||||
logger = logging.getLogger("scheduler")
|
||||
|
||||
CONFIG_FILE = "config.ini"
|
||||
_scheduler_thread: "threading.Thread | None" = None
|
||||
_stop_event = threading.Event()
|
||||
|
||||
@@ -39,56 +40,53 @@ _stop_event = threading.Event()
|
||||
# ─── Config helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def load_email_config() -> dict:
|
||||
"""Load email settings from app_settings table."""
|
||||
from config import get_settings_dict
|
||||
from utils.config_crypto import decrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
return {}
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
if "email" not in cfg:
|
||||
return {}
|
||||
s = cfg["email"]
|
||||
# security field: "starttls" | "ssl" | "none"
|
||||
# Falls back from legacy use_tls boolean for existing configs.
|
||||
security = s.get("security", "")
|
||||
s = get_settings_dict("email.")
|
||||
security = s.get("email.security", "")
|
||||
if not security:
|
||||
security = "starttls" if s.getboolean("use_tls", fallback=True) else "none"
|
||||
# Legacy: fall back from boolean use_tls if security key absent
|
||||
use_tls = s.get("email.use_tls", "true").lower() == "true"
|
||||
security = "starttls" if use_tls else "none"
|
||||
return {
|
||||
"enabled": s.getboolean("enabled", fallback=False),
|
||||
"smtp_host": s.get("smtp_host", ""),
|
||||
"smtp_port": s.getint("smtp_port", fallback=587),
|
||||
"smtp_user": s.get("smtp_user", ""),
|
||||
"smtp_password": decrypt_value(s.get("smtp_password", "")),
|
||||
"enabled": s.get("email.enabled", "false").lower() == "true",
|
||||
"smtp_host": s.get("email.smtp_host", ""),
|
||||
"smtp_port": int(s.get("email.smtp_port", "587") or "587"),
|
||||
"smtp_user": s.get("email.smtp_user", ""),
|
||||
"smtp_password": decrypt_value(s.get("email.smtp_password", "")),
|
||||
"security": security,
|
||||
"use_tls": security == "starttls", # kept for compatibility
|
||||
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
||||
"send_time": s.get("send_time", "18:00"),
|
||||
"use_tls": security == "starttls",
|
||||
"recipients": [r.strip() for r in
|
||||
s.get("email.recipients", "").split(",") if r.strip()],
|
||||
"send_time": s.get("email.send_time", "18:00"),
|
||||
}
|
||||
|
||||
|
||||
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
||||
smtp_user: str, smtp_password: str, security: str,
|
||||
recipients: str, send_time: str):
|
||||
"""Persist email settings to app_settings table."""
|
||||
from config import get_setting, set_setting
|
||||
from utils.config_crypto import encrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
# Preserve last_sent_date if present
|
||||
last_sent = cfg.get("email", "last_sent_date", fallback="")
|
||||
cfg["email"] = {
|
||||
"enabled": str(enabled).lower(),
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": str(smtp_port),
|
||||
"smtp_user": smtp_user,
|
||||
"smtp_password": encrypt_value(smtp_password),
|
||||
"security": security,
|
||||
"use_tls": str(security == "starttls").lower(), # legacy compat
|
||||
"recipients": recipients,
|
||||
"send_time": send_time,
|
||||
# Preserve last_sent_date — do not overwrite it on a normal save
|
||||
last_sent = get_setting("email.last_sent_date", "")
|
||||
pairs = {
|
||||
"email.enabled": str(enabled).lower(),
|
||||
"email.smtp_host": smtp_host,
|
||||
"email.smtp_port": str(smtp_port),
|
||||
"email.smtp_user": smtp_user,
|
||||
"email.smtp_password": encrypt_value(smtp_password),
|
||||
"email.security": security,
|
||||
"email.use_tls": str(security == "starttls").lower(),
|
||||
"email.recipients": recipients,
|
||||
"email.send_time": send_time,
|
||||
}
|
||||
for k, v in pairs.items():
|
||||
set_setting(k, v)
|
||||
if last_sent:
|
||||
cfg["email"]["last_sent_date"] = last_sent
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
cfg.write(fh)
|
||||
logger.info(f"Email configuration saved (security={security}, encrypted).")
|
||||
set_setting("email.last_sent_date", last_sent)
|
||||
logger.info(f"Email configuration saved to app_settings (security={security}).")
|
||||
|
||||
|
||||
def _make_smtp_server(smtp_host: str, smtp_port: int,
|
||||
@@ -108,7 +106,7 @@ def _make_smtp_server(smtp_host: str, smtp_port: int,
|
||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
|
||||
server.ehlo()
|
||||
server.starttls()
|
||||
server.ehlo() # re-identify after TLS upgrade — mandatory
|
||||
server.ehlo()
|
||||
else:
|
||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
|
||||
server.ehlo()
|
||||
@@ -120,16 +118,9 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
||||
security: str) -> tuple:
|
||||
"""
|
||||
Step-by-step SMTP diagnostic. Returns (success: bool, message: str).
|
||||
Each step is attempted independently so the error message tells the
|
||||
admin exactly where the failure occurred:
|
||||
Step 1 — DNS resolution
|
||||
Step 2 — TCP connect
|
||||
Step 3 — TLS handshake (if applicable)
|
||||
Step 4 — Authentication
|
||||
"""
|
||||
import socket
|
||||
|
||||
# Step 1: DNS resolution
|
||||
try:
|
||||
addr = socket.getaddrinfo(smtp_host, smtp_port,
|
||||
socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
@@ -143,7 +134,6 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
||||
f"Check the hostname and your network connection.\n({e})"
|
||||
)
|
||||
|
||||
# Step 2: TCP connect (raw socket, before any SMTP protocol)
|
||||
try:
|
||||
sock = socket.create_connection((smtp_host, smtp_port), timeout=8)
|
||||
sock.close()
|
||||
@@ -154,43 +144,30 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
||||
f"The port may be blocked by a firewall or the server is down.\n({e})"
|
||||
)
|
||||
|
||||
# Steps 3 + 4: SMTP protocol, TLS handshake, authentication
|
||||
try:
|
||||
server = _make_smtp_server(smtp_host, smtp_port, security)
|
||||
logger.info(f"SMTP test: TLS/connection OK (security={security})")
|
||||
except smtplib.SMTPConnectError as e:
|
||||
return False, (
|
||||
f"Step 3 FAILED — SMTP connect: {e}\n"
|
||||
f"Try a different Security mode or port."
|
||||
)
|
||||
return False, (f"Step 3 FAILED — SMTP connect: {e}\nTry a different Security mode or port.")
|
||||
except smtplib.SMTPException as e:
|
||||
return False, (
|
||||
f"Step 3 FAILED — TLS handshake: {e}\n"
|
||||
f"Try switching Security mode (e.g. SSL/TLS on port 465)."
|
||||
)
|
||||
return False, (f"Step 3 FAILED — TLS handshake: {e}\nTry switching Security mode.")
|
||||
except OSError as e:
|
||||
return False, (
|
||||
f"Step 3 FAILED — connection dropped: {e}\n"
|
||||
f"Try switching Security mode or port."
|
||||
)
|
||||
return False, (f"Step 3 FAILED — connection dropped: {e}\nTry switching Security mode or port.")
|
||||
|
||||
try:
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.quit()
|
||||
logger.info("SMTP test: authentication OK")
|
||||
return True, (
|
||||
f"All steps passed.\n"
|
||||
f"Connected to {smtp_host}:{smtp_port} "
|
||||
f"All steps passed.\nConnected to {smtp_host}:{smtp_port} "
|
||||
f"({security.upper()}) and authenticated successfully."
|
||||
)
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
try: server.quit()
|
||||
except Exception: pass
|
||||
return False, (
|
||||
f"Step 4 FAILED — Authentication: username or password rejected.\n"
|
||||
f"For Gmail/Google Workspace use an App Password, not your account password.\n({e})"
|
||||
f"For Gmail/Google Workspace use an App Password.\n({e})"
|
||||
)
|
||||
except smtplib.SMTPException as e:
|
||||
return False, f"Step 4 FAILED — SMTP error during login: {e}"
|
||||
@@ -201,10 +178,7 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
||||
def send_test_email(smtp_host: str, smtp_port: int,
|
||||
smtp_user: str, smtp_password: str,
|
||||
security: str, recipients: list) -> tuple:
|
||||
"""
|
||||
Send a real test email through the full pipeline.
|
||||
Returns (success: bool, message: str).
|
||||
"""
|
||||
"""Send a real test email through the full pipeline."""
|
||||
try:
|
||||
subject = "Website Checker — SMTP Test"
|
||||
body = (
|
||||
@@ -226,10 +200,8 @@ def send_test_email(smtp_host: str, smtp_port: int,
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
|
||||
msg["X-Mailer"] = "WebChecker"
|
||||
# Plain-text part must come first; HTML second.
|
||||
# Spam filters heavily penalise HTML-only messages with no text/plain alternative.
|
||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(body, "html", "utf-8"))
|
||||
msg.attach(MIMEText(body, "html", "utf-8"))
|
||||
|
||||
server = _make_smtp_server(smtp_host, smtp_port, security)
|
||||
server.login(smtp_user, smtp_password)
|
||||
@@ -238,10 +210,8 @@ def send_test_email(smtp_host: str, smtp_port: int,
|
||||
logger.info(f"Test email sent to {recipients} via {smtp_host}:{smtp_port}")
|
||||
return True, f"Test email sent successfully to: {', '.join(recipients)}"
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
return False, (
|
||||
f"Authentication failed — check username and password.\n"
|
||||
f"For Gmail/Google Workspace use an App Password.\n({e})"
|
||||
)
|
||||
return False, (f"Authentication failed — check username and password.\n"
|
||||
f"For Gmail/Google Workspace use an App Password.\n({e})")
|
||||
except smtplib.SMTPConnectError as e:
|
||||
return False, f"Could not connect to {smtp_host}:{smtp_port} — {e}"
|
||||
except smtplib.SMTPException as e:
|
||||
@@ -269,8 +239,6 @@ def _build_html_report() -> str:
|
||||
skipped_no_shift = 0
|
||||
for r in rows:
|
||||
total = int(r.get("total_sites") or 0)
|
||||
# Skip users with no shifts scheduled today — they have no expected
|
||||
# work for this day and showing them as "0 / 0 — 0%" is misleading.
|
||||
if total == 0:
|
||||
skipped_no_shift += 1
|
||||
continue
|
||||
@@ -324,11 +292,11 @@ def _build_html_report() -> str:
|
||||
|
||||
def _send_report(cfg: dict):
|
||||
"""Build and send the daily report email."""
|
||||
html = _build_html_report()
|
||||
today = datetime.date.today().strftime("%d %b %Y")
|
||||
subject = f"Website Checker — Daily Report {today}"
|
||||
|
||||
html = _build_html_report()
|
||||
today = datetime.date.today().strftime("%d %b %Y")
|
||||
subject = f"Website Checker — Daily Report {today}"
|
||||
smtp_user = cfg["smtp_user"]
|
||||
|
||||
plain = (
|
||||
f"Website Checker — Daily Report {today}\n\n"
|
||||
"Please view this report in an HTML-capable email client for full formatting.\n"
|
||||
@@ -341,17 +309,14 @@ def _send_report(cfg: dict):
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
|
||||
msg["X-Mailer"] = "WebChecker"
|
||||
# Plain-text part must come first; HTML second.
|
||||
# Spam filters heavily penalise HTML-only messages with no text/plain alternative.
|
||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
security = cfg.get("security", "starttls")
|
||||
server = _make_smtp_server(
|
||||
cfg["smtp_host"], cfg["smtp_port"], security)
|
||||
server.login(cfg["smtp_user"], cfg["smtp_password"])
|
||||
server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string())
|
||||
server = _make_smtp_server(cfg["smtp_host"], cfg["smtp_port"], security)
|
||||
server.login(smtp_user, cfg["smtp_password"])
|
||||
server.sendmail(smtp_user, cfg["recipients"], msg.as_string())
|
||||
server.quit()
|
||||
logger.info(f"Daily report emailed to: {cfg['recipients']}")
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
@@ -367,12 +332,9 @@ def _send_report(cfg: dict):
|
||||
|
||||
|
||||
def _get_last_sent_date() -> "datetime.date | None":
|
||||
"""Read the last-sent date from config.ini [email] last_sent_date key."""
|
||||
cfg = configparser.ConfigParser()
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
return None
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
raw = cfg.get("email", "last_sent_date", fallback="")
|
||||
"""Read the last-sent date from app_settings."""
|
||||
from config import get_setting
|
||||
raw = get_setting("email.last_sent_date", "")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
@@ -382,25 +344,18 @@ def _get_last_sent_date() -> "datetime.date | None":
|
||||
|
||||
|
||||
def _set_last_sent_date(d: "datetime.date"):
|
||||
"""Persist the last-sent date to config.ini [email] last_sent_date key."""
|
||||
cfg = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
if "email" not in cfg:
|
||||
cfg["email"] = {}
|
||||
cfg["email"]["last_sent_date"] = d.isoformat()
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
cfg.write(fh)
|
||||
"""Persist the last-sent date to app_settings."""
|
||||
from config import set_setting
|
||||
set_setting("email.last_sent_date", d.isoformat())
|
||||
|
||||
|
||||
# ─── Scheduler loop ───────────────────────────────────────────────────────────
|
||||
|
||||
def _scheduler_loop():
|
||||
# Seed from persisted value so a restart after send_time does not re-send.
|
||||
last_sent_date = _get_last_sent_date()
|
||||
|
||||
while not _stop_event.is_set():
|
||||
_stop_event.wait(60) # sleep 60 seconds between checks
|
||||
_stop_event.wait(60)
|
||||
if _stop_event.is_set():
|
||||
break
|
||||
|
||||
@@ -427,16 +382,10 @@ def _scheduler_loop():
|
||||
def start():
|
||||
"""Start the background scheduler thread. Call once after successful login."""
|
||||
global _scheduler_thread, _stop_event
|
||||
# Guard against double-start: if a thread is already alive (e.g. admin
|
||||
# logs out and back in), stop it cleanly before spawning a new one.
|
||||
# Without this guard, _stop_event.clear() would unblock the sleeping
|
||||
# thread while a second thread also starts, resulting in two scheduler
|
||||
# threads firing simultaneously and potentially sending duplicate emails.
|
||||
if _scheduler_thread is not None and _scheduler_thread.is_alive():
|
||||
logger.info("Email scheduler already running - stopping before restart.")
|
||||
_stop_event.set()
|
||||
_scheduler_thread.join(timeout=5)
|
||||
# Create a fresh Event so there is no residual set-state from a prior stop()
|
||||
_stop_event = threading.Event()
|
||||
_scheduler_thread = threading.Thread(target=_scheduler_loop,
|
||||
name="EmailScheduler", daemon=True)
|
||||
|
||||
Reference in New Issue
Block a user