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

211 lines
8.7 KiB
Markdown

# 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 <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
```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-<key>=<value>`:
```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 `<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`](../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`](../public/widgets/email-clean/social_links.html):
```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](../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 |