Repository navigation
Rework EMailTemplate: blocks rendered at the end, one template file per block, shared design tokens #65330
Description
After the email redesign for 36 (#65193, #65194, #65312 and the PRs moving mails over), lib/private/Mail/EMailTemplate.php has become hard to change, and hard for customers to customise. Ten PRs in, these are the parts that slowed us down:
| Today | What it cost |
|---|---|
The HTML lives in ~35 PHP heredocs filled with positional vsprintf (%7\$s) |
Every visual tweak is a careful edit of long strings |
Colors, radius and font stack repeated in about 50 inline styles, dark mode as a separate set of rules in <head> |
The NcNoteCard colors (#65222) and dark mode links touched dozens of places |
bodyOpened / bodyListOpened / footerAdded decide where HTML gets appended |
Headings and the sender block have to close the body by hand |
addBodyText($text, $plainText) silently treats $text as raw HTML when $plainText is given |
That's how the &amp; heading bug happened (#65219) |
| Right-to-left mirroring is a regex over the finished HTML (#65312) | Works, but it's a workaround |
Byte-exact whole-email fixtures, plus full HTML copied into app tests (NewUserMailHelperTest) |
Each style change rewrites 5+ fixtures |
Customers customise by copying and patching the protected HTML strings (mail_template_class) |
Every redesign breaks their templates, see nextcloud-gmbh/email_template_example#10 |
Note
mail_template_class stays the supported way for customers to customise emails, and gets simpler. The public API (IEMailTemplate, EMailDetails) doesn't change.
Proposal, in four PRs:
- Blocks first, render last. Each
add*()call appends a small block object,renderHtml()andrenderText()walk the list at the end. Each block also knows its plain text. No more open/close state, HTML and text can't drift apart. Pure refactor: the existing fixtures must stay byte-identical. - One template file per block in
core/templates/mail/(layout.php,heading.php,note.php,details.php...), rendered withITemplateManagerandp(), so escaping is the default. - Design tokens defined once (text, muted, borders, backgrounds, note colors, radius, font) in a small
EMailStyle, used by the templates and the dark mode rules. Direction is a token too (start/end), which replaces the regex mirroring. - Tests per block: a small fixture per block and direction, a couple of full email snapshots, and app tests asserting template calls instead of comparing HTML.
With that, a customer's mail_template_class can customise at the level it needs, without copying HTML:
| Level | How | Survives a redesign |
|---|---|---|
| Branding | Theming app: logo, color, name, slogan. No code | Yes |
| Look and feel | Override getStyle() and change tokens: font, colors, radius, backgrounds |
Yes |
| One block's markup | Ship templates/mail/<block>.php in the customer's app, set $templateApp. Lookup falls back to core for every other block |
Mostly, if the block's parameters stay |
| Behaviour per email | Override add*() methods, use $this->emailId and $this->data, as today |
Yes |
These extension points (getStyle(), the token names, each block template's parameters, the add*() methods) become a documented contract with @since, covered by a test rendering a sample customer subclass.
Existing subclasses that override the old HTML strings get a compatibility layer for one release: an overridden string like $header is still used for its block, with a deprecation warning in the log pointing to the matching template file. It's removed the release after. Subclasses overriding methods need nothing.
Customer material to update along the way: the email_template_example app (one small example per level), the admin manual section on mail_template_class, and the portal article on customised email templates.
Not planned: a CSS inliner library or MJML. Plain PHP tokens are enough and add no dependency.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTo triage
Activity