Email: add explicit SMTP security mode

This commit is contained in:
mechramc
2026-06-01 23:15:06 -05:00
committed by GitHub
parent ccc0b9ab0c
commit 9d0a18a5b5
6 changed files with 195 additions and 40 deletions

View File

@@ -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."""

View File

@@ -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

View File

@@ -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", "")),

View File

@@ -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}

View File

@@ -2594,6 +2594,7 @@ async function initEmailAccountsSettings() {
const _providerOptions = Object.entries(PROVIDERS)
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
.join('');
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
formEl.innerHTML = `
<h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3>
<div class="settings-col">
@@ -2609,6 +2610,7 @@ async function initEmailAccountsSettings() {
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
<div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="eaf-smtp-port" class="settings-input" type="number" value="${esc(a.smtp_port || 465)}" style="max-width:100px"></div>
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="eaf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (this is right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-smtp-same" ${(!isEdit || (a.smtp_user && a.imap_user && a.smtp_user === a.imap_user)) ? 'checked' : ''}><span class="admin-slider"></span></label></div>
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="eaf-smtp-user" class="settings-input" value="${esc(a.smtp_user || '')}"></div>
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div>
@@ -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]) => `<option value="${k}">${esc(v.label)}</option>`).join('');
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2>
@@ -3698,6 +3704,7 @@ async function initUnifiedIntegrations() {
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
<div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="uf-smtp-port" class="settings-input" type="number" placeholder="465" style="max-width:100px"></div>
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-smtp-same" checked><span class="admin-slider"></span></label></div>
<div class="settings-row uf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="uf-smtp-user" class="settings-input"></div>
<div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
@@ -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,
};

View File

@@ -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)