# 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 ```bash pm-cli-cms email-send --page-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-` | — | Template variable: `--var-name=hobbit` → `${name}` becomes `hobbit` | ### Examples ```bash # 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): ```json [ { "email": "user@example.com", "name": "John", "Language": "en" }, { "email": "other@example.com", "name": "Jane", "Language": "de" } ] ``` Object format (legacy): ```json { "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`](../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-=`: ```bash --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 `` and `` in the email gets `?tracking=` 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`](../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/` 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`](../public/widgets/email-clean/social_links.html): ```html Unsubscribe ``` ### 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](../cli-ts/src/lib/email-send.ts) | Core library: auth, fetch, substitute, track, send | | [send.ts](../cli-ts/src/commands/email/send.ts) | CLI wrapper (yargs args, var extraction) | | [index.ts](../server/src/products/email/index.ts) | Server EmailProduct (render, send, track, unsubscribe) | | [routes.ts](../server/src/products/email/routes.ts) | Route definitions (zod-openapi) | | [pages-email.ts](../server/src/products/serving/pages/pages-email.ts) | Page → email HTML renderer | | [social_links.html](../public/widgets/email-clean/social_links.html) | Email footer template (social icons + unsubscribe) | | [nodemailer/index.ts](../../polymech-mono/packages/mail/src/lib/nodemailer/index.ts) | `@polymech/mail` transport wrapper |