Newsletter
Mirror opted-in users into a Listmonk or Resend audience with a daily reconciliation job, gated by config.newsletter.
Newsletter list sync is gated by config.newsletter in packages/config/src/index.ts. Generated projects ship { enabled: false }; enable it and pick a provider, and a daily job mirrors opted-in users into that provider's audience. When off, the job is unregistered and getNewsletterProvider() throws.
This is bulk list/audience sync, not transactional email. Per-user mail (welcome, receipts) goes through config.email - see Email.
Configuration
config.newsletter is a discriminated union: { enabled: false } or { enabled: true; provider }.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false (shipped) | Master flag; false unregisters the sync job. |
provider | "listmonk" | "resend" | required when enabled | Audience backend - self-hosted Listmonk, or Resend Audiences. |
newsletter: {
enabled: true,
provider: "listmonk"
}Credentials live in environment variables, not in config.
| Provider | Env vars |
|---|---|
| Listmonk | LISTMONK_URL, LISTMONK_API_USER, LISTMONK_API_TOKEN, LISTMONK_LIST_ID |
| Resend | RESEND_API_KEY, RESEND_AUDIENCE_ID |
config.newsletter.provider: "resend" syncs an Audience (a list). config.email.provider: "resend" sends transactional mail. They are independent settings - use Resend for either, or both.
How sync works
The newsletterSyncFunction job (packages/api/src/functions/notifications/newsletter-sync.ts, cron 0 3 * * *) is the only caller of getNewsletterProvider():
| Step | Action |
|---|---|
fetch-db-emails | Load users with marketingOptIn = true. |
fetch-provider-emails | listContacts() - current subscribed emails in the audience. |
add-contacts | syncContact() (upsert) for DB users missing from the audience. |
remove-contacts | removeContact() for audience emails no longer opted in. |
- Operations are spaced 500ms apart; the job retries up to 3 times.
- It is registered in
packages/api/src/routes/inngest.tsonly whenconfig.newsletter?.enabledistrue, and returns early (provider-not-configured) when the env vars are unset. See Background jobs.
Subscription state
There is no subscribe endpoint - membership is the marketingOptIn field (defaultValue: true) on the user record. Toggling email preferences updates the field; the next nightly run reconciles the audience, so an opt-out is removed on the following run rather than instantly. removeContact() also clears users who deleted their account.
Adding a provider
Both providers implement NewsletterProvider from packages/api/src/types/newsletter.ts (syncContact, listContacts, removeContact).
packages/api/src/services/newsletter/providers/ implementing NewsletterProvider.NewsletterProvider union in packages/config/src/types/services.ts.case for it in the switch inside getNewsletterProvider() (packages/api/src/services/newsletter/index.ts) - the exhaustiveCheck enforces this at build time.packages/runtime/src/env.ts - providers read from env, not config.