Status as of 15 Sep 2026: Implemented, PRs open, not merged, not on beta.
Parent SP-721.
Phase A SP-730 = PR
#453 (CI green).
Phase B SP-731 = PR
#454, stacked on #453;
re-target to main after #453 merges.
Both verified locally in Docker on 15 Sep 2026 with screenshots below.
Gmail and other-client manual check still pending on beta.
On one page: The per-tenant email sender identity and brand
object already exist. The gap is that email colors are still hardcoded and
there is no tenant editor. We recommend shipping an auto-themed layout first
(low risk), then adding safe tenant content blocks with the existing rich-text
editor.
A brand object with name, logo, address, contact, phone, site URL.
Logo already limited to png/jpg/gif (no svg/webp in email).
Tenant context reaches async sends through post-commit hooks.
Tenant colors exist as branding.colors (primary, secondary, footer).
Gaps fix
Email colors are hardcoded. CTA is always blue #2563eb.
Tenant colors never reach email; the color math is frontend-only.
No tenant-facing email editor.
No backend HTML sanitizer and no CSS inliner installed.
Templates are hardcoded Vietnamese; weak plain-text fallback.
brand.site_url and brand.phone resolved but unused.
Email types today
All go through one send_custom_email path and one of three templates. No password-reset email (Firebase owns auth). No customer invoice email yet.
Type
Template
Trigger
Student added
simple.html
admin adds student
Tenant invitation
simple.html
invite created
Test email
simple.html
admin clicks test
Enrollment welcome
simple.html
enrollment completes
Admin enrollment / payment notice
simple.html
paid enrollment
Assessment deadline reminder
assessment_deadline_reminder.html
cron
Live class reminder
live_class_reminder.html
cron
2. Goals and non-goals
Goals
Emails match tenant logo, colors, name, footer, and links.
Tenant R2 images allowed inside emails.
Tenant admins adjust safe content without a developer.
Graceful fallback to today's look when nothing is set.
Keep the working sender identity untouched.
Non-goals (first release)
No full raw-HTML template override per email type.
No per-tenant verified domain or DKIM.
No new email types.
No drag-and-drop builder.
No multi-locale content editing UI yet.
3. Design options
All options keep a locked, table-based layout shell for email client safety.
Option
What tenant controls
Editor
Effort
Risk
Verdict
A. Branded layout only
Colors, logo, name, footer from existing branding
None (uses Branding page)
S
very low
Ship first
B. A + content blocks
A, plus header tagline, footer signature, intro/outro text
Reduced Quill editor
M
medium
Ship second
C. Full raw template per type
Whole HTML body per email type
Full HTML editor
L
high
Skip
D. Markdown into shell
Markdown body per type
Markdown editor
M
medium
Later, if asked
Recommendation: A now, B as a fast follow
A closes the main complaint (emails look off-brand) with almost no risk and
reuses colors that already exist. B then adds real voice control by reusing the
Quill editor with a reduced toolbar and a backend sanitizer, storing only small
content blocks, never a full body. Skip C. Revisit D only on demand.
Reuse the existing react-quill-new editor for B.
It already uploads images to R2. What it lacks for email: table layout (kept in
the locked shell), an email-client preview and width guard (we add an iframe and
fixed 600px width), email-safe output (we strip classes and keep an inline-style
allowlist on save), and dark-mode handling (done in the shell, not tenant HTML).
4. Logo placement and external images
Logo
Default header, centered; offer left or centered only.
Cap height 40px; set explicit height/width for Outlook.
Upload at 2x, render at 1x; no srcset (email ignores it).
No email-safe logo means the tenant name shows as text.
Served from R2 public URL; long cache lifetime.
External images
Tenant R2 uploads only. No arbitrary https URLs (tracking, privacy).
Reuse presigned PUT; keys under the tenant email prefix.
png/jpg/gif only; about 1MB cap; 600px max width.
On save, every image must point at this tenant's prefix.
Deleted image shows broken; warn on delete or clean up later.
5. Variables and sanitization
Variables (Option B)
Expose a small fixed set: user first name and email, tenant name, contact,
site URL, course title and URL, plus action_url.
Sandbox: use simple named placeholder substitution, not a full Jinja2
environment. Replace known tokens from a fixed dict, escape every value, and
leave unknown tokens blank. Keep full Jinja2 only for the shell we control.
Sanitization and security
Add backend sanitizer nh3 (fast, maintained). Run on save and render.
Allowlist tags and a narrow inline-style set; no class, no id.
Block script, style, iframe, handlers, javascript: and data:.
Links: only http, https, mailto; force rel=noopener.
Enforce tenant image prefix so tenants cannot cross-reference assets.
Size caps per block and per tenant.
Preview in a sandboxed iframe with srcdoc, scripts off.
6. Data model, API, and rendering
Data
Phase A: no new table. Reuse branding.colors and logo; add an optional email_layout sub-object.
Phase A: tenant-branded email shell and preview. Backend color helper, themed templates with logo alignment, real plain-text fallback, preview endpoint, preview panel on the Branding page.
Each phase ships as one PR titled with its key. The detailed checklist for each phase lives in the ticket description.
10. As built
This section records what actually shipped, where it differs from the plan, and what we found while verifying.
a) Phase A (SP-730)
Backend color helper: sRGB darken plus WCAG luminance to pick a readable foreground. Falls back to the old hardcoded hex palette when a tenant has no colors.
EmailBrand extended with resolved colors and logo alignment. Logo alignment is stored in tenants.branding.email_logo_alignment, so no migration was needed.
Three templates themed with inline styles that read the brand colors.
Plain-text part derived from the rendered HTML.
Preview endpoint POST /admin/tenant-settings/email-branding/preview.
Preview card in the UI (later moved to the Emails page in Phase B).
b) Phase B (SP-731)
nh3 sanitizer. Allowlist: p, br, strong, em, u, a, ul, ol, li, span, img. Links only http, https, mailto with a forced rel. Inline style limited to color, font-weight, text-decoration. Images only under tenants/{id}/email/ on R2. data: URLs, other hosts, and blocks over 10 KB are rejected. Runs on save and on render.
Placeholder substitution of a fixed token set, with escaping.
Entitlement email.branding.enabled, off by default. PUT is refused when off; GET is allowed.
GET/PUT /admin/tenant-settings/email-branding.
Email image presign and complete endpoints. png/jpg/gif, 1 MB cap.
Render slots: tagline under the logo, intro above the body, outro after the CTA, signature replaces the default sign-off.
Platform endpoints GET /platform/tenants/{id}/email-branding and POST .../reset.
New tenant admin page /admin/email-content (hidden when the flag is off): enabled switch, logo position, tagline and signature with counters, intro and outro in a reduced Quill toolbar (bold, italic, underline, lists, link, text color, image), placeholder helper, debounced sandboxed live preview, dirty state, and inline field errors.
Platform-admin read-only Emails tab with reset and a rendered sample-email preview (its own platform preview endpoint, same render path).
c) Differences from the plan
Logo alignment lives in the branding JSONB, not in email_branding.
The Phase A preview card moved from the Branding page to the new Emails page.
There is no separate email_layout object.
d) Issues found during verification, and known limits
Found and fixed fixed
The header tagline used the footer text color and was invisible on the white header. Fixed to a neutral grey.
Quill stores spaces as non-breaking spaces, and nh3 writes them back as , so {{ token }} placeholders never matched. Fixed by normalising nbsp on save and making the matcher tolerant (commit 8f7e1d02).
Known limits watch
The email table is a fixed 600px wide (pre-existing). Narrow clients that do not scale will clip it.
Local image upload from the editor failed only because the R2 bucket CORS does not include the local .test origin. Needs a beta check.
Two pre-existing test dictionaries were updated for the new branding key.
e) Screenshots
All captured locally in Docker on 15 Sep 2026.
Rendered email, tenant colors, logo left (Phase A).Rendered email with no branding, same as before.Rendered email with all content blocks through the real render path.Admin sidebar with the new Email content item.Branding page keeps colors and logo only.Emails page before edits, desktop.Emails page with blocks filled and live preview, desktop.Preview iframe after the nbsp fix, placeholders substituted.Saved state.Fresh session shows persisted values.Editor image upload blocked locally by R2 CORS for the .test origin.Platform admin read-only Emails tab with reset and the rendered preview.
Prepared for review. See scoping.md for the full text and findings.md for file references.