Steeren/Newsletters: publications, subscribers & opt-inlive from the platform← site
Engage your audience

Newsletters: publications, subscribers & opt-in

A newsletter in Nukipa is a publication — a named, addressable mailing list with its own subscribe page, sender identity, and opt-in policy. Subscribers join it, you write issues against it, and each issue is sent to the active subscribers. This article covers the data model, how to create a publication, and how the double-opt-in flow actually works under the hood. Issue authoring and sending mechanics are covered separately.

The model

Four objects form the spine, parent to child:

Object What it is Table
Publication Slug, sender, opt-in policy, subscribe-form gate newsletters.newsletters
Subscription One person's relationship to one publication (status, tokens) newsletters.subscriptions
Issue One email's content (subject + body) newsletters.issues
Send → Delivery One execution of an issue, and one row per recipient within it newsletters.sends, newsletters.deliveries

The send pipeline is: an issue goes from draft → scheduled → sending → sent; sending creates one send row (the execution) and pre-inserts one delivery row per recipient, which then tracks that address through sent → delivered → opened/clicked/bounced/complained as Resend webhooks come in.

A subscriber is not owned here. Every subscription carries a soft contact_id pointing at crm.contacts — the CRM is the system of record for the person. Subscribing creates or upserts that CRM contact; the subscription row just records the relationship and the opt-in state. If the CRM call fails, the subscribe still succeeds with contact_id: null.

[!NOTE] This article is about the publication and subscriber layer — newsletters.newsletters and newsletters.subscriptions. Issues, sends, and per-recipient delivery tracking are the next layer down.

Creating a publication

A publication needs, at minimum, a slug and a name. Everything else has a default. Via MCP:

newsletters_create
  workspace_id: "<tenant-uuid>"
  slug: "weekly"
  name: "The Weekly"
  from_email: "\"The Weekly\" <hello@yourbrand.com>"
  double_opt_in: true
  subscribe_gate:
    fields:
      - { key: "company", label: "Company", type: "text", required: false }
    consent_text: "I agree to receive The Weekly."

The fields you can set:

Field Type Notes
slug string, ^[a-z0-9-]+$, 2–64 URL segment for the public subscribe page. Unique per tenant — see below.
name string, 1–200 Display name; also {{newsletter_name}} in emails.
description string | null Shown on the public card; {{newsletter_description}} token.
enabled boolean (default true) The public subscribe page 404s unless enabled=true.
from_email string | null Bare hi@brand.com or named "Brand" <hi@brand.com>. Validated — see the domain gate.
reply_to string | null Same validation as from_email.
double_opt_in boolean (default true) Whether new subscribers must confirm.
subscribe_gate { fields[], consent_text? } Extra form fields beyond email/consent.
subscribe_redirect_url string | null Where the form sends the visitor after subscribing.
unsubscribe_text string | null Optional override copy for the unsubscribe footer.
confirmation_subject string | null Overrides the built-in confirm subject line.
welcome_subject string | null Overrides the built-in welcome subject line.

A slug collision returns 409 with A newsletter with that slug already exists.

Slug uniqueness is per-tenant

The original schema declared slug citext NOT NULL UNIQUE — globally unique across the whole platform. That was changed in migration 20260526000000_newsletters_slug_per_tenant.sql to a composite UNIQUE (tenant_id, slug) (constraint newsletters_tenant_slug_unique). So two different tenants can both own a weekly or monthly slug; only collisions within your own tenant are rejected.

The public subscribe page (/public/newsletters/:slug) resolves which tenant the visitor is on from the X-Forwarded-Host header (the gateway forwards it), then looks the slug up scoped to that tenant. If the host doesn't resolve to a tenant — direct-IP or internal testing — the lookup falls back to a tenant-less query.

[!WARNING] That fallback has a sharp edge. loadNewsletterBySlug ends in .maybeSingle(), which errors on more than one row. If two tenants both own the same enabled slug and the host didn't resolve, the multi-row error is swallowed (logged as a warning) and the function returns null — i.e. a 404, not "the first match." In production the gateway always forwards X-Forwarded-Host, so this path shouldn't fire on real traffic; it's a dev/direct-IP concern.

The from-email domain gate

