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 newsletter: { enabled: false }; enable it and pick a provider (listmonk or resend) in config.newsletter and a daily background job mirrors opted-in users into the provider's audience. When off, the job is unregistered and getNewsletterProvider() throws.
What you get
- A daily reconciliation job (
newsletter-sync, cron0 3 * * *) that diffs your users against the provider audience - adding opted-in users and removing opted-out/deleted ones. - A swappable provider behind one
NewsletterProviderinterface: Listmonk (self-hosted, default) or Resend Audiences. - Subscription state tracked by the
marketingOptInuser field - no separate subscribe form.
This is bulk list/audience sync, not transactional email. Per-user emails (welcome, receipts) go through config.email. The two resend settings are independent - see Email.
Configuration
config.newsletter is a discriminated union: { enabled: false } or { enabled: true; provider }.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master flag; false unregisters the sync job. |
provider | "listmonk" | "resend" | "listmonk" | Audience backend. |
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 |
How sync works
getNewsletterProvider() (packages/api/src/services/newsletter/index.ts) lazily imports and caches the provider, throwing when config.newsletter.enabled is false. The newsletterSyncFunction job (packages/api/src/functions/notifications/newsletter-sync.ts) is the only caller:
| 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 and the job retries up to 3 times. It is only registered in packages/api/src/routes/inngest.ts when config.newsletter?.enabled is true. 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 marketingOptIn; the following sync run reconciles the audience. The removeContact() path 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/index.ts.case for it in the switch inside getNewsletterProvider() - the exhaustiveCheck enforces this at build time.packages/runtime/src/env.ts (providers read from env, not config).Frequently asked questions
Do I need a separate subscribe form?
No. Membership tracks the marketingOptIn user field; the newsletter-sync cron mirrors opted-in users into the provider audience.
Is config.newsletter.provider: "resend" the same as config.email.provider?
No. Newsletter resend syncs an Audience (list); email resend sends transactional mail. They can use Resend independently or together.
A user opted out but is still in the audience - why?
Sync is a daily cron (0 3 * * *), not inline. Their email is removed on the following run.
Why didn't sync run?
It is unregistered when config.newsletter.enabled is false, and returns early (provider-not-configured) if the provider env vars are unset.