Coverage for apps/communications/email_content.py: 97%

90 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +0000

1"""Branded email layout (brand-email-template). 

2 

3An email is a list of blocks. The same blocks give the plain-text part, which is what is stored on the 

4``Communication`` and keeps each email's wording exactly as written, and the HTML part, which renders 

5them in one branded layout (``templates/email/layout.html``): 

6 

7- ``Paragraph``: a normal paragraph. 

8- ``Action``: a lead line plus a link, shown as a button. 

9- ``Details``: ``Label: value`` lines, shown in a highlighted box. 

10- ``Code``: a one-time code, shown large. 

11- ``Note``: small print. 

12 

13Free text (staff Compose, message templates) becomes paragraphs via ``EmailContent.from_text``. 

14""" 

15 

16from __future__ import annotations 

17 

18import html 

19import re 

20from dataclasses import dataclass, field 

21 

22from django.template.loader import render_to_string 

23from django.utils.safestring import SafeString, mark_safe 

24 

25_URL_RE = re.compile(r"(https?://[^\s<]+)") 

26 

27 

28def _inline_html(text: str) -> SafeString: 

29 """Escaped text with clickable links and line breaks.""" 

30 escaped = html.escape(text) 

31 linked = _URL_RE.sub( 

32 lambda m: f'<a href="{m.group(1)}" style="color:#8A6A2B;text-decoration:underline">' 

33 f"{m.group(1)}</a>", 

34 escaped, 

35 ) 

36 return mark_safe(linked.replace("\n", "<br>")) 

37 

38 

39@dataclass(frozen=True) 

40class Paragraph: 

41 text: str 

42 kind = "paragraph" 

43 

44 def as_text(self) -> str: 

45 return self.text 

46 

47 @property 

48 def html(self) -> SafeString: 

49 return _inline_html(self.text) 

50 

51 

52@dataclass(frozen=True) 

53class Action: 

54 """A lead line and a link, e.g. "Reset it here (link expires in 1 hour):" + the reset URL. 

55 Shown as a button labelled ``label``, with the raw link underneath as a fallback.""" 

56 

57 lead: str 

58 url: str 

59 label: str 

60 kind = "action" 

61 

62 def as_text(self) -> str: 

63 return f"{self.lead}\n{self.url}" 

64 

65 @property 

66 def lead_html(self) -> SafeString: 

67 return _inline_html(self.lead.rstrip(":")) 

68 

69 

70@dataclass(frozen=True) 

71class Details: 

72 """``Label: value`` rows, e.g. the email and temporary password of an invite.""" 

73 

74 rows: tuple[tuple[str, str], ...] 

75 kind = "details" 

76 

77 def as_text(self) -> str: 

78 return "\n".join(f"{label}: {value}" for label, value in self.rows) 

79 

80 

81@dataclass(frozen=True) 

82class Code: 

83 """A one-time code: "{lead} {code}. {hint}" in text, a large code box in HTML.""" 

84 

85 lead: str 

86 code: str 

87 hint: str = "" 

88 kind = "code" 

89 

90 def as_text(self) -> str: 

91 return f"{self.lead} {self.code}." + (f" {self.hint}" if self.hint else "") 

92 

93 

94@dataclass(frozen=True) 

95class Note: 

96 """Small print, e.g. "If you didn't request this, you can safely ignore this email.".""" 

97 

98 text: str 

99 kind = "note" 

100 

101 def as_text(self) -> str: 

102 return self.text 

103 

104 @property 

105 def html(self) -> SafeString: 

106 return _inline_html(self.text) 

107 

108 

109Block = Paragraph | Action | Details | Code | Note 

110 

111 

112@dataclass(frozen=True) 

113class EmailContent: 

114 blocks: tuple[Block, ...] = field(default_factory=tuple) 

115 

116 @classmethod 

117 def of(cls, *blocks: Block | None) -> EmailContent: 

118 """Build from blocks, skipping ``None`` (handy for optional paragraphs).""" 

119 return cls(tuple(b for b in blocks if b is not None)) 

120 

121 @classmethod 

122 def from_text(cls, body: str) -> EmailContent: 

123 """Free text (staff Compose / templates): blank-line separated paragraphs.""" 

124 paragraphs = [p.strip("\n") for p in re.split(r"\n\s*\n", body or "")] 

125 return cls(tuple(Paragraph(p) for p in paragraphs if p.strip())) 

126 

127 def to_text(self) -> str: 

128 return "\n\n".join(b.as_text() for b in self.blocks) 

129 

130 def preheader(self) -> str: 

131 """The inbox preview line: the first paragraph (or code lead), one line, ≤ 120 chars.""" 

132 for b in self.blocks: 132 ↛ 136line 132 didn't jump to line 136 because the loop on line 132 didn't complete

133 if isinstance(b, Paragraph | Code): 133 ↛ 132line 133 didn't jump to line 132 because the condition on line 133 was always true

134 line = " ".join(b.as_text().split()) 

135 return line if len(line) <= 120 else line[:117].rstrip() + "…" 

136 return "" 

137 

138 

139@dataclass(frozen=True) 

140class Brand: 

141 """Who the email is from: the lender's name and the footer lines (Admin → Company).""" 

142 

143 name: str 

144 footer: tuple[str, ...] = () 

145 

146 

147def company_brand() -> Brand: 

148 """The brand from the Company record; empty fields are left out of the footer.""" 

149 from apps.accounts.admin_api import _get_company 

150 

151 company = _get_company() 

152 name = company.dba or company.name or "Lumen Lending" 

153 city_line = " ".join( 

154 p for p in [", ".join(p for p in [company.city, company.state_code] if p), 

155 company.postal_code] if p 

156 ) 

157 address = ", ".join(p for p in [company.address_line, city_line] if p) 

158 contact = " · ".join( 

159 p for p in [company.phone, f"NMLS #{company.nmls_id}" if company.nmls_id else ""] if p 

160 ) 

161 legal = company.name if company.dba and company.name and company.name != company.dba else "" 

162 footer = tuple(line for line in [legal, address, contact] if line) 

163 return Brand(name=name, footer=footer) 

164 

165 

166def render_html(content: EmailContent, subject: str, brand: Brand | None = None) -> str: 

167 """The branded HTML part for ``content``.""" 

168 brand = brand or company_brand() 

169 return render_to_string( 

170 "email/layout.html", 

171 { 

172 "subject": subject, 

173 "brand": brand, 

174 "blocks": content.blocks, 

175 "preheader": content.preheader(), 

176 }, 

177 )