mono/packages/ui/docs/emails.md
2026-03-21 20:18:25 +01:00

8.7 KiB

Email System

Newsletter and marketing email pipeline — from page rendering to delivery and tracking.

Architecture Overview

┌──────────────┐     fetch HTML     ┌──────────────────┐
│  CLI         │ ──────────────────>│  Server           │
│  email-send  │     /email-preview │  (pages-email.ts) │
│              │<───────────────────│                    │
│  sends via   │     rendered HTML  └──────────────────┘
│  @polymech/  │
│  mail        │── POST /track ───> ┌──────────────────┐
│              │<── {id, token} ──  │  Server API       │
│              │── PATCH /track/:id │  (EmailProduct)   │
│              │                    │  → marketing_emails│
└──────────────┘                    └──────────────────┘

Flow:

  1. CLI authenticates with Supabase (get bearer token)
  2. Optionally filters unsubscribed (--check-unsubs) and already-sent (--check-sent) recipients
  3. Fetches rendered email HTML from server (/user/:id/pages/:slug/email-preview)
  4. Applies --var-* template variables (keep=true to preserve ${unsubscribe})
  5. Injects tracking parameters into all links/images (via cheerio)
  6. For each recipient:
    • POST /api/email/track → creates pending row, returns {id, unsubscribe_token}
    • Substitutes ${unsubscribe} with per-recipient unsubscribe URL
    • Sends via @polymech/mail (nodemailer, newsletter transport)
    • PATCH /api/email/track/:id → updates status to sent/failed

CLI Usage

pm-cli-cms email-send --page-slug <slug> [options]

Required

Arg Description
--page-slug Page slug to render as email

Optional

Arg Default Description
--user cgo User identifier (username or UUID)
--subject Newsletter Polymech - DD:HH:mm Email subject line
--recipient cgoflyn@gmail.com Single email or path to .json contacts file
--targethost https://service.polymech.info Server base URL
--lang Language tag for translated content
--dry false Log actions without sending
--tracking mail-DD-HH-mm Tracking param appended to all href/src URLs
--campaign Campaign identifier string
--check-unsubs false Query DB and skip unsubscribed recipients
--check-sent false Skip recipients already sent to (matches email + campaign + subject)
--var-<key> Template variable: --var-name=hobbit${name} becomes hobbit

Examples

# Basic send
pm-cli-cms email-send --page-slug newsletter-march

# Send to contacts file with campaign tracking
pm-cli-cms email-send --page-slug newsletter-march \
  --recipient ./ref/cscart-contacts.json \
  --campaign spring-2026 \
  --var-emailName "Dear Customer"

# Dry run with language override
pm-cli-cms email-send --page-slug newsletter-march \
  --dry --lang de

# Full safety checks (skip unsubs + already sent)
pm-cli-cms email-send --page-slug newsletter-march \
  --recipient ./ref/cscart-contacts.json \
  --campaign spring-2026 \
  --check-unsubs --check-sent

# Send to a single recipient
pm-cli-cms email-send --page-slug email-2026 --targethost http://localhost:3333/ --var-emailName "Hobbit Ex" --subject test2 --campaign=test2222 --lang=de

Contacts File Format

JSON array with email and optional name fields (e.g. CS-Cart export):

[
  { "email": "user@example.com", "name": "John", "Language": "en" },
  { "email": "other@example.com", "name": "Jane", "Language": "de" }
]

Object format (legacy):

{
  "contact1": { "email": "user@example.com" },
  "contact2": { "email": "other@example.com" }
}

Only entries with a non-empty email field are used. See cli-ts/ref/cscart-contacts.json for a real example.

Template Variables

The server renders email HTML with substitute() from @polymech/commons/variables. Unresolved ${key} patterns are preserved (keep=true) so the CLI can resolve them client-side.

Pass variables via --var-<key>=<value>:

--var-emailName "John Doe" --var-company "Acme Inc"

This replaces ${emailName} and ${company} in the rendered HTML.

Reserved variable: ${unsubscribe} — auto-substituted per recipient with the unsubscribe URL. Do not pass this via --var-*.

Tracking

Every <a href> and <img src> in the email gets ?tracking=<value> appended (via cheerio DOM manipulation). Defaults to mail-DD-HH-mm timestamp.

The tracking ID is also stored in marketing_emails.tracking_id.

Server API Endpoints

Method Path Auth Description
GET /api/email/unsubscribe/:token None Marks recipient as unsubscribed
POST /api/email/track Admin Creates pending tracking row, returns {id, unsubscribe_token}
PATCH /api/email/track/:id Admin Updates row status (sent/failed/bounced)
GET /api/render/email/:id Auth Renders email HTML for a post
POST /api/send/email/:id Auth Generates and sends email for a post

Database: marketing_emails

Column Type Notes
id uuid PK, auto-generated
name text Contact name
email text not null
status text pending / sent / failed / bounced
sent_at timestamptz When successfully sent
page_slug text not null
subject text Email subject
tracking_id text Tracking tag
campaign text Campaign identifier
lang text Language used
error_message text Failure reason
retry_count int Send attempts (default 0)
last_retry_at timestamptz Last retry timestamp
sender_id uuid FK → auth.users
from_address text Sender email
unsubscribed boolean Default false
unsubscribed_at timestamptz When unsubscribed
unsubscribe_token uuid Auto-generated, used in unsubscribe links
meta jsonb Flexible metadata (vars, targethost)

Indexes: email, status, page_slug, tracking_id, campaign, unsubscribe_token

Migration: supabase/migrations/20260302163400_create_marketing_emails.sql

Unsubscribe

Flow

  1. CLI inserts a pending row via POST /api/email/track → gets unsubscribe_token
  2. CLI substitutes ${unsubscribe} in HTML with targethost/api/email/unsubscribe/<token>
  3. Email is sent with the per-recipient unsubscribe URL
  4. Recipient clicks → GET /api/email/unsubscribe/:token → sets unsubscribed=true, shows confirmation

Template

The unsubscribe link lives in public/widgets/email-clean/social_links.html:

<a href="${unsubscribe}">Unsubscribe</a>

RLS

  • Admins: full access to all rows
  • Authenticated users: can view rows matching their email
  • Anonymous: can update unsubscribed=true only (via token)

Environment Variables

Variable Required Description
SUPABASE_URL Supabase project URL
SUPABASE_ANON_KEY Supabase anon/publishable key
ADMIN_EMAIL Admin email for auth
ADMIN_PASSWORD Admin password for auth
TEST_EMAIL_FROM Sender address (default: newsletter@osr-plastic.org)

Source Files

File Description
email-send.ts Core library: auth, fetch, substitute, track, send
send.ts CLI wrapper (yargs args, var extraction)
index.ts Server EmailProduct (render, send, track, unsubscribe)
routes.ts Route definitions (zod-openapi)
pages-email.ts Page → email HTML renderer
social_links.html Email footer template (social icons + unsubscribe)
nodemailer/index.ts @polymech/mail transport wrapper