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: Ticketed, not started. Parent SP-721; Phase A SP-730; Phase B SP-731. One PR per phase.
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-721 (parent)Tenant-branded transactional emails--
SP-730Phase 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.Mnone
SP-731Phase 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.LSP-730

Each phase ships as one PR titled with its key. The detailed checklist for each phase lives in the ticket description.

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