Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
60 lines
3.0 KiB
Markdown
60 lines
3.0 KiB
Markdown
# 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`.
|