""" utils/scheduler.py — Daily completion report email scheduler. 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) 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 from email.mime.text import MIMEText logger = logging.getLogger("scheduler") CONFIG_FILE = "config.ini" _scheduler_thread: "threading.Thread | None" = None _stop_event = threading.Event() # ─── Config helpers ─────────────────────────────────────────────────────────── def load_email_config() -> 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", "") if not security: security = "starttls" if s.getboolean("use_tls", fallback=True) 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", "")), "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"), } 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): 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, } 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).") def _make_smtp_server(smtp_host: str, smtp_port: int, security: str) -> "smtplib.SMTP": """ Open and return a ready-to-login SMTP connection. security: "starttls" | "ssl" | "none" Explicit ehlo() calls are required on Windows — smtplib's automatic greeting is unreliable there and causes 'Connection unexpectedly closed' on many servers (Office 365, Exchange, Google Workspace) when omitted. """ if security == "ssl": server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=15) server.ehlo() elif security == "starttls": server = smtplib.SMTP(smtp_host, smtp_port, timeout=15) server.ehlo() server.starttls() server.ehlo() # re-identify after TLS upgrade — mandatory else: server = smtplib.SMTP(smtp_host, smtp_port, timeout=15) server.ehlo() return server def test_smtp_connection(smtp_host: str, smtp_port: int, smtp_user: str, smtp_password: str, 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) if not addr: raise OSError("No addresses returned") ip = addr[0][4][0] logger.info(f"SMTP test: {smtp_host} resolved to {ip}") except OSError as e: return False, ( f"Step 1 FAILED — DNS: Cannot resolve '{smtp_host}'.\n" 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() logger.info(f"SMTP test: TCP connect to {smtp_host}:{smtp_port} OK") except OSError as e: return False, ( f"Step 2 FAILED — TCP: Cannot reach {smtp_host}:{smtp_port}.\n" 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." ) 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)." ) except OSError as e: return False, ( f"Step 3 FAILED — connection dropped: {e}\n" f"Try 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"({security.upper()}) and authenticated successfully." ) except smtplib.SMTPAuthenticationError as e: 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})" ) except smtplib.SMTPException as e: return False, f"Step 4 FAILED — SMTP error during login: {e}" except Exception as e: return False, f"Step 4 FAILED — Unexpected error: {e}" 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). """ try: subject = "Website Checker — SMTP Test" body = ( "" "

Website Checker — SMTP Test

" "

This is a test email confirming your SMTP configuration is working.

" "

You can now enable daily reports from the Email Settings panel.

" "" ) msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = smtp_user msg["To"] = ", ".join(recipients) msg.attach(MIMEText(body, "html", "utf-8")) server = _make_smtp_server(smtp_host, smtp_port, security) server.login(smtp_user, smtp_password) server.sendmail(smtp_user, recipients, msg.as_string()) server.quit() 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})" ) except smtplib.SMTPConnectError as e: return False, f"Could not connect to {smtp_host}:{smtp_port} — {e}" except smtplib.SMTPException as e: return False, f"SMTP error: {e}" except OSError as e: return False, f"Network error: {e}" except Exception as e: return False, str(e) # ─── Report builder ─────────────────────────────────────────────────────────── def _build_html_report() -> str: """Build a simple HTML daily summary table.""" try: from models import get_admin_dashboard_stats stats = get_admin_dashboard_stats() except Exception as e: return f"

Error generating report: {e}

" today = datetime.date.today().strftime("%A, %d %B %Y") rows = stats.get("user_stats", []) table_rows = "" 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 pct = float(r.get("pct_complete") or 0) color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f" table_rows += ( f"" f"{r['username']}" f"{r['full_name'] or ''}" f"{int(r['checked_count'] or 0)}" f"{total}" f"{pct:.0f}%" f"" ) if not table_rows: table_rows = ( "No users with active shifts today." ) no_shift_note = ( f"

" f"{skipped_no_shift} user(s) had no shifts scheduled today and are " f"excluded from this report.

" ) if skipped_no_shift else "" return f"""

Website Checker — Daily Report

{today}

{table_rows}
Username Full Name Checked Total Completion
{no_shift_note}

Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.

""" 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}" msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = cfg["smtp_user"] msg["To"] = ", ".join(cfg["recipients"]) 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.quit() logger.info(f"Daily report emailed to: {cfg['recipients']}") except smtplib.SMTPAuthenticationError as e: logger.error(f"Failed to send daily report — authentication error: {e}") except smtplib.SMTPConnectError as e: logger.error(f"Failed to send daily report — connection error: {e}") except smtplib.SMTPException as e: logger.error(f"Failed to send daily report — SMTP error: {e}") except OSError as e: logger.error(f"Failed to send daily report — network error: {e}") except Exception as e: logger.error(f"Failed to send daily report email: {e}") 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="") if not raw: return None try: return datetime.date.fromisoformat(raw) except ValueError: return 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) # ─── 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 if _stop_event.is_set(): break cfg = load_email_config() if not cfg.get("enabled") or not cfg.get("smtp_host") or not cfg.get("recipients"): continue try: send_h, send_m = map(int, cfg["send_time"].split(":")) except Exception: continue now = datetime.datetime.now() today = now.date() if (now.hour > send_h or (now.hour == send_h and now.minute >= send_m)): if last_sent_date != today: last_sent_date = today _set_last_sent_date(today) logger.info(f"Scheduler: sending daily report at {now.strftime('%H:%M')}.") _send_report(cfg) 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) _scheduler_thread.start() logger.info("Email scheduler started.") def stop(): """Signal the scheduler to stop cleanly.""" global _stop_event _stop_event.set() logger.info("Email scheduler stopped.")