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():