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:
- CLI authenticates with Supabase (get bearer token)
- Optionally filters unsubscribed (
--check-unsubs) and already-sent (--check-sent) recipients - Fetches rendered email HTML from server (
/user/:id/pages/:slug/email-preview) - Applies
--var-*template variables (keep=trueto preserve${unsubscribe}) - Injects tracking parameters into all links/images (via cheerio)
- 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,newslettertransport) PATCH /api/email/track/:id→ updates status tosent/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
- CLI inserts a
pendingrow viaPOST /api/email/track→ getsunsubscribe_token - CLI substitutes
${unsubscribe}in HTML withtargethost/api/email/unsubscribe/<token> - Email is sent with the per-recipient unsubscribe URL
- Recipient clicks →
GET /api/email/unsubscribe/:token→ setsunsubscribed=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=trueonly (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 |