diff --git a/core/database.py b/core/database.py index 7fcc0f3..8f21f53 100644 --- a/core/database.py +++ b/core/database.py @@ -298,6 +298,7 @@ class EmailAccount(TimestampMixin, Base): # SMTP (sending) smtp_host = Column(String, default="") smtp_port = Column(Integer, default=465) + smtp_security = Column(String, default="ssl") # ssl | starttls | none smtp_user = Column(String, default="") smtp_password = Column(String, default="") @@ -1517,6 +1518,7 @@ def init_db(): _migrate_drop_ping_notes_tasks() _migrate_add_crew_member_id() _migrate_add_assistant_columns() + _migrate_add_email_smtp_security() _migrate_seed_email_account() _migrate_add_calendar_metadata() _migrate_add_calendar_is_utc() @@ -1525,6 +1527,32 @@ def init_db(): _migrate_encrypt_endpoint_keys() +def _migrate_add_email_smtp_security(): + """Add explicit SMTP security mode for Proton Bridge/custom local SMTP.""" + import sqlite3 + db_path = DATABASE_URL.replace("sqlite:///", "") + if not os.path.exists(db_path): + return + try: + conn = sqlite3.connect(db_path) + cursor = conn.execute("PRAGMA table_info(email_accounts)") + columns = [row[1] for row in cursor.fetchall()] + if columns and "smtp_security" not in columns: + conn.execute("ALTER TABLE email_accounts ADD COLUMN smtp_security TEXT DEFAULT 'ssl'") + conn.execute( + "UPDATE email_accounts SET smtp_security = CASE " + "WHEN COALESCE(smtp_port, 465) = 587 THEN 'starttls' " + "WHEN COALESCE(smtp_port, 465) = 465 THEN 'ssl' " + "ELSE 'ssl' END " + "WHERE smtp_security IS NULL OR smtp_security = ''" + ) + conn.commit() + logging.getLogger(__name__).info("Migrated: added smtp_security column to email_accounts") + conn.close() + except Exception as e: + logging.getLogger(__name__).warning(f"smtp_security migration skipped: {e}") + + def _migrate_encrypt_endpoint_keys(): """Encrypt any plaintext provider API keys in model_endpoints. Idempotent; raw SQL so the EncryptedText decorator isn't applied twice.""" diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py index bde4307..ed98ccc 100644 --- a/mcp_servers/email_server.py +++ b/mcp_servers/email_server.py @@ -70,10 +70,12 @@ def _list_accounts_raw() -> list: try: conn = sqlite3.connect(str(path)) conn.row_factory = sqlite3.Row - rows = conn.execute(""" + columns = {r[1] for r in conn.execute("PRAGMA table_info(email_accounts)").fetchall()} + smtp_security_select = "smtp_security" if "smtp_security" in columns else "'' AS smtp_security" + rows = conn.execute(f""" SELECT id, name, is_default, enabled, imap_host, imap_port, imap_user, imap_password, imap_starttls, - smtp_host, smtp_port, smtp_user, smtp_password, from_address + smtp_host, smtp_port, {smtp_security_select}, smtp_user, smtp_password, from_address FROM email_accounts WHERE enabled = 1 ORDER BY is_default DESC, created_at ASC """).fetchall() @@ -145,6 +147,7 @@ def _load_config(account: str | None = None) -> dict: "imap_starttls": os.environ.get("IMAP_STARTTLS", "true").lower() == "true", "smtp_host": os.environ.get("SMTP_HOST", ""), "smtp_port": int(os.environ.get("SMTP_PORT", "465")), + "smtp_security": os.environ.get("SMTP_SECURITY", ""), "smtp_user": os.environ.get("SMTP_USER", ""), "smtp_password": os.environ.get("SMTP_PASSWORD", ""), "smtp_starttls": os.environ.get("SMTP_STARTTLS", "false").lower() == "true", @@ -189,6 +192,7 @@ def _load_config(account: str | None = None) -> dict: cfg["imap_ssl"] = int(cfg["imap_port"]) == 993 and not cfg["imap_starttls"] cfg["smtp_host"] = row["smtp_host"] or cfg["smtp_host"] cfg["smtp_port"] = int(row["smtp_port"] or cfg["smtp_port"]) + cfg["smtp_security"] = row["smtp_security"] or cfg["smtp_security"] or ("starttls" if int(cfg["smtp_port"]) == 587 else "ssl") cfg["smtp_user"] = row["smtp_user"] or cfg["smtp_user"] cfg["smtp_password"] = _decrypt(row["smtp_password"]) if row["smtp_password"] else cfg["smtp_password"] cfg["from_address"] = row["from_address"] or row["imap_user"] or cfg["from_address"] @@ -739,17 +743,17 @@ def _smtp_connect(account=None, cfg=None): if not _smtp_ready(cfg): raise ValueError(f"Email account {cfg.get('account_name') or account or 'default'} has no SMTP configured") port = int(cfg.get("smtp_port") or 465) - # Account rows only store host/port, not the legacy env-level smtp_ssl - # toggle. Infer the conventional TLS mode from the port so MCP tools match - # the web send path: 465 = implicit SSL, 587 = STARTTLS. - if port == 587: + security = str(cfg.get("smtp_security") or "").strip().lower() + if security not in {"ssl", "starttls", "none"}: + security = "starttls" if port == 587 else "ssl" + if security == "starttls": conn = smtplib.SMTP( cfg["smtp_host"], port, timeout=EMAIL_SOCKET_TIMEOUT, ) conn.starttls() - elif cfg.get("smtp_ssl", True): + elif security == "ssl": conn = smtplib.SMTP_SSL( cfg["smtp_host"], port, @@ -761,8 +765,6 @@ def _smtp_connect(account=None, cfg=None): port, timeout=EMAIL_SOCKET_TIMEOUT, ) - if cfg["smtp_starttls"]: - conn.starttls() if cfg["smtp_user"] and cfg["smtp_password"]: conn.login(cfg["smtp_user"], cfg["smtp_password"]) return conn diff --git a/routes/email_helpers.py b/routes/email_helpers.py index cf02ced..26d1e0a 100644 --- a/routes/email_helpers.py +++ b/routes/email_helpers.py @@ -15,7 +15,6 @@ and `email_pollers.py` (the background loops): import os import imaplib import smtplib -import ssl import email as email_mod import email.header import email.utils @@ -39,41 +38,37 @@ from src.secret_storage import decrypt as _decrypt logger = logging.getLogger(__name__) -def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None: - """Send through SMTP using the conventional TLS mode for the configured port. +def _smtp_security_mode(cfg: dict) -> str: + raw = str(cfg.get("smtp_security") or "").strip().lower() + if raw in {"ssl", "starttls", "none"}: + return raw + port = int(cfg.get("smtp_port") or 465) + if port == 587: + return "starttls" + return "ssl" - Account settings only store host/port today. Port 465 is implicit TLS - (SMTP_SSL); port 587 is plain SMTP upgraded with STARTTLS. Using SSL - directly against 587 raises the classic "[SSL: WRONG_VERSION_NUMBER]" - error even when credentials are correct. - """ + +def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None: + """Send through SMTP using the configured transport security mode.""" host = cfg["smtp_host"] port = int(cfg.get("smtp_port") or 465) user = cfg.get("smtp_user") or "" password = cfg.get("smtp_password") or "" - def _send_starttls(starttls_port: int = 587) -> None: - with smtplib.SMTP(host, starttls_port, timeout=timeout) as smtp: - smtp.starttls() - if user and password: - smtp.login(user, password) - smtp.sendmail(from_addr, recipients, message) + security = _smtp_security_mode(cfg) - if port == 587: - _send_starttls(587) - return - - try: + if security == "ssl": with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp: if user and password: smtp.login(user, password) smtp.sendmail(from_addr, recipients, message) return - except (TimeoutError, ssl.SSLError) as e: - if port == 465: - logger.warning("SMTP implicit TLS on %s:465 failed (%s); retrying STARTTLS on 587", host, e) - _send_starttls(587) - return - raise + + with smtplib.SMTP(host, port, timeout=timeout) as smtp: + if security == "starttls": + smtp.starttls() + if user and password: + smtp.login(user, password) + smtp.sendmail(from_addr, recipients, message) def _strip_think(text: str) -> str: @@ -543,6 +538,7 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict: "account_name": row.name, "smtp_host": row.smtp_host or "", "smtp_port": int(row.smtp_port or 465), + "smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}), "smtp_user": row.smtp_user or "", "smtp_password": _decrypt(row.smtp_password or ""), "imap_host": row.imap_host or "", @@ -569,6 +565,10 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict: "account_name": "legacy", "smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")), "smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465), + "smtp_security": _smtp_security_mode({ + "smtp_security": settings.get("smtp_security", os.environ.get("SMTP_SECURITY", "")), + "smtp_port": settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")), + }), "smtp_user": settings.get("smtp_user", os.environ.get("SMTP_USER", "")), "smtp_password": settings.get("smtp_password", os.environ.get("SMTP_PASSWORD", "")), "imap_host": settings.get("imap_host", os.environ.get("IMAP_HOST", "")), diff --git a/routes/email_routes.py b/routes/email_routes.py index 8b82aa5..24f085b 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -40,7 +40,7 @@ from routes.email_helpers import ( _strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account, _q, _attach_compose_uploads, _cleanup_compose_uploads, _load_settings, _save_settings, _get_email_config, - _send_smtp_message, + _send_smtp_message, _smtp_security_mode, _imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder, _extract_attachment_text, _list_attachments_from_msg, _extract_attachment_to_disk, _extract_html, _extract_text, @@ -2146,6 +2146,7 @@ def setup_email_routes(): _from = cfg["from_address"] _smtp_host = cfg["smtp_host"] _smtp_port = cfg["smtp_port"] + _smtp_security = cfg.get("smtp_security") _smtp_user = cfg["smtp_user"] _smtp_pw = cfg["smtp_password"] _recipients = list(recipients) @@ -2163,6 +2164,7 @@ def setup_email_routes(): { "smtp_host": _smtp_host, "smtp_port": _smtp_port, + "smtp_security": _smtp_security, "smtp_user": _smtp_user, "smtp_password": _smtp_pw, }, @@ -2820,7 +2822,7 @@ def setup_email_routes(): db.add(row) field_map = { "smtp_host": "smtp_host", "smtp_port": "smtp_port", "smtp_user": "smtp_user", - "imap_host": "imap_host", "imap_port": "imap_port", "imap_user": "imap_user", + "smtp_security": "smtp_security", "imap_host": "imap_host", "imap_port": "imap_port", "imap_user": "imap_user", "imap_starttls": "imap_starttls", "email_from": "from_address", } for in_key, col_name in field_map.items(): @@ -2902,6 +2904,7 @@ def setup_email_routes(): "imap_starttls": bool(r.imap_starttls), "smtp_host": r.smtp_host or "", "smtp_port": int(r.smtp_port or 465), + "smtp_security": _smtp_security_mode({"smtp_security": getattr(r, "smtp_security", ""), "smtp_port": r.smtp_port}), "smtp_user": r.smtp_user or "", "from_address": r.from_address or "", "has_imap_password": bool(r.imap_password), @@ -2934,6 +2937,7 @@ def setup_email_routes(): imap_starttls=bool(data.get("imap_starttls", True)), smtp_host=(data.get("smtp_host") or "").strip(), smtp_port=int(data.get("smtp_port") or 465), + smtp_security=_smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": data.get("smtp_port") or 465}), smtp_user=(data.get("smtp_user") or "").strip(), smtp_password=_enc(data.get("smtp_password") or ""), from_address=(data.get("from_address") or "").strip(), @@ -2977,6 +2981,8 @@ def setup_email_routes(): for key in ("imap_port", "smtp_port"): if data.get(key) not in (None, ""): setattr(row, key, int(data[key])) + if "smtp_security" in data: + row.smtp_security = _smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": data.get("smtp_port") or row.smtp_port}) for key in ("imap_starttls", "enabled"): if key in data: setattr(row, key, bool(data[key])) @@ -3061,6 +3067,7 @@ def setup_email_routes(): "imap_starttls": bool(row.imap_starttls), "smtp_host": row.smtp_host or "", "smtp_port": row.smtp_port or 465, + "smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}), "smtp_user": row.smtp_user or "", "smtp_password": _decrypt(row.smtp_password or ""), } @@ -3112,14 +3119,16 @@ def setup_email_routes(): smtp_host = (body.get("smtp_host") or "").strip() if smtp_host: smtp_port = int(body.get("smtp_port") or 465) + smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}) smtp_user = (body.get("smtp_user") or imap_user).strip() smtp_pass = body.get("smtp_password") or imap_pass try: - if smtp_port == 587: - smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10) - smtp.starttls() - else: + if smtp_security == "ssl": smtp = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) + else: + smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10) + if smtp_security == "starttls": + smtp.starttls() try: smtp.login(smtp_user, smtp_pass) smtp_result = {"ok": True} diff --git a/static/js/settings.js b/static/js/settings.js index 1677947..27febf7 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2594,6 +2594,7 @@ async function initEmailAccountsSettings() { const _providerOptions = Object.entries(PROVIDERS) .map(([k, v]) => ``) .join(''); + const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl'); formEl.innerHTML = `

