···4455## Goal
6677-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.
77+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.
8899## Data Model
10101111-### `publication_newsletter_settings` — per-publication newsletter configuration
1111+Four new tables; two legacy tables (`subscribers_to_publications`, `email_subscriptions_to_entity`) are dropped.
1212+1313+### `publication_newsletter_settings` — per-publication config
12141313-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.
1515+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.
14161517- `publication` (text, PK, FK → `publications.uri` ON DELETE CASCADE)
1616-- `enabled` (boolean, not null, default false)
1717-- `enabled_at` (timestamptz, nullable) — first time newsletter was turned on
1818-- `reply_to_email` (text, nullable) — owner's address; replies from readers go here
1919-- `reply_to_verified_at` (timestamptz, nullable) — null means unverified; set once the confirmation code flow completes
2020-- `created_at` (timestamptz, not null, default now())
2121-- `updated_at` (timestamptz, not null, default now())
1818+- `enabled` (boolean, default false)
1919+- `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.
2020+- `reply_to_verified_at` (timestamptz, nullable) — set once the confirmation-code flow completes
2121+- `created_at`, `updated_at`
22222323-All sends go From `newsletters@leaflet.pub` (or similar, a domain we control); no per-publication sender verification needed.
2323+Partial index on `enabled WHERE enabled` for ops/billing reconciliation.
24242525-Indexes:
2626-- Partial index on `enabled WHERE enabled` — for ops/billing reconciliation queries.
2525+### `publication_email_subscribers` — per-publication subscriber list
27262828-RLS:
2929-- Writes: publication owner only (same ACL as `publication_domains`).
3030-- 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.
2727+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.
31283232-### Migration sketch
2929+Unsubscribes are soft: the row stays, state flips to `unsubscribed`, so re-subscribes are clean and history stays intact.
33303434-```sql
3535-create table "public"."publication_newsletter_settings" (
3636- "publication" text primary key
3737- references publications(uri) on delete cascade,
3838- "enabled" boolean not null default false,
3939- "enabled_at" timestamptz,
4040- "reply_to_email" text,
4141- "reply_to_verified_at" timestamptz,
4242- "created_at" timestamptz not null default now(),
4343- "updated_at" timestamptz not null default now()
4444-);
3131+- `id` (uuid, PK)
3232+- `publication` (text, FK → `publications.uri` ON DELETE CASCADE)
3333+- `email` (text)
3434+- `identity_id` (uuid, nullable, FK → `identities.id` ON DELETE SET NULL) — set if subscriber logged in at signup
3535+- `state` — `pending` | `confirmed` | `unsubscribed`
3636+- `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.
3737+- `unsubscribe_token` (uuid, unique) — opaque token for `List-Unsubscribe` URLs
3838+- `created_at`, `confirmed_at`, `unsubscribed_at`
45394646-create index publication_newsletter_settings_enabled_idx
4747- on publication_newsletter_settings (enabled) where enabled;
4040+Indexes: `unique (publication, email)` (re-subscribe updates the existing row); partial index on `(publication) WHERE state = 'confirmed'` for send targeting; unique on `unsubscribe_token`.
48414949-alter table "public"."publication_newsletter_settings"
5050- enable row level security;
4242+### `publication_email_subscriber_events` — append-only event log
51435252--- grants + RLS policies TBD
5353-```
4444+Full lifecycle log for debugging delivery, timeseries, and billing reconciliation. Timestamp columns on `publication_email_subscribers` are convenience caches derivable from this log.
54455555-Plus:
5656-- Add matching `pgTable` in `drizzle/schema.ts`.
5757-- Run `npm run generate-db-types` to refresh Supabase types.
5858-- Extend `get_publication_data` (or add a new RPC) to join this row into the dashboard payload.
4646+- `id` (uuid, PK)
4747+- `subscriber` (uuid, FK → `publication_email_subscribers.id` ON DELETE CASCADE)
4848+- `publication` (text, FK → `publications.uri`) — denormalized for cheap per-pub queries
4949+- `event_type`: `subscribe_requested` | `confirmation_sent` | `confirmed` | `unsubscribe_requested` | `resubscribed` | `post_sent` | `bounce` | `complaint`
5050+- `occurred_at`
5151+- `metadata` (jsonb, nullable) — e.g. `{ document: "at://…" }` on `post_sent`, `{ reason: "…" }` on `bounce`
59526060-### `publication_email_subscribers` — per-publication email subscriber list
5353+Indexes: `(subscriber, occurred_at desc)` for per-subscriber timeline; `(publication, event_type, occurred_at desc)` for aggregates.
61546262-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.
5555+### `publication_post_sends` — per-post send record
63566464-Columns:
6565-- `id` (uuid, PK, default `gen_random_uuid()`)
6666-- `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE)
6767-- `email` (text, not null)
6868-- `identity_id` (uuid, nullable, FK → `identities.id` ON DELETE SET NULL) — set if the subscriber is a logged-in Leaflet user at signup time
6969-- `state` (text, not null, default `'pending'`) — one of `pending` (awaiting confirmation), `confirmed`, `unsubscribed`. A text check constraint keeps this honest.
7070-- `confirmation_code` (text, nullable) — cleared once confirmed
7171-- `unsubscribe_token` (uuid, not null, default `gen_random_uuid()`, unique) — opaque token used in Postmark `List-Unsubscribe` URLs
7272-- `created_at` (timestamptz, not null, default now())
7373-- `confirmed_at` (timestamptz, nullable)
7474-- `unsubscribed_at` (timestamptz, nullable)
5757+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.
75587676-Indexes / constraints:
7777-- `unique (publication, email)` — one record per address per publication; a re-subscribe updates the existing row back to `pending`/`confirmed` rather than inserting.
7878-- Partial index on `(publication) WHERE state = 'confirmed'` — counts and send targeting hit this.
7979-- Unique index on `unsubscribe_token`.
5959+- `publication`, `document` (PK, both FK ON DELETE CASCADE)
6060+- `status` — `pending` | `sending` | `sent` | `failed`
6161+- `subscriber_count` (int, nullable) — snapshot of confirmed subscribers at send time
6262+- `started_at`, `completed_at` (nullable), `error` (text, nullable)
80638181-RLS:
8282-- Inserts by anon: allowed (subscribe form), but only with `state = 'pending'`.
8383-- Reads of individual rows: public by `unsubscribe_token` (manage-subscription link); full list is owner-only.
8484-- Updates: service role + the owner.
6464+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.
85658686-### `publication_email_subscriber_events` — append-only event log
6666+### Authorization
87678888-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.
6868+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.
89699090-Columns:
9191-- `id` (uuid, PK, default `gen_random_uuid()`)
9292-- `subscriber` (uuid, not null, FK → `publication_email_subscribers.id` ON DELETE CASCADE)
9393-- `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE) — denormalized for cheap per-publication queries
9494-- `event_type` (text, not null) — `subscribe_requested`, `confirmation_sent`, `confirmed`, `unsubscribe_requested`, `resubscribed`, `post_sent`, `bounce`, `complaint`. Check constraint enforces the enum.
9595-- `occurred_at` (timestamptz, not null, default now())
9696-- `metadata` (jsonb, nullable) — event-specific payload (e.g. `{ "document": "at://…" }` for `post_sent`, `{ "reason": "…" }` for `bounce`)
7070+### Dropped tables
97719898-Indexes:
9999-- `(subscriber, occurred_at desc)` — per-subscriber timeline.
100100-- `(publication, event_type, occurred_at desc)` — "how many confirms this week" etc.
7272+`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.
10173102102-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.
7474+`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.
10375104104-### `publication_post_sends` — per-post send record
7676+### Rollout
10577106106-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.
7878+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.
10779108108-Columns:
109109-- `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE)
110110-- `document` (text, not null, FK → `documents.uri` ON DELETE CASCADE)
111111-- `status` (text, not null, default `'pending'`) — `pending`, `sending`, `sent`, `failed`. Check constraint enforces the enum.
112112-- `subscriber_count` (int, nullable) — snapshot of confirmed subscribers at send time; null until the batch is built
113113-- `started_at` (timestamptz, not null, default now())
114114-- `completed_at` (timestamptz, nullable) — set when `status` moves to `sent` or `failed`
115115-- `error` (text, nullable) — populated when `status = 'failed'`
8080+## Delivery (Postmark + Inngest)
11681117117-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.
8282+### From / Reply-To
11883119119-Indexes:
120120-- PK covers the common lookup.
121121-- `(publication, started_at desc)` — for the dashboard's recent-sends list.
8484+- **From:** fixed `newsletters@leaflet.pub` (domain we control; no per-publication DKIM/SPF).
8585+- **Reply-To:** publication owner's verified `reply_to_email`.
12286123123-Per-recipient delivery detail (which addresses got it, bounces, complaints) stays in `publication_email_subscriber_events`; this table is only the post-level aggregate.
8787+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).
12488125125-### Tables to drop
8989+### Message streams
12690127127-Both are replaced by the above and have no remaining real consumers after the newsletter flow lands:
9191+- `broadcast` — newsletter sends. Honors Postmark's suppression list and one-click `List-Unsubscribe`.
9292+- transactional (existing `outbound` or dedicated) — subscriber + reply-to confirmation codes. Not suppression-gated.
12893129129-- `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`.
130130-- `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.
9494+**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.
13195132132-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).
9696+### Sending path (per-post)
13397134134-### Migration sketch (additions)
9898+**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.
13599136136-```sql
137137-create table "public"."publication_email_subscribers" (
138138- "id" uuid primary key default gen_random_uuid(),
139139- "publication" text not null
140140- references publications(uri) on delete cascade,
141141- "email" text not null,
142142- "identity_id" uuid
143143- references identities(id) on delete set null,
144144- "state" text not null default 'pending'
145145- check (state in ('pending', 'confirmed', 'unsubscribed')),
146146- "confirmation_code" text,
147147- "unsubscribe_token" uuid not null default gen_random_uuid(),
148148- "created_at" timestamptz not null default now(),
149149- "confirmed_at" timestamptz,
150150- "unsubscribed_at" timestamptz,
151151- unique (publication, email),
152152- unique (unsubscribe_token)
153153-);
100100+1. Publish action inserts into `publication_post_sends` with `ON CONFLICT DO NOTHING`. PK is the idempotency guard.
101101+2. Action fires `newsletter/post.send.requested` with publication + document URIs.
102102+3. Inngest function:
103103+ - Snapshots `confirmed` subscribers, writes `subscriber_count`, flips `status = 'sending'`.
104104+ - Renders via React Email templates in `emails/`.
105105+ - Chunks into batches of ≤500 per `/email/batch` call (Postmark's cap).
106106+ - 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).
107107+ - 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.
108108+ - Uses step retries for per-batch transport failures (network / 5xx).
109109+ - On terminal completion sets `status` + `completed_at` (+ `error` on fail).
154110155155-create index publication_email_subscribers_confirmed_idx
156156- on publication_email_subscribers (publication)
157157- where state = 'confirmed';
111111+### Preview sends
158112159159-create table "public"."publication_email_subscriber_events" (
160160- "id" uuid primary key default gen_random_uuid(),
161161- "subscriber" uuid not null
162162- references publication_email_subscribers(id) on delete cascade,
163163- "publication" text not null
164164- references publications(uri) on delete cascade,
165165- "event_type" text not null check (event_type in (
166166- 'subscribe_requested', 'confirmation_sent', 'confirmed',
167167- 'unsubscribe_requested', 'resubscribed',
168168- 'post_sent', 'bounce', 'complaint'
169169- )),
170170- "occurred_at" timestamptz not null default now(),
171171- "metadata" jsonb
172172-);
113113+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.
173114174174-create index publication_email_subscriber_events_subscriber_idx
175175- on publication_email_subscriber_events (subscriber, occurred_at desc);
115115+Cut-outs so the preview doesn't pollute real-send state:
176116177177-create index publication_email_subscriber_events_publication_idx
178178- on publication_email_subscriber_events (publication, event_type, occurred_at desc);
117117+- Uses the transactional stream, not `broadcast` — skips Postmark suppression-gating and omits `List-Unsubscribe` headers.
118118+- No `publication_post_sends` row (would PK-conflict the real send later) and no events appended (preview is not metered as a send).
119119+- Template renders with a placeholder "(preview)" footer since there's no subscriber row / `unsubscribe_token`.
179120180180-create table "public"."publication_post_sends" (
181181- "publication" text not null
182182- references publications(uri) on delete cascade,
183183- "document" text not null
184184- references documents(uri) on delete cascade,
185185- "status" text not null default 'pending'
186186- check (status in ('pending', 'sending', 'sent', 'failed')),
187187- "subscriber_count" integer,
188188- "started_at" timestamptz not null default now(),
189189- "completed_at" timestamptz,
190190- "error" text,
191191- primary key (publication, document)
192192-);
121121+### Postmark webhooks
193122194194-create index publication_post_sends_publication_idx
195195- on publication_post_sends (publication, started_at desc);
123123+New endpoint at `app/api/postmark/webhook`:
196124197197-alter table "public"."publication_email_subscribers" enable row level security;
198198-alter table "public"."publication_email_subscriber_events" enable row level security;
199199-alter table "public"."publication_post_sends" enable row level security;
125125+- `Bounce` — append `bounce`. Hard bounces → `unsubscribed`; soft bounces stay `confirmed` (the event log carries the signal if we want retry logic later).
126126+- `SpamComplaint` — append `complaint`; → `unsubscribed`.
127127+- `SubscriptionChange` — one-click `List-Unsubscribe`. Append `unsubscribe_requested`; → `unsubscribed`.
128128+- `Delivery` / `Open` / `Click` — not captured; revisit if product wants analytics.
200129201201--- drop legacy
202202-drop table "public"."subscribers_to_publications";
203203-drop table "public"."email_subscriptions_to_entity";
204204-```
130130+Idempotency: `SubscriptionChange` for in-app unsubscribes arrives *after* we already wrote local state. Handler no-ops on already-`unsubscribed` rows.
205131206206-Plus:
207207-- 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.
208208-- Remove the dropped tables from `drizzle/schema.ts` and `drizzle/relations.ts`; add the two new tables.
209209-- Regenerate Supabase types.
132132+### Template rendering
210133211211-## Delivery (Postmark + Inngest)
134134+Ported from `feature/follow-via-email` (commit `c09ca71e`) into `emails/`:
212135213213-### From / Reply-To
136136+- `post.tsx` — newsletter post template; also exports shared primitives (`Text`, `Heading`, `LinkBlock`, `CodeBlock`, `BlockNotSupported`, `List`, `LeafletWatermark`).
137137+- `leafletConfirmEmail.tsx` — reply-to verification code.
138138+- `pubConfirmEmail.tsx` — subscriber confirmation code.
139139+- `static/` — bundled icon assets.
214140215215-- **From:** fixed `newsletters@leaflet.pub` for every publication. No per-publication DKIM/SPF setup.
216216-- **Reply-To:** publication owner's verified `reply_to_email`. Replies land in the owner's inbox.
217217-- Reply-To verification reuses the same confirmation-code mechanism as subscriber confirmations (code emailed, owner pastes it back). No DNS involved.
141141+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.
218142219219-### Message streams
143143+**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).
220144221221-- `broadcast` — newsletter sends. Honors Postmark's native suppression list and one-click `List-Unsubscribe`.
222222-- transactional (existing `outbound` or a dedicated stream) — subscriber confirmation codes and reply-to verification codes. Not suppression-gated.
145145+### Suppression ownership
223146224224-### Sending path (per-post)
147147+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`.)
225148226226-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.
227227-2. Action fires an Inngest event (e.g. `newsletter/post.send.requested`) with publication URI + document URI.
228228-3. Inngest function:
229229- - Snapshots `confirmed` subscribers, writes `subscriber_count` and flips `status = 'sending'`.
230230- - Renders the email via the React Email templates in `emails/` (see "Template rendering" below).
231231- - Chunks into batches of ≤500 recipients per `/email/batch` call (Postmark's batch size cap).
232232- - Appends one `post_sent` event per recipient to `publication_email_subscriber_events`.
233233- - Uses Inngest step retries for per-batch failures.
234234- - On terminal completion, updates `status` to `sent` or `failed` + `completed_at` + `error`.
149149+- **Send targeting** reads local `state = 'confirmed'` only. The `broadcast` stream drops suppressed addresses itself and fires `SubscriptionChange` for reconciliation.
150150+- **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`.
151151+- **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.
152152+- **Bounce / spam complaint**: Postmark-only signals; mirror to DB + events. Hard bounce and spam complaint both flip local state to `unsubscribed`.
153153+- **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.
235154236236-### Postmark webhooks
155155+## Existing UI placeholders
237156238238-New appview endpoint (e.g. `app/api/postmark/webhook`) receives:
157157+Most UI already exists unwired — wire these up rather than adding new components.
239158240240-- `Bounce` — append `bounce` event; on hard bounce transition subscriber to `unsubscribed`.
241241-- `SpamComplaint` — append `complaint` event; transition to `unsubscribed`.
242242-- `SubscriptionChange` — fires when a reader uses Postmark's one-click `List-Unsubscribe`. Append `unsubscribe_requested` + transition to `unsubscribed`.
243243-- `Delivery` / `Open` / `Click` — not captured for now; revisit if product wants open/click analytics.
159159+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.
244160245245-### Template rendering
161161+### Subscribe (reader → publication)
246162247247-Ported from `feature/follow-via-email` (commit `c09ca71e`). Lives in `emails/`:
163163+`SubscribeInput` in `components/Subscribe/SubscribeButton.tsx` — rendered by `PostPubInfo`, `PublicationContent`, `PubListing`. Branches on `newsletterMode`: email vs. Atmosphere handle.
248164249249-- `emails/post.tsx` — newsletter post template. Also exports shared primitives used by the other templates: `Text`, `Heading`, `LinkBlock`, `CodeBlock`, `BlockNotSupported`, `List`, `LeafletWatermark`.
250250-- `emails/leafletConfirmEmail.tsx` — code-based confirmation email for Leaflet-level email verification (reply-to verification, etc).
251251-- `emails/pubConfirmEmail.tsx` — code-based confirmation email for publication subscribe flow.
252252-- `emails/static/` — bundled icon assets (`leaflet.png`, `comment.png`, `external-link.png`, `quote.png`).
165165+`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`.
253166254254-Built on [React Email](https://react.email) per the [manual setup guide](https://react.email/docs/getting-started/manual-setup):
167167+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`.
255168256256-- `@react-email/components` (prod) — primitive components (`Html`, `Body`, `Section`, `Tailwind`, etc.)
257257-- `react-email` (prod) — provides the `email` CLI used by the `email:dev` script
258258-- `@react-email/ui` (dev) — preview server UI
169169+### Manage / unsubscribe (logged-in reader)
259170260260-The deprecated `@react-email/preview-server` has been dropped. Local iteration runs via `npm run email:dev` (invokes `email dev` against `emails/`).
171171+`ManageSubscription` in `components/Subscribe/ManageSubscribe.tsx`. Two buttons need handlers:
261172262262-Each template ships its own inlined Tailwind config (via `@react-email/components`'s `Tailwind` wrapper) because email clients strip external stylesheets.
173173+- 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`.
174174+- Unsubscribe: flips state, appends `unsubscribe_requested`.
263175264264-**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.
176176+### Token-based unsubscribe (email footer / `List-Unsubscribe`)
265177266266-**Remaining work on the renderer:**
178178+`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.
267179268268-- Pass real props (title, author, cover image, body blocks, post URL, unsubscribe token) into each template.
269269-- 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`.
270270-- 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.
271271-- 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.
180180+### Enable / disable newsletter (publisher)
272181273273-### Suppression ownership
182182+`NewsletterSettings` in `app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx`. Branches on `dummy.newsletterMode`.
274183275275-Postmark is the source of truth for suppressions. Flow:
184184+- 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.
185185+- Disable: confirmation modal gated on typing the publication name. Flip `enabled = false`.
186186+- Subscriber count + price copy are hardcoded and tagged with "don't let us merge" comments — replace with `count(*)` on confirmed subscribers + Stripe product config.
276187277277-- Postmark webhook → we mirror the state into `publication_email_subscribers.state`.
278278-- Our in-app unsubscribe (via `unsubscribe_token`) calls Postmark's Suppressions API first, then writes the local row.
279279-- Resubscribing a previously-unsubscribed reader requires unsuppressing via Postmark's API before the next broadcast will reach them.
188188+## Metering
189189+190190+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:
191191+192192+- **Active subscribers per publication** — `count(*) from publication_email_subscribers where publication = $1 and state = 'confirmed'`. Queried on demand.
193193+- **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.
194194+195195+No reporting cron for v1; surface both counters in the dashboard and let Stripe reporting layer on later.
280196281281-## Open questions / deferred
197197+## Open questions
282198283283-- **Preview / test send** — authors sending a test email to themselves before broadcasting.
284284-- **Scheduled / backdated sends** — does a backdated publish still trigger a send? Probably not; spec should nail down.
285285-- **Resubscribe UX** — specifically, what the subscribe form does when `state = 'unsubscribed'` (silent resubscribe vs re-confirm). Tied to the Postmark unsuppress call.
199199+- **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.
286200- **Confirmation rate limiting / CAPTCHA** — the anon subscribe endpoint is spammable.
287201- **Identity linking** — backfilling `identity_id` when a subscriber later logs in.
288288-- **Retention** — how long `unsubscribed` rows and the events log stick around (GDPR angle).
289289-- **Stripe metering** — where the usage-reporting cron reads subscriber counts from.
290290-- **Subscriber-side auth for manage/unsubscribe** — `unsubscribe_token` is sketched; confirm that's sufficient or if we want short-lived signed links too.
291291-- **Naming** — `publications.record` (the AT-Proto mirror) vs appview-only columns. Current plan: appview-only state never touches `record`.
202202+- **Retention** — how long `unsubscribed` rows and the events log stick around (GDPR).
203203+- **Subscriber-side auth for manage/unsubscribe** — is `unsubscribe_token` sufficient, or do we also want short-lived signed links?