Skip to content

Commit fab9331

Browse files
Backend/emails: Use user content as the preview line (#9122)
1 parent 9c82b0d commit fab9331

4 files changed

Lines changed: 147 additions & 33 deletions

File tree

app/backend/src/couchers/email/emails.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from couchers.email.locales import get_emails_i18next
2424
from couchers.i18n import LocalizationContext
2525
from couchers.i18n.localize import format_phone_number
26+
from couchers.markup import markdown_to_plaintext
2627
from couchers.notifications.quick_links import generate_quick_decline_link
2728
from couchers.proto import conversations_pb2, events_pb2, notification_data_pb2
2829
from couchers.utils import now, to_aware_datetime
@@ -271,6 +272,9 @@ def get_subject_line(self, loc_context: LocalizationContext) -> str:
271272
loc_context, ".subject", {"author": self.author.name, "group": self.group_chat_title or ""}
272273
)
273274

275+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
276+
return self.text
277+
274278
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
275279
builder = self._body_builder(loc_context)
276280
builder.para(".body", {"author": self.author.name, "group": self.group_chat_title or ""})
@@ -327,6 +331,11 @@ def string_key_base(self) -> str:
327331
def get_subject_line(self, loc_context: LocalizationContext) -> str:
328332
return self._localize(loc_context, ".subject")
329333

334+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
335+
if len(self.entries) != 1:
336+
return None
337+
return self.entries[0].latest_message_text
338+
330339
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
331340
builder = self._body_builder(loc_context)
332341
for entry in self.entries:
@@ -391,6 +400,9 @@ def string_key_base(self) -> str:
391400
def get_subject_line(self, loc_context: LocalizationContext) -> str:
392401
return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.title})
393402

403+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
404+
return markdown_to_plaintext(self.markdown_text)
405+
394406
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
395407
builder = self._body_builder(loc_context)
396408
builder.para(
@@ -451,6 +463,9 @@ def get_subject_line(self, loc_context: LocalizationContext) -> str:
451463
loc_context, ".subject", {"author": self.author.name, "discussion_title": self.discussion_title}
452464
)
453465

466+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
467+
return markdown_to_plaintext(self.markdown_text)
468+
454469
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
455470
builder = self._body_builder(loc_context)
456471
builder.para(
@@ -503,9 +518,6 @@ class DonationReceivedEmail(EmailBase):
503518
def string_key_base(self) -> str:
504519
return "donation_received"
505520

506-
def get_preview_line(self, loc_context: LocalizationContext) -> str:
507-
return self._localize(loc_context, ".thanks_amount", {"amount": self.amount})
508-
509521
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
510522
builder = self._body_builder(loc_context, standard_closing=False)
511523
builder.para(".thanks_amount", {"amount": self.amount})
@@ -680,6 +692,9 @@ def get_subject_line(self, loc_context: LocalizationContext) -> str:
680692
loc_context, ".subject", {"user": self.inviting_user.name, "title": self.event_info.title}
681693
)
682694

695+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
696+
return markdown_to_plaintext(self.event_info.description_markdown)
697+
683698
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
684699
builder = self._body_builder(loc_context)
685700
if self.community_name:
@@ -882,6 +897,9 @@ def string_key_base(self) -> str:
882897
def get_subject_line(self, loc_context: LocalizationContext) -> str:
883898
return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.event_info.title})
884899

900+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
901+
return markdown_to_plaintext(self.comment_markdown)
902+
885903
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
886904
builder = self._body_builder(loc_context)
887905
builder.para(".body", {"author": self.author.name, "title": self.event_info.title})
@@ -1036,6 +1054,9 @@ def string_key_base(self) -> str:
10361054
def get_subject_line(self, loc_context: LocalizationContext) -> str:
10371055
return self._localize(loc_context, ".subject", {"name": self.from_user.name})
10381056

1057+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1058+
return self.text
1059+
10391060
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
10401061
builder = self._body_builder(loc_context)
10411062
builder.para(".body", {"name": self.from_user.name})
@@ -1072,9 +1093,6 @@ def string_key_base(self) -> str:
10721093
def get_subject_line(self, loc_context: LocalizationContext) -> str:
10731094
return self._localize(loc_context, ".subject", {"name": self.befriender.name})
10741095

1075-
def get_preview_line(self, loc_context: LocalizationContext) -> str:
1076-
return self._localize(loc_context, ".body", {"name": self.befriender.name})
1077-
10781096
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
10791097
builder = self._body_builder(loc_context)
10801098
builder.para(".body", {"name": self.befriender.name})
@@ -1111,9 +1129,6 @@ def string_key_base(self) -> str:
11111129
def get_subject_line(self, loc_context: LocalizationContext) -> str:
11121130
return self._localize(loc_context, ".subject", {"name": self.new_friend.name})
11131131

