GenerateSaaS

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 }.

KeyTypeDefaultDescription
enabledbooleanfalse (shipped)Master flag; false unregisters the sync job.
provider"listmonk" | "resend"required when enabledAudience backend - self-hosted Listmonk, or Resend Audiences.
packages/config/src/index.ts
newsletter: {
  enabled: true,
  provider: "listmonk"
}

Credentials live in environment variables, not in config.

ProviderEnv vars
ListmonkLISTMONK_URL, LISTMONK_API_USER, LISTMONK_API_TOKEN, LISTMONK_LIST_ID
ResendRESEND_API_KEY, RESEND_AUDIENCE_ID

See Environment Variables.

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():

StepAction
fetch-db-emailsLoad users with marketingOptIn = true.
fetch-provider-emailslistContacts() - current subscribed emails in the audience.
add-contactssyncContact() (upsert) for DB users missing from the audience.
remove-contactsremoveContact() 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.ts only when config.newsletter?.enabled is true, 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).

Create a class in packages/api/src/services/newsletter/providers/ implementing NewsletterProvider.
Add the provider literal to the NewsletterProvider union in packages/config/src/types/services.ts.
Add a case for it in the switch inside getNewsletterProvider() (packages/api/src/services/newsletter/index.ts) - the exhaustiveCheck enforces this at build time.
Register any new credential env vars in packages/runtime/src/env.ts - providers read from env, not config.

On this page