From b055eea1c2f394f86f583ccfeb6f02aa0dbee084 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 27 May 2026 21:33:06 +0200 Subject: [PATCH] email: split digest renderer to digest_email.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit email_service.py was 428 lines covering three different concerns: SMTP transport, OTP/welcome rendering (tightly coupled — same brand template + theme), and digest rendering (a totally different shape of email, different layout, different copy cadence). The two halves changed at different cadences and made the file noisy to navigate. Extracted render_digest_email + _DIGEST_HTML_TEMPLATE + _strip_html_to_text to app/services/digest_email.py. SMTP transport and the OTP/welcome surface stay in email_service.py. Import sites updated: email_digest_job and test_email_render now import render_digest_email from digest_email. The OTP/welcome import sites (auth router, branding tests, test_email_service) are untouched. No behaviour change — pure relocation. Templates byte-identical. Co-Authored-By: Claude Opus 4.7 --- app/jobs/email_digest_job.py | 3 +- app/services/digest_email.py | 116 ++++++++++++++++++++++++++++++++++ app/services/email_service.py | 105 ------------------------------ tests/test_email_render.py | 2 +- 4 files changed, 119 insertions(+), 107 deletions(-) create mode 100644 app/services/digest_email.py diff --git a/app/jobs/email_digest_job.py b/app/jobs/email_digest_job.py index 0bad288..dc89e5b 100644 --- a/app/jobs/email_digest_job.py +++ b/app/jobs/email_digest_job.py @@ -29,7 +29,8 @@ from app.jobs._market_context import ( from app.models import EmailSend, User from app.routers.email import sign_unsubscribe_token from app.services.access import paid_status -from app.services.email_service import render_digest_email, send_email +from app.services.digest_email import render_digest_email +from app.services.email_service import send_email from app.services.i18n import ACTIVE_LANGUAGES from app.services.llm_prompts import ( PROMPT_VERSION, diff --git a/app/services/digest_email.py b/app/services/digest_email.py new file mode 100644 index 0000000..3d416f6 --- /dev/null +++ b/app/services/digest_email.py @@ -0,0 +1,116 @@ +"""Daily/weekly digest email rendering. + +Pure prose → HTML/text rendering. SMTP transport stays in +``email_service.send_email``; this module only assembles the message +body, subject, and a text-only fallback for clients without HTML +rendering. + +Split from email_service.py during the Tier 2 cleanup pass — the +SMTP/OTP/welcome surface and the digest renderer changed at very +different cadences and made the file noisy to navigate. +""" +from __future__ import annotations + +import html as _html_lib +import re as _re + +from app import branding + + +_DIGEST_HTML_TEMPLATE = """\ + + + + + + + {brand} — {label} + + + + + +
+
+ ▰ {brand_upper} · {label_upper} +
+
 
+
+ {content_html} +
+
 
+
+
 
+ +
+ + +""" + + +def _strip_html_to_text(html_body: str) -> str: + """Best-effort HTML → plain text for the multipart fallback. We don't + need perfection — just readable prose for clients that won't render + HTML.""" + text = _re.sub(r"(?i)<(/(p|h[1-6]|li|ul|ol)|br\s*/?)>", "\n", html_body) + text = _re.sub(r"<[^>]+>", "", text) + text = _html_lib.unescape(text) + text = _re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def render_digest_email( + *, + kind: str, + date_str: str, + content_html: str, + unsubscribe_url: str, + settings_url: str, +) -> tuple[str, str, str]: + """Returns (subject, text_body, html_body) for a digest email. + + `kind` is "daily" or "weekly". Anything else raises ValueError.""" + if kind == "daily": + label = "Daily" + subject = f"{branding.BRAND_NAME} · Daily — {date_str}" + elif kind == "weekly": + label = "Weekly recap" + subject = f"{branding.BRAND_NAME} · Weekly recap — {date_str}" + else: + raise ValueError(f"unknown digest kind: {kind!r}") + + html_body = _DIGEST_HTML_TEMPLATE.format( + brand=branding.BRAND_NAME, + brand_upper=branding.BRAND_NAME.upper(), + label=label, + label_upper=label.upper(), + FONT_MONO=branding.FONT_MONO, + content_html=content_html, + unsubscribe_url=unsubscribe_url, + settings_url=settings_url, + **{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()}, + **{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()}, + ) + + text_lines = [ + f"{branding.BRAND_NAME} — {label}", + date_str, + "", + _strip_html_to_text(content_html), + "", + f"Unsubscribe: {unsubscribe_url}", + f"Manage preferences: {settings_url}", + ] + text_body = "\n".join(text_lines) + return subject, text_body, html_body diff --git a/app/services/email_service.py b/app/services/email_service.py index d3ed9f7..8180ca6 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -18,8 +18,6 @@ convenient for local dev that doesn't want a mail server configured. """ from __future__ import annotations -import html as _html_lib -import re as _re from email.message import EmailMessage import aiosmtplib @@ -323,106 +321,3 @@ async def send_welcome_email(to: str) -> None: subject, text, html = render_welcome_email() await send_email(to, subject, text, html_body=html) - -# --------------------------------------------------------------------------- -# Digest email rendering -# --------------------------------------------------------------------------- - - -_DIGEST_HTML_TEMPLATE = """\ - - - - - - - {brand} — {label} - - - - - -
-
- ▰ {brand_upper} · {label_upper} -
-
 
-
- {content_html} -
-
 
-
-
 
- -
- - -""" - - -def _strip_html_to_text(html_body: str) -> str: - """Best-effort HTML → plain text for the multipart fallback. We don't - need perfection — just readable prose for clients that won't render - HTML.""" - text = _re.sub(r"(?i)<(/(p|h[1-6]|li|ul|ol)|br\s*/?)>", "\n", html_body) - text = _re.sub(r"<[^>]+>", "", text) - text = _html_lib.unescape(text) - text = _re.sub(r"\n{3,}", "\n\n", text) - return text.strip() - - -def render_digest_email( - *, - kind: str, - date_str: str, - content_html: str, - unsubscribe_url: str, - settings_url: str, -) -> tuple[str, str, str]: - """Returns (subject, text_body, html_body) for a digest email. - - `kind` is "daily" or "weekly". Anything else raises ValueError.""" - if kind == "daily": - label = "Daily" - subject = f"{branding.BRAND_NAME} · Daily — {date_str}" - elif kind == "weekly": - label = "Weekly recap" - subject = f"{branding.BRAND_NAME} · Weekly recap — {date_str}" - else: - raise ValueError(f"unknown digest kind: {kind!r}") - - html_body = _DIGEST_HTML_TEMPLATE.format( - brand=branding.BRAND_NAME, - brand_upper=branding.BRAND_NAME.upper(), - label=label, - label_upper=label.upper(), - FONT_MONO=branding.FONT_MONO, - content_html=content_html, - unsubscribe_url=unsubscribe_url, - settings_url=settings_url, - **{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()}, - **{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()}, - ) - - text_lines = [ - f"{branding.BRAND_NAME} — {label}", - date_str, - "", - _strip_html_to_text(content_html), - "", - f"Unsubscribe: {unsubscribe_url}", - f"Manage preferences: {settings_url}", - ] - text_body = "\n".join(text_lines) - return subject, text_body, html_body diff --git a/tests/test_email_render.py b/tests/test_email_render.py index f955066..35a130d 100644 --- a/tests/test_email_render.py +++ b/tests/test_email_render.py @@ -1,7 +1,7 @@ """Unit tests for render_digest_email.""" from __future__ import annotations -from app.services.email_service import render_digest_email +from app.services.digest_email import render_digest_email def test_daily_subject_and_bodies():