Skip to content

Commit 17c1a30

Browse files
Backend/emails: Support side-by-side buttons in generic emails (#8715)
1 parent fc38c23 commit 17c1a30

3 files changed

Lines changed: 130 additions & 11 deletions

File tree

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

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
import re
6-
from dataclasses import dataclass
6+
from dataclasses import asdict, dataclass
77
from functools import lru_cache
88
from html import unescape
99
from pathlib import Path
@@ -27,22 +27,22 @@ class EmailBlock:
2727
pass
2828

2929

30-
@dataclass(kw_only=True)
30+
@dataclass(kw_only=True, slots=True)
3131
class ParaBlock(EmailBlock):
3232
"""A paragraph of text which may contain span-level HTML."""
3333

3434
text: str | Markup
3535

3636

37-
@dataclass(kw_only=True)
37+
@dataclass(kw_only=True, slots=True)
3838
class UserBlock(EmailBlock):
3939
"""A banner with another user's profile information, for example preceding a quoted message."""
4040

4141
info: UserInfo
4242
comment: str | Markup | None
4343

4444

45-
@dataclass(kw_only=True)
45+
@dataclass(kw_only=True, slots=True)
4646
class UserInfo:
4747
name: str
4848
age: int
@@ -71,22 +71,32 @@ def dummy_bob() -> UserInfo:
7171
)
7272

7373

74-
@dataclass(kw_only=True)
74+
@dataclass(kw_only=True, slots=True)
7575
class QuoteBlock(EmailBlock):
7676
"""A quoted message, typically from another user. Either plaintext or markdown."""
7777

7878
text: str
7979
markdown: bool
8080

8181

82-
@dataclass(kw_only=True)
82+
@dataclass(kw_only=True, slots=True)
8383
class ActionBlock(EmailBlock):
8484
"""An action that can be performed by the user in response to the email."""
8585

8686
text: str
8787
target_url: str
8888

8989

