04/23 Fix smtp issue
This commit is contained in:
+3
-2
@@ -17,8 +17,9 @@ enabled = true
|
|||||||
smtp_host = mail.ltservicesinc.com
|
smtp_host = mail.ltservicesinc.com
|
||||||
smtp_port = 465
|
smtp_port = 465
|
||||||
smtp_user = donotreply@ltservicesinc.com
|
smtp_user = donotreply@ltservicesinc.com
|
||||||
smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAMimxQU449tZCXoe+d12bn9uAi6ZwuSzZGg7Mg/XYa5sAAAAADoAAAAACAAAgAAAAAnuQcfyetLSl/SIMvKqsb9GPv8e0Boh2/KUMnCKrtVcQAAAAU2cmO41/lsCJ3CBdnIYo1EAAAACXyd4+ESZhFnrZKdgWbDCwzVRSRyuOn37sdNpFrHRmGzD3Zy8/FivfcNBbFnfJNheVpeKsld+wnGfOlxiW0Tmm
|
smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAh3WeFBaZFJ3jXPYBcRFrzeouer4eNi8f1V4e9htctPQAAAAADoAAAAACAAAgAAAApIGw/W7uxwcLxCFmKJtbd0lsr0Ipem4mjBeaF7ePQa4QAAAANsAPqIR2ODnOCJC3BEIY80AAAAAXFi4ah2FcXjr8cWMirk5zHMDBRBKWu2MU/UvTNWuY5VR4br/uZ7ZSk9wExBCr/vuLq7eJ7v5JnoIG570fIlue
|
||||||
use_tls = true
|
security = ssl
|
||||||
|
use_tls = false
|
||||||
recipients = da.nguyen8744@gmail.com
|
recipients = da.nguyen8744@gmail.com
|
||||||
send_time = 18:00
|
send_time = 18:00
|
||||||
|
|
||||||
|
|||||||
+173
-24
@@ -46,53 +46,196 @@ def load_email_config() -> dict:
|
|||||||
if "email" not in cfg:
|
if "email" not in cfg:
|
||||||
return {}
|
return {}
|
||||||
s = cfg["email"]
|
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 {
|
return {
|
||||||
"enabled": s.getboolean("enabled", fallback=False),
|
"enabled": s.getboolean("enabled", fallback=False),
|
||||||
"smtp_host": s.get("smtp_host", ""),
|
"smtp_host": s.get("smtp_host", ""),
|
||||||
"smtp_port": s.getint("smtp_port", fallback=587),
|
"smtp_port": s.getint("smtp_port", fallback=587),
|
||||||
"smtp_user": s.get("smtp_user", ""),
|
"smtp_user": s.get("smtp_user", ""),
|
||||||
"smtp_password": decrypt_value(s.get("smtp_password", "")),
|
"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()],
|
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
||||||
"send_time": s.get("send_time", "18:00"),
|
"send_time": s.get("send_time", "18:00"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
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):
|
recipients: str, send_time: str):
|
||||||
from utils.config_crypto import encrypt_value
|
from utils.config_crypto import encrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||||
|
# Preserve last_sent_date if present
|
||||||
|
last_sent = cfg.get("email", "last_sent_date", fallback="")
|
||||||
cfg["email"] = {
|
cfg["email"] = {
|
||||||
"enabled": str(enabled).lower(),
|
"enabled": str(enabled).lower(),
|
||||||
"smtp_host": smtp_host,
|
"smtp_host": smtp_host,
|
||||||
"smtp_port": str(smtp_port),
|
"smtp_port": str(smtp_port),
|
||||||
"smtp_user": smtp_user,
|
"smtp_user": smtp_user,
|
||||||
"smtp_password": encrypt_value(smtp_password),
|
"smtp_password": encrypt_value(smtp_password),
|
||||||
"use_tls": str(use_tls).lower(),
|
"security": security,
|
||||||
"recipients": recipients,
|
"use_tls": str(security == "starttls").lower(), # legacy compat
|
||||||
"send_time": send_time,
|
"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:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||||
cfg.write(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).
|
Returns (success: bool, message: str).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if use_tls:
|
subject = "Website Checker — SMTP Test"
|
||||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=8)
|
body = (
|
||||||
server.starttls()
|
"<html><body style='font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e'>"
|
||||||
else:
|
"<h3 style='color:#5b4de8'>Website Checker — SMTP Test</h3>"
|
||||||
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=8)
|
"<p>This is a test email confirming your SMTP configuration is working.</p>"
|
||||||
|
"<p>You can now enable daily reports from the Email Settings panel.</p>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
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.login(smtp_user, smtp_password)
|
||||||
|
server.sendmail(smtp_user, recipients, msg.as_string())
|
||||||
server.quit()
|
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:
|
except Exception as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
@@ -180,15 +323,21 @@ def _send_report(cfg: dict):
|
|||||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if cfg["use_tls"]:
|
security = cfg.get("security", "starttls")
|
||||||
server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=10)
|
server = _make_smtp_server(
|
||||||
server.starttls()
|
cfg["smtp_host"], cfg["smtp_port"], security)
|
||||||
else:
|
|
||||||
server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=10)
|
|
||||||
server.login(cfg["smtp_user"], cfg["smtp_password"])
|
server.login(cfg["smtp_user"], cfg["smtp_password"])
|
||||||
server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string())
|
server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string())
|
||||||
server.quit()
|
server.quit()
|
||||||
logger.info(f"Daily report emailed to: {cfg['recipients']}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send daily report email: {e}")
|
logger.error(f"Failed to send daily report email: {e}")
|
||||||
|
|
||||||
|
|||||||
+164
-74
@@ -15,6 +15,13 @@ from utils.ui_helpers import (
|
|||||||
|
|
||||||
logger = logging.getLogger("email_settings_view")
|
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):
|
class EmailSettingsView(tk.Toplevel):
|
||||||
def __init__(self, master, current_user: dict):
|
def __init__(self, master, current_user: dict):
|
||||||
@@ -31,119 +38,159 @@ class EmailSettingsView(tk.Toplevel):
|
|||||||
|
|
||||||
def _centre(self):
|
def _centre(self):
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
w, h = 500, 560
|
w, h = 540, 610
|
||||||
x = (self.winfo_screenwidth() - w) // 2
|
x = (self.winfo_screenwidth() - w) // 2
|
||||||
y = (self.winfo_screenheight() - h) // 2
|
y = (self.winfo_screenheight() - h) // 2
|
||||||
self.geometry(f"{w}x{h}+{x}+{y}")
|
self.geometry(f"{w}x{h}+{x}+{y}")
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
|
C = COLOURS
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
hdr = tk.Frame(self, bg=COLOURS["accent"], pady=14)
|
hdr = tk.Frame(self, bg=C["accent"], pady=14)
|
||||||
hdr.pack(fill="x")
|
hdr.pack(fill="x")
|
||||||
tk.Label(hdr, text="Daily Report Email Settings",
|
tk.Label(hdr, text="Daily Report Email Settings",
|
||||||
font=FONT_BOLD, bg=COLOURS["accent"],
|
font=FONT_BOLD, bg=C["accent"],
|
||||||
fg=COLOURS["white"]).pack()
|
fg=C["white"]).pack()
|
||||||
tk.Label(hdr,
|
tk.Label(hdr,
|
||||||
text="Send an HTML completion report to recipients on a daily schedule.",
|
text="Send an HTML completion report to recipients on a daily schedule.",
|
||||||
font=FONT_SMALL, bg=COLOURS["accent"],
|
font=FONT_SMALL, bg=C["accent"],
|
||||||
fg=COLOURS["white"]).pack(pady=(2, 0))
|
fg=C["white"]).pack(pady=(2, 0))
|
||||||
|
|
||||||
# Form
|
# 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.pack(fill="both", expand=True)
|
||||||
form.columnconfigure(1, weight=1)
|
form.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
# Enable toggle
|
self._fields = [] # widgets to enable/disable with the checkbox
|
||||||
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)
|
|
||||||
|
|
||||||
def field(label, row, show=None, width=28):
|
def lbl(text, row):
|
||||||
tk.Label(form, text=label, bg=COLOURS["bg"],
|
tk.Label(form, text=text, bg=C["bg"], fg=C["text_dim"],
|
||||||
fg=COLOURS["text_dim"], font=FONT_SMALL,
|
font=FONT_SMALL, anchor="w").grid(
|
||||||
anchor="w").grid(row=row, column=0, sticky="w",
|
row=row, column=0, sticky="w", padx=(0, 12), pady=5)
|
||||||
padx=(0, 12), pady=5)
|
|
||||||
|
def entry(row, show=None, width=28):
|
||||||
var = tk.StringVar()
|
var = tk.StringVar()
|
||||||
ent = tk.Entry(form, textvariable=var, width=width,
|
ent = tk.Entry(form, textvariable=var, width=width,
|
||||||
bg=COLOURS["surface2"], fg=COLOURS["text"],
|
bg=C["surface2"], fg=C["text"],
|
||||||
insertbackground=COLOURS["text"],
|
insertbackground=C["text"],
|
||||||
relief="flat", font=FONT, show=show or "")
|
relief="flat", font=FONT, show=show or "")
|
||||||
ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=5)
|
ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=5)
|
||||||
self._fields.append(ent)
|
self._fields.append(ent)
|
||||||
return var
|
return var
|
||||||
|
|
||||||
self._fields = []
|
# Row 0 — Enable toggle
|
||||||
self.smtp_host_var = field("SMTP Host", 1)
|
lbl("Enable daily emails", 0)
|
||||||
self.smtp_port_var = field("SMTP Port", 2, width=8)
|
self.enabled_var = tk.BooleanVar(value=False)
|
||||||
self.smtp_user_var = field("SMTP Username", 3)
|
tk.Checkbutton(form, variable=self.enabled_var,
|
||||||
self.smtp_pass_var = field("SMTP Password", 4, show="•")
|
bg=C["bg"], activebackground=C["bg"],
|
||||||
|
selectcolor=C["surface2"],
|
||||||
|
command=self._toggle_fields).grid(
|
||||||
|
row=0, column=1, sticky="w", pady=6)
|
||||||
|
|
||||||
# TLS toggle
|
# Row 1 — SMTP Host
|
||||||
tk.Label(form, text="Use STARTTLS", bg=COLOURS["bg"],
|
lbl("SMTP Host", 1)
|
||||||
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
|
self.smtp_host_var = entry(1)
|
||||||
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)
|
|
||||||
|
|
||||||
self.recipients_var = field("Recipients (comma-sep)", 6)
|
# Row 2 — Security mode (replaces the old Use STARTTLS checkbox)
|
||||||
self.send_time_var = field("Send Time (HH:MM)", 7, width=8)
|
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")
|
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")
|
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_var = tk.StringVar()
|
||||||
self._status_lbl = tk.Label(form, textvariable=self._status_var,
|
self._status_lbl = tk.Label(
|
||||||
bg=COLOURS["bg"], fg=COLOURS["text_dim"],
|
form, textvariable=self._status_var,
|
||||||
font=FONT_SMALL, wraplength=380, anchor="w")
|
bg=C["bg"], fg=C["text_dim"],
|
||||||
self._status_lbl.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(6, 0))
|
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
|
# 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")
|
btn_row.pack(fill="x")
|
||||||
|
|
||||||
self._save_btn = tk.Button(
|
self._save_btn = tk.Button(
|
||||||
btn_row, text="Save Settings",
|
btn_row, text="Save Settings",
|
||||||
command=self._save,
|
command=self._save,
|
||||||
bg=COLOURS["accent"], fg=COLOURS["white"],
|
bg=C["accent"], fg=C["white"],
|
||||||
activebackground=COLOURS["accent_hover"],
|
activebackground=C["accent_hover"],
|
||||||
activeforeground=COLOURS["white"],
|
activeforeground=C["white"],
|
||||||
relief="flat", font=FONT_BOLD, cursor="hand2",
|
relief="flat", font=FONT_BOLD, cursor="hand2",
|
||||||
padx=14, pady=8)
|
padx=14, pady=8)
|
||||||
self._save_btn.pack(side="right", padx=(8, 0))
|
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(
|
self._test_btn = tk.Button(
|
||||||
btn_row, text="Test Connection",
|
btn_row, text="🔌 Test Connection",
|
||||||
command=self._test,
|
command=self._test,
|
||||||
bg=COLOURS["surface2"], fg=COLOURS["text"],
|
bg=C["surface2"], fg=C["text"],
|
||||||
activebackground=COLOURS["surface2"],
|
activebackground=C["surface2"],
|
||||||
activeforeground=COLOURS["accent"],
|
activeforeground=C["accent"],
|
||||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||||
padx=10, pady=8)
|
padx=10, pady=8)
|
||||||
self._test_btn.pack(side="right")
|
self._test_btn.pack(side="right")
|
||||||
|
|
||||||
tk.Button(btn_row, text="Cancel",
|
tk.Button(btn_row, text="Cancel",
|
||||||
command=self.destroy,
|
command=self.destroy,
|
||||||
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
|
bg=C["surface"], fg=C["text_dim"],
|
||||||
activebackground=COLOURS["surface2"],
|
activebackground=C["surface2"],
|
||||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||||
padx=10, pady=8).pack(side="left")
|
padx=10, pady=8).pack(side="left")
|
||||||
|
|
||||||
self._toggle_fields()
|
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):
|
def _toggle_fields(self):
|
||||||
"""Enable/disable SMTP fields based on the enabled checkbox."""
|
|
||||||
state = "normal" if self.enabled_var.get() else "disabled"
|
state = "normal" if self.enabled_var.get() else "disabled"
|
||||||
for w in self._fields:
|
for w in self._fields:
|
||||||
try:
|
try:
|
||||||
@@ -161,7 +208,11 @@ class EmailSettingsView(tk.Toplevel):
|
|||||||
self.smtp_port_var.set(str(cfg.get("smtp_port", 587)))
|
self.smtp_port_var.set(str(cfg.get("smtp_port", 587)))
|
||||||
self.smtp_user_var.set(cfg.get("smtp_user", ""))
|
self.smtp_user_var.set(cfg.get("smtp_user", ""))
|
||||||
self.smtp_pass_var.set(cfg.get("smtp_password", ""))
|
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.recipients_var.set(", ".join(cfg.get("recipients", [])))
|
||||||
self.send_time_var.set(cfg.get("send_time", "18:00"))
|
self.send_time_var.set(cfg.get("send_time", "18:00"))
|
||||||
self._toggle_fields()
|
self._toggle_fields()
|
||||||
@@ -171,13 +222,14 @@ class EmailSettingsView(tk.Toplevel):
|
|||||||
port_str = self.smtp_port_var.get().strip()
|
port_str = self.smtp_port_var.get().strip()
|
||||||
user = self.smtp_user_var.get().strip()
|
user = self.smtp_user_var.get().strip()
|
||||||
password = self.smtp_pass_var.get()
|
password = self.smtp_pass_var.get()
|
||||||
use_tls = self.tls_var.get()
|
security = self.security_var.get()
|
||||||
recipients = self.recipients_var.get().strip()
|
recipients = self.recipients_var.get().strip()
|
||||||
send_time = self.send_time_var.get().strip()
|
send_time = self.send_time_var.get().strip()
|
||||||
enabled = self.enabled_var.get()
|
enabled = self.enabled_var.get()
|
||||||
|
|
||||||
if enabled and (not host or not user or not recipients):
|
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
|
return None
|
||||||
try:
|
try:
|
||||||
port = int(port_str)
|
port = int(port_str)
|
||||||
@@ -186,7 +238,6 @@ class EmailSettingsView(tk.Toplevel):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
self._set_status("Port must be a number between 1 and 65535.", "danger")
|
self._set_status("Port must be a number between 1 and 65535.", "danger")
|
||||||
return None
|
return None
|
||||||
# Validate send_time
|
|
||||||
try:
|
try:
|
||||||
h, m = map(int, send_time.split(":"))
|
h, m = map(int, send_time.split(":"))
|
||||||
assert 0 <= h <= 23 and 0 <= m <= 59
|
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")
|
self._set_status("Send time must be in HH:MM format (e.g. 18:00).", "danger")
|
||||||
return None
|
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"):
|
def _set_status(self, msg, level="dim"):
|
||||||
colours = {"danger": COLOURS["danger"], "success": COLOURS["success"],
|
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_lbl.config(fg=colours.get(level, COLOURS["text_dim"]))
|
||||||
self._status_var.set(msg)
|
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):
|
def _test(self):
|
||||||
result = self._get_fields()
|
result = self._get_fields()
|
||||||
if result is None:
|
if result is None:
|
||||||
return
|
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._set_status("Running diagnostics — please wait...", "dim")
|
||||||
self._test_btn.config(state="disabled")
|
self._lock_buttons()
|
||||||
self._save_btn.config(state="disabled")
|
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
from utils.scheduler import test_smtp_connection
|
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"
|
level = "success" if ok else "danger"
|
||||||
self.after(0, lambda: self._set_status(msg, level))
|
self.after(0, lambda: self._set_status(msg, level))
|
||||||
self.after(0, lambda: self._test_btn.config(state="normal"))
|
self.after(0, self._unlock_buttons)
|
||||||
self.after(0, lambda: self._save_btn.config(state="normal"))
|
|
||||||
|
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()
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
@@ -226,14 +315,15 @@ class EmailSettingsView(tk.Toplevel):
|
|||||||
result = self._get_fields()
|
result = self._get_fields()
|
||||||
if result is None:
|
if result is None:
|
||||||
return
|
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
|
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)
|
recipients, send_time)
|
||||||
from models import log_action
|
from models import log_action
|
||||||
log_action(self.current_user["id"], "UPDATE_EMAIL_SETTINGS", "config",
|
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.")
|
show_info("Email settings saved successfully.")
|
||||||
logger.info(f"Email settings saved by {self.current_user['username']}.")
|
logger.info(f"Email settings saved by {self.current_user['username']}.")
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|||||||
@@ -581,7 +581,15 @@ class UserDashboardView(ttk.Frame):
|
|||||||
self._notify_job = self.after(60_000, self._schedule_notifications)
|
self._notify_job = self.after(60_000, self._schedule_notifications)
|
||||||
|
|
||||||
def _check_notifications(self):
|
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
|
from models import get_unchecked_sites_for_user
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
try:
|
try:
|
||||||
@@ -590,6 +598,10 @@ class UserDashboardView(ttk.Frame):
|
|||||||
return
|
return
|
||||||
|
|
||||||
now = dt.datetime.now().time()
|
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:
|
for site in unchecked:
|
||||||
end_time = site.get("end_time")
|
end_time = site.get("end_time")
|
||||||
if not end_time:
|
if not end_time:
|
||||||
@@ -606,20 +618,41 @@ class UserDashboardView(ttk.Frame):
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Compute minutes until shift end
|
|
||||||
now_mins = now.hour * 60 + now.minute
|
now_mins = now.hour * 60 + now.minute
|
||||||
end_mins = end_t.hour * 60 + end_t.minute
|
end_mins = end_t.hour * 60 + end_t.minute
|
||||||
diff = end_mins - now_mins
|
diff = end_mins - now_mins
|
||||||
|
|
||||||
key = (site["id"], end_t)
|
if 0 < diff <= NOTIFY_MINUTES_BEFORE:
|
||||||
if 0 < diff <= NOTIFY_MINUTES_BEFORE and key not in self._notified_sites:
|
if end_t not in groups:
|
||||||
self._notified_sites.add(key)
|
groups[end_t] = {"diff": diff, "names": []}
|
||||||
self._fire_notification(site["name"], diff, end_t)
|
groups[end_t]["names"].append(site["name"])
|
||||||
|
|
||||||
def _fire_notification(self, site_name: str, minutes_left: int, end_time):
|
# Fire one notification per end-time group that has not been notified yet
|
||||||
title = "Shift Reminder"
|
for end_t, info in groups.items():
|
||||||
message = (f"'{site_name}' is unchecked — "
|
key = (frozenset(info["names"]), end_t)
|
||||||
f"shift ends at {end_time.strftime('%H:%M')} "
|
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).")
|
f"({minutes_left} min remaining).")
|
||||||
logger.info(f"Notification: {message}")
|
logger.info(f"Notification: {message}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user