Coverage for app/backend/src/couchers/email/dump_emails.py: 86%
78 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 21:43 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 21:43 +0000
1"""
2Dumps emails subjects and html/plaintext bodies with dummy data in every supported
3locale, plus a browsable HTML index with a locale selector and expandable previews.
5All bodies are gzipped and inlined, so the index is a single self-contained page.
6"""
8import base64
9import gzip
10import inspect
11import json
12import re
13import shutil
14import sys
15from argparse import ArgumentParser
16from dataclasses import dataclass
17from datetime import UTC
18from pathlib import Path
20from markupsafe import Markup
22import couchers.email.emails
23from couchers.email.blocks import (
24 EmailBase,
25 EmailFooter,
26 UnsubscribeInfo,
27 UnsubscribeLink,
28)
29from couchers.email.rendering import render_email, template_folder
30from couchers.i18n import LocalizationContext
31from couchers.i18n.locales import DEFAULT_LOCALE, get_locales_with_translations
32from couchers.templating import Jinja2Template
35@dataclass
36class CommandLineArgs:
37 filter: str
38 outdir: Path
39 locales: str
41 @staticmethod
42 def parse(args: list[str]) -> CommandLineArgs:
43 parser = ArgumentParser(description=__doc__)
44 parser.add_argument("--filter", type=str, default="*", help="A filter for email classes to dump.")
45 parser.add_argument(
46 "--outdir", type=Path, default=template_folder, help="The directory to write email bodies to."
47 )
48 parser.add_argument(
49 "--locales",
50 type=str,
51 default="all",
52 help='Comma-separated locales to render, or "all" for every supported locale.',
53 )
54 parsed_args = parser.parse_args(args)
55 return CommandLineArgs(**parsed_args.__dict__)
58@dataclass
59class RenderedVariation:
60 email_class: str
61 variation: int
62 variation_count: int
63 subjects: dict[str, str] # locale -> subject line
64 name: str
67def _ordered_locales(locales: list[str] | None) -> list[str]:
68 if locales is None:
69 locales = get_locales_with_translations()
70 return sorted(locales, key=lambda locale: (locale != DEFAULT_LOCALE, locale))
73def dump_all(outdir: Path, *, filter_glob: str = "*", locales: list[str] | None = None) -> list[RenderedVariation]:
74 """Dumps all emails matching the filter into a single self-contained outdir/index.html,
75 a browsable index with a locale selector and expandable previews.
77 Requires the relevant config (e.g. BASE_URL) to be available, as when run inside the
78 test harness or with the deployment environment loaded.
79 """
80 locales = _ordered_locales(locales)
81 footer = EmailFooter(
82 timezone_name="UTC",
83 unsubscribe_info=UnsubscribeInfo(
84 manage_notifications_url="https://example.com/manage-notifications",
85 do_not_email_url="https://example.com/do-not-email",
86 topic_action_link=UnsubscribeLink(text="topic-action", url="https://example.com/unsubscribe"),
87 ),
88 )
89 filter_regex = re.compile(re.escape(filter_glob).replace(r"\*", ".*?"))
91 rendered: list[RenderedVariation] = []
92 bodies: dict[str, dict[str, dict[str, str]]] = {locale: {} for locale in locales}
93 # Iterate over all email classes and dump their test instances if they match the filter
94 for _, klass in inspect.getmembers(couchers.email.emails, lambda o: inspect.isclass(o) and o.__base__ == EmailBase):
95 email_class: type[EmailBase] = klass
96 if filter_regex.fullmatch(email_class.__name__): 96 ↛ 94line 96 didn't jump to line 94 because the condition on line 96 was always true
97 test_instances = email_class.test_instances()
98 for i in range(len(test_instances)):
99 name = email_class.__name__
100 if len(test_instances) > 1:
101 name += f"_{i}"
102 print(f"Dumping email class {email_class.__name__} ({len(locales)} locale(s))")
103 subjects = {}
104 for locale in locales:
105 loc_context = LocalizationContext(locale=locale, timezone=UTC)
106 email = render_email(test_instances[i], footer, loc_context, embed_images=False)
107 subjects[locale] = email.subject
108 bodies[locale][name] = {"html": email.body_html, "txt": email.body_plaintext}
109 rendered.append(
110 RenderedVariation(
111 email_class=email_class.__name__,
112 variation=i,
113 variation_count=len(test_instances),
114 subjects=subjects,
115 name=name,
116 )
117 )
119 outdir.mkdir(parents=True, exist_ok=True)
120 if rendered: 120 ↛ 123line 120 didn't jump to line 123 because the condition on line 120 was always true
121 shutil.copytree(template_folder / "attachment_imgs", outdir / "attachment_imgs", dirs_exist_ok=True)
123 write_index(outdir / "index.html", rendered, locales, bodies)
124 return rendered
127def compress_bodies(bodies: dict[str, dict[str, str]]) -> str:
128 return base64.b64encode(gzip.compress(json.dumps(bodies, ensure_ascii=False).encode())).decode()
131def write_index(
132 index_path: Path,
133 rendered: list[RenderedVariation],
134 locales: list[str],
135 bodies: dict[str, dict[str, dict[str, str]]],
136) -> None:
137 """Writes a self-contained HTML index with a locale selector and an accordion entry per
138 rendered email variation, expanding to side-by-side HTML and plaintext previews."""
139 rendered = sorted(rendered, key=lambda r: (r.email_class, r.variation))
141 def embed(value: object) -> Markup:
142 # Guard against a literal "</script>" in the data breaking out of the script tag
143 return Markup(json.dumps(value, ensure_ascii=False).replace("</", "<\\/"))
145 template = Jinja2Template(
146 source=(Path(__file__).parent / "dump_emails_index.html.jinja2").read_text(encoding="utf-8"), html=True
147 )
148 index_html = template.render(
149 {
150 "rendered": rendered,
151 "locales": locales,
152 "class_count": len({r.email_class for r in rendered}),
153 "subjects_json": embed({r.name: r.subjects for r in rendered}),
154 "payloads_json": embed({locale: compress_bodies(bodies[locale]) for locale in locales}),
155 }
156 )
157 index_path.parent.mkdir(parents=True, exist_ok=True)
158 index_path.write_text(index_html, encoding="utf-8")
159 print(f"Wrote index of {len(rendered)} variation(s) in {len(locales)} locale(s) to {index_path}")
162def main() -> None:
163 args = CommandLineArgs.parse(sys.argv[1:])
164 locales = None if args.locales == "all" else args.locales.split(",")
165 dump_all(args.outdir, filter_glob=args.filter, locales=locales)
168if __name__ == "__main__": 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 main()