Last Updated: 2026-06-28 Status: Active
Source of truth for the template authoring + render contract. Updated when the contract changes; not a roadmap.
Variable contract
Variables are written as {{ path.to.value }} (Mustache-style, whitespace tolerated). The send-time renderer in apps/newsletter-service/src/lib/render.ts resolves the paths below and only these. Tokens that don't match are passed through unchanged and logged so we can spot bad templates in prod.
| Token | Source | Notes |
|---|---|---|
{{ subscriber.name }} |
nl_subscribers.name |
Falls back to "there" when null. |
{{ subscriber.email }} |
nl_subscribers.email |
|
{{ subscriber.tags }} |
nl_subscribers.tags[] |
Comma-joined string. |
{{ subscriber.metadata.* }} |
nl_subscribers.metadata jsonb |
Dotted-path lookup. Missing key → empty string + log. |
{{ unsubscribe_url }} |
Built per-recipient | HMAC-signed when UNSUBSCRIBE_SECRET is set. |
{{ campaign.name }} |
nl_campaigns.name |
|
{{ campaign.subject }} |
nl_campaigns.subject |
|
{{ tenant.name }} |
comms_sender_settings.senderName via comms /internal/sender-settings |
Falls back to "Vlozi" when comms returns null/error. |
{{ tenant.from_email }} |
comms_sender_settings.fromEmail → senderEmail → platform default |
Resolution chain in that order. Falls back to hello@vlozi.app. |
{{ brand.logo_url }} |
comms_sender_settings.brandLogoUrl |
Tenant brand kit. Substitutes empty string when null — keeps <img src="…"> valid before configuration. |
{{ brand.primary_color }} |
comms_sender_settings.brandPrimaryColor |
Hex (#RRGGBB or #RGB). Validated at PUT time. Empty string when null. |
{{ now.year }} |
Render time | UTC. For footer copyright. |
Blog-post-triggered campaigns only (when source_kind = 'blog_post'):
| Token | Source | Notes |
|---|---|---|
{{ post.title }} |
Blog post title | |
{{ post.excerpt }} |
Blog post excerpt | |
{{ post.slug }} |
Blog post slug | Use to build the post URL: https://example.com/blog/{{ post.slug }} |
{{ post.cover_url }} |
Blog post featuredImageUrl |
Empty string when null |
{{ post.author_name }} |
Blog post author name |
These tokens are populated only when the campaign was created via /internal/blog/post-published. In regular campaigns they resolve to empty string.
Variables resolve in subject, htmlBody, textBody, and previewText.
HTML escaping
User-controlled string substitutions in htmlBody are HTML-escaped (& < > " '). This is non-negotiable — subscriber.name can contain <script> and we ship it to thousands of inboxes.
htmlBody→ escape every substitution.textBody→ no escaping.subject,previewText→ escape.unsubscribe_url→ already a URL we built; not escaped (must remain a usable href).
If a tenant ever needs raw HTML in a metadata field, that's a future opt-in — not v1.
Unknown-variable handling
On save (POST/PUT /templates):
- Backend extracts every
{{ x.y }}reference fromhtmlBodyand stores the unique list innl_templates.variables[](already implemented). - Backend does not reject unknown tokens.
- Frontend (template editor) compares the extracted list against this contract and renders an inline warning banner listing unknowns. The user can save anyway.
On render (queue worker):
- Unknown tokens pass through verbatim.
- One
console.warnper unknown token per send (sampled if needed).
Editor
CodeMirror 6 with @codemirror/lang-html, lazy-loaded on the template editor route. Light/dark theme tied to dashboard theme. Cmd/Ctrl+S saves. No WYSIWYG in v1; visual block builder (MJML) is deferred.
Templates ↔ campaigns: snapshot semantics
When a campaign is created from a template, the campaign row stores its own copy of subject, htmlBody, textBody, and previewText. The templateId column is a soft attribution link — it is not read at send time.
This means:
- Editing a template after a campaign has been created does not retroactively change that campaign. Drafts, scheduled sends, and sent campaigns all use the body bytes copied at create time.
- Deleting a template does not break or alter any campaign that was based on it.
- The dashboard surfaces the link as a "Based on …" attribution chip on the campaign create form, with copy that explains the snapshot semantics.
If we ever want "live templates" (campaigns re-render from the current template body at send), that's an opt-in mode, not a default — it would break the principle that scheduled sends are deterministic at the moment they were scheduled.
Out of scope for v1
- Conditionals / loops (
{% if %},{% for %}). - Per-template "test data" overrides (preview always uses canned mocks).
- Versioning / template history.
- A/B subject lines.
- MJML / visual block builder.
- Inline CSS auto-inliner (juice). Authors are expected to inline styles or use
<style>tags that survive Gmail.
Render version field
nl_templates.renderVersion: integer not null default 1 will be added when this contract is implemented (Phase 4). It pins each row to the renderer that authored it, so a future contract change can ship without retro-rendering existing templates.
Tenant lookup wire
fireCampaign and the template test-send route resolve tenant.* once per fire by calling comms over the COMMS_SERVICE Fetcher binding:
GET /internal/sender-settings?tenantId=<id>
x-internal-key: <INTERNAL_KEY>Returns the raw comms_sender_settings row or platform defaults when the tenant has nothing configured. Newsletter never reads the comms DB directly — the helper at apps/newsletter-service/src/lib/sender-settings.ts is the single touchpoint and is fail-soft (any error returns the platform-default pair so a campaign still ships).
Files this contract touches
apps/newsletter-service/src/lib/render.ts(pure renderer)apps/newsletter-service/src/lib/sender-settings.ts(comms /internal/sender-settings client + fallback)apps/newsletter-service/src/lib/campaign-send.ts(uses both — fetches sender once per fire, renders per recipient)apps/newsletter-service/src/routes/templates.route.ts(test-send mirrors the same render context)apps/newsletter-service/src/db/schema.ts(renderVersioncolumn)apps/newsletter-service/test/render.test.ts,test/campaign-send.test.ts(unit + integration coverage)apps/communication/src/index.ts(/internal/sender-settings route, INTERNAL_KEY-gated)apps/seller-dashboard/src/modules/newsletter/lib/variables.ts(single shared source for the contract list, picker palette, warning logic)