1114-
def get_preview_line(self, loc_context: LocalizationContext) -> str:
1115-
return self._localize(loc_context, ".body", {"name": self.new_friend.name})
1116-
11171132
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
11181133
builder = self._body_builder(loc_context)
11191134
builder.para(".body", {"name": self.new_friend.name})
@@ -1183,6 +1198,9 @@ def string_key_base(self) -> str:
11831198
def get_subject_line(self, loc_context: LocalizationContext) -> str:
11841199
return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
11851200

1201+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1202+
return self.text
1203+
11861204
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
11871205
builder = self._body_builder(loc_context)
11881206
builder.para(".body", {"surfer_name": self.surfer.name})
@@ -1301,6 +1319,9 @@ def string_key_base(self) -> str:
13011319
def get_subject_line(self, loc_context: LocalizationContext) -> str:
13021320
return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
13031321

1322+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1323+
return self.text
1324+
13041325
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
13051326
builder = self._body_builder(loc_context)
13061327
builder.para(".body", {"other_name": self.other_user.name})
@@ -1519,6 +1540,9 @@ def string_role_subkey(self) -> str:
15191540
def get_subject_line(self, loc_context: LocalizationContext) -> str:
15201541
return self._localize(loc_context, ".subject", {"name": self.from_user.name})
15211542

1543+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1544+
return self.text
1545+
15221546
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
15231547
builder = self._body_builder(loc_context)
15241548
builder.para(f".{self.string_role_subkey}.body", {"name": self.from_user.name})
@@ -2045,6 +2069,9 @@ def get_subject_line(self, loc_context: LocalizationContext) -> str:
20452069
loc_context, ".subject", {"author": self.author.name, "parent_context": self.parent_context}
20462070
)
20472071

2072+
def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
2073+
return markdown_to_plaintext(self.markdown_text)
2074+
20482075
def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
20492076
builder = self._body_builder(loc_context)
20502077
builder.para(".body", {"author": self.author.name, "parent_context": self.parent_context})

app/backend/src/couchers/email/rendering.py

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@
66
from dataclasses import asdict, dataclass
77
from email.headerregistry import Address
88
from functools import cache
9-
from html import unescape
109
from pathlib import Path
1110
from typing import Any
1211

13-
from markdown_it import MarkdownIt
1412
from markupsafe import Markup
1513

1614
from couchers.config import config
@@ -19,15 +17,12 @@
1917
from couchers.email.smtp import embed_html_relative_images
2018
from couchers.i18n import LocalizationContext
2119
from couchers.i18n.i18next import SubstitutionDict, full_string_key
20+
from couchers.markup import html_to_plaintext, markdown_to_html
2221
from couchers.proto.internal import jobs_pb2
2322
from couchers.templating import Jinja2Template
2423

2524
template_folder = Path(__file__).parent.parent.parent.parent / "templates" / "v2"
2625

27-
_markdown = MarkdownIt("zero", {"typographer": True}).enable(
28-
["smartquotes", "heading", "hr", "list", "link", "emphasis"]
29-
)
30-
3126

