From 262c71e93b98330e88925cd40a1a026e141a3238 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 23 Apr 2026 17:52:34 -0400 Subject: [PATCH] 04/23 Fix smtp issue --- config.ini | 5 +- utils/scheduler.py | 197 +++++++++++++++++++++++++---- views/email_settings_view.py | 238 ++++++++++++++++++++++++----------- views/user_dashboard_view.py | 53 ++++++-- 4 files changed, 383 insertions(+), 110 deletions(-) diff --git a/config.ini b/config.ini index c2137be..22925d1 100644 --- a/config.ini +++ b/config.ini @@ -17,8 +17,9 @@ enabled = true smtp_host = mail.ltservicesinc.com smtp_port = 465 smtp_user = donotreply@ltservicesinc.com -smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAMimxQU449tZCXoe+d12bn9uAi6ZwuSzZGg7Mg/XYa5sAAAAADoAAAAACAAAgAAAAAnuQcfyetLSl/SIMvKqsb9GPv8e0Boh2/KUMnCKrtVcQAAAAU2cmO41/lsCJ3CBdnIYo1EAAAACXyd4+ESZhFnrZKdgWbDCwzVRSRyuOn37sdNpFrHRmGzD3Zy8/FivfcNBbFnfJNheVpeKsld+wnGfOlxiW0Tmm -use_tls = true +smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAh3WeFBaZFJ3jXPYBcRFrzeouer4eNi8f1V4e9htctPQAAAAADoAAAAACAAAgAAAApIGw/W7uxwcLxCFmKJtbd0lsr0Ipem4mjBeaF7ePQa4QAAAANsAPqIR2ODnOCJC3BEIY80AAAAAXFi4ah2FcXjr8cWMirk5zHMDBRBKWu2MU/UvTNWuY5VR4br/uZ7ZSk9wExBCr/vuLq7eJ7v5JnoIG570fIlue +security = ssl +use_tls = false recipients = da.nguyen8744@gmail.com send_time = 18:00 diff --git a/utils/scheduler.py b/utils/scheduler.py index 61cc643..9aa0b34 100644 --- a/utils/scheduler.py +++ b/utils/scheduler.py @@ -46,53 +46,196 @@ def load_email_config() -> dict: 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", "")), - "use_tls": s.getboolean("use_tls", fallback=True), + "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, use_tls: bool, + 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), - "use_tls": str(use_tls).lower(), - "recipients": recipients, - "send_time": send_time, + "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("Email configuration saved (password encrypted).") + logger.info(f"Email configuration saved (security={security}, encrypted).") -def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple: +def _make_smtp_server(smtp_host: str, smtp_port: int, + security: str) -> "smtplib.SMTP": """ - Attempt a connection without sending mail. + 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: - if use_tls: - server = smtplib.SMTP(smtp_host, smtp_port, timeout=8) - server.starttls() - else: - server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=8) + 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() - return True, "Connection successful." + 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) @@ -180,15 +323,21 @@ def _send_report(cfg: dict): msg.attach(MIMEText(html, "html", "utf-8")) try: - if cfg["use_tls"]: - server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=10) - server.starttls() - else: - server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=10) + 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}") diff --git a/views/email_settings_view.py b/views/email_settings_view.py index 30ee15e..824b443 100644 --- a/views/email_settings_view.py +++ b/views/email_settings_view.py @@ -15,6 +15,13 @@ from utils.ui_helpers import ( logger = logging.getLogger("email_settings_view") +# Security mode -> (label, default_port) +_SECURITY_MODES = { + "starttls": ("STARTTLS (port 587, most common)", 587), + "ssl": ("SSL / TLS (port 465, Gmail direct)", 465), + "none": ("None (port 25, internal relay only)", 25), +} + class EmailSettingsView(tk.Toplevel): def __init__(self, master, current_user: dict): @@ -31,119 +38,159 @@ class EmailSettingsView(tk.Toplevel): def _centre(self): self.update_idletasks() - w, h = 500, 560 + w, h = 540, 610 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") def _build_ui(self): + C = COLOURS + # Header - hdr = tk.Frame(self, bg=COLOURS["accent"], pady=14) + hdr = tk.Frame(self, bg=C["accent"], pady=14) hdr.pack(fill="x") tk.Label(hdr, text="Daily Report Email Settings", - font=FONT_BOLD, bg=COLOURS["accent"], - fg=COLOURS["white"]).pack() + font=FONT_BOLD, bg=C["accent"], + fg=C["white"]).pack() tk.Label(hdr, text="Send an HTML completion report to recipients on a daily schedule.", - font=FONT_SMALL, bg=COLOURS["accent"], - fg=COLOURS["white"]).pack(pady=(2, 0)) + font=FONT_SMALL, bg=C["accent"], + fg=C["white"]).pack(pady=(2, 0)) # Form - form = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=16) + form = tk.Frame(self, bg=C["bg"], padx=32, pady=16) form.pack(fill="both", expand=True) form.columnconfigure(1, weight=1) - # Enable toggle - tk.Label(form, text="Enable daily emails", bg=COLOURS["bg"], - fg=COLOURS["text_dim"], font=FONT_SMALL).grid( - row=0, column=0, sticky="w", padx=(0, 12), pady=6) - self.enabled_var = tk.BooleanVar(value=False) - tk.Checkbutton(form, variable=self.enabled_var, - bg=COLOURS["bg"], - activebackground=COLOURS["bg"], - selectcolor=COLOURS["surface2"], - command=self._toggle_fields).grid( - row=0, column=1, sticky="w", pady=6) + self._fields = [] # widgets to enable/disable with the checkbox - def field(label, row, show=None, width=28): - tk.Label(form, text=label, bg=COLOURS["bg"], - fg=COLOURS["text_dim"], font=FONT_SMALL, - anchor="w").grid(row=row, column=0, sticky="w", - padx=(0, 12), pady=5) + def lbl(text, row): + tk.Label(form, text=text, bg=C["bg"], fg=C["text_dim"], + font=FONT_SMALL, anchor="w").grid( + row=row, column=0, sticky="w", padx=(0, 12), pady=5) + + def entry(row, show=None, width=28): var = tk.StringVar() ent = tk.Entry(form, textvariable=var, width=width, - bg=COLOURS["surface2"], fg=COLOURS["text"], - insertbackground=COLOURS["text"], + bg=C["surface2"], fg=C["text"], + insertbackground=C["text"], relief="flat", font=FONT, show=show or "") ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=5) self._fields.append(ent) return var - self._fields = [] - self.smtp_host_var = field("SMTP Host", 1) - self.smtp_port_var = field("SMTP Port", 2, width=8) - self.smtp_user_var = field("SMTP Username", 3) - self.smtp_pass_var = field("SMTP Password", 4, show="•") + # Row 0 — Enable toggle + lbl("Enable daily emails", 0) + self.enabled_var = tk.BooleanVar(value=False) + tk.Checkbutton(form, variable=self.enabled_var, + bg=C["bg"], activebackground=C["bg"], + selectcolor=C["surface2"], + command=self._toggle_fields).grid( + row=0, column=1, sticky="w", pady=6) - # TLS toggle - tk.Label(form, text="Use STARTTLS", bg=COLOURS["bg"], - fg=COLOURS["text_dim"], font=FONT_SMALL).grid( - row=5, column=0, sticky="w", padx=(0, 12), pady=5) - self.tls_var = tk.BooleanVar(value=True) - tls_cb = tk.Checkbutton(form, variable=self.tls_var, - bg=COLOURS["bg"], - activebackground=COLOURS["bg"], - selectcolor=COLOURS["surface2"]) - tls_cb.grid(row=5, column=1, sticky="w", pady=5) - self._fields.append(tls_cb) + # Row 1 — SMTP Host + lbl("SMTP Host", 1) + self.smtp_host_var = entry(1) - self.recipients_var = field("Recipients (comma-sep)", 6) - self.send_time_var = field("Send Time (HH:MM)", 7, width=8) + # Row 2 — Security mode (replaces the old Use STARTTLS checkbox) + lbl("Security", 2) + self.security_var = tk.StringVar(value="starttls") + sec_frame = tk.Frame(form, bg=C["bg"]) + sec_frame.grid(row=2, column=1, sticky="w", pady=5) + for key, (label, _) in _SECURITY_MODES.items(): + rb = tk.Radiobutton( + sec_frame, text=label, + variable=self.security_var, value=key, + command=self._on_security_change, + bg=C["bg"], fg=C["text"], + activebackground=C["bg"], activeforeground=C["accent"], + selectcolor=C["surface2"], font=FONT_SMALL, cursor="hand2", + ) + rb.pack(anchor="w") + self._fields.append(rb) + + # Row 3 — SMTP Port (auto-filled when security mode changes) + lbl("SMTP Port", 3) + self.smtp_port_var = entry(3, width=8) self.smtp_port_var.set("587") + + # Row 4 — Username + lbl("SMTP Username", 4) + self.smtp_user_var = entry(4) + + # Row 5 — Password + lbl("SMTP Password", 5) + self.smtp_pass_var = entry(5, show="•") + + # Row 6 — Recipients + lbl("Recipients (comma-sep)", 6) + self.recipients_var = entry(6) + + # Row 7 — Send time + lbl("Send Time (HH:MM)", 7) + self.send_time_var = entry(7, width=8) self.send_time_var.set("18:00") - # Status line + # Row 8 — Status line (multi-line for diagnostic output) self._status_var = tk.StringVar() - self._status_lbl = tk.Label(form, textvariable=self._status_var, - bg=COLOURS["bg"], fg=COLOURS["text_dim"], - font=FONT_SMALL, wraplength=380, anchor="w") - self._status_lbl.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(6, 0)) + self._status_lbl = tk.Label( + form, textvariable=self._status_var, + bg=C["bg"], fg=C["text_dim"], + font=FONT_SMALL, wraplength=460, + anchor="w", justify="left", + ) + self._status_lbl.grid(row=8, column=0, columnspan=2, + sticky="ew", pady=(6, 0)) # Buttons - btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=14) + btn_row = tk.Frame(self, bg=C["bg"], padx=32, pady=14) btn_row.pack(fill="x") self._save_btn = tk.Button( btn_row, text="Save Settings", command=self._save, - bg=COLOURS["accent"], fg=COLOURS["white"], - activebackground=COLOURS["accent_hover"], - activeforeground=COLOURS["white"], + bg=C["accent"], fg=C["white"], + activebackground=C["accent_hover"], + activeforeground=C["white"], relief="flat", font=FONT_BOLD, cursor="hand2", padx=14, pady=8) self._save_btn.pack(side="right", padx=(8, 0)) + self._send_btn = tk.Button( + btn_row, text="📧 Send Test Email", + command=self._send_test_email, + bg=C["success"], fg=C["white"], + activebackground="#3d9140", + activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=8) + self._send_btn.pack(side="right", padx=(8, 0)) + self._test_btn = tk.Button( - btn_row, text="Test Connection", + btn_row, text="🔌 Test Connection", command=self._test, - bg=COLOURS["surface2"], fg=COLOURS["text"], - activebackground=COLOURS["surface2"], - activeforeground=COLOURS["accent"], + bg=C["surface2"], fg=C["text"], + activebackground=C["surface2"], + activeforeground=C["accent"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=8) self._test_btn.pack(side="right") tk.Button(btn_row, text="Cancel", command=self.destroy, - bg=COLOURS["surface"], fg=COLOURS["text_dim"], - activebackground=COLOURS["surface2"], + bg=C["surface"], fg=C["text_dim"], + activebackground=C["surface2"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=8).pack(side="left") self._toggle_fields() + def _on_security_change(self): + """Auto-fill the port when the security mode radio changes.""" + mode = self.security_var.get() + self.smtp_port_var.set(str(_SECURITY_MODES[mode][1])) + def _toggle_fields(self): - """Enable/disable SMTP fields based on the enabled checkbox.""" state = "normal" if self.enabled_var.get() else "disabled" for w in self._fields: try: @@ -161,7 +208,11 @@ class EmailSettingsView(tk.Toplevel): self.smtp_port_var.set(str(cfg.get("smtp_port", 587))) self.smtp_user_var.set(cfg.get("smtp_user", "")) self.smtp_pass_var.set(cfg.get("smtp_password", "")) - self.tls_var.set(cfg.get("use_tls", True)) + # Load security mode; fall back from legacy use_tls bool + security = cfg.get("security", "") + if not security: + security = "starttls" if cfg.get("use_tls", True) else "none" + self.security_var.set(security if security in _SECURITY_MODES else "starttls") self.recipients_var.set(", ".join(cfg.get("recipients", []))) self.send_time_var.set(cfg.get("send_time", "18:00")) self._toggle_fields() @@ -171,13 +222,14 @@ class EmailSettingsView(tk.Toplevel): port_str = self.smtp_port_var.get().strip() user = self.smtp_user_var.get().strip() password = self.smtp_pass_var.get() - use_tls = self.tls_var.get() + security = self.security_var.get() recipients = self.recipients_var.get().strip() send_time = self.send_time_var.get().strip() enabled = self.enabled_var.get() if enabled and (not host or not user or not recipients): - self._set_status("Host, username, and recipients are required when enabled.", "danger") + self._set_status( + "Host, username, and recipients are required when enabled.", "danger") return None try: port = int(port_str) @@ -186,7 +238,6 @@ class EmailSettingsView(tk.Toplevel): except ValueError: self._set_status("Port must be a number between 1 and 65535.", "danger") return None - # Validate send_time try: h, m = map(int, send_time.split(":")) assert 0 <= h <= 23 and 0 <= m <= 59 @@ -194,7 +245,7 @@ class EmailSettingsView(tk.Toplevel): self._set_status("Send time must be in HH:MM format (e.g. 18:00).", "danger") return None - return enabled, host, port, user, password, use_tls, recipients, send_time + return enabled, host, port, user, password, security, recipients, send_time def _set_status(self, msg, level="dim"): colours = {"danger": COLOURS["danger"], "success": COLOURS["success"], @@ -202,23 +253,61 @@ class EmailSettingsView(tk.Toplevel): self._status_lbl.config(fg=colours.get(level, COLOURS["text_dim"])) self._status_var.set(msg) + def _lock_buttons(self): + self._test_btn.config(state="disabled") + self._send_btn.config(state="disabled") + self._save_btn.config(state="disabled") + + def _unlock_buttons(self): + self._test_btn.config(state="normal") + self._send_btn.config(state="normal") + self._save_btn.config(state="normal") + def _test(self): result = self._get_fields() if result is None: return - enabled, host, port, user, password, use_tls, recipients, send_time = result + enabled, host, port, user, password, security, recipients, send_time = result + if not host or not user: + self._set_status("Please fill in host and username before testing.", "warning") + return - self._set_status("Testing SMTP connection...", "dim") - self._test_btn.config(state="disabled") - self._save_btn.config(state="disabled") + self._set_status("Running diagnostics — please wait...", "dim") + self._lock_buttons() def _run(): from utils.scheduler import test_smtp_connection - ok, msg = test_smtp_connection(host, port, user, password, use_tls) + ok, msg = test_smtp_connection(host, port, user, password, security) level = "success" if ok else "danger" self.after(0, lambda: self._set_status(msg, level)) - self.after(0, lambda: self._test_btn.config(state="normal")) - self.after(0, lambda: self._save_btn.config(state="normal")) + self.after(0, self._unlock_buttons) + + threading.Thread(target=_run, daemon=True).start() + + def _send_test_email(self): + result = self._get_fields() + if result is None: + return + enabled, host, port, user, password, security, recipients, send_time = result + + recip_list = [r.strip() for r in recipients.split(",") if r.strip()] + if not recip_list: + self._set_status("Please enter at least one recipient.", "danger") + return + if not host or not user: + self._set_status("Please fill in host and username before sending.", "warning") + return + + self._set_status("Sending test email — please wait...", "dim") + self._lock_buttons() + + def _run(): + from utils.scheduler import send_test_email + ok, msg = send_test_email(host, port, user, password, + security, recip_list) + level = "success" if ok else "danger" + self.after(0, lambda: self._set_status(msg, level)) + self.after(0, self._unlock_buttons) threading.Thread(target=_run, daemon=True).start() @@ -226,14 +315,15 @@ class EmailSettingsView(tk.Toplevel): result = self._get_fields() if result is None: return - enabled, host, port, user, password, use_tls, recipients, send_time = result + enabled, host, port, user, password, security, recipients, send_time = result from utils.scheduler import save_email_config - save_email_config(enabled, host, port, user, password, use_tls, + save_email_config(enabled, host, port, user, password, security, recipients, send_time) from models import log_action log_action(self.current_user["id"], "UPDATE_EMAIL_SETTINGS", "config", - None, f"Email reports {'enabled' if enabled else 'disabled'}.") + None, f"Email reports {'enabled' if enabled else 'disabled'} " + f"security={security}.") show_info("Email settings saved successfully.") logger.info(f"Email settings saved by {self.current_user['username']}.") self.destroy() diff --git a/views/user_dashboard_view.py b/views/user_dashboard_view.py index aebe60c..9b515ef 100644 --- a/views/user_dashboard_view.py +++ b/views/user_dashboard_view.py @@ -581,7 +581,15 @@ class UserDashboardView(ttk.Frame): self._notify_job = self.after(60_000, self._schedule_notifications) def _check_notifications(self): - """Fire a desktop notification if a shift ends within NOTIFY_MINUTES_BEFORE.""" + """ + Fire a single consolidated desktop notification listing ALL unchecked + sites whose shift ends within NOTIFY_MINUTES_BEFORE minutes. + + Previously the loop fired one notification per site, which could + produce a burst of popups when multiple sites were unchecked. + Now one notification is fired per shift-end-time group, listing all + unchecked sites for that shift together. + """ from models import get_unchecked_sites_for_user import datetime as dt try: @@ -590,6 +598,10 @@ class UserDashboardView(ttk.Frame): return now = dt.datetime.now().time() + + # Group unchecked sites by their shift end_time so one notification + # covers all sites in the same shift. + groups = {} # end_t -> {"diff": int, "names": [str]} for site in unchecked: end_time = site.get("end_time") if not end_time: @@ -606,20 +618,41 @@ class UserDashboardView(ttk.Frame): except Exception: continue - # Compute minutes until shift end now_mins = now.hour * 60 + now.minute end_mins = end_t.hour * 60 + end_t.minute diff = end_mins - now_mins - key = (site["id"], end_t) - if 0 < diff <= NOTIFY_MINUTES_BEFORE and key not in self._notified_sites: - self._notified_sites.add(key) - self._fire_notification(site["name"], diff, end_t) + if 0 < diff <= NOTIFY_MINUTES_BEFORE: + if end_t not in groups: + groups[end_t] = {"diff": diff, "names": []} + groups[end_t]["names"].append(site["name"]) - def _fire_notification(self, site_name: str, minutes_left: int, end_time): - title = "Shift Reminder" - message = (f"'{site_name}' is unchecked — " - f"shift ends at {end_time.strftime('%H:%M')} " + # Fire one notification per end-time group that has not been notified yet + for end_t, info in groups.items(): + key = (frozenset(info["names"]), end_t) + if key in self._notified_sites: + continue + self._notified_sites.add(key) + self._fire_notification(info["names"], info["diff"], end_t) + + def _fire_notification(self, site_names: list, minutes_left: int, end_time): + """ + Fire one consolidated desktop notification for all unchecked sites + in a shift group. + site_names: list of unchecked website names + """ + title = "Shift Reminder — Unchecked Sites" + count = len(site_names) + if count == 1: + body = f"{site_names[0]} is unchecked." + else: + # Show up to 3 names inline; append "+ N more" if longer + preview = ", ".join(site_names[:3]) + body = (f"{preview}" if count <= 3 + else f"{preview} + {count - 3} more") + body = f"{count} sites unchecked: {body}." + message = (f"{body} Shift ends at " + f"{end_time.strftime('%H:%M')} " f"({minutes_left} min remaining).") logger.info(f"Notification: {message}")