""" 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 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 datetime import logging import smtplib import threading from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formatdate, make_msgid, formataddr logger = logging.getLogger("scheduler") _scheduler_thread: "threading.Thread | None" = None _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.crypto import decrypt s = get_settings_dict("email.") security = s.get("email.security", "") if not security: # 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.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(s.get("email.smtp_password", "")), "security": security, "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.crypto import encrypt # 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(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: 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, 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() 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). """ import socket 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})" ) 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})" ) 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}\nTry a different Security mode or port.") except smtplib.SMTPException as e: 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}\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.\nConnected 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.\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.""" 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.

" "" ) plain = ( "Website Checker — SMTP Test\n\n" "This is a test email confirming your SMTP configuration is working.\n" "You can now enable daily reports from the Email Settings panel." ) msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = formataddr(("Website Checker", smtp_user)) msg["To"] = ", ".join(recipients) 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" msg.attach(MIMEText(plain, "plain", "utf-8")) 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) 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}" 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" "This email was sent automatically by Website Checker." ) msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = formataddr(("Website Checker", smtp_user)) msg["To"] = ", ".join(cfg["recipients"]) 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" msg.attach(MIMEText(plain, "plain", "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(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: 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 app_settings.""" from config import get_setting raw = get_setting("email.last_sent_date", "") 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 app_settings.""" from config import set_setting set_setting("email.last_sent_date", d.isoformat()) # ─── Scheduler loop ─────────────────────────────────────────────────────────── def _scheduler_loop(): last_sent_date = _get_last_sent_date() while not _stop_event.is_set(): _stop_event.wait(60) 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 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) _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.")