There are two independent guards on from_email / reply_to, with different sources of truth. Don't conflate them.

1. Create/update time (POST / PATCH /newsletters). Both addresses run through a Zod validator (extractAddress + FromOrReplyTo) that accepts a bare email or the RFC 5322 named form, then checks the @domain against an allow-list from NEWSLETTERS_ALLOWED_FROM_DOMAINS (e.g. nukipa.com,kibert.de):

  • If the env var is unset (dev/staging), it falls back to a plain email-shape check — anything email-shaped is accepted.
  • If it is set (production), an unverified domain is rejected: The @<domain> domain isn't verified for this workspace. Verified domains: … Leave blank to use the system default.
  • Empty/null normalises to null, which means "use the system default sender."

2. Send time (the worker, checkSenderDomain in workers/sendIssue.js). This is a separate mechanism — it does not read NEWSLETTERS_ALLOWED_FROM_DOMAINS. Acceptance order, first match wins:

  1. from_email empty/null → ok (system default).
  2. Host is in the PLATFORM_OWNED_SENDER_DOMAINS env list → ok.
  3. A row exists in newsletters.verified_domains with status='verified' → ok.
  4. Otherwise → blocked, with a reason surfaced into the issue's metrics.reason.

[!WARNING] Because the two checks use unrelated config, an address that passed create-time validation can still be blocked at send time if its domain has no verified DB row. When a send fails on the sender domain, look at verified_domains and PLATFORM_OWNED_SENDER_DOMAINS, not the create-time allow-list.

The send-time gate fails closed in production on a missing verified_domains table/column (e.g. a pre-migration database): the send is blocked with gate_schema_missing. Outside production it fails open on the same schema errors. NEWSLETTERS_GATE_FAIL_OPEN_ON_SCHEMA=true|false overrides that default either way.

Brand is global, not per-publication