3227
@dataclass(kw_only=True, slots=True)
3328
class RenderedEmail:
@@ -116,25 +111,11 @@ def _to_plaintext(text: str | Markup) -> str:
116111
Converts any markup in its plaintext equivalent, allowing reuse of translations that have span-level markup
117112
like <b> when formatting as plaintext email bodies.
118113
"""
119-
if not isinstance(text, Markup): # Markup derives from str so can't test for isinstance(, str)
114+
if isinstance(text, Markup):
115+
return html_to_plaintext(text)
116+
else:
120117
return text
121118

122-
# Convert markup to its plaintext equivalent.
123-
# This code is not security-sensitive since we're producing a plaintext string where markup will not be evaluated.
124-
125-
# Strip/convert any markup since we can't render it in plaintext.
126-
text = text.replace("\n", "") # Newlines are irrelevant in markup
127-
text = re.sub(r"<br\s*/?>", "\n", text) # But <br>'s should be newlines in plaintext
128-
129-
# Keep the content of span-level markup (assume no nesting)
130-
text = re.sub(
131-
r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", lambda match: match.group("inner"), text
132-
)
133-
text = re.sub(r"<\w+[^/>]*/>", "", text) # Remove any other self-closing tag
134-
135-
# We've handled tags but still have escapes like "&gt;", convert those to plaintext.
136-
return unescape(text)
137-
138119

139120
def _get_footer_template_args(footer: EmailFooter, loc_context: LocalizationContext) -> dict[str, Any]:
140121
i18n = get_emails_i18next()
@@ -264,7 +245,7 @@ def render(
264245
)
265246
)
266247
case QuoteBlock():
267-
args = {"text": Markup(_markdown.render(block.text)) if block.markdown else block.text}
248+
args = {"text": Markup(markdown_to_html(block.text)) if block.markdown else block.text}
268249
concats.append(self.quote_block_template.render(args))
269250
case ActionBlock():
270251
concats.append(self.action_block_template.render(asdict(block)))

app/backend/src/couchers/markup.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from html.parser import HTMLParser
2+
from typing import Any
3+
4+
from markdown_it import MarkdownIt
5+
from markupsafe import Markup
6+
7+
# Markdown config should match frontend's MarkdownNoSSR component.
8+
_markdown = MarkdownIt(
9+
"zero", # Base configuration disables all features
10+
options_update={
11+
"typographer": True, # Enable some language-neutral replacement + quotes beautification
12+
"breaks": True, # Convert '\n' in paragraphs into <br>
13+
},
14+
).enable(
15+
[
16+
"emphasis", # Process *this* and _that_
17+
"heading", # Headings (#, ##, ...)
18+
"hr", # Horizontal rule
19+
"link", # Process [link](<to> "stuff")
20+
"list", # Lists
21+
"newline", # Process '\n'
22+
"smartquotes", # Convert straight quotation marks to typographic ones
23+
]
24+
)
25+
26+
27+
def markdown_to_html(text: str) -> Markup:
28+
return Markup(_markdown.render(text))
29+
30+
31+
def markdown_to_plaintext(text: str) -> str:
32+
return html_to_plaintext(markdown_to_html(text))
33+
34+
35+
def html_to_plaintext(html: str | Markup) -> str:
36+
"""
37+
Renders a plaintext version of HTML by extracting inner HTML and converting entities+newlines.
38+
Do not use for sanitization. The resulting string may not be markup-safe.
39+
"""
40+
41+
if isinstance(html, Markup):
42+
html = str(html)
43+
44+
converter = _HTMLToPlaintext()
45+
converter.feed(html)
46+
return converter.plaintext
47+
48+
49+
class _HTMLToPlaintext(HTMLParser):
50+
plaintext: str
51+
52+
def __init__(self) -> None:
53+
super().__init__()
54+
self.plaintext = ""
55+
56+
def handle_starttag(self, tag: str, attrs: Any) -> None:
57+
if tag == "br":
58+
self.plaintext += "\n"
59+
60+
def handle_data(self, data: str) -> None:
61+
# Escapes have already been unescaped
62+
self.plaintext += data.replace("\n", "") # Newlines in html are meaningless
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from couchers.markup import html_to_plaintext, markdown_to_html, markdown_to_plaintext
2+
3+
4+
def test_markdown_to_html() -> None:
5+
def to_para(span: str) -> str:
6+
return f"<p>{span}</p>\n"
7+
8+
assert markdown_to_html("new\nline") == to_para("new<br>\nline")
9+
assert markdown_to_html("a & b") == to_para("a &amp; b")
10+
11+
assert markdown_to_html("**bold**") == to_para("<strong>bold</strong>")
12+
assert markdown_to_html("*italic*") == to_para("<em>italic</em>")
13+
assert markdown_to_html("_italic_") == to_para("<em>italic</em>")
14+
assert markdown_to_html("[link](url)") == to_para('<a href="url">link</a>')
15+
assert markdown_to_html('"quoted"') == to_para("“quoted”")
16+
17+
assert markdown_to_html("<script/>") == to_para("&lt;script/&gt;")
18+
19+
assert markdown_to_html("# title") == "<h1>title</h1>\n"
20+
assert markdown_to_html("- a\n- b") == "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n"
21+
assert markdown_to_html("---") == "<hr>\n"
22+
23+
24+
def test_markdown_to_plaintext() -> None:
25+
assert markdown_to_plaintext("new\nline") == "new\nline"
26+
assert markdown_to_plaintext("a & b") == "a & b"
27+
28+
assert markdown_to_plaintext("**bold**") == "bold"
29+
assert markdown_to_plaintext("_italic_") == "italic"
30+
assert markdown_to_plaintext("[link](url)") == "link"
31+
32+
# By design since plaintext should never be interpreted as markup.
33+
assert markdown_to_plaintext("<script/>") == "<script/>"
34+
35+
assert markdown_to_plaintext("# title") == "title"
36+
37+
38+
def test_html_to_plaintext() -> None:
39+
assert html_to_plaintext("new<br>line") == "new\nline"
40+
assert html_to_plaintext("entity&excl;") == "entity!"
41+
assert html_to_plaintext("<b>stripped</b>") == "stripped"
42+
assert html_to_plaintext("a<script/>b") == "ab"
43+
assert html_to_plaintext('<a href="https://example.com">attributes</a>') == "attributes"
44+
assert html_to_plaintext("</b>malformed<a>") == "malformed"

0 commit comments

Comments
 (0)