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 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, cron 0 3 * * *) that diffs your users against the provider audience - adding opted-in users and removing opted-out/deleted ones.
  • A swappable provider behind one NewsletterProvider interface: Listmonk (self-hosted, default) or Resend Audiences.
  • Subscription state tracked by the marketingOptIn user 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 }.

KeyTypeDefaultDescription
enabledbooleantrueMaster flag; false unregisters the sync job.
provider"listmonk" | "resend""listmonk"Audience backend.
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.

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:

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

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/index.ts.
Add a case for it in the switch inside getNewsletterProvider() - 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).

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.

On this page