There is no per-publication branding. The branding and *_html template columns were dropped in 20260524200000_audits_newsletters_drop_per_row_brand.sql. Brand — logo, colors, font, name — is read globally from public.tenants.settings.theme (and settings.logo_url) by lib/tenantBrand.js, with fallbacks (Instrument Sans, #0054C9) if the tenant row is missing or errors.

What stays per-publication is routing, not brand: from_email, reply_to, and the subject-line overrides (confirmation_subject, welcome_subject). The email bodies are built-in HTML shells in lib/render.js; tenants override subject lines, not bodies.

[!NOTE] If you want a different logo or color on a newsletter, you change the tenant theme — and that changes it everywhere (CMS, audits, newsletters). There is no per-newsletter override, by design.

The subscribe gate

subscribe_gate.fields defines the form beyond the required email + consent (consent must be literally true). name is optional. Each gate field:

Property Notes
key ^[a-z0-9_]+$, ≤64. Storage key under the subscriber's custom_fields.
label Shown on the form; reused in validation error messages.
type One of text, email, tel, textarea, select, checkbox, number.
required Default false.
placeholder Optional.
options For select — submitted value must be in this list.

On subscribe, submitted custom_fields are validated against the gate: required-but-empty fails, email fields are format-checked, select values must match options, checkbox coerces true/'true'/'on'. Keys not declared in the gate are ignored (only declared fields are iterated). Validation failures return 400 with a per-field fields[] array. The kept values are stored on the subscription's custom_fields and become {{custom.<key>}} tokens in issue bodies later.

Double opt-in

Double opt-in means a new subscriber doesn't go live until they click a confirmation link. It's the default (double_opt_in: true), and it's what keeps the list CASL/CAN-SPAM-clean.

The status machine for a subscription:

                  double_opt_in = true
  subscribe ───────────────────────────► pending ──(confirm link)──► active
                  double_opt_in = false
  subscribe ───────────────────────────────────────────────────────► active

  active ──(unsubscribe link / admin)──► unsubscribed
  any status ──(Resend webhook, if subscription_id is set)──► bounced | complained

What POST /public/newsletters/:slug/subscribe does, in order:

  1. Validate the body — email (valid), consent: true (literal), optional name, custom_fields, referrer_url. Anything else → 400.
  2. Rate-limit. Two layers: an in-memory edge limiter at 5 subscribes/min/IP (per-route, in-process), plus a DB-backed 10 subscribes/hour per newsletter keyed on a hashed IP (a salted, truncated SHA-256 — sha256(ip + "|" + tenant_id) sliced to 32 hex chars, stored as source.ip_hash). Over either limit → 429.
  3. Suppression check. If the address is on the tenant's suppression_list (PK (tenant_id, email) — it hard-bounced or complained on any publication in this tenant), refuse with 410 GoneThis email address is on our suppression list and can't be re-subscribed. Tenant-wide block, not per-publication.
  4. Validate custom_fields against the gate (above) → 400 on failure.
  5. Create/upsert the CRM contact (createLead, idempotent on email + tenant). If this fails the subscribe still succeeds, just with contact_id: null.
  6. Upsert the subscription (see resubscribe, below). Status is pending if double_opt_in, else active with confirmed_at stamped now. If the upsert itself errors → 500 ({ code: 'internal', message: 'Could not subscribe.' }).
  7. Mint tokens and send one email: the confirmation email if pending, the welcome email if active. Both are best-effort.
  8. Return 202 with { status, confirm_required }.

With double_opt_in: false, steps collapse: the subscriber lands on active immediately and gets the welcome email instead of the confirmation email.

Confirming

GET /public/newsletters/confirm/:token verifies the token (see the dual-token model below), and if the subscription isn't already active, sets status='active', stamps confirmed_at, and clears confirmation_token_hash. It's idempotent — a second click on the same link returns success without re-touching the row.

[!NOTE] Confirm and unsubscribe are themselves rate-limited: a 30/hour/IP in-memory limiter (tokenLimiter) sits on the confirm, unsubscribe, and nurture-unsubscribe endpoints. A flood of clicks can return 429 there too. The form-view GET /public/newsletters/:slug has its own, looser 60/min/IP limiter.

The dual JWT + hash token model

Confirm and unsubscribe links carry a token that is checked twice: once as a signed JWT, once against a hash stored on the row. Each guards a different thing.

The raw token is a short JWT signed with INTERNAL_JWT_SECRET, issuer newsletters, payload { kind, sub_id }:

// lib/tokens.js
signSubscriptionToken({ subId, kind: 'confirm', expiresIn: '30d' })  // double-opt-in only
signSubscriptionToken({ subId, kind: 'unsubscribe' })                // never expires

The confirm token is minted only when double_opt_in is on (otherwise confirmation_token_hash is null). The raw token only ever travels in the email link. What's stored on the subscription row is its SHA-256 hash — confirmation_token_hash and unsubscribe_token_hash. On click, both checks must pass:

Check What it proves Failure
verifySubscriptionToken(token, kind) The link was issued by us, isn't tampered, isn't expired, and is the right kind 400 bad_token — "invalid or expired"
row.*_token_hash === hashToken(token) This is still the link we consider current — not one superseded by a token rotation 400 bad_token — "has been superseded"

The hash check is what makes rotation meaningful. When someone unsubscribes and later resubscribes, a fresh token is minted and its hash overwrites the old one. The old email's link still passes JWT verification (it was validly signed) but fails the hash comparison — so a stale link can't silently re-activate or re-unsubscribe a row. Confirmation has a built-in expiry on top (30 days); the unsubscribe token deliberately never expires, so a one-click unsubscribe in a years-old email still works (a legal requirement).

Token expiry parameters:

Token kind Expiry Why
confirm 30 days A double-opt-in invite that goes stale is fine; the address never went active.
unsubscribe none Old emails must stay one-click-unsubscribe-compliant indefinitely.

[!WARNING] INTERNAL_JWT_SECRET signs these tokens and powers service-to-service auth. Rotating it invalidates every outstanding token at once: pending confirmation links (they'd have to re-subscribe), and every unsubscribe link in already-sent emails, and every nurture-unsubscribe link (signNurtureUnsubscribeToken, 180-day expiry). A dead unsubscribe link is a compliance problem, not just an inconvenience — treat a rotation as a deliberate, scheduled event.

Unsubscribe is handled on both GET (the visible link a person clicks) and POST (RFC 8058 one-click headers that mail clients hit programmatically) — same handler, same dual check. A ?reason= query param is captured into unsubscribe_reason. Like confirm, it's idempotent: if the row is already unsubscribed, the click returns success without re-stamping. Note the asymmetry: confirm clears its hash on success; unsubscribe never clears unsubscribe_token_hash, so the same link keeps working idempotently.

Resubscribe idempotency

There is one row per (newsletter_id, email), enforced by UNIQUE (newsletter_id, email). Subscribing an address that already exists never creates a second row — the subscribe handler upserts with onConflict: 'newsletter_id,email':

db.schema('newsletters').from('subscriptions')
  .upsert(row, { onConflict: 'newsletter_id,email' })

So an unsubscribed (or bounced/complained, modulo the suppression block) address that subscribes again flips status back — to pending under double opt-in, or straight to active otherwise — and rotates both token hashes (the upsert always writes a new unsubscribe_token_hash, and a new confirmation_token_hash or null). The previous email's links, hashed against the now-overwritten value, stop working (the supersession case above). The row's id and created_at are preserved; the lifecycle fields are what change.

This is why the model has no "duplicate subscriber" problem: resubscribe is a state transition on an existing row, not a new insert.

Admin-side subscriber management

Operators can act on subscribers without going through the public links (MCP tools newsletters_admin_unsubscribe / newsletters_admin_mark_complained):

Action Route Effect
List subscribers GET /newsletters/:id/subscribers Paginated, optional status filter, limit≤500.
Head-count GET /newsletters/:id/subscriber-count { count, status_breakdown, language_breakdown }. count is the active-only number — the eligible-to-send cohort.
Manual unsubscribe POST /newsletters/:id/subscribers/:subId/unsubscribe Flips to unsubscribed, stamps unsubscribed_at. Does not send the public unsub-confirmation email. Optional reason → custom_fields.admin_unsubscribe_reason.
Mark complained POST /newsletters/:id/subscribers/:subId/mark-complained Flips to complained. Optional reason → custom_fields.admin_complaint_reason.

The subscriber-count response also carries language_breakdown: a per-language tally of the active cohort, or null on tenants that haven't applied migration 20260527110000 (the one that adds subscriptions.language). null means "language tracking not enabled here," distinct from an empty object ("on, but nobody set a preference").

Admin actions are deliberately not user-initiated, so they skip the confirmation email. Bounces are not exposed as a manual button — those come from the Resend webhook automatically, and a manual control would only invite drift from the real deliverability signal. The webhook flips subscriptions.status to bounced/complained only when the matched delivery still has a live subscription_id (the column is set to null if the subscription is deleted) — and it does so from whatever the current status is, then upserts the address into the tenant suppression_list. A delivery whose subscription_id was nulled still archives the event but flips no subscription and suppresses nothing.

FAQ

Can two of my newsletters share a slug? No — slug is unique within your tenant. A second newsletter with the same slug returns 409. Across different tenants, the same slug is fine.

If I turn off double opt-in, do existing pending subscribers go active? No. The flag only changes what new subscribers get. A pending row stays pending until its confirmation link is clicked.

A subscriber unsubscribed, then subscribed again — is there a duplicate row? No. The UNIQUE (newsletter_id, email) upsert flips the existing row's status and rotates its tokens. One row, reused.

Why is my unsubscribe link from an old email failing? The address was resubscribed after that email went out, which rotated the token hash. The old link passes JWT verification but fails the hash check, returning "has been superseded." The link from the most recent email is the valid one.

My from_email was accepted when I saved it, but the send is blocked. Why? The create-time check and the send-time check are different gates with different config. Saving only checks NEWSLETTERS_ALLOWED_FROM_DOMAINS (or, in dev, just email shape). Sending requires the domain to be in PLATFORM_OWNED_SENDER_DOMAINS or to have a verified_domains row with status='verified'. Add and verify the sender domain, then resend.

Can I give one newsletter a different logo or color? No. Brand is global per tenant, read from the tenant theme. Change the theme to change it everywhere. You can only override the sender address and the confirm/welcome subject lines per publication.

An address gets 410 Gone on subscribe — why? It's on the tenant's suppression list from a prior hard bounce or complaint on any publication in the tenant. Suppression is tenant-wide and blocks re-subscription to every newsletter, to protect sender reputation.

Served live from the platform · /docs/newsletters-and-opt-in