04/23 Fix smtp issue

This commit is contained in:
2026-04-23 17:52:34 -04:00
parent 58c6218c14
commit 262c71e93b
4 changed files with 383 additions and 110 deletions
+164 -74
View File
@@ -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()
+43 -10
View File
@@ -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}")