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

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

5 

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 

15 

16from markupsafe import Markup 

17 

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 

29 

30 

31@dataclass 

32class CommandLineArgs: 

33 filter: str 

34 outdir: Path 

35 locales: str 

36 

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

52 

53 

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 

61 

62 @property 

63 def html_filename(self) -> str: 

64 return f"{self.name}.html" 

65 

66 @property 

67 def plaintext_filename(self) -> str: 

68 return f"{self.name}.txt" 

69 

70 

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

75 

76 

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. 

80 

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

94 

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 ) 

121 

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) 

124 

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

126 return rendered 

127 

128 

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

133 

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) 

137 

138 return rendered.subject 

139 

140 

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

159 

160 

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) 

165 

166 

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

168 main()