tenants.email_branding; the
email.branding.enabled flag is off by default for every tenant.
Gmail and other-client manual check still pending on beta.
brand object with name, logo, address, contact, phone, site URL.branding.colors (primary, secondary, footer).#2563eb.brand.site_url and brand.phone resolved but unused.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 |
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 |
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).
email prefix.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.
nh3 (fast, maintained). Run on save and render.javascript: and data:.rel=noopener.srcdoc, scripts off.branding.colors and logo; add an optional email_layout sub-object.tenants.email_branding for header tagline, footer signature, intro/outro, alignment, enabled, updated_by.tenant_email_templates table if per-type templates ever land. Defer./admin/tenant-settings/email-branding; sanitize on write..../email-branding/preview returns safe HTML, no send.effective_* values, like the current email settings contract.EmailBrand with resolved email colors (primary, CTA, footer, foregrounds).css_inline only if we move to a shared style block.email.branding.enabled.All six are now decided. Each followed the recommendation.
| # | Decision | Recommendation | Outcome |
|---|---|---|---|
| 1 | Ship A first, then B? | Yes. A is low risk, fixes the main complaint. | Decided Phased: A then B. |
| 2 | CTA color: derive from primary or explicit field? | Derive from primary; allow override later. | Decided CTA derived from branding primary. |
| 3 | Phase B editor: Quill or Markdown? | Quill; tenants know it and it uploads to R2. | Decided Reduced Quill editor. |
| 4 | Backend sanitizer: nh3 or bleach? | nh3; fast and maintained. | Decided nh3. |
| 5 | External images: R2 only or any https? | R2 uploads only. | Decided R2 uploads only. |
| 6 | Email language: add English now? | Keep Vietnamese; multi-locale is a later ticket. | Decided Vietnamese only. |
| Ticket | Scope | Size | Depends on | PR |
|---|---|---|---|---|
| SP-721 (parent) | Tenant-branded transactional emails | - | - | - |
| SP-730 | 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. | M | none | #453 merged 17 Sep 2026 |
| SP-731 | Phase B: tenant-editable email content blocks. nh3 sanitizer, placeholder substitution, email_branding JSONB + API, email asset uploads, entitlement, platform-admin view, Emails UI with reduced Quill and live preview. | L | SP-730 | #454 merged 17 Sep 2026 |
Each phase ships as one PR titled with its key. The detailed checklist for each phase lives in the ticket description.
This section records what actually shipped, where it differs from the plan, and what we found while verifying.
EmailBrand extended with resolved colors and logo alignment. Logo alignment is stored in tenants.branding.email_logo_alignment, so no migration was needed.POST /admin/tenant-settings/email-branding/preview.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.tenants.email_branding JSONB. Migration 516241d5e328, additive, default {}.email.branding.enabled, off by default. PUT is refused when off; GET is allowed.GET/PUT /admin/tenant-settings/email-branding.GET /platform/tenants/{id}/email-branding and POST .../reset./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.branding JSONB, not in email_branding.email_layout object. , so {{ token }} placeholders never matched. Fixed by normalising nbsp on save and making the matcher tolerant (commit 8f7e1d02)..test origin. Needs a beta check.All captured locally in Docker on 15 Sep 2026.
Prepared for review. See scoping.md for the full text and findings.md for file references.