From cbf0e2aa686cbcacfff6862cbf06fb2a251e6b57 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Apr 2026 13:42:48 -0400 Subject: [PATCH] update spec --- specs/2026-04-20-newsletter-mode.md | 386 +++++++++++----------------- 1 file changed, 149 insertions(+), 237 deletions(-) diff --git a/specs/2026-04-20-newsletter-mode.md b/specs/2026-04-20-newsletter-mode.md index ef60388f..d3cf8efc 100644 --- a/specs/2026-04-20-newsletter-mode.md +++ b/specs/2026-04-20-newsletter-mode.md @@ -4,288 +4,200 @@ ## Goal -Let publication owners opt in to sending posts as emails to their subscribers. Newsletter mode is appview-specific configuration (not part of the AT-Proto publication record), so all state lives in Postgres. +Let publication owners send posts as emails to subscribers. Newsletter mode is appview-specific config (not part of the AT-Proto publication record), so all state lives in Postgres. ## Data Model -### `publication_newsletter_settings` — per-publication newsletter configuration +Four new tables; two legacy tables (`subscribers_to_publications`, `email_subscriptions_to_entity`) are dropped. -A dedicated table rather than a `metadata` jsonb column on `publications`, because sender-email verification has a real lifecycle (enable → verify → rotate → revoke) that benefits from typed columns, and because "list publications with newsletter enabled" is a query we'll want cheap once metering lands. +### `publication_newsletter_settings` — per-publication config + +Dedicated table rather than a `metadata` jsonb on `publications`: sender-email verification has a real lifecycle (enable → verify → rotate → revoke), and "list publications with newsletter enabled" is a query we'll want cheap once metering lands. - `publication` (text, PK, FK → `publications.uri` ON DELETE CASCADE) -- `enabled` (boolean, not null, default false) -- `enabled_at` (timestamptz, nullable) — first time newsletter was turned on -- `reply_to_email` (text, nullable) — owner's address; replies from readers go here -- `reply_to_verified_at` (timestamptz, nullable) — null means unverified; set once the confirmation code flow completes -- `created_at` (timestamptz, not null, default now()) -- `updated_at` (timestamptz, not null, default now()) - -All sends go From `newsletters@leaflet.pub` (or similar, a domain we control); no per-publication sender verification needed. - -Indexes: -- Partial index on `enabled WHERE enabled` — for ops/billing reconciliation queries. - -RLS: -- Writes: publication owner only (same ACL as `publication_domains`). -- Reads: `enabled` is effectively public (surfaced on the subscribe UI), but `sender_email` and verification state are owner-only. Either split into two tables or enforce at the RPC layer. - -### Migration sketch - -```sql -create table "public"."publication_newsletter_settings" ( - "publication" text primary key - references publications(uri) on delete cascade, - "enabled" boolean not null default false, - "enabled_at" timestamptz, - "reply_to_email" text, - "reply_to_verified_at" timestamptz, - "created_at" timestamptz not null default now(), - "updated_at" timestamptz not null default now() -); - -create index publication_newsletter_settings_enabled_idx - on publication_newsletter_settings (enabled) where enabled; - -alter table "public"."publication_newsletter_settings" - enable row level security; - --- grants + RLS policies TBD -``` - -Plus: -- Add matching `pgTable` in `drizzle/schema.ts`. -- Run `npm run generate-db-types` to refresh Supabase types. -- Extend `get_publication_data` (or add a new RPC) to join this row into the dashboard payload. - -### `publication_email_subscribers` — per-publication email subscriber list - -Replaces the two legacy tables that are being dropped (see below). One row per `(publication, email)`. Unsubscribes are soft — the row stays, state transitions to `unsubscribed`. This lets us honor re-subscribe requests cleanly and keeps history intact for the events table. - -Columns: -- `id` (uuid, PK, default `gen_random_uuid()`) -- `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE) -- `email` (text, not null) -- `identity_id` (uuid, nullable, FK → `identities.id` ON DELETE SET NULL) — set if the subscriber is a logged-in Leaflet user at signup time -- `state` (text, not null, default `'pending'`) — one of `pending` (awaiting confirmation), `confirmed`, `unsubscribed`. A text check constraint keeps this honest. -- `confirmation_code` (text, nullable) — cleared once confirmed -- `unsubscribe_token` (uuid, not null, default `gen_random_uuid()`, unique) — opaque token used in Postmark `List-Unsubscribe` URLs -- `created_at` (timestamptz, not null, default now()) -- `confirmed_at` (timestamptz, nullable) -- `unsubscribed_at` (timestamptz, nullable) - -Indexes / constraints: -- `unique (publication, email)` — one record per address per publication; a re-subscribe updates the existing row back to `pending`/`confirmed` rather than inserting. -- Partial index on `(publication) WHERE state = 'confirmed'` — counts and send targeting hit this. -- Unique index on `unsubscribe_token`. - -RLS: -- Inserts by anon: allowed (subscribe form), but only with `state = 'pending'`. -- Reads of individual rows: public by `unsubscribe_token` (manage-subscription link); full list is owner-only. -- Updates: service role + the owner. +- `enabled` (boolean, default false) +- `reply_to_email` (text, nullable) — owner's address; reader replies go here. Kept separate from `identities.email` so a publisher can expose a dedicated address without leaking their login email. +- `reply_to_verified_at` (timestamptz, nullable) — set once the confirmation-code flow completes +- `created_at`, `updated_at` -### `publication_email_subscriber_events` — append-only event log +Partial index on `enabled WHERE enabled` for ops/billing reconciliation. + +### `publication_email_subscribers` — per-publication subscriber list -One table per event type would balloon; instead, one append-only log covering the lifecycle. Useful for debugging delivery issues, computing timeseries, and later billing reconciliation. +Email subscribers only. The Atmosphere/handle subscribe path continues to live in `publication_subscriptions` — the two flows are intentionally separate (a handle subscriber has no email). Cross-flow queries union the two tables. -Columns: -- `id` (uuid, PK, default `gen_random_uuid()`) -- `subscriber` (uuid, not null, FK → `publication_email_subscribers.id` ON DELETE CASCADE) -- `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE) — denormalized for cheap per-publication queries -- `event_type` (text, not null) — `subscribe_requested`, `confirmation_sent`, `confirmed`, `unsubscribe_requested`, `resubscribed`, `post_sent`, `bounce`, `complaint`. Check constraint enforces the enum. -- `occurred_at` (timestamptz, not null, default now()) -- `metadata` (jsonb, nullable) — event-specific payload (e.g. `{ "document": "at://…" }` for `post_sent`, `{ "reason": "…" }` for `bounce`) +Unsubscribes are soft: the row stays, state flips to `unsubscribed`, so re-subscribes are clean and history stays intact. -Indexes: -- `(subscriber, occurred_at desc)` — per-subscriber timeline. -- `(publication, event_type, occurred_at desc)` — "how many confirms this week" etc. +- `id` (uuid, PK) +- `publication` (text, FK → `publications.uri` ON DELETE CASCADE) +- `email` (text) +- `identity_id` (uuid, nullable, FK → `identities.id` ON DELETE SET NULL) — set if subscriber logged in at signup +- `state` — `pending` | `confirmed` | `unsubscribed` +- `confirmation_code` (text, nullable) — 6 uppercase hex chars, same format as `email_auth_tokens.confirmation_code`. Re-requesting overwrites; no expiry column for v1 (rate-limiting on the anon subscribe endpoint covers the abuse case). Reusing the login-flow format keeps `OneTimePasswordField` and template copy consistent. +- `unsubscribe_token` (uuid, unique) — opaque token for `List-Unsubscribe` URLs +- `created_at`, `confirmed_at`, `unsubscribed_at` + +Indexes: `unique (publication, email)` (re-subscribe updates the existing row); partial index on `(publication) WHERE state = 'confirmed'` for send targeting; unique on `unsubscribe_token`. + +### `publication_email_subscriber_events` — append-only event log -This is the single source of truth for "when did X happen" — subscribe confirmations, unsubscribes, per-post sends, and Postmark bounce/complaint webhooks all land here. Columns on `publication_email_subscribers` like `confirmed_at` / `unsubscribed_at` are convenience caches derivable from this log. +Full lifecycle log for debugging delivery, timeseries, and billing reconciliation. Timestamp columns on `publication_email_subscribers` are convenience caches derivable from this log. + +- `id` (uuid, PK) +- `subscriber` (uuid, FK → `publication_email_subscribers.id` ON DELETE CASCADE) +- `publication` (text, FK → `publications.uri`) — denormalized for cheap per-pub queries +- `event_type`: `subscribe_requested` | `confirmation_sent` | `confirmed` | `unsubscribe_requested` | `resubscribed` | `post_sent` | `bounce` | `complaint` +- `occurred_at` +- `metadata` (jsonb, nullable) — e.g. `{ document: "at://…" }` on `post_sent`, `{ reason: "…" }` on `bounce` + +Indexes: `(subscriber, occurred_at desc)` for per-subscriber timeline; `(publication, event_type, occurred_at desc)` for aggregates. ### `publication_post_sends` — per-post send record -One row per `(publication, document)` send attempt. Primary job is idempotency: the publish action checks this table before triggering a send so republishing/retrying can't double-email. Secondary job is cheap dashboard counts ("emailed N subscribers") without scanning the events log. - -Columns: -- `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE) -- `document` (text, not null, FK → `documents.uri` ON DELETE CASCADE) -- `status` (text, not null, default `'pending'`) — `pending`, `sending`, `sent`, `failed`. Check constraint enforces the enum. -- `subscriber_count` (int, nullable) — snapshot of confirmed subscribers at send time; null until the batch is built -- `started_at` (timestamptz, not null, default now()) -- `completed_at` (timestamptz, nullable) — set when `status` moves to `sent` or `failed` -- `error` (text, nullable) — populated when `status = 'failed'` - -Primary key: `(publication, document)` — the unique constraint is the idempotency guarantee. Inserting with `ON CONFLICT DO NOTHING` is the race-safe "reserve this send" pattern. - -Indexes: -- PK covers the common lookup. -- `(publication, started_at desc)` — for the dashboard's recent-sends list. - -Per-recipient delivery detail (which addresses got it, bounces, complaints) stays in `publication_email_subscriber_events`; this table is only the post-level aggregate. - -### Tables to drop - -Both are replaced by the above and have no remaining real consumers after the newsletter flow lands: - -- `subscribers_to_publications` — old email-keyed stub from `20250321233105_add_subscribers_to_publications_table.sql`. Only writer is `actions/subscribeToPublicationWithEmail.ts`; nothing reads it. Drop the table + the action + `unsubscribeFromPublication.ts`. -- `email_subscriptions_to_entity` — the mailbox-era table from `20240821203026_add_email_subscription_tables.sql`. Consumers: `actions/subscriptions/{subscribeToMailboxWithEmail,sendPostToSubscribers,confirmEmailSubscription,deleteSubscription}.ts`, plus `app/emails/unsubscribe/route.ts` and `migrate_user_to_standard.ts`. Mailbox flow is being retired; drop the table and those actions/routes. - -Audit before the drop migration runs: verify no live writes in the last 30 days for either table (quick `select max(created_at)` sanity check). - -### Migration sketch (additions) - -```sql -create table "public"."publication_email_subscribers" ( - "id" uuid primary key default gen_random_uuid(), - "publication" text not null - references publications(uri) on delete cascade, - "email" text not null, - "identity_id" uuid - references identities(id) on delete set null, - "state" text not null default 'pending' - check (state in ('pending', 'confirmed', 'unsubscribed')), - "confirmation_code" text, - "unsubscribe_token" uuid not null default gen_random_uuid(), - "created_at" timestamptz not null default now(), - "confirmed_at" timestamptz, - "unsubscribed_at" timestamptz, - unique (publication, email), - unique (unsubscribe_token) -); - -create index publication_email_subscribers_confirmed_idx - on publication_email_subscribers (publication) - where state = 'confirmed'; - -create table "public"."publication_email_subscriber_events" ( - "id" uuid primary key default gen_random_uuid(), - "subscriber" uuid not null - references publication_email_subscribers(id) on delete cascade, - "publication" text not null - references publications(uri) on delete cascade, - "event_type" text not null check (event_type in ( - 'subscribe_requested', 'confirmation_sent', 'confirmed', - 'unsubscribe_requested', 'resubscribed', - 'post_sent', 'bounce', 'complaint' - )), - "occurred_at" timestamptz not null default now(), - "metadata" jsonb -); - -create index publication_email_subscriber_events_subscriber_idx - on publication_email_subscriber_events (subscriber, occurred_at desc); - -create index publication_email_subscriber_events_publication_idx - on publication_email_subscriber_events (publication, event_type, occurred_at desc); - -create table "public"."publication_post_sends" ( - "publication" text not null - references publications(uri) on delete cascade, - "document" text not null - references documents(uri) on delete cascade, - "status" text not null default 'pending' - check (status in ('pending', 'sending', 'sent', 'failed')), - "subscriber_count" integer, - "started_at" timestamptz not null default now(), - "completed_at" timestamptz, - "error" text, - primary key (publication, document) -); - -create index publication_post_sends_publication_idx - on publication_post_sends (publication, started_at desc); - -alter table "public"."publication_email_subscribers" enable row level security; -alter table "public"."publication_email_subscriber_events" enable row level security; -alter table "public"."publication_post_sends" enable row level security; - --- drop legacy -drop table "public"."subscribers_to_publications"; -drop table "public"."email_subscriptions_to_entity"; -``` - -Plus: -- Delete the accompanying actions/routes: `actions/subscribeToPublicationWithEmail.ts`, `actions/unsubscribeFromPublication.ts`, `actions/subscriptions/*.ts`, `app/emails/unsubscribe/route.ts`. Any references in `migrate_user_to_standard.ts` need cleaning up too. -- Remove the dropped tables from `drizzle/schema.ts` and `drizzle/relations.ts`; add the two new tables. -- Regenerate Supabase types. +One row per `(publication, document)`. Primary job is idempotency: the publish action `INSERT … ON CONFLICT DO NOTHING`s here before triggering a send, so republishing can't double-email. Secondary job is cheap dashboard counts. + +- `publication`, `document` (PK, both FK ON DELETE CASCADE) +- `status` — `pending` | `sending` | `sent` | `failed` +- `subscriber_count` (int, nullable) — snapshot of confirmed subscribers at send time +- `started_at`, `completed_at` (nullable), `error` (text, nullable) + +Index on `(publication, started_at desc)` for the dashboard's recent-sends list. Per-recipient detail (bounces, complaints) lives in the events log; this table is the post-level aggregate only. + +### Authorization + +RLS is enabled on all four tables as defense-in-depth, but effective authority lives at the server-action layer (same pattern as `publication_domains`). Every mutation goes through a server action that loads the caller via `getIdentityData()` and asserts ownership (owner ops) or rate-limits (anon ops). Public reads of `enabled` go through the `get_publication_data` RPC, which projects only public columns; `reply_to_email` and verification state are never returned to non-owners. + +### Dropped tables + +`subscribers_to_publications` goes — no live rows, and its writers (`actions/subscribeToPublicationWithEmail.ts`, `actions/unsubscribeFromPublication.ts`, defensive rewrite in `migrate_user_to_standard.ts`) retire with it. + +`email_subscriptions_to_entity` stays for now even though it has no live rows — `MailboxBlock` is a deprecation placeholder and its other consumers (`actions/subscriptions/*.ts`, `src/hooks/useSubscriptionStatus.ts`) aren't on this feature's path. `app/emails/unsubscribe/route.ts` is the exception: it's replaced in-place (see UI placeholders), so its reader of the old table goes away regardless. + +### Rollout + +Write the migration in `supabase/migrations/`, update `drizzle/schema.ts` + `relations.ts`, run `npm run generate-db-types`, delete the `subscribers_to_publications` writers, and extend `get_publication_data` to join `publication_newsletter_settings` into the dashboard payload. ## Delivery (Postmark + Inngest) ### From / Reply-To -- **From:** fixed `newsletters@leaflet.pub` for every publication. No per-publication DKIM/SPF setup. -- **Reply-To:** publication owner's verified `reply_to_email`. Replies land in the owner's inbox. -- Reply-To verification reuses the same confirmation-code mechanism as subscriber confirmations (code emailed, owner pastes it back). No DNS involved. +- **From:** fixed `newsletters@leaflet.pub` (domain we control; no per-publication DKIM/SPF). +- **Reply-To:** publication owner's verified `reply_to_email`. + +Reply-To verification is required even when the publisher has a verified `identities.email` — keeping them separate lets the publisher pick exactly which address is exposed. Verification reuses the subscriber confirmation-code flow (code emailed, owner pastes it back; no DNS). ### Message streams -- `broadcast` — newsletter sends. Honors Postmark's native suppression list and one-click `List-Unsubscribe`. -- transactional (existing `outbound` or a dedicated stream) — subscriber confirmation codes and reply-to verification codes. Not suppression-gated. +- `broadcast` — newsletter sends. Honors Postmark's suppression list and one-click `List-Unsubscribe`. +- transactional (existing `outbound` or dedicated) — subscriber + reply-to confirmation codes. Not suppression-gated. + +**One shared `broadcast` stream across all publications.** Postmark caps servers at 10 streams and their multi-tenant guidance is server-per-tenant, which is too operationally heavy (separate API tokens, webhooks, DKIM, analytics per publication). Tradeoff: Postmark suppressions are keyed on `(stream, email)`, so a hard bounce / complaint / one-click unsubscribe from Pub A suppresses that address for Pub B on the same stream. Our DB stays per-publication and remains the subscription source of truth; Postmark's list is a stream-wide deliverability guardrail. See "Suppression ownership" below. ### Sending path (per-post) -1. Publish action inserts `publication_post_sends` with `status = 'pending'` using `INSERT … ON CONFLICT DO NOTHING`. The PK doubles as the idempotency guard — a repeat publish is a no-op. -2. Action fires an Inngest event (e.g. `newsletter/post.send.requested`) with publication URI + document URI. +**Trigger:** only the first successful publish of a post fires a send. Edits and reposts don't re-send (PK conflict no-ops). Enabling newsletter on a publication with existing posts doesn't backfill — posts published before the enable flip are never emailed. + +1. Publish action inserts into `publication_post_sends` with `ON CONFLICT DO NOTHING`. PK is the idempotency guard. +2. Action fires `newsletter/post.send.requested` with publication + document URIs. 3. Inngest function: - - Snapshots `confirmed` subscribers, writes `subscriber_count` and flips `status = 'sending'`. - - Renders the email via the React Email templates in `emails/` (see "Template rendering" below). - - Chunks into batches of ≤500 recipients per `/email/batch` call (Postmark's batch size cap). - - Appends one `post_sent` event per recipient to `publication_email_subscriber_events`. - - Uses Inngest step retries for per-batch failures. - - On terminal completion, updates `status` to `sent` or `failed` + `completed_at` + `error`. + - Snapshots `confirmed` subscribers, writes `subscriber_count`, flips `status = 'sending'`. + - Renders via React Email templates in `emails/`. + - Chunks into batches of ≤500 per `/email/batch` call (Postmark's cap). + - Attaches per-recipient `Metadata: { subscriber_id, publication }` so `Bounce` / `SpamComplaint` / `SubscriptionChange` webhooks resolve back to the right row (robust to plus-addressing, forwarding, and the same email subscribed to multiple pubs). + - Iterates the response array: appends `post_sent` only for `ErrorCode === 0`; appends a failure event otherwise. A 200 on `/email/batch` does **not** mean every recipient was accepted — Postmark returns per-message status and the current mailbox-era code in `actions/subscriptions/sendPostToSubscribers.ts` ignores it. + - Uses step retries for per-batch transport failures (network / 5xx). + - On terminal completion sets `status` + `completed_at` (+ `error` on fail). + +### Preview sends + +A "Send preview" button in the publish flow lets the author send the rendered email to a one-off address (typed into an input next to the button) before broadcasting. Ownership-gated server action; no DB writes. + +Cut-outs so the preview doesn't pollute real-send state: + +- Uses the transactional stream, not `broadcast` — skips Postmark suppression-gating and omits `List-Unsubscribe` headers. +- No `publication_post_sends` row (would PK-conflict the real send later) and no events appended (preview is not metered as a send). +- Template renders with a placeholder "(preview)" footer since there's no subscriber row / `unsubscribe_token`. ### Postmark webhooks -New appview endpoint (e.g. `app/api/postmark/webhook`) receives: +New endpoint at `app/api/postmark/webhook`: -- `Bounce` — append `bounce` event; on hard bounce transition subscriber to `unsubscribed`. -- `SpamComplaint` — append `complaint` event; transition to `unsubscribed`. -- `SubscriptionChange` — fires when a reader uses Postmark's one-click `List-Unsubscribe`. Append `unsubscribe_requested` + transition to `unsubscribed`. -- `Delivery` / `Open` / `Click` — not captured for now; revisit if product wants open/click analytics. +- `Bounce` — append `bounce`. Hard bounces → `unsubscribed`; soft bounces stay `confirmed` (the event log carries the signal if we want retry logic later). +- `SpamComplaint` — append `complaint`; → `unsubscribed`. +- `SubscriptionChange` — one-click `List-Unsubscribe`. Append `unsubscribe_requested`; → `unsubscribed`. +- `Delivery` / `Open` / `Click` — not captured; revisit if product wants analytics. + +Idempotency: `SubscriptionChange` for in-app unsubscribes arrives *after* we already wrote local state. Handler no-ops on already-`unsubscribed` rows. ### Template rendering -Ported from `feature/follow-via-email` (commit `c09ca71e`). Lives in `emails/`: +Ported from `feature/follow-via-email` (commit `c09ca71e`) into `emails/`: + +- `post.tsx` — newsletter post template; also exports shared primitives (`Text`, `Heading`, `LinkBlock`, `CodeBlock`, `BlockNotSupported`, `List`, `LeafletWatermark`). +- `leafletConfirmEmail.tsx` — reply-to verification code. +- `pubConfirmEmail.tsx` — subscriber confirmation code. +- `static/` — bundled icon assets. -- `emails/post.tsx` — newsletter post template. Also exports shared primitives used by the other templates: `Text`, `Heading`, `LinkBlock`, `CodeBlock`, `BlockNotSupported`, `List`, `LeafletWatermark`. -- `emails/leafletConfirmEmail.tsx` — code-based confirmation email for Leaflet-level email verification (reply-to verification, etc). -- `emails/pubConfirmEmail.tsx` — code-based confirmation email for publication subscribe flow. -- `emails/static/` — bundled icon assets (`leaflet.png`, `comment.png`, `external-link.png`, `quote.png`). +Built on [React Email](https://react.email). Local iteration via `npm run email:dev`. Each template inlines its Tailwind config since email clients strip external stylesheets. -Built on [React Email](https://react.email) per the [manual setup guide](https://react.email/docs/getting-started/manual-setup): +**Status:** scaffolding only. `PostEmail` renders placeholder content and isn't wired to real post blocks. A block-type → email-component mapping is still needed — `components/Blocks/Block.tsx` is too coupled to DOM features email clients strip, so continue the email-native primitives pattern. Static assets need an absolute URL in production (Postmark fetches `Img src` as-is). -- `@react-email/components` (prod) — primitive components (`Html`, `Body`, `Section`, `Tailwind`, etc.) -- `react-email` (prod) — provides the `email` CLI used by the `email:dev` script -- `@react-email/ui` (dev) — preview server UI +### Suppression ownership -The deprecated `@react-email/preview-server` has been dropped. Local iteration runs via `npm run email:dev` (invokes `email dev` against `emails/`). +Postgres is source of truth for subscription state. Postmark's suppression list is a stream-wide deliverability guardrail we mirror from — not a subscriber list. (Postmark is explicit that they don't store mailing lists; the Suppressions API tracks only `HardBounce`, `SpamComplaint`, `ManualSuppression`.) -Each template ships its own inlined Tailwind config (via `@react-email/components`'s `Tailwind` wrapper) because email clients strip external stylesheets. +- **Send targeting** reads local `state = 'confirmed'` only. The `broadcast` stream drops suppressed addresses itself and fires `SubscriptionChange` for reconciliation. +- **In-app unsubscribe** (`unsubscribe_token` endpoint, dashboard button): write DB, **don't** call the Suppressions API. Keeps a Pub A unsubscribe from silently breaking Pub B deliveries on the shared stream. Send-filter honors local `unsubscribed`. +- **One-click `List-Unsubscribe` from the email client**: Postmark suppresses the address stream-wide and fires `SubscriptionChange` with `Origin: Recipient`. Mirror to DB + append `unsubscribe_requested`. Cross-publication bleed is accepted here because the user acted from inside an actual email and their intent plausibly extends to the sender domain. +- **Bounce / spam complaint**: Postmark-only signals; mirror to DB + events. Hard bounce and spam complaint both flip local state to `unsubscribed`. +- **Resubscribe**: flip local state to `pending`, run the confirmation flow. If the address is on Postmark's suppression list: `HardBounce` / `ManualSuppression` → call Suppressions API to delete before the next broadcast; `SpamComplaint` → Postmark refuses deletion (permanent), surface in resubscribe UI. -**Status at port time:** scaffolding only. `PostEmail` currently renders hardcoded placeholder content (demo lists, demo code block, etc.); it is not yet wired to real post blocks. The confirm-email templates are closer to shippable but also contain some placeholder copy. +## Existing UI placeholders -**Remaining work on the renderer:** +Most UI already exists unwired — wire these up rather than adding new components. -- Pass real props (title, author, cover image, body blocks, post URL, unsubscribe token) into each template. -- Implement a block-type → email-component mapping so `PostEmail` can walk the actual block list instead of rendering demo content. `components/Blocks/Block.tsx` is already imported in `post.tsx` but unused — decide whether to reuse the on-site renderer (likely too coupled to DOM features email strips) or continue the pattern of email-native primitives in `emails/post.tsx`. -- Static assets need to be served from a public, absolute URL in production emails (Postmark fetches `Img src` as-is; relative `/static/*.png` won't resolve for recipients). Point them at a CDN or `app/static/` route. -- Render path in the Inngest send function: call `render()` from `@react-email/components` with the template and props to produce `HtmlBody` / `TextBody` for the Postmark batch payload. +Placeholders branch on a hardcoded `dummy` object in `app/lish/[did]/[publication]/[rkey]/PostPubInfo.tsx` (`{ newsletterMode, user: { loggedIn, email, handle, subscribed } }`). Replacing `dummy` with real data — `newsletterMode` from `publication_newsletter_settings.enabled`, the rest from viewer identity, `subscribed` as a union of confirmed `publication_email_subscribers` and `publication_subscriptions` — is a prerequisite for every flow below. -### Suppression ownership +### Subscribe (reader → publication) + +`SubscribeInput` in `components/Subscribe/SubscribeButton.tsx` — rendered by `PostPubInfo`, `PublicationContent`, `PubListing`. Branches on `newsletterMode`: email vs. Atmosphere handle. + +`EmailInput`, `EmailConfirm`, `EmailSubscribeSuccess` in `components/Subscribe/EmailSubscribe.tsx` are pure UI. `EmailConfirm` uses Radix `OneTimePasswordField` with `validationType="alphanumeric"` and `autoSubmit` — this settles an open item: **subscriber confirmation is a pasted code, not a link.** The 6-char uppercase hex format matches `actions/emailAuth.ts` and `emails/pubConfirmEmail.tsx`. + +Subscribe button `onClick` and `EmailConfirm.onSubmit` currently only flip local state — wire to a new action that generates the confirmation code, sends via the transactional stream, and appends `subscribe_requested` + `confirmation_sent`. + +### Manage / unsubscribe (logged-in reader) + +`ManageSubscription` in `components/Subscribe/ManageSubscribe.tsx`. Two buttons need handlers: + +- Link-email (`EmailInput` + `EmailConfirm`): wires to an "attach email to existing subscription" action creating a `publication_email_subscribers` row keyed to the caller's `identity_id`. +- Unsubscribe: flips state, appends `unsubscribe_requested`. + +### Token-based unsubscribe (email footer / `List-Unsubscribe`) + +`app/emails/unsubscribe/route.ts` currently keys on legacy `sub_id`. Replace (don't extend): new endpoint takes `unsubscribe_token` from `publication_email_subscribers`, writes unsubscribe. Keep the URL path stable so already-sent footers don't 404. A GET confirmation page at the same route would cover Postmark's one-click nicely but is out of scope for the first cut. + +### Enable / disable newsletter (publisher) + +`NewsletterSettings` in `app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx`. Branches on `dummy.newsletterMode`. + +- Enable: modal with `EmailInput` → `EmailConfirm` → "Enable Newsletter". Upsert `publication_newsletter_settings` with `enabled = true` and the verified `reply_to_email`. The current placeholder short-circuits when `dummy.user.email` is set — remove that branch and always require confirmation. +- Disable: confirmation modal gated on typing the publication name. Flip `enabled = false`. +- Subscriber count + price copy are hardcoded and tagged with "don't let us merge" comments — replace with `count(*)` on confirmed subscribers + Stripe product config. + +## Metering + +No paid subscriptions yet, but we want the usage data to exist so billing can layer on without a backfill. Two counters matter; both are derivable from tables above without new schema: -Postmark is the source of truth for suppressions. Flow: +- **Active subscribers per publication** — `count(*) from publication_email_subscribers where publication = $1 and state = 'confirmed'`. Queried on demand. +- **Emails sent per publication per period** — `publication_post_sends.subscriber_count` is snapshotted at send time, so `sum(subscriber_count) where status = 'sent' and completed_at between $start and $end` gives monthly sent volume. -- Postmark webhook → we mirror the state into `publication_email_subscribers.state`. -- Our in-app unsubscribe (via `unsubscribe_token`) calls Postmark's Suppressions API first, then writes the local row. -- Resubscribing a previously-unsubscribed reader requires unsuppressing via Postmark's API before the next broadcast will reach them. +No reporting cron for v1; surface both counters in the dashboard and let Stripe reporting layer on later. -## Open questions / deferred +## Open questions -- **Preview / test send** — authors sending a test email to themselves before broadcasting. -- **Scheduled / backdated sends** — does a backdated publish still trigger a send? Probably not; spec should nail down. -- **Resubscribe UX** — specifically, what the subscribe form does when `state = 'unsubscribed'` (silent resubscribe vs re-confirm). Tied to the Postmark unsuppress call. +- **Resubscribe UX** — what the form does when `state = 'unsubscribed'` (silent resubscribe vs re-confirm). Spam-complaint suppressions are permanent in Postmark; UI needs a terminal "can't resubscribe from here" branch. - **Confirmation rate limiting / CAPTCHA** — the anon subscribe endpoint is spammable. - **Identity linking** — backfilling `identity_id` when a subscriber later logs in. -- **Retention** — how long `unsubscribed` rows and the events log stick around (GDPR angle). -- **Stripe metering** — where the usage-reporting cron reads subscriber counts from. -- **Subscriber-side auth for manage/unsubscribe** — `unsubscribe_token` is sketched; confirm that's sufficient or if we want short-lived signed links too. -- **Naming** — `publications.record` (the AT-Proto mirror) vs appview-only columns. Current plan: appview-only state never touches `record`. +- **Retention** — how long `unsubscribed` rows and the events log stick around (GDPR). +- **Subscriber-side auth for manage/unsubscribe** — is `unsubscribe_token` sufficient, or do we also want short-lived signed links? -- 2.51.2