Email Campaigns
Prov0.1.1Send marketing and product-update emails to segments of your users and imported contacts: Markdown campaigns with merge tags, scheduling, batched sending through Resend with daily caps, one-click unsubscribe and bounce suppression.
terminalnpx ajolla add email-campaignsWhat you get
- Segments of your users by sign-up date, language or profile metadata, plus imported contacts
- CSV import with validation, deduplication and optional double opt-in
- Markdown editor with merge tags ({{first_name|there}}), live preview and test sends
- Send now or schedule; cancel, pause and resume
- Batched, idempotent sending with retries, per-run and per-day caps
- One-click unsubscribe (RFC 8058 List-Unsubscribe) and a signed-out preferences page
- Delivery, bounce and complaint tracking via Resend webhooks, with automatic suppression
- Per-campaign stats: sent, failed, delivered, bounced, unsubscribed
Routes it adds
/admin/campaigns/admin/campaigns/[campaignId]/admin/campaigns/contacts/admin/campaigns/new/admin/campaigns/segments/admin/campaigns/segments/[segmentId]/admin/campaigns/settings/unsubscribe/[token]
/api/ajolla/email-campaigns/send/api/ajolla/email-campaigns/unsubscribe/api/ajolla/email-campaigns/webhook
Plus 1 Supabase migration with row-level security, and an AGENTS.md your coding agent reads after install.
Installs with
Configuration
- RESEND_API_KEY
- Resend API key (shared with Notifications). When unset, campaign emails are printed to the server console instead of sent.
- EMAIL_CAMPAIGNS_FROM_EMAIL
- Verified sender for campaigns, e.g. "Acme <news@acme.com>". Falls back to NOTIFICATIONS_FROM_EMAIL. Required when RESEND_API_KEY is set.
- NEXT_PUBLIC_SITE_URL (required)
- Your site's origin (https://example.com). Required: every campaign email links to /unsubscribe/<token>.
- EMAIL_CAMPAIGNS_APP_NAME
- Sender name used when a campaign has no from name (default "Newsletter").
- EMAIL_CAMPAIGNS_BATCH_SIZE
- Max emails per sender run (default 100, max 1000).
- EMAIL_CAMPAIGNS_DAILY_LIMIT
- Max campaign emails per UTC day (default 1000). Protects your sender reputation and email bill.
- EMAIL_CAMPAIGNS_SEND_INTERVAL_MS
- Pause between sends in ms (default 500, matching Resend's default 2 requests/second).
- EMAIL_CAMPAIGNS_MAX_AUDIENCE
- Max recipients per campaign (default 50000).
- RESEND_WEBHOOK_SECRET
- Signing secret (whsec_…) of a Resend webhook pointed at /api/ajolla/email-campaigns/webhook. Enables delivered/bounced/complained tracking.
- EMAIL_CAMPAIGNS_LOGO_URL
- https URL of a PNG/JPG logo for campaign emails (falls back to NOTIFICATIONS_LOGO_URL).
- EMAIL_CAMPAIGNS_BRAND_COLOR
- Hex colour for campaign links and the accent bar (falls back to NOTIFICATIONS_BRAND_COLOR).
Events
Other modules (and your own code) can react to these:
email_campaigns.campaign_sentemail_campaigns.unsubscribed
Use it from any stack
Ajolla Hosted runs the same code as a multi-tenant API. Create an app in your dashboard, enable Email Campaigns, and call /api/m/email-campaigns/v1 with your app key.
OpenAPI document — feed it to Ajolla to generate SDKs in 7 languages and an MCP server.
AGENTS.md
Read the instructions installed with this module
markdown# Ajolla Email Campaigns
Send newsletters and announcements from your own app. You write campaigns in Markdown with merge tags and send them to **segments**: your app users (from Profiles) or contacts you imported. Sending happens in batches, with daily caps, retries, one-click unsubscribe and automatic suppression of bounces and spam complaints. Admin screens need the `campaigns.manage` permission.
## What was installed
**Routes**
- `/admin/campaigns`: the campaign list, filterable by status.
- `/admin/campaigns/new`: write a draft.
- `/admin/campaigns/[campaignId]`: edit, preview (sample data in a sandboxed iframe), send a test to yourself, send now, schedule or unschedule, pause, resume, cancel, delete. Also shows stats (sent, delivered, failed, bounced, spam reports, unsubscribed, skipped) and the recipient log.
- `/admin/campaigns/contacts`: counts by status, search, add a contact, CSV import (up to 5,000 rows / 1 MB, with a consent checkbox), and unsubscribe, resubscribe or delete.
- `/admin/campaigns/segments` and `/admin/campaigns/segments/[segmentId]`: create, edit, count and delete segments.
- `/admin/campaigns/settings`: default from name, reply-to, postal address (shown in every footer), double opt-in, and the active sending limits.
- `/unsubscribe/[token]`: the email preferences page. It works signed out. Opening it never changes anything; unsubscribe, resubscribe and confirm are all POSTs.
- `GET /api/ajolla/email-campaigns/send`: the batched sender (cron, `CRON_SECRET`).
- `POST /api/ajolla/email-campaigns/unsubscribe?token=…`: RFC 8058 one-click unsubscribe, used by the `List-Unsubscribe` header. A GET here redirects to the preferences page.
- `POST /api/ajolla/email-campaigns/webhook`: the Resend delivery webhook (Svix signature, `RESEND_WEBHOOK_SECRET`).
**Code** (`lib/ajolla/email-campaigns/`)
- `core.ts`: all logic, framework-free, including `processCampaigns` (the sender).
- `render.ts`: a pure Markdown → inline-styled HTML + plain-text renderer with merge tags. It has no raw-HTML passthrough; links are limited to https/http/mailto/same-site and images to https.
- `csv.ts`: the CSV parser.
- `token.ts`: signed unsubscribe tokens (HMAC-SHA256 over the contact and campaign ids).
- `email.ts`: the Resend provider (List-Unsubscribe headers, tags, idempotency keys) and a console provider for development.
- `webhook.ts`: Svix signature verification and Resend event parsing.
- `config.ts`: env → config.
- `server.ts`: the secret-key scope, `runSender()` and server-side event emission.
- `lib/ajolla/admin/email-campaigns.ts`: the admin page registration.
**Tables** (`ajolla_email_campaigns_`)
- `settings`: one row, which holds the token signing secret.
- `contacts`: unique email (stored lowercase), with status `pending | subscribed | unsubscribed | bounced | complained`.
- `segments`: `source` (`profiles` or `contacts`) plus a `filter`.
- `campaigns`
- `recipients`: the send queue and log, unique on `(campaign_id, email)`.
RLS: only `campaigns.manage` can read or write anything, and `recipients` is read-only for admins. Other users and anonymous visitors see nothing. The unsubscribe page and the cron use the secret key, after verifying the token or `CRON_SECRET`.
**Events**
- `email_campaigns.campaign_sent`: `{ campaignId, name, recipients, sent, failed, skipped }`
- `email_campaigns.unsubscribed`: `{ contactId, email, userId, campaignId }`
**Env**
- `RESEND_API_KEY`: shared with Notifications. Without it, emails print to the server console.
- `EMAIL_CAMPAIGNS_FROM_EMAIL`: falls back to `NOTIFICATIONS_FROM_EMAIL`.
- `NEXT_PUBLIC_SITE_URL`: required. Without it, campaigns pause, because emails would have no unsubscribe link.
- `EMAIL_CAMPAIGNS_APP_NAME`
- `EMAIL_CAMPAIGNS_BATCH_SIZE` (default 100)
- `EMAIL_CAMPAIGNS_DAILY_LIMIT` (default 1000 per UTC day)
- `EMAIL_CAMPAIGNS_SEND_INTERVAL_MS` (default 500)
- `EMAIL_CAMPAIGNS_MAX_AUDIENCE` (default 50000)
- `RESEND_WEBHOOK_SECRET`
- `CRON_SECRET`: from the kernel.
## Setup
1. Apply the migration with `supabase db push`.
2. In Resend, verify your sending domain, then set `RESEND_API_KEY`, `EMAIL_CAMPAIGNS_FROM_EMAIL` and `NEXT_PUBLIC_SITE_URL`.
3. Schedule the sender in `vercel.json`:
```json
{ "crons": [{ "path": "/api/ajolla/email-campaigns/send", "schedule": "*/5 * * * *" }] }
```
"Send now" also starts the first batch immediately.
4. Optional, for delivery tracking: in Resend, add a webhook to `https://<site>/api/ajolla/email-campaigns/webhook` for `email.delivered`, `email.bounced` and `email.complained`, and set `RESEND_WEBHOOK_SECRET` to its signing secret. Hard bounces and complaints then suppress the contact automatically.
5. As an admin, open `/admin/campaigns/settings` and add your postal address (anti-spam laws require it). Then create a segment and your first campaign, and send yourself a test.
## How sending works
- Sending a campaign (now, or when its schedule is due) **freezes the audience**. The sender adds recipients a few thousand per run, skipping anyone who is not `subscribed`. App users get a contact row the first time a campaign targets them.
- Each run sends up to the batch size, but never more than the daily cap, counting double opt-in confirmations too.
- Recipients are claimed with a 10-minute lease. `(campaign_id, email)` is unique, and each message carries an idempotency key (`campaign:<id>:<recipient>`). Together these mean overlapping cron runs and retries never double-send.
- Provider rate limits and outages (429/5xx) are retried with backoff (1, 2, 4, 8 minutes…), up to 5 attempts. Permanent rejections are marked `failed`.
- Contacts who unsubscribe after the audience is built are skipped.
- The campaign becomes `sent` once nothing is left, which emits `email_campaigns.campaign_sent`.
- Campaigns **pause** with a reason, rather than sending badly, when there is no sender or no site URL to link to.
## Merge tags
`{{first_name}}`, `{{last_name}}`, `{{name}}` and `{{email}}`, with an optional fallback: `{{first_name|there}}`. Unknown tags are rejected when you save. Values are escaped, so they can't inject Markdown or HTML.
## Double opt-in
Turn it on in Settings. New contacts (added, imported or via API) start as `pending`, and the sender emails them a confirmation link (`/unsubscribe/<token>?confirm=1`). Confirming is a button press, not a link visit.
## Customising
- **Branding:** `EMAIL_CAMPAIGNS_LOGO_URL` and `EMAIL_CAMPAIGNS_BRAND_COLOR` (falling back to the `NOTIFICATIONS_*` ones) add your logo above each campaign and colour its links and accent bar.
- **Email look:** the `STYLE` map in `render.ts`.
- **Footer wording:** `renderCampaignEmail`.
- **Limits:** the env vars above, or `DEFAULT_LIMITS` in `core.ts`.
- **Another provider:** implement `EmailProvider` in `email.ts` and return it from `config.ts`.
- **Styles:** `components/ajolla/email-campaigns/email-campaigns.module.css`, plus the kit's `--aj-*` variables.
## Ajolla Hosted
The same core runs at `/api/m/email-campaigns/v1`.
- **Secret-key endpoints** cover contacts, import, segments, campaigns (schedule, send, cancel, pause, resume, test, stats, recipients, preview), settings, and `POST /delivery-events` for forwarding verified provider webhooks.
- **Publishable-key endpoints** are `GET/POST /unsubscribe/{token}`, plus `/resubscribe` and `/confirm`, for your own unsubscribe page.
- **Sending** runs from the hosted scheduler every ~5 minutes, up to 50 emails per app per run and at most `emailCampaignsDailyLimit` (app setting, default 1000) per day. Managed email is reserved before each batch. When the monthly allowance runs out, the campaign pauses with that reason.
- **Unsubscribe links** go to your `unsubscribeUrl` setting (an https URL with `{token}`), or `<siteUrl>/unsubscribe/{token}`. That page must also accept a POST and forward it to `POST /unsubscribe/{token}`.