${isEdit ? 'Edit Account' : 'New Account'}

@@ -2609,6 +2610,7 @@ async function initEmailAccountsSettings() {
SMTP (Sending) — optional, leave blank for read-only
+
@@ -2635,7 +2637,9 @@ async function initEmailAccountsSettings() { el('eaf-imap-starttls').checked = !!p.imap.starttls; el('eaf-smtp-host').value = p.smtp.host; el('eaf-smtp-port').value = p.smtp.port; + el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl'); }); + el('eaf-smtp-security').value = _smtpSecurity(a); // "Same as IMAP" toggle — hide the SMTP creds rows when on. The save // handler copies the IMAP user/password into SMTP at submit time. @@ -2659,6 +2663,7 @@ async function initEmailAccountsSettings() { imap_starttls: el('eaf-imap-starttls').checked, smtp_host: el('eaf-smtp-host').value.trim(), smtp_port: parseInt(el('eaf-smtp-port').value) || 465, + smtp_security: el('eaf-smtp-security').value, smtp_user: el('eaf-smtp-user').value.trim(), }; if (el('eaf-imap-pass').value) body.imap_password = el('eaf-imap-pass').value; @@ -3681,6 +3686,7 @@ async function initUnifiedIntegrations() { }; const _providerOptions = Object.entries(PROVIDERS) .map(([k, v]) => ``).join(''); + const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl'); formEl.innerHTML = `

${isEdit ? 'Edit' : 'Add'} Email Account

@@ -3698,6 +3704,7 @@ async function initUnifiedIntegrations() {
SMTP (Sending) — optional, leave blank for read-only
+
@@ -3824,6 +3831,7 @@ async function initUnifiedIntegrations() { el('uf-imap-starttls').checked = !!p.imap.starttls; el('uf-smtp-host').value = p.smtp.host; el('uf-smtp-port').value = p.smtp.port; + el('uf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl'); if (p.emailEx) { el('uf-email-from').placeholder = p.emailEx; el('uf-imap-user').placeholder = p.emailEx; @@ -3849,6 +3857,7 @@ async function initUnifiedIntegrations() { el('uf-imap-starttls').checked = existing.imap_starttls !== false; el('uf-smtp-host').value = existing.smtp_host || ''; el('uf-smtp-port').value = existing.smtp_port || 465; + el('uf-smtp-security').value = _smtpSecurity(existing); el('uf-smtp-user').value = existing.smtp_user || ''; el('uf-email-default').checked = !!existing.is_default; // If the saved SMTP user matches the IMAP user, keep the "Same as @@ -3860,6 +3869,7 @@ async function initUnifiedIntegrations() { } else { el('uf-imap-port').value = 993; el('uf-smtp-port').value = 465; + el('uf-smtp-security').value = 'ssl'; } el('uf-email-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); @@ -3895,6 +3905,7 @@ async function initUnifiedIntegrations() { imap_starttls: el('uf-imap-starttls').checked, smtp_host: el('uf-smtp-host').value.trim(), smtp_port: parseInt(el('uf-smtp-port').value) || 465, + smtp_security: el('uf-smtp-security').value, smtp_user: el('uf-smtp-user').value.trim(), is_default: el('uf-email-default').checked, }; diff --git a/tests/test_email_smtp_security.py b/tests/test_email_smtp_security.py new file mode 100644 index 0000000..590a5e6 --- /dev/null +++ b/tests/test_email_smtp_security.py @@ -0,0 +1,105 @@ +import os +import tempfile +from pathlib import Path + +_tmp_data = Path(tempfile.mkdtemp(prefix="odysseus-email-smtp-test-")) +os.environ.setdefault("DATA_DIR", str(_tmp_data)) +os.environ.setdefault("DATABASE_URL", f"sqlite:///{_tmp_data / 'app.db'}") + +from routes.email_helpers import _send_smtp_message + + +class _FakeSMTP: + calls = [] + + def __init__(self, host, port, timeout=None): + self.host = host + self.port = port + self.timeout = timeout + self.starttls_called = False + _FakeSMTP.calls.append(("connect", self.__class__.__name__, host, port)) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def starttls(self): + self.starttls_called = True + _FakeSMTP.calls.append(("starttls", self.host, self.port)) + + def login(self, user, password): + _FakeSMTP.calls.append(("login", user, password)) + + def sendmail(self, from_addr, recipients, message): + _FakeSMTP.calls.append(("sendmail", from_addr, tuple(recipients), message, self.starttls_called)) + + +class _FakeSMTPSSL(_FakeSMTP): + pass + + +def _cfg(security, port=2525): + return { + "smtp_host": "smtp.local", + "smtp_port": port, + "smtp_security": security, + "smtp_user": "user", + "smtp_password": "pw", + } + + +def test_send_smtp_message_supports_plain_smtp(monkeypatch): + import routes.email_helpers as helpers + + _FakeSMTP.calls = [] + monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP) + monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL) + + _send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello") + + assert _FakeSMTP.calls[0] == ("connect", "_FakeSMTP", "smtp.local", 2525) + assert not any(call[0] == "starttls" for call in _FakeSMTP.calls) + assert _FakeSMTP.calls[-1] == ("sendmail", "from@example.com", ("to@example.com",), "hello", False) + + +def test_send_smtp_message_supports_explicit_starttls(monkeypatch): + import routes.email_helpers as helpers + + _FakeSMTP.calls = [] + monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP) + monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL) + + _send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello") + + assert _FakeSMTP.calls[0] == ("connect", "_FakeSMTP", "smtp.local", 2525) + assert ("starttls", "smtp.local", 2525) in _FakeSMTP.calls + assert _FakeSMTP.calls[-1] == ("sendmail", "from@example.com", ("to@example.com",), "hello", True) + + +def test_send_smtp_message_defaults_587_to_starttls(monkeypatch): + import routes.email_helpers as helpers + + _FakeSMTP.calls = [] + monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP) + monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL) + + cfg = _cfg("", port=587) + _send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello") + + assert _FakeSMTP.calls[0] == ("connect", "_FakeSMTP", "smtp.local", 587) + assert ("starttls", "smtp.local", 587) in _FakeSMTP.calls + + +def test_send_smtp_message_uses_ssl_when_configured(monkeypatch): + import routes.email_helpers as helpers + + _FakeSMTP.calls = [] + monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP) + monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL) + + _send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello") + + assert _FakeSMTP.calls[0] == ("connect", "_FakeSMTPSSL", "smtp.local", 465) + assert not any(call[0] == "starttls" for call in _FakeSMTP.calls)