Scoping document

Tenant-customizable email templates and branding

Multi-tenant LMS. Backend FastAPI, frontend React, platform-admin Next.js.
Status as of 14 Sep 2026: Scoping, not started.
No code written. This document sets direction and a ticket plan for review by Khang.
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.

Contents

1. Current state and gaps

Already works good

  • Per-tenant sender identity: from-name, reply-to, optional tenant SMTP.
  • 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.

TypeTemplateTrigger
Student addedsimple.htmladmin adds student
Tenant invitationsimple.htmlinvite created
Test emailsimple.htmladmin clicks test
Enrollment welcomesimple.htmlenrollment completes
Admin enrollment / payment noticesimple.htmlpaid enrollment
Assessment deadline reminderassessment_deadline_reminder.htmlcron
Live class reminderlive_class_reminder.htmlcron

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.

OptionWhat tenant controlsEditorEffortRiskVerdict
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).

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 B: JSONB tenants.email_branding for header tagline, footer signature, intro/outro, alignment, enabled, updated_by.
  • Only add a tenant_email_templates table if per-type templates ever land. Defer.
  • Migrations generated with Alembic; additive and backward compatible.

API

  • GET/PUT /admin/tenant-settings/email-branding; sanitize on write.
  • POST .../email-branding/preview returns safe HTML, no send.
  • Reuse existing test-send and asset-upload endpoints; add an email asset kind.
  • Platform-admin read and reset mirror.
  • Return raw plus effective_* values, like the current email settings contract.

Rendering pipeline

  • Extend EmailBrand with resolved email colors (primary, CTA, footer, foregrounds).
  • Port a small sRGB darken plus luminance foreground pick to the backend; no full OKLCH needed.
  • Templates read brand colors instead of hardcoded hex.
  • Styles stay inline; add css_inline only if we move to a shared style block.
  • Build a real plain-text fallback from the content.
  • Optionally cache the resolved color set per tenant briefly.

Sender identity

  • From-name is the tenant name; reply-to is the tenant contact. Already done.
  • From-address stays on the platform relay unless custom SMTP is set.
  • Verified domain and DKIM are a follow-up, out of scope here.

7. Frontend and rollout

Frontend

  • Phase A: add an email preview panel to the existing Branding page.
  • Phase B: an Emails section with a reduced Quill editor and image upload.
  • Live preview via sandboxed iframe fed by the server preview endpoint.
  • Works at 360px; preview scrolls in its own container; strings via i18n.

Rollout and fallback

  • Default is today's look; a tenant that sets nothing is unchanged.
  • Gate the Phase B editor behind email.branding.enabled.
  • Phase A auto-theme needs no flag; safe for everyone.
  • The default (platform) tenant must not change.

8. Open decisions for Khang

#DecisionRecommendation
1Ship A first, then B?Yes. A is low risk, fixes the main complaint.
2CTA color: derive from primary or explicit field?Derive from primary; allow override later.
3Phase B editor: Quill or Markdown?Quill; tenants know it and it uploads to R2.
4Backend sanitizer: nh3 or bleach?nh3; fast and maintained.
5External images: R2 only or any https?R2 uploads only.
6Email language: add English now?Keep Vietnamese; multi-locale is a later ticket.

9. Ticket breakdown

TicketScopeSizeDepends on
SP-XXX (parent)Tenant-branded transactional emails--
SP-XXX-1Backend color helper (hex to shades, readable foreground)Mnone
SP-XXX-2Templates use resolved colors + logo alignment; real plain-textM1
SP-XXX-6Preview render endpoint (safe HTML, no send)S2
SP-XXX-3Frontend email preview panel on Branding pageS2, 6
SP-XXX-4Backend sanitizer (nh3) + placeholder substitutionMnone
SP-XXX-5Data model + tenant-admin API for content blocksL4
SP-XXX-8Entitlement flag + platform-admin read/resetS5
SP-XXX-7Frontend Emails settings UI (reduced Quill + preview)M5, 6

Order: 1, 2, 6, 3 (Phase A ships) then 4, 5, 8, 7 (Phase B ships).

Prepared for review. See scoping.md for the full text and findings.md for file references.