Repository navigation
🎯 Config-only email customization: brand, layout, per-email templates #4127
Copy link
Copy link
Closed
7 / 77 of 7 issues completedClosed
7 / 77 of 7 issues completed
Copy link
Description
Problem
- Every email goes through
sendMail, which reads a hardcoded./config/templates/<name>.htmland compiles it as-is. There is no config key for templates, layout or brand. - The 15 shared templates are standalone HTML documents. Each one copies the shell, the sign-off and (in 6 of them) the CTA button. Colors are hardcoded, there is no logo, and 5 mails have no contact footer.
- The only way to customize an email today is to edit
config/templates/*.html. The update-stack drift gate blocks that edit, so a downstream has no legal way to change an email's look or copy. - The docs describe an override mechanism that does not exist: the welcome email section of
MIGRATIONS.md, and the JSDoc inmodules/billing/billing.email.jsabout a "template-resolution glob-merge". - Brand values are built by hand at every call site (
appName,appContact, and the app title in 8 subjects). Billing links readconfig.app.url, which no Devkit default config defines, so they render an empty href unless a downstream sets it.
Target design: three levels, config only
A downstream edits only its own config/defaults/<project>.config.js and its own config/templates/<project>/ folder. It never touches a shared file.
- Brand values:
mailer.brand. Set name, url, logoUrl, colors, font, contact, signature, footer text and links in config. The mailer injects them into every template, including templates that exist only in a downstream. - Own layout:
mailer.layout+mailer.partials. Point the layout and its partials (header, footer, button, styles) to project-owned files. The layout wraps every template body. - Own per-email template:
mailer.templates. Map one key to a project-owned file. Callers keep passing the same key.
Rules:
render(key, params, { subject })returns the html, andsendMailcalls it. The caller key goes throughpath.basename. A key in the map uses its path. Any other key falls back toconfig/templates/<key>.html, so templates that exist only in a downstream keep working. The default map is empty.- A configured path must be relative, end in
.htmland resolve inside the project root. Otherwise the mailer throws a clear error. - A template whose trimmed source starts with
<!doctypeor<html(case-insensitive) is a legacy full document and is sent unwrapped. Everything else gets the layout. - Rendering uses an isolated
handlebars.create()instance. Only the layout's{{{body}}}is unescaped. - Namespace: Devkit owns the flat
config/templates/*.htmlfiles and everyconfig/templates/_*folder.config/templates/<project>/belongs to the downstream. - Action links (reset, verify, invite, org) keep their current base URL.
brand.urlonly feeds the header, the logo and the footer link.
Merge order
- Billing links
- Resolver
- Brand
- Call sites + subjects, and layout (these two in parallel)
- Fragments
- Guide
The billing-links fix and the wrong-doc corrections do not depend on anything else.
Backward compatibility
- Templates that exist only in a downstream: nothing to change. The fallback finds them, and full documents are not wrapped.
- A shared template edited in place: move it to
config/templates/<project>/, add onemailer.templatesentry, then restore the shared file. The old full-document copy keeps rendering the whole time. - No new config: body copy, subjects and links are unchanged. Only the shell is normalized: one signature, and a contact footer on every mail.
Definition of Done
- Each of the 3 levels can be customized from project config plus a project folder, with zero shared-file edits. Tests use a mocked config shaped like a downstream override.
- Real-render tests cover all 15 shared templates, plus resolution order, containment, legacy passthrough and brand injection.
- Billing links are never empty, and a downstream that sets
app.urlkeeps its links. - Setting
mailer.brand.namechanges both the body and the subject of every mail that names the app. - No doc tells a downstream to edit a shared template, and the guide covers the 3 levels and the migration path.
Out of scope
- Per-template subject override (see open question)
- A separate sender name or reply-to setting (
mailer.fromalready accepts"Name" <address>) - i18n, plain-text parts, a CSS inliner, MJML and a compile cache
- DB-stored templates, an admin UI and per-organization branding
- Changes in downstream projects (each adopts this through update-stack)
Refs: lib/helpers/mailer/index.js, config/templates/, config/defaults/development.config.js (mailer), modules/billing/billing.email.js, MIGRATIONS.md, config/defaults/myproject.config.js. Baseline: origin/master 36703d2.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
PierreBrisorgueil commentedon Oct 4, 2026
All 7 sub-issues merged: #4128 (PR #4175), #4129 (#4164), #4130 (#4165), #4131 (#4177), #4132 (#4166), #4133 (#4178), #4134 (#4179).