Coverage for app/backend/src/couchers/email/dump_emails.py: 86%
82 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-14 20:06 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-14 20:06 +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.
4"""
6import inspect
7import json
8import re
9import shutil
10import sys
11from argparse import ArgumentParser
12from dataclasses import dataclass
13from datetime import UTC
14from pathlib import Path
16from markupsafe import Markup
18import couchers.email.emails
19from couchers.email.blocks import (
20 EmailBase,
21 EmailFooter,
22 UnsubscribeInfo,
23 UnsubscribeLink,
24)
25from couchers.email.rendering import render_email, template_folder
26from couchers.i18n import LocalizationContext
27from couchers.i18n.locales import DEFAULT_LOCALE, get_supported_locales
28from couchers.templating import Jinja2Template
31@dataclass
32class CommandLineArgs:
33 filter: str
34 outdir: Path
35 locales: str
37 @staticmethod
38 def parse(args: list[str]) -> CommandLineArgs:
39 parser = ArgumentParser(description=__doc__)
40 parser.add_argument("--filter", type=str, default="*", help="A filter for email classes to dump.")
41 parser.add_argument(
42 "--outdir", type=Path, default=template_folder, help="The directory to write email bodies to."
43 )
44 parser.add_argument(
45 "--locales",
46 type=str,
47 default="all",
48 help='Comma-separated locales to render, or "all" for every supported locale.',
49 )
50 parsed_args = parser.parse_args(args)
51 return CommandLineArgs(**parsed_args.__dict__)
54@dataclass
55class RenderedVariation:
56 email_class: str
57 variation: int
58 variation_count: int
59 subjects: dict[str, str] # locale -> subject line
60 name: str # filename without extension, relative to the locale directory
62 @property
63 def html_filename(self) -> str:
64 return f"{self.name}.html"
66 @property
67 def plaintext_filename(self) -> str:
68 return f"{self.name}.txt"
71def _ordered_locales(locales: list[str] | None) -> list[str]:
72 if locales is None: 72 ↛ 74line 72 didn't jump to line 74 because the condition on line 72 was always true
73 locales = get_supported_locales()
74 return sorted(locales, key=lambda locale: (locale != DEFAULT_LOCALE, locale))
77def dump_all(outdir: Path, *, filter_glob: str = "*", locales: list[str] | None = None) -> list[RenderedVariation]:
78 """Dumps all emails matching the filter to outdir (one subdirectory per locale) and
79 writes a browsable index.html with a locale selector and expandable previews.
81 Requires the relevant config (e.g. BASE_URL) to be available, as when run inside the
82 test harness or with the deployment environment loaded.
83 """
84 locales = _ordered_locales(locales)
85 footer = EmailFooter(
86 timezone_name="UTC",
87 unsubscribe_info=UnsubscribeInfo(
88 manage_notifications_url="https://example.com/manage-notifications",
89 do_not_email_url="https://example.com/do-not-email",
90 topic_action_link=UnsubscribeLink(text="topic-action", url="https://example.com/unsubscribe"),
91 ),
92 )
93 filter_regex = re.compile(re.escape(filter_glob).replace(r"\*", ".*?"))
95 rendered: list[RenderedVariation] = []
96 # Iterate over all email classes and dump their test instances if they match the filter
97 for _, klass in inspect.getmembers(couchers.email.emails, lambda o: inspect.isclass(o) and o.__base__ == EmailBase):
98 email_class: type[EmailBase] = klass
99 if filter_regex.fullmatch(email_class.__name__): 99 ↛ 97line 99 didn't jump to line 97 because the condition on line 99 was always true
100 test_instances = email_class.test_instances()
101 for i in range(len(test_instances)):
102 filename_no_ext = email_class.__name__
103 if len(test_instances) > 1:
104 filename_no_ext += f"_{i}"
105 print(f"Dumping email class {email_class.__name__} ({len(locales)} locale(s))")
106 subjects = {}
107 for locale in locales:
108 loc_context = LocalizationContext(locale=locale, timezone=UTC)
109 subjects[locale] = dump_email(
110 test_instances[i], footer, loc_context, outdir / locale / filename_no_ext
111 )
112 rendered.append(
113 RenderedVariation(
114 email_class=email_class.__name__,
115 variation=i,
116 variation_count=len(test_instances),
117 subjects=subjects,
118 name=filename_no_ext,
119 )
120 )
122 if rendered: 122 ↛ 125line 122 didn't jump to line 125 because the condition on line 122 was always true
123 shutil.copytree(template_folder / "attachment_imgs", outdir / "attachment_imgs", dirs_exist_ok=True)
125 write_index(outdir / "index.html", rendered, locales)
126 return rendered
129def dump_email(email: EmailBase, footer: EmailFooter, loc_context: LocalizationContext, filepath_no_ext: Path) -> str:
130 """Dumps an email's subject and plaintext+html body to a file, returning the subject line."""
131 rendered = render_email(email, footer, loc_context, embed_images=False)
132 html = rendered.body_html.replace("attachment_imgs/", "../attachment_imgs/")
134 filepath_no_ext.parent.mkdir(parents=True, exist_ok=True)
135 filepath_no_ext.with_suffix(".html").write_text(html)
136 filepath_no_ext.with_suffix(".txt").write_text(rendered.body_plaintext)
138 return rendered.subject
141def write_index(index_path: Path, rendered: list[RenderedVariation], locales: list[str]) -> None:
142 """Writes a browsable HTML index with a locale selector and an accordion entry per
143 rendered email variation, expanding to side-by-side HTML and plaintext previews."""
144 rendered = sorted(rendered, key=lambda r: (r.email_class, r.variation))
145 # Guard against a literal "</script>" in subject lines breaking out of the script tag
146 subjects_json = json.dumps({r.name: r.subjects for r in rendered}, ensure_ascii=False).replace("</", "<\\/")
147 template = Jinja2Template(source=(Path(__file__).parent / "dump_emails_index.html.jinja2").read_text(), html=True)
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": Markup(subjects_json),
154 }
155 )
156 index_path.parent.mkdir(parents=True, exist_ok=True)
157 index_path.write_text(index_html)
158 print(f"Wrote index of {len(rendered)} variation(s) in {len(locales)} locale(s) to {index_path}")
161def main() -> None:
162 args = CommandLineArgs.parse(sys.argv[1:])
163 locales = None if args.locales == "all" else args.locales.split(",")
164 dump_all(args.outdir, filter_glob=args.filter, locales=locales)
167if __name__ == "__main__": 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 main()