diff --git a/app/services/email_service.py b/app/services/email_service.py
index 274e526..8546182 100644
--- a/app/services/email_service.py
+++ b/app/services/email_service.py
@@ -18,6 +18,8 @@ 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
@@ -196,3 +198,107 @@ def render_otp_email(code: str, ttl_minutes: int) -> tuple[str, str, str]:
async def send_otp(to: str, code: str, ttl_minutes: int) -> None:
subject, text, html = render_otp_email(code, ttl_minutes)
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
new file mode 100644
index 0000000..f955066
--- /dev/null
+++ b/tests/test_email_render.py
@@ -0,0 +1,44 @@
+"""Unit tests for render_digest_email."""
+from __future__ import annotations
+
+from app.services.email_service import render_digest_email
+
+
+def test_daily_subject_and_bodies():
+ subj, text, html = render_digest_email(
+ kind="daily",
+ date_str="2026-05-25",
+ content_html="Markets did stuff today.
",
+ unsubscribe_url="https://read.markets/email/unsubscribe?token=abc",
+ settings_url="https://read.markets/settings",
+ )
+ assert "Daily" in subj
+ assert "2026-05-25" in subj
+ assert "Markets did stuff today" in html
+ assert "abc" in html # unsubscribe link landed
+ assert "/settings" in html
+ # Plain-text fallback strips HTML.
+ assert "" not in text
+ assert "Markets did stuff today" in text
+
+
+def test_weekly_subject_says_recap():
+ subj, _, _ = render_digest_email(
+ kind="weekly",
+ date_str="2026-05-25",
+ content_html="
x
",
+ unsubscribe_url="https://x/u",
+ settings_url="https://x/s",
+ )
+ assert "Weekly" in subj
+ assert "recap" in subj.lower()
+
+
+def test_invalid_kind_raises():
+ import pytest
+ with pytest.raises(ValueError):
+ render_digest_email(
+ kind="bogus", date_str="2026-05-25",
+ content_html="x
",
+ unsubscribe_url="u", settings_url="s",
+ )