33"""
44
55import re
6- from dataclasses import dataclass
6+ from dataclasses import asdict , dataclass
77from functools import lru_cache
88from html import unescape
99from 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 )
3131class 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 )
3838class 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 )
4646class 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 )
7575class 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 )
8383class 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+
90100class 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 (),
0 commit comments