# Email sending (marketing campaigns) Tenant email delivery for Descrybe v2 marketing campaigns. **Platform invite SMTP** (`SMTP_*` + `internal/mail`) is separate and must not be used for blasts. Schema lives in `011_email_campaigns.sql` (`email_providers`, `email_unsubscribes`, `email_sends`). Pending unsubscribe tokens: `014_email_unsub_pending.sql`. ## Providers | Provider | Company settings | Env fallback | |----------|------------------|--------------| | **Resend** | API key (AES-GCM in `secrets_enc`), `from_email`, domain in `config` | `RESEND_API_KEY` if company key empty | | **SMTP** | host/port/user/password in `secrets_enc`, from | `SMTP_HOST` / `SMTP_USER` / `SMTP_PASSWORD` | Configure in UI: **`/integrations/email`** (Account → Email sending). ## Environment Bootstrap encryption and rate limits live in the **root** `.env` (not `apps/api/.env`). Provider API keys belong in **`/integrations/email`**. | Variable | Required | Purpose | |----------|----------|---------| | `APP_ENCRYPTION_KEY` | **Prod yes** | AES-256-GCM for email secrets at rest (`enc:v1:…`). 32-byte hex or base64. | | `CREDENTIALS_ENCRYPTION_KEY` | fallback | Used if `APP_ENCRYPTION_KEY` unset. | | `TOKEN_SIGNING_SECRET` | fallback | Last-resort key material (dev only). | | `EMAIL_DRY_RUN` | no | `true` → never deliver; log dry-run / skipped sends. | | `RESEND_API_KEY` | no | Optional platform Resend key fallback (prefer dashboard). | | `EMAIL_SEND_RPM` | no | Per-company sends/minute (default `30`). | | `EMAIL_SEND_RPH` | no | Per-company sends/hour (default `500`). | | `PUBLIC_API_URL` | yes for unsub | Absolute API origin for `List-Unsubscribe` one-click URL. | | `WEB_ORIGIN` | yes | Origin for `/unsubscribe` landing links. | | `SMTP_*` | optional | Platform invite mailer (`internal/mail`); not a substitute for `/integrations/email`. | ```bash openssl rand -hex 32 ``` ## API | Method | Path | Notes | |--------|------|-------| | GET | `/api/integrations/email` | Public config (no secrets) | | PUT/PATCH | `/api/integrations/email` | Upsert; blank secrets keep existing | | POST | `/api/integrations/email/verify` | Resend domain API / SMTP domain match | | POST | `/api/integrations/email/test` | Test send; success → `status=verified` | | POST | `/api/email/send` | `mode=test\|blast`; blast needs `confirm_understood: "I understand"` | | GET/POST | `/api/public/unsubscribe?token=…` | One-click + landing | Blast without confirmation → `400`. Unverified blast → `412 email_not_verified`. Free plan / `EMAIL_DRY_RUN` → dry-run only. ## Setup 1. Set `APP_ENCRYPTION_KEY`; restart API. 2. `make migrate` (includes `011` + `014`). 3. Open `/integrations/email` → Resend or SMTP → save from + domain. 4. **Verify domain** (Resend: verify domain in Resend dashboard first). 5. **Send test** (marks provider verified). 6. Campaigns / blasts: `POST /api/email/send` with `confirm_understood: "I understand"`. Package: `internal/email` (AES-GCM, Resend/SMTP transports, rate limits, unsubscribe). Shared table with `internal/campaigns`.