Private
Public Access
A live "no emails arriving" report produced literally nothing in the logs, not even the SMTP-not-configured line -- this app has no logging config lowering the root level below Python's own WARNING default, so every .debug()/.info() call has been silently invisible in production all along. Bumped the SMTP-not-configured line to .warning, and added one at each early-return in _maybe_email_dm_notification (no other participant, recipient online, already has unread messages) plus a confirmation right before actually sending -- next attempt will show exactly which branch is being hit instead of nothing at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
254 lines
10 KiB
Python
254 lines
10 KiB
Python
import html
|
|
import logging
|
|
from email.message import EmailMessage
|
|
|
|
import aiosmtplib
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.crypto import decrypt
|
|
from app.models import CustomTheme, SmtpSettings, User
|
|
from app.services.smtp_settings_service import get_smtp_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SmtpNotConfiguredError(Exception):
|
|
pass
|
|
|
|
|
|
# #68: the 6 tokens actually used by the email template below, out of the
|
|
# full ~12-token palette themes.css defines per preset -- an email has no
|
|
# equivalent of --ds-surface-2/--ds-void-2/--ds-highlight/--ds-danger, it's
|
|
# one card on one background with one accent. Kept in sync by hand with
|
|
# frontend/src/styles/tokens.css (the default) and themes.css (the other
|
|
# three presets) -- there's no way to share the source of truth across the
|
|
# Python/CSS boundary, so if either changes the other needs updating too.
|
|
DEFAULT_PALETTE = {
|
|
"void": "#07080f",
|
|
"surface": "#101030",
|
|
"border": "#242478",
|
|
"text": "#fce4fc",
|
|
"muted": "#c0ccd8",
|
|
"accent": "#60d8fc",
|
|
}
|
|
_PRESET_PALETTES: dict[str, dict[str, str]] = {
|
|
"dark": DEFAULT_PALETTE,
|
|
"light": {
|
|
"void": "#f5f3fb",
|
|
"surface": "#ffffff",
|
|
"border": "#d8d2ee",
|
|
"text": "#1a1030",
|
|
"muted": "#675f80",
|
|
"accent": "#0891b2",
|
|
},
|
|
"midnight": {
|
|
"void": "#000000",
|
|
"surface": "#0a0a14",
|
|
"border": "#262640",
|
|
"text": "#ffffff",
|
|
"muted": "#a8b0c0",
|
|
"accent": "#00f0ff",
|
|
},
|
|
"sunset": {
|
|
"void": "#120a07",
|
|
"surface": "#241408",
|
|
"border": "#4a2c14",
|
|
"text": "#fce8d8",
|
|
"muted": "#c8b0a0",
|
|
"accent": "#fca050",
|
|
},
|
|
}
|
|
|
|
|
|
async def _resolve_palette(db: AsyncSession, theme_user: User | None) -> dict[str, str]:
|
|
"""#68: an email addressed to an existing user is styled with *their*
|
|
selected theme (mirroring the app itself), not a fixed look -- but
|
|
there's no such thing as "their theme" for someone who doesn't have an
|
|
account yet (site invites), so theme_user is None there and this falls
|
|
back to the default DarkSingularity palette, same as a logged-out page.
|
|
"""
|
|
if theme_user is None or theme_user.theme is None:
|
|
return DEFAULT_PALETTE
|
|
if theme_user.theme == "custom":
|
|
if theme_user.active_custom_theme_id is not None:
|
|
# A fresh PK fetch, not `theme_user.active_custom_theme` --
|
|
# that relationship is essentially never eager-loaded by
|
|
# whatever query got this User row in the first place, and
|
|
# touching it lazily here would raise MissingGreenlet in
|
|
# async SQLAlchemy.
|
|
custom = await db.get(CustomTheme, theme_user.active_custom_theme_id)
|
|
if custom is not None:
|
|
colors = custom.colors
|
|
return {key: colors[key] for key in DEFAULT_PALETTE}
|
|
return DEFAULT_PALETTE
|
|
return _PRESET_PALETTES.get(theme_user.theme, DEFAULT_PALETTE)
|
|
|
|
|
|
def _render_text(paragraphs: list[str], cta_label: str | None, cta_url: str | None) -> str:
|
|
body = "\n\n".join(paragraphs)
|
|
if cta_label and cta_url:
|
|
body += f"\n\n{cta_label}: {cta_url}"
|
|
return body
|
|
|
|
|
|
def _render_html(
|
|
palette: dict[str, str],
|
|
subject: str,
|
|
paragraphs: list[str],
|
|
cta_label: str | None,
|
|
cta_url: str | None,
|
|
) -> str:
|
|
# Table-based layout with everything inlined -- not the app's own CSS
|
|
# custom properties (email clients strip <style> blocks and don't
|
|
# support :root variables), just their resolved hex values baked in
|
|
# per send. Deliberately plain: one card, one accent color, no imagery
|
|
# that could get blocked by a client's "show images" gate and leave
|
|
# the email looking broken instead of just plain.
|
|
paragraphs_html = "".join(
|
|
f'<p style="margin:0 0 16px;color:{palette["muted"]};font-size:15px;'
|
|
f'line-height:1.6;">{html.escape(p)}</p>'
|
|
for p in paragraphs
|
|
)
|
|
cta_html = ""
|
|
if cta_label and cta_url:
|
|
cta_html = f"""
|
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">
|
|
<tr>
|
|
<td style="border-radius:8px;background:{palette["accent"]};">
|
|
<a href="{html.escape(cta_url)}" style="display:inline-block;padding:12px 22px;
|
|
font-size:15px;font-weight:700;color:{palette["void"]};text-decoration:none;
|
|
border-radius:8px;">{html.escape(cta_label)}</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
"""
|
|
return f"""<!doctype html>
|
|
<html>
|
|
<body style="margin:0;padding:0;background:{palette["void"]};">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
|
style="background:{palette["void"]};padding:32px 16px;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="480" cellpadding="0" cellspacing="0"
|
|
style="max-width:480px;width:100%;background:{palette["surface"]};
|
|
border:1px solid {palette["border"]};border-radius:12px;padding:32px;
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
|
<tr>
|
|
<td>
|
|
<div style="font-size:13px;font-weight:800;letter-spacing:0.06em;
|
|
text-transform:uppercase;color:{palette["accent"]};margin:0 0 20px;">DS Chat</div>
|
|
<h1 style="margin:0 0 16px;font-size:20px;font-weight:800;
|
|
color:{palette["text"]};">{html.escape(subject)}</h1>
|
|
{paragraphs_html}
|
|
{cta_html}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
async def _deliver(
|
|
cfg: SmtpSettings, to_address: str, subject: str, html_body: str, text_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
|
|
# Plain-text part first, HTML as the alternative -- standard
|
|
# multipart/alternative ordering (least to most preferred), so a
|
|
# client with no HTML support (or a spam filter) still gets a normal
|
|
# readable email instead of raw markup.
|
|
message.set_content(text_body)
|
|
message.add_alternative(html_body, subtype="html")
|
|
|
|
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,
|
|
paragraphs: list[str],
|
|
*,
|
|
cta_label: str | None = None,
|
|
cta_url: str | None = None,
|
|
theme_user: User | None = None,
|
|
) -> 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.
|
|
|
|
`paragraphs` replaces the old flat `body: str` (#68) -- each entry
|
|
renders as its own paragraph in both the HTML and plain-text parts,
|
|
which a single pre-formatted string can't cleanly become HTML from
|
|
without re-parsing it. `theme_user`, when given, styles the email with
|
|
that user's own selected theme (default palette if they haven't picked
|
|
one, or don't have an account at all -- see _resolve_palette).
|
|
"""
|
|
cfg = await get_smtp_settings(db)
|
|
if cfg is None:
|
|
# WARNING, not .debug -- this app has no logging config lowering
|
|
# the root level below Python's own WARNING default, so anything
|
|
# below that is silently invisible in production (confirmed live:
|
|
# a real "no emails arriving" report produced nothing in the logs
|
|
# at all, this line included, even though it was relevant).
|
|
logger.warning("SMTP not configured; skipping email to %s", to_address)
|
|
return
|
|
palette = await _resolve_palette(db, theme_user)
|
|
html_body = _render_html(palette, subject, paragraphs, cta_label, cta_url)
|
|
text_body = _render_text(paragraphs, cta_label, cta_url)
|
|
try:
|
|
await _deliver(cfg, to_address, subject, html_body, text_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, theme_user: User | None = None) -> 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. theme_user
|
|
is the admin themselves (see routers/admin.py) -- the preview shows
|
|
them their own emails' real look, not a generic default."""
|
|
cfg = await get_smtp_settings(db)
|
|
if cfg is None:
|
|
raise SmtpNotConfiguredError()
|
|
subject = "DS Chat test email"
|
|
paragraphs = ["This is a test email from DS Chat to confirm your SMTP settings are working."]
|
|
palette = await _resolve_palette(db, theme_user)
|
|
html_body = _render_html(palette, subject, paragraphs, None, None)
|
|
text_body = _render_text(paragraphs, None, None)
|
|
await _deliver(cfg, to_address, subject, html_body, text_body)
|