Newsletter signup capture
Code:services/newsletter_audience.py, services/newsletter_signup_canary.py,
services/jobs/sync_newsletter_audience.py, services/jobs/probe_newsletter_signup.py,
web/public-site/app/api/newsletter/subscribe/route.ts,
web/public-site/app/legal/privacy/page.tsx (section 3.4)
CLI: poindexter newsletter sync [--dry-run], poindexter newsletter canary [--url]
Last reviewed: 2026-09-28
A newsletter signup starts in a visitor’s browser on the public site and has to
end as a row in newsletter_subscribers, because that table is what
newsletter_service.send_post_newsletter mails on every publish. The site is
served from Vercel. The worker is local-first and has no public ingress, so
the site cannot write that table. This page is how the signup gets there anyway,
and how we know it still does.
services/resend_delivery.py)
and the Lemon Squeezy invoice poll: when a provider looks like it needs an
inbound hook, check whether its API already knows. Resend’s contacts API
already holds every signup the site captures, so the worker reads it outbound.
Why the site no longer writes to the worker
The route used to write the signup to two stores: the worker’sPOST /api/newsletter/subscribe through a Tailscale Funnel hostname, and a
Resend audience. It returned success if either accepted. On 2026-09-28 both
stores were checked:
So nothing recoverable was missing, and nothing noticed the path was down. There
was nothing to backfill. Whether visitors tried and got the route’s 503 is
unknowable from what is retained.
This was the path’s second silent outage. Before 2026-06-03 the route stored
signups nowhere: a send-only Resend key, an unset audience, and the failure
swallowed, while the visitor still got a welcome email.
Re-exposing the worker route on the funnel was considered and rejected. It
would add public ingress to a local-first worker, and it would rebuild the exact
dependency that broke: a node name, a funnel path table other apps also claim
(the funnel root now serves an unrelated app), and a Vercel env value that has
to track both. The worker also restarts routinely, and a signup that arrives
mid-deploy would fail. Resend is up whenever the site is.
Resend facts the code relies on
Verified against the live API on 2026-09-28 with transient@resend.dev test
contacts, created and then deleted, with no email sent:
POST /contactsis an upsert. A duplicate returns 201 with the same id."unsubscribed": falsein the body clears a prior opt-out, so a returning subscriber re-consents by signing up again."segments": [{"id": …}]on create puts the contact in the segment. Adding it again is idempotent.GET /segments/{id}/contactskeeps unsubscribed contacts in the listing, with"unsubscribed": true. That makes a Resend-side opt-out visible to the pull.- The legacy
/audiences/{id}/contactsendpoints still work but are deprecated. An audience id is the segment id: the account’s one audience is listed under both names. The setting keeps its old name,resend_audience_id.
Opt-out precedence
This is the invariant that matters most, because getting it backwards mails someone who asked to leave.
Matching is case-insensitive. The table’s UNIQUE constraint is on the raw
column, so
Foo@x.com and foo@x.com would otherwise become two subscribers.
The deliberate cost: someone who unsubscribes through the relay and later
signs up again on the site is not re-activated by the pull. A Resend contact
carries no timestamp that tells “signed up again” apart from “never left”, so
the pull cannot tell a re-subscribe from a stale contact. Missing a re-subscribe
is the safe failure. The opted_out_kept counter makes it visible. A
re-activation by hand is poindexter newsletter sync --dry-run to confirm,
then clearing unsubscribed_at for that row.
Resend does not learn about opt-outs made here. An unsubscribe through the
relay or POST /api/newsletter/unsubscribe updates newsletter_subscribers
only, and the Resend contact stays active. That is safe today, because nothing
sends from the Resend side: the newsletter is per-recipient sends from the
worker’s own list, which honours the opt-out. Do not send a Resend Broadcast
to this segment until owned opt-outs are mirrored to Resend. It would mail
people who unsubscribed.
The canary
A low-traffic newsletter makes “nobody signed up” and “the form is broken” look identical from the owned table.ProbeNewsletterSignupJob tells them apart by
running the real path once a day:
- POST
{email, first_name: "Canary"}tonewsletter_signup_canary_url, exactly as the form does. - With the worker’s Resend key, check that the contact is in the segment
named by
resend_audience_id. A 200 alone is not proof. A site whoseRESEND_AUDIENCE_IDorRESEND_API_KEYpoints at a different segment or Resend team also answers 200, and the worker would never see its signups. - Delete the contact, always, including after a failure.
newsletter_signup_capture_broken (Discord, daily cooldown).
The address is one of Resend’s own test inboxes
(delivered+signup-canary@resend.dev). It costs one send against the quota per
run and does not affect domain reputation. Two consumers skip it on purpose:
- The pull, in case a failed cleanup leaves the canary in the segment.
- The delivery poll. The site sends the canary a welcome email every day.
Were those recorded in
subscriber_events, the table would stay fresh on the canary alone, and the webhook-freshness probe that watches it would read a dead newsletter as healthy.
newsletter_signup_canary_url empty), because
switching it on means a daily welcome email. Run it once without switching it on
with poindexter newsletter canary --url https://<site>/api/newsletter/subscribe.
Configuration
The site keeps only email and first/last name, and the signup form asks for
nothing more. Until 2026-09-28 it also asked for company, interests and a
marketing-consent tick, which nothing downstream read. Its small print also
claimed the visitor’s IP address and user-agent were stored with the
subscription, which nothing did. Submitting the form is the consent to the
newsletter, so there is no separate tick. Add a form field only together with
the code that stores and reads it.
The worker’s
POST /api/newsletter/subscribe now stores what the pull stores:
the address, first and last name, the verified flag and an unsubscribe token.
Until 2026-09-28 it also stored company, interest_categories and
marketing_consent from direct API callers, plus the caller’s IP address and
user-agent. The last two described a proxy hop or a server-side fetch, not the
subscriber. Nothing read any of the five, and migration 20260928_184647
dropped their columns (Glad-Labs/poindexter#1109). A caller that still sends
the three request fields gets its signup, plus a Deprecation: true header
that names what was ignored. See the
API reference.
Operating it
- See what the pull would do:
poindexter newsletter sync --dry-run. - Pull now (backfill, or after fixing config):
poindexter newsletter sync. It exits non-zero on any error. - Check the path end to end:
poindexter newsletter canary. It exits non-zero when the signup is not captured and names the cause. - Grafana: the Integrations & Admin board’s Newsletter Distribution row
shows the last canary result and the subscribers the pull imported (both read
the jobs’
job_runrows inaudit_log). - Findings:
newsletter_audience_sync_failedmeans the pull failed. Signups are still safe in Resend, but they are not being mailed until it clears.newsletter_signup_capture_brokenmeans the public form is not capturing, so every real signup is failing the same way.
The public preview page was retired
web/public-site/app/preview/[token] rendered a draft by fetching the worker’s
/api/posts/preview/{token} from Vercel. That hit the same wall, so it has
returned 404 for every token since the funnel stopped fronting the worker.
Nothing linked to it. Approval notifications build preview links from
preview_base_url, and the operator console links the worker’s own
server-rendered /preview/{token} relative to itself.
Re-wiring it would mean publishing unpublished drafts to the internet behind a
bearer token, which cuts against the approval gates deciding what leaves the
machine. The worker’s /preview/{token} over the tailnet remains the preview
surface.
What the privacy policy says about this path
The public privacy policy (web/public-site/app/legal/privacy/page.tsx: section
3.4, plus entries in sections 2, 4, 5, 9, 10 and 11 and in the FAQ) describes
this path to visitors. It said nothing about the newsletter or Resend until
2026-09-28 (stack#4216). Each sentence in it is a claim about what the code
does, so a change to what this path stores, sends or keeps is also a change
to the policy. The claims, and what each one rests on:
web/public-site/app/legal/__tests__/pages.test.js pins that the policy still
has the newsletter section, that the signup modal’s link lands on it, and that
every provider host the signup route calls appears in the processors table. It
reads the route’s source, so a provider added to the route fails that test
until the policy names it.
Erasing a subscriber
There is no command for this yet, and the order matters, because the pull treats the Resend contact as the source of new subscribers:- Delete the Resend contact first (
DELETE /contacts/{email}accepts the address in the path). Delete only the row here and the next pull finds an active contact with no owned row and imports it again as a new subscriber. That is also why an unsubscribed row is kept rather than deleted. - Delete the
newsletter_subscribersrow.campaign_email_logsrows go with it (ON DELETE CASCADE). - Delete the
subscriber_eventsrows for the address. That table is keyed by email and has no foreign key, so nothing removes them for you.