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

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 

5All bodies are gzipped and inlined, so the index is a single self-contained page. 

6""" 

7 

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 

19 

20from markupsafe import Markup 

21 

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 

33 

34 

35@dataclass 

36class CommandLineArgs: 

37 filter: str 

38 outdir: Path 

39 locales: str 

40 

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__) 

56 

57 

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 

65 

66 

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

71 

72 

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. 

76 

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"\*", ".*?")) 

90 

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 ) 

118 

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) 

122 

123 write_index(outdir / "index.html", rendered, locales, bodies) 

124 return rendered 

125 

126 

127def compress_bodies(bodies: dict[str, dict[str, str]]) -> str: 

128 return base64.b64encode(gzip.compress(json.dumps(bodies, ensure_ascii=False).encode())).decode() 

129 

130 

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

140 

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("</", "<\\/")) 

144 

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}") 

160 

161 

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) 

166 

167 

168if __name__ == "__main__": 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true

169 main()