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.Email deliverability
Log every email the app sends, read bounces and complaints from SES or Resend webhooks, and block suppressed addresses before they reach the provider.
Notifications
Deliver in-app bell notifications, broadcasts, and unsubscribe handling with @repo/notifications, gated by config.notifications.