Files
ds-chat/backend/app/services/email_service.py
T
ksmithandClaude Sonnet 5 691f597597 Fix SMTP sending forcing implicit TLS regardless of port
cfg.use_tls was passed straight through as aiosmtplib's use_tls kwarg,
which means implicit TLS -- encrypted from the first byte, port 465's
convention. Port 587, what most providers (including the one that
surfaced this: DreamHost) document as their primary submission port,
needs STARTTLS instead -- a plaintext connection that upgrades in-band.
Forcing implicit TLS against a STARTTLS-only port breaks the handshake
outright: [SSL: WRONG_VERSION_NUMBER], a client TLS ClientHello sent to a
server still expecting a plaintext SMTP greeting.

The "Use TLS" checkbox still means "encrypt this connection" -- the fix
infers which of the two negotiation modes to use from the port (465 ->
implicit, everything else -> STARTTLS), matching the convention every
mail client uses. start_tls is passed as an explicit requirement rather
than left to aiosmtplib's opportunistic default, so a server that turns
out not to support STARTTLS fails loudly instead of silently sending in
plaintext despite the admin asking for encryption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 23:09:39 -06:00

84 lines
3.3 KiB
Python

import logging
from email.message import EmailMessage
import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
from app.crypto import decrypt
from app.models import SmtpSettings
from app.services.smtp_settings_service import get_smtp_settings
logger = logging.getLogger(__name__)
class SmtpNotConfiguredError(Exception):
pass
async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None:
"""Raises on failure -- internal helper only. Callers decide whether to
swallow (send_email) or surface (send_test_email) the error."""
message = EmailMessage()
message["From"] = cfg.from_address
message["To"] = to_address
message["Subject"] = subject
message.set_content(body)
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
# "Use TLS" means "encrypt this connection", but SMTP has two genuinely
# different ways to do that, and picking the wrong one breaks the
# handshake outright rather than just failing to encrypt -- aiosmtplib's
# own `use_tls` param means *implicit* TLS (encrypted from the first
# byte, port 465's convention); attempting that against a STARTTLS-only
# port produces exactly `[SSL: WRONG_VERSION_NUMBER]` (a client TLS
# ClientHello sent to a server still expecting a plaintext SMTP
# greeting). So the actual negotiation mode has to be inferred from the
# port, matching the convention every mail client uses: 465 is implicit
# TLS, everything else (587, 25, ...) is STARTTLS (plaintext connection,
# then upgrade). `start_tls=True` (rather than leaving it to
# aiosmtplib's opportunistic default) makes the requirement strict --
# if the server doesn't actually support STARTTLS, this fails loudly
# instead of silently sending in plaintext despite the admin asking for
# encryption.
use_implicit_tls = cfg.use_tls and cfg.port == 465
require_starttls = cfg.use_tls and cfg.port != 465
await aiosmtplib.send(
message,
hostname=cfg.host,
port=cfg.port,
username=cfg.username or None,
password=password,
use_tls=use_implicit_tls,
start_tls=require_starttls,
)
async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None:
"""Best-effort -- used by invite/notification flows. Never raises: an
SMTP outage or missing configuration must never block an action (an
invite, a room membership) that already succeeded in the database."""
cfg = await get_smtp_settings(db)
if cfg is None:
logger.debug("SMTP not configured; skipping email to %s", to_address)
return
try:
await _deliver(cfg, to_address, subject, body)
except Exception:
logger.warning("Failed to send email to %s", to_address, exc_info=True)
async def send_test_email(db: AsyncSession, to_address: str) -> None:
"""Used only by the admin 'send test email' button -- raises so the
admin UI can show why it failed instead of a silent no-op."""
cfg = await get_smtp_settings(db)
if cfg is None:
raise SmtpNotConfiguredError()
await _deliver(
cfg,
to_address,
"DS Chat test email",
"This is a test email from DS Chat to confirm your SMTP settings are working.",
)