Skip to content

Commit d90b1ba

Browse files
Backend: Proof of concept of email templating rev
1 parent 5ce988c commit d90b1ba

File tree

4 files changed

+617
-0
lines changed

4 files changed

+617
-0
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from dataclasses import dataclass
2+
import re
3+
from typing import Any
4+
5+
from couchers.templates.v2 import template_folder, render_template, Context
6+
7+
@dataclass
8+
class EmailContent:
9+
subject: str
10+
preview: str
11+
body: list[BodySection]
12+
unsubscribe_info: UnsubscribeInfo | None # None if security critical
13+
14+
@dataclass
15+
class BodySection:
16+
"""Base class for email sections."""
17+
pass
18+
19+
@dataclass
20+
class TextSection(BodySection):
21+
text: str
22+
23+
@dataclass
24+
class ButtonSection(BodySection):
25+
url: str
26+
text: str
27+
28+
@dataclass
29+
class PersonSection(BodySection):
30+
name: str
31+
age: int
32+
city: str
33+
avatar_url: str
34+
text: str | None
35+
36+
@dataclass
37+
class QuoteSection(BodySection):
38+
text: str
39+
40+
@dataclass
41+
class UnsubscribeInfo:
42+
topic_text: str | None
43+
topic_url: str | None
44+
topic_action_text: str | None
45+
topic_action_url: str | None
46+
47+
@dataclass
48+
class EmailTemplate:
49+
"""jinja templates for the different parts of an email."""
50+
51+
header: str | None
52+
footer: str | None
53+
text_section: str
54+
button_section: str
55+
person_section: str
56+
quote_section: str
57+
58+
_SECTION_RE = re.compile(r"""
59+
<!-- begin-section:(?P<name>\w+) -->\s*
60+
(?P<snippet>[\s\S]*?)
61+
\s*<!-- end-section:(?P=name) -->
62+
""".strip(), re.MULTILINE)
63+
64+
def load_html_template() -> EmailTemplate:
65+
full_template = (template_folder / "generated_html" / "prototype.html").read_text(encoding="utf8")
66+
section_matches = list(_SECTION_RE.finditer(full_template))
67+
68+
header = full_template[:section_matches[0].start()]
69+
footer = full_template[section_matches[-1].end():]
70+
sections = { match.group("name"): match.group("snippet") for match in section_matches }
71+
72+
return EmailTemplate(
73+
header=header,
74+
footer=footer,
75+
text_section=sections["text"],
76+
button_section=sections["button"],
77+
person_section=sections["person"],
78+
quote_section=sections["quote"],
79+
)
80+
81+
def load_text_template() -> EmailTemplate:
82+
return EmailTemplate(
83+
header="",
84+
footer=(template_folder / "_footer.txt").read_text("utf8"),
85+
text_section="{{ text }}",
86+
button_section="{{ label }}: {{ url }}",
87+
person_section="{{ name }}, {{ age }}\n{{ city }}",
88+
quote_section="> {{ text }}"
89+
)
90+
91+
def render_email(content: EmailContent, template: EmailTemplate, context: Context) -> str:
92+
email_str = ""
93+
94+
if template.header:
95+
email_str = render_template(template.header, {
96+
"header_subject": content.subject,
97+
"header_preview": content.preview,
98+
}, context)
99+
100+
email_str += "\n"
101+
email_str += "\n"
102+
103+
for section in content.body:
104+
match section:
105+
case TextSection():
106+
email_str += render_template(template.text_section, {
107+
"text": section.text
108+
}, context)
109+
case ButtonSection():
110+
email_str += render_template(template.button_section, {
111+
"url": section.url,
112+
"label": section.text
113+
}, context)
114+
case PersonSection():
115+
email_str += render_template(template.person_section, {
116+
"name": section.name,
117+
"age": section.age,
118+
"city": section.city,
119+
"avatar_url": section.avatar_url
120+
}, context)
121+
case QuoteSection():
122+
email_str += render_template(template.quote_section, {
123+
"text": section.text
124+
}, context)
125+
case _:
126+
assert False, "Unexpected section!"
127+
128+
email_str += "\n"
129+
email_str += "\n"
130+
131+
if template.footer:
132+
footer_args: dict[str, Any] = {
133+
"footer_email_is_critical": content.unsubscribe_info is None
134+
}
135+
# if content.unsubscribe_info:
136+
# footer_args["footer_manage_notifications_link"] = urls.notification_settings_link()
137+
# footer_args["footer_notification_topic_action"] = rendered.topic_action_unsubscribe_text
138+
# footer_args["footer_notification_topic_action_link"] = generate_unsub_topic_action(notification)
139+
# footer_args["footer_notification_topic_key"] = rendered.topic_key_unsubscribe_text
140+
# footer_args["footer_notification_topic_key_link"] = generate_unsub_topic_key(notification)
141+
# footer_args["footer_do_not_email_link"] = generate_do_not_email(user)
142+
143+
email_str += render_template(template.footer, footer_args, context)
144+
145+
return email_str
146+
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from couchers.email.templating import EmailContent, EmailTemplate, TextSection, ButtonSection, QuoteSection, load_html_template, load_text_template, render_email
5+
from couchers.templates.v2 import Context, template_folder
6+
7+
logger = logging.getLogger(__name__)
8+
9+
def test_email_templating() -> None:
10+
email = EmailContent(
11+
subject="New host request from Bob",
12+
preview="Preview",
13+
body=[
14+
TextSection(text="You received a request from Bob"),
15+
QuoteSection(text="Yo can you host me?"),
16+
ButtonSection(url="https://foo", text="Accept"),
17+
TextSection(text="Yours truly, Couchers"),
18+
],
19+
unsubscribe_info=None
20+
)
21+
22+
html_template = load_html_template()
23+
html = render_email(email, html_template, Context(timezone=None, locale="en", plaintext=False))
24+
Path(__file__ + ".out.html").write_text(html, encoding="utf8")
25+
26+
plaintext_template = EmailTemplate(
27+
header="",
28+
footer=(template_folder / "_footer.txt").read_text("utf8"),
29+
text_section="{{ text }}",
30+
button_section="{{ label }}: {{ url }}",
31+
person_section="{{ name }}, {{ age }}\n{{ city }}",
32+
quote_section="> {{ text }}"
33+
)
34+
35+
plaintext_template = load_text_template()
36+
plaintext = render_email(email, plaintext_template, Context(timezone=None, locale="en", plaintext=True))
37+
Path(__file__ + ".out.txt").write_text(plaintext, encoding="utf8")
38+

0 commit comments

Comments
 (0)