90+
@dataclass(kw_only=True, slots=True)
91+
class TwoButtonHTMLBlock(EmailBlock):
92+
"""An HTML-only block for rendering as side-by-side buttons."""
93+
94+
text_1: str
95+
target_url_1: str
96+
text_2: str
97+
target_url_2: str
98+
99+
90100
class EmailBlocksBuilder:
91101
"""
92102
Builder object for constructing a list of EmailBlock's to form the body of an email.
@@ -288,6 +298,7 @@ class HTMLRenderer:
288298
user_block_template: Jinja2Template
289299
quote_block_template: Jinja2Template
290300
action_block_template: Jinja2Template
301+
two_buttons_block_template: Jinja2Template
291302

292303
def render(
293304
self,
@@ -312,10 +323,10 @@ def render(
312323
)
313324

314325
# Render each block
315-
for block in blocks:
326+
for block in type(self)._merge_action_blocks(blocks):
316327
match block:
317328
case ParaBlock():
318-
concats.append(self.para_block_template.render(block.__dict__, loc_context))
329+
concats.append(self.para_block_template.render(asdict(block), loc_context))
319330
case UserBlock():
320331
concats.append(
321332
self.user_block_template.render(
@@ -333,7 +344,9 @@ def render(
333344
args = {"text": Markup(_markdown.render(block.text)) if block.markdown else block.text}
334345
concats.append(self.quote_block_template.render(args, loc_context))
335346
case ActionBlock():
336-
concats.append(self.action_block_template.render(block.__dict__, loc_context))
347+
concats.append(self.action_block_template.render(asdict(block), loc_context))
348+
case TwoButtonHTMLBlock():
349+
concats.append(self.two_buttons_block_template.render(asdict(block), loc_context))
337350
case _:
338351
raise TypeError(f"Unexpected email block type: {block.__class__}")
339352

@@ -343,6 +356,28 @@ def render(
343356

344357
return "\n".join(concats)
345358

359+
@staticmethod
360+
def _merge_action_blocks(blocks: list[EmailBlock]) -> list[EmailBlock]:
361+
"""Merge any two subsequent action blocks into a single two-button block."""
362+
blocks = blocks.copy()
363+
364+
block_index = 0
365+
while block_index + 1 < len(blocks):
366+
block = blocks[block_index]
367+
next_block = blocks[block_index + 1]
368+
if isinstance(block, ActionBlock) and isinstance(next_block, ActionBlock):
369+
blocks[block_index] = TwoButtonHTMLBlock(
370+
target_url_1=block.target_url,
371+
text_1=block.text,
372+
target_url_2=next_block.target_url,
373+
text_2=next_block.text,
374+
)
375+
blocks.pop(block_index + 1)
376+
377+
block_index += 1
378+
379+
return blocks
380+
346381
@lru_cache(maxsize=1)
347382
@staticmethod
348383
def default() -> HTMLRenderer:
@@ -364,13 +399,14 @@ def from_template(template: str) -> HTMLRenderer:
364399
user_block_template=Jinja2Template(source=block_templates["user"], html=True),
365400
quote_block_template=Jinja2Template(source=block_templates["quote"], html=True),
366401
action_block_template=Jinja2Template(source=block_templates["action"], html=True),
402+
two_buttons_block_template=Jinja2Template(source=block_templates["two-buttons"], html=True),
367403
)
368404

369405

370406
# Matches a begin-block / end-block pair of comments in the html file containing template blocks.
371407
_block_regex = re.compile(
372408
r"""
373-
<!-- begin-block:(?P<name>\w+) -->\s*
409+
<!-- begin-block:(?P<name>[\w-]+) -->\s*
374410
(?P<snippet>[\s\S]*?)
375411
\s*<!-- end-block:(?P=name) -->
376412
""".strip(),

app/backend/templates/v2/blocks.mjml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@
1616
</mj-section>
1717
<!-- end-block:action -->
1818

19+
<!-- begin-block:two-buttons -->
20+
<mj-section padding="0px">
21+
<mj-column>
22+
<mj-button background-color="#00a398" color="white" border-radius="5px" href="{{ target_url_1 }}">
23+
<b>{{ text_1 }}</b>
24+
</mj-button>
25+
</mj-column>
26+
<mj-column>
27+
<mj-button background-color="#00a398" color="white" border-radius="5px" href="{{ target_url_2 }}">
28+
<b>{{ text_2 }}</b>
29+
</mj-button>
30+
</mj-column>
31+
</mj-section>
32+
<!-- end-block:two-buttons -->
33+
1934
<!-- begin-block:user -->
2035
<mj-section padding="0px" text-align="left">
2136
<mj-column>

app/backend/templates/v2/generated_html/blocks.html

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@
7070
width: 100% !important;
7171
max-width: 100%;
7272
}
73+
74+
.mj-column-per-50 {
75+
width: 50% !important;
76+
max-width: 50%;
77+
}
7378
}
7479

7580
</style>
@@ -79,6 +84,11 @@
7984
max-width: 100%;
8085
}
8186

87+
.moz-text-html .mj-column-per-50 {
88+
width: 50% !important;
89+
max-width: 50%;
90+
}
91+
8292
</style>
8393
<style type="text/css">
8494
@media only screen and (max-width:479px) {
@@ -218,7 +228,65 @@
218228
</table>
219229
</div>
220230
<!--[if mso | IE]></td></tr></table></td></tr><![endif]-->
221-
<!-- end-block:action --><!-- begin-block:user -->
231+
<!-- end-block:action --><!-- begin-block:two-buttons -->
232+
<!--[if mso | IE]><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:560px;" width="560" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
233+
<div style="margin:0px auto;max-width:560px;">
234+
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
235+
<tbody>
236+
<tr>
237+
<td style="direction:ltr;font-size:0px;padding:0px;text-align:center;">
238+
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:280px;" ><![endif]-->
239+
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
240+
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
241+
<tbody>
242+
<tr>
243+
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
244+
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
245+
<tbody>
246+
<tr>
247+
<td align="center" bgcolor="#00a398" role="presentation" style="border:none;border-radius:5px;cursor:auto;mso-padding-alt:10px 25px;background:#00a398;" valign="middle">
248+
<a href="{{ target_url_1 }}" style="display: inline-block; background: #00a398; color: white; font-family: Ubuntu, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: normal; line-height: 120%; margin: 0; text-decoration: none; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 5px;" target="_blank">
249+
<b>{{ text_1 }}</b>
250+
</a>
251+
</td>
252+
</tr>
253+
</tbody>
254+
</table>
255+
</td>
256+
</tr>
257+
</tbody>
258+
</table>
259+
</div>
260+
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:280px;" ><![endif]-->
261+
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
262+
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
263+
<tbody>
264+
<tr>
265+
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
266+
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
267+
<tbody>
268+
<tr>
269+
<td align="center" bgcolor="#00a398" role="presentation" style="border:none;border-radius:5px;cursor:auto;mso-padding-alt:10px 25px;background:#00a398;" valign="middle">
270+
<a href="{{ target_url_2 }}" style="display: inline-block; background: #00a398; color: white; font-family: Ubuntu, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: normal; line-height: 120%; margin: 0; text-decoration: none; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 5px;" target="_blank">
271+
<b>{{ text_2 }}</b>
272+
</a>
273+
</td>
274+
</tr>
275+
</tbody>
276+
</table>
277+
</td>
278+
</tr>
279+
</tbody>
280+
</table>
281+
</div>
282+
<!--[if mso | IE]></td></tr></table><![endif]-->
283+
</td>
284+
</tr>
285+
</tbody>
286+
</table>
287+
</div>
288+
<!--[if mso | IE]></td></tr></table></td></tr><![endif]-->
289+
<!-- end-block:two-buttons --><!-- begin-block:user -->
222290
<!--[if mso | IE]><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:560px;" width="560" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
223291
<div style="margin:0px auto;max-width:560px;">
224292
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">

0 commit comments

Comments
 (0)