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
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Branded email layout (brand-email-template).
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``):
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.
13Free text (staff Compose, message templates) becomes paragraphs via ``EmailContent.from_text``.
14"""
16from __future__ import annotations
18import html
19import re
20from dataclasses import dataclass, field
22from django.template.loader import render_to_string
23from django.utils.safestring import SafeString, mark_safe
25_URL_RE = re.compile(r"(https?://[^\s<]+)")
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>"))
39@dataclass(frozen=True)
40class Paragraph:
41 text: str
42 kind = "paragraph"
44 def as_text(self) -> str:
45 return self.text
47 @property
48 def html(self) -> SafeString:
49 return _inline_html(self.text)
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."""
57 lead: str
58 url: str
59 label: str
60 kind = "action"
62 def as_text(self) -> str:
63 return f"{self.lead}\n{self.url}"
65 @property
66 def lead_html(self) -> SafeString:
67 return _inline_html(self.lead.rstrip(":"))
70@dataclass(frozen=True)
71class Details:
72 """``Label: value`` rows, e.g. the email and temporary password of an invite."""
74 rows: tuple[tuple[str, str], ...]
75 kind = "details"
77 def as_text(self) -> str:
78 return "\n".join(f"{label}: {value}" for label, value in self.rows)
81@dataclass(frozen=True)
82class Code:
83 """A one-time code: "{lead} {code}. {hint}" in text, a large code box in HTML."""
85 lead: str
86 code: str
87 hint: str = ""
88 kind = "code"
90 def as_text(self) -> str:
91 return f"{self.lead} {self.code}." + (f" {self.hint}" if self.hint else "")
94@dataclass(frozen=True)
95class Note:
96 """Small print, e.g. "If you didn't request this, you can safely ignore this email."."""
98 text: str
99 kind = "note"
101 def as_text(self) -> str:
102 return self.text
104 @property
105 def html(self) -> SafeString:
106 return _inline_html(self.text)
109Block = Paragraph | Action | Details | Code | Note
112@dataclass(frozen=True)
113class EmailContent:
114 blocks: tuple[Block, ...] = field(default_factory=tuple)
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))
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()))
127 def to_text(self) -> str:
128 return "\n\n".join(b.as_text() for b in self.blocks)
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 ""
139@dataclass(frozen=True)
140class Brand:
141 """Who the email is from: the lender's name and the footer lines (Admin → Company)."""
143 name: str
144 footer: tuple[str, ...] = ()
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
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)
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 )