logicspike/docs

Newsletter

Newsletter Templates — v1 Spec

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.fromEmailsenderEmail → 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 from htmlBody and stores the unique list in nl_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.warn per 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 (renderVersion column)
  • 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)
Newsletter