a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

update spec

Jared Pereira (Apr 21, 2026, 1:42 PM EDT) cbf0e2aa e7610236

+124 -212
+124 -212
specs/2026-04-20-newsletter-mode.md
··· 4 4 5 5 ## Goal 6 6 7 - 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. 7 + 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. 8 8 9 9 ## Data Model 10 10 11 - ### `publication_newsletter_settings` — per-publication newsletter configuration 11 + Four new tables; two legacy tables (`subscribers_to_publications`, `email_subscriptions_to_entity`) are dropped. 12 + 13 + ### `publication_newsletter_settings` — per-publication config 12 14 13 - 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. 15 + 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. 14 16 15 17 - `publication` (text, PK, FK → `publications.uri` ON DELETE CASCADE) 16 - - `enabled` (boolean, not null, default false) 17 - - `enabled_at` (timestamptz, nullable) — first time newsletter was turned on 18 - - `reply_to_email` (text, nullable) — owner's address; replies from readers go here 19 - - `reply_to_verified_at` (timestamptz, nullable) — null means unverified; set once the confirmation code flow completes 20 - - `created_at` (timestamptz, not null, default now()) 21 - - `updated_at` (timestamptz, not null, default now()) 18 + - `enabled` (boolean, default false) 19 + - `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. 20 + - `reply_to_verified_at` (timestamptz, nullable) — set once the confirmation-code flow completes 21 + - `created_at`, `updated_at` 22 22 23 - All sends go From `newsletters@leaflet.pub` (or similar, a domain we control); no per-publication sender verification needed. 23 + Partial index on `enabled WHERE enabled` for ops/billing reconciliation. 24 24 25 - Indexes: 26 - - Partial index on `enabled WHERE enabled` — for ops/billing reconciliation queries. 25 + ### `publication_email_subscribers` — per-publication subscriber list 27 26 28 - RLS: 29 - - Writes: publication owner only (same ACL as `publication_domains`). 30 - - 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. 27 + 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. 31 28 32 - ### Migration sketch 29 + Unsubscribes are soft: the row stays, state flips to `unsubscribed`, so re-subscribes are clean and history stays intact. 33 30 34 - ```sql 35 - create table "public"."publication_newsletter_settings" ( 36 - "publication" text primary key 37 - references publications(uri) on delete cascade, 38 - "enabled" boolean not null default false, 39 - "enabled_at" timestamptz, 40 - "reply_to_email" text, 41 - "reply_to_verified_at" timestamptz, 42 - "created_at" timestamptz not null default now(), 43 - "updated_at" timestamptz not null default now() 44 - ); 31 + - `id` (uuid, PK) 32 + - `publication` (text, FK → `publications.uri` ON DELETE CASCADE) 33 + - `email` (text) 34 + - `identity_id` (uuid, nullable, FK → `identities.id` ON DELETE SET NULL) — set if subscriber logged in at signup 35 + - `state` — `pending` | `confirmed` | `unsubscribed` 36 + - `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. 37 + - `unsubscribe_token` (uuid, unique) — opaque token for `List-Unsubscribe` URLs 38 + - `created_at`, `confirmed_at`, `unsubscribed_at` 45 39 46 - create index publication_newsletter_settings_enabled_idx 47 - on publication_newsletter_settings (enabled) where enabled; 40 + Indexes: `unique (publication, email)` (re-subscribe updates the existing row); partial index on `(publication) WHERE state = 'confirmed'` for send targeting; unique on `unsubscribe_token`. 48 41 49 - alter table "public"."publication_newsletter_settings" 50 - enable row level security; 42 + ### `publication_email_subscriber_events` — append-only event log 51 43 52 - -- grants + RLS policies TBD 53 - ``` 44 + Full lifecycle log for debugging delivery, timeseries, and billing reconciliation. Timestamp columns on `publication_email_subscribers` are convenience caches derivable from this log. 54 45 55 - Plus: 56 - - Add matching `pgTable` in `drizzle/schema.ts`. 57 - - Run `npm run generate-db-types` to refresh Supabase types. 58 - - Extend `get_publication_data` (or add a new RPC) to join this row into the dashboard payload. 46 + - `id` (uuid, PK) 47 + - `subscriber` (uuid, FK → `publication_email_subscribers.id` ON DELETE CASCADE) 48 + - `publication` (text, FK → `publications.uri`) — denormalized for cheap per-pub queries 49 + - `event_type`: `subscribe_requested` | `confirmation_sent` | `confirmed` | `unsubscribe_requested` | `resubscribed` | `post_sent` | `bounce` | `complaint` 50 + - `occurred_at` 51 + - `metadata` (jsonb, nullable) — e.g. `{ document: "at://…" }` on `post_sent`, `{ reason: "…" }` on `bounce` 59 52 60 - ### `publication_email_subscribers` — per-publication email subscriber list 53 + Indexes: `(subscriber, occurred_at desc)` for per-subscriber timeline; `(publication, event_type, occurred_at desc)` for aggregates. 61 54 62 - 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. 55 + ### `publication_post_sends` — per-post send record 63 56 64 - Columns: 65 - - `id` (uuid, PK, default `gen_random_uuid()`) 66 - - `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE) 67 - - `email` (text, not null) 68 - - `identity_id` (uuid, nullable, FK → `identities.id` ON DELETE SET NULL) — set if the subscriber is a logged-in Leaflet user at signup time 69 - - `state` (text, not null, default `'pending'`) — one of `pending` (awaiting confirmation), `confirmed`, `unsubscribed`. A text check constraint keeps this honest. 70 - - `confirmation_code` (text, nullable) — cleared once confirmed 71 - - `unsubscribe_token` (uuid, not null, default `gen_random_uuid()`, unique) — opaque token used in Postmark `List-Unsubscribe` URLs 72 - - `created_at` (timestamptz, not null, default now()) 73 - - `confirmed_at` (timestamptz, nullable) 74 - - `unsubscribed_at` (timestamptz, nullable) 57 + 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. 75 58 76 - Indexes / constraints: 77 - - `unique (publication, email)` — one record per address per publication; a re-subscribe updates the existing row back to `pending`/`confirmed` rather than inserting. 78 - - Partial index on `(publication) WHERE state = 'confirmed'` — counts and send targeting hit this. 79 - - Unique index on `unsubscribe_token`. 59 + - `publication`, `document` (PK, both FK ON DELETE CASCADE) 60 + - `status` — `pending` | `sending` | `sent` | `failed` 61 + - `subscriber_count` (int, nullable) — snapshot of confirmed subscribers at send time 62 + - `started_at`, `completed_at` (nullable), `error` (text, nullable) 80 63 81 - RLS: 82 - - Inserts by anon: allowed (subscribe form), but only with `state = 'pending'`. 83 - - Reads of individual rows: public by `unsubscribe_token` (manage-subscription link); full list is owner-only. 84 - - Updates: service role + the owner. 64 + 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. 85 65 86 - ### `publication_email_subscriber_events` — append-only event log 66 + ### Authorization 87 67 88 - 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. 68 + 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. 89 69 90 - Columns: 91 - - `id` (uuid, PK, default `gen_random_uuid()`) 92 - - `subscriber` (uuid, not null, FK → `publication_email_subscribers.id` ON DELETE CASCADE) 93 - - `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE) — denormalized for cheap per-publication queries 94 - - `event_type` (text, not null) — `subscribe_requested`, `confirmation_sent`, `confirmed`, `unsubscribe_requested`, `resubscribed`, `post_sent`, `bounce`, `complaint`. Check constraint enforces the enum. 95 - - `occurred_at` (timestamptz, not null, default now()) 96 - - `metadata` (jsonb, nullable) — event-specific payload (e.g. `{ "document": "at://…" }` for `post_sent`, `{ "reason": "…" }` for `bounce`) 70 + ### Dropped tables 97 71 98 - Indexes: 99 - - `(subscriber, occurred_at desc)` — per-subscriber timeline. 100 - - `(publication, event_type, occurred_at desc)` — "how many confirms this week" etc. 72 + `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. 101 73 102 - 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. 74 + `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. 103 75 104 - ### `publication_post_sends` — per-post send record 76 + ### Rollout 105 77 106 - 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. 78 + 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. 107 79 108 - Columns: 109 - - `publication` (text, not null, FK → `publications.uri` ON DELETE CASCADE) 110 - - `document` (text, not null, FK → `documents.uri` ON DELETE CASCADE) 111 - - `status` (text, not null, default `'pending'`) — `pending`, `sending`, `sent`, `failed`. Check constraint enforces the enum. 112 - - `subscriber_count` (int, nullable) — snapshot of confirmed subscribers at send time; null until the batch is built 113 - - `started_at` (timestamptz, not null, default now()) 114 - - `completed_at` (timestamptz, nullable) — set when `status` moves to `sent` or `failed` 115 - - `error` (text, nullable) — populated when `status = 'failed'` 80 + ## Delivery (Postmark + Inngest) 116 81 117 - 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. 82 + ### From / Reply-To 118 83 119 - Indexes: 120 - - PK covers the common lookup. 121 - - `(publication, started_at desc)` — for the dashboard's recent-sends list. 84 + - **From:** fixed `newsletters@leaflet.pub` (domain we control; no per-publication DKIM/SPF). 85 + - **Reply-To:** publication owner's verified `reply_to_email`. 122 86 123 - Per-recipient delivery detail (which addresses got it, bounces, complaints) stays in `publication_email_subscriber_events`; this table is only the post-level aggregate. 87 + 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). 124 88 125 - ### Tables to drop 89 + ### Message streams 126 90 127 - Both are replaced by the above and have no remaining real consumers after the newsletter flow lands: 91 + - `broadcast` — newsletter sends. Honors Postmark's suppression list and one-click `List-Unsubscribe`. 92 + - transactional (existing `outbound` or dedicated) — subscriber + reply-to confirmation codes. Not suppression-gated. 128 93 129 - - `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`. 130 - - `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. 94 + **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. 131 95 132 - 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). 96 + ### Sending path (per-post) 133 97 134 - ### Migration sketch (additions) 98 + **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. 135 99 136 - ```sql 137 - create table "public"."publication_email_subscribers" ( 138 - "id" uuid primary key default gen_random_uuid(), 139 - "publication" text not null 140 - references publications(uri) on delete cascade, 141 - "email" text not null, 142 - "identity_id" uuid 143 - references identities(id) on delete set null, 144 - "state" text not null default 'pending' 145 - check (state in ('pending', 'confirmed', 'unsubscribed')), 146 - "confirmation_code" text, 147 - "unsubscribe_token" uuid not null default gen_random_uuid(), 148 - "created_at" timestamptz not null default now(), 149 - "confirmed_at" timestamptz, 150 - "unsubscribed_at" timestamptz, 151 - unique (publication, email), 152 - unique (unsubscribe_token) 153 - ); 100 + 1. Publish action inserts into `publication_post_sends` with `ON CONFLICT DO NOTHING`. PK is the idempotency guard. 101 + 2. Action fires `newsletter/post.send.requested` with publication + document URIs. 102 + 3. Inngest function: 103 + - Snapshots `confirmed` subscribers, writes `subscriber_count`, flips `status = 'sending'`. 104 + - Renders via React Email templates in `emails/`. 105 + - Chunks into batches of ≤500 per `/email/batch` call (Postmark's cap). 106 + - 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). 107 + - 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. 108 + - Uses step retries for per-batch transport failures (network / 5xx). 109 + - On terminal completion sets `status` + `completed_at` (+ `error` on fail). 154 110 155 - create index publication_email_subscribers_confirmed_idx 156 - on publication_email_subscribers (publication) 157 - where state = 'confirmed'; 111 + ### Preview sends 158 112 159 - create table "public"."publication_email_subscriber_events" ( 160 - "id" uuid primary key default gen_random_uuid(), 161 - "subscriber" uuid not null 162 - references publication_email_subscribers(id) on delete cascade, 163 - "publication" text not null 164 - references publications(uri) on delete cascade, 165 - "event_type" text not null check (event_type in ( 166 - 'subscribe_requested', 'confirmation_sent', 'confirmed', 167 - 'unsubscribe_requested', 'resubscribed', 168 - 'post_sent', 'bounce', 'complaint' 169 - )), 170 - "occurred_at" timestamptz not null default now(), 171 - "metadata" jsonb 172 - ); 113 + 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. 173 114 174 - create index publication_email_subscriber_events_subscriber_idx 175 - on publication_email_subscriber_events (subscriber, occurred_at desc); 115 + Cut-outs so the preview doesn't pollute real-send state: 176 116 177 - create index publication_email_subscriber_events_publication_idx 178 - on publication_email_subscriber_events (publication, event_type, occurred_at desc); 117 + - Uses the transactional stream, not `broadcast` — skips Postmark suppression-gating and omits `List-Unsubscribe` headers. 118 + - No `publication_post_sends` row (would PK-conflict the real send later) and no events appended (preview is not metered as a send). 119 + - Template renders with a placeholder "(preview)" footer since there's no subscriber row / `unsubscribe_token`. 179 120 180 - create table "public"."publication_post_sends" ( 181 - "publication" text not null 182 - references publications(uri) on delete cascade, 183 - "document" text not null 184 - references documents(uri) on delete cascade, 185 - "status" text not null default 'pending' 186 - check (status in ('pending', 'sending', 'sent', 'failed')), 187 - "subscriber_count" integer, 188 - "started_at" timestamptz not null default now(), 189 - "completed_at" timestamptz, 190 - "error" text, 191 - primary key (publication, document) 192 - ); 121 + ### Postmark webhooks 193 122 194 - create index publication_post_sends_publication_idx 195 - on publication_post_sends (publication, started_at desc); 123 + New endpoint at `app/api/postmark/webhook`: 196 124 197 - alter table "public"."publication_email_subscribers" enable row level security; 198 - alter table "public"."publication_email_subscriber_events" enable row level security; 199 - alter table "public"."publication_post_sends" enable row level security; 125 + - `Bounce` — append `bounce`. Hard bounces → `unsubscribed`; soft bounces stay `confirmed` (the event log carries the signal if we want retry logic later). 126 + - `SpamComplaint` — append `complaint`; → `unsubscribed`. 127 + - `SubscriptionChange` — one-click `List-Unsubscribe`. Append `unsubscribe_requested`; → `unsubscribed`. 128 + - `Delivery` / `Open` / `Click` — not captured; revisit if product wants analytics. 200 129 201 - -- drop legacy 202 - drop table "public"."subscribers_to_publications"; 203 - drop table "public"."email_subscriptions_to_entity"; 204 - ``` 130 + Idempotency: `SubscriptionChange` for in-app unsubscribes arrives *after* we already wrote local state. Handler no-ops on already-`unsubscribed` rows. 205 131 206 - Plus: 207 - - 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. 208 - - Remove the dropped tables from `drizzle/schema.ts` and `drizzle/relations.ts`; add the two new tables. 209 - - Regenerate Supabase types. 132 + ### Template rendering 210 133 211 - ## Delivery (Postmark + Inngest) 134 + Ported from `feature/follow-via-email` (commit `c09ca71e`) into `emails/`: 212 135 213 - ### From / Reply-To 136 + - `post.tsx` — newsletter post template; also exports shared primitives (`Text`, `Heading`, `LinkBlock`, `CodeBlock`, `BlockNotSupported`, `List`, `LeafletWatermark`). 137 + - `leafletConfirmEmail.tsx` — reply-to verification code. 138 + - `pubConfirmEmail.tsx` — subscriber confirmation code. 139 + - `static/` — bundled icon assets. 214 140 215 - - **From:** fixed `newsletters@leaflet.pub` for every publication. No per-publication DKIM/SPF setup. 216 - - **Reply-To:** publication owner's verified `reply_to_email`. Replies land in the owner's inbox. 217 - - Reply-To verification reuses the same confirmation-code mechanism as subscriber confirmations (code emailed, owner pastes it back). No DNS involved. 141 + 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. 218 142 219 - ### Message streams 143 + **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). 220 144 221 - - `broadcast` — newsletter sends. Honors Postmark's native suppression list and one-click `List-Unsubscribe`. 222 - - transactional (existing `outbound` or a dedicated stream) — subscriber confirmation codes and reply-to verification codes. Not suppression-gated. 145 + ### Suppression ownership 223 146 224 - ### Sending path (per-post) 147 + 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`.) 225 148 226 - 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. 227 - 2. Action fires an Inngest event (e.g. `newsletter/post.send.requested`) with publication URI + document URI. 228 - 3. Inngest function: 229 - - Snapshots `confirmed` subscribers, writes `subscriber_count` and flips `status = 'sending'`. 230 - - Renders the email via the React Email templates in `emails/` (see "Template rendering" below). 231 - - Chunks into batches of ≤500 recipients per `/email/batch` call (Postmark's batch size cap). 232 - - Appends one `post_sent` event per recipient to `publication_email_subscriber_events`. 233 - - Uses Inngest step retries for per-batch failures. 234 - - On terminal completion, updates `status` to `sent` or `failed` + `completed_at` + `error`. 149 + - **Send targeting** reads local `state = 'confirmed'` only. The `broadcast` stream drops suppressed addresses itself and fires `SubscriptionChange` for reconciliation. 150 + - **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`. 151 + - **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. 152 + - **Bounce / spam complaint**: Postmark-only signals; mirror to DB + events. Hard bounce and spam complaint both flip local state to `unsubscribed`. 153 + - **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. 235 154 236 - ### Postmark webhooks 155 + ## Existing UI placeholders 237 156 238 - New appview endpoint (e.g. `app/api/postmark/webhook`) receives: 157 + Most UI already exists unwired — wire these up rather than adding new components. 239 158 240 - - `Bounce` — append `bounce` event; on hard bounce transition subscriber to `unsubscribed`. 241 - - `SpamComplaint` — append `complaint` event; transition to `unsubscribed`. 242 - - `SubscriptionChange` — fires when a reader uses Postmark's one-click `List-Unsubscribe`. Append `unsubscribe_requested` + transition to `unsubscribed`. 243 - - `Delivery` / `Open` / `Click` — not captured for now; revisit if product wants open/click analytics. 159 + 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. 244 160 245 - ### Template rendering 161 + ### Subscribe (reader → publication) 246 162 247 - Ported from `feature/follow-via-email` (commit `c09ca71e`). Lives in `emails/`: 163 + `SubscribeInput` in `components/Subscribe/SubscribeButton.tsx` — rendered by `PostPubInfo`, `PublicationContent`, `PubListing`. Branches on `newsletterMode`: email vs. Atmosphere handle. 248 164 249 - - `emails/post.tsx` — newsletter post template. Also exports shared primitives used by the other templates: `Text`, `Heading`, `LinkBlock`, `CodeBlock`, `BlockNotSupported`, `List`, `LeafletWatermark`. 250 - - `emails/leafletConfirmEmail.tsx` — code-based confirmation email for Leaflet-level email verification (reply-to verification, etc). 251 - - `emails/pubConfirmEmail.tsx` — code-based confirmation email for publication subscribe flow. 252 - - `emails/static/` — bundled icon assets (`leaflet.png`, `comment.png`, `external-link.png`, `quote.png`). 165 + `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`. 253 166 254 - Built on [React Email](https://react.email) per the [manual setup guide](https://react.email/docs/getting-started/manual-setup): 167 + 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`. 255 168 256 - - `@react-email/components` (prod) — primitive components (`Html`, `Body`, `Section`, `Tailwind`, etc.) 257 - - `react-email` (prod) — provides the `email` CLI used by the `email:dev` script 258 - - `@react-email/ui` (dev) — preview server UI 169 + ### Manage / unsubscribe (logged-in reader) 259 170 260 - The deprecated `@react-email/preview-server` has been dropped. Local iteration runs via `npm run email:dev` (invokes `email dev` against `emails/`). 171 + `ManageSubscription` in `components/Subscribe/ManageSubscribe.tsx`. Two buttons need handlers: 261 172 262 - Each template ships its own inlined Tailwind config (via `@react-email/components`'s `Tailwind` wrapper) because email clients strip external stylesheets. 173 + - 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`. 174 + - Unsubscribe: flips state, appends `unsubscribe_requested`. 263 175 264 - **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. 176 + ### Token-based unsubscribe (email footer / `List-Unsubscribe`) 265 177 266 - **Remaining work on the renderer:** 178 + `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. 267 179 268 - - Pass real props (title, author, cover image, body blocks, post URL, unsubscribe token) into each template. 269 - - 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`. 270 - - 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. 271 - - 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. 180 + ### Enable / disable newsletter (publisher) 272 181 273 - ### Suppression ownership 182 + `NewsletterSettings` in `app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx`. Branches on `dummy.newsletterMode`. 274 183 275 - Postmark is the source of truth for suppressions. Flow: 184 + - 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. 185 + - Disable: confirmation modal gated on typing the publication name. Flip `enabled = false`. 186 + - Subscriber count + price copy are hardcoded and tagged with "don't let us merge" comments — replace with `count(*)` on confirmed subscribers + Stripe product config. 276 187 277 - - Postmark webhook → we mirror the state into `publication_email_subscribers.state`. 278 - - Our in-app unsubscribe (via `unsubscribe_token`) calls Postmark's Suppressions API first, then writes the local row. 279 - - Resubscribing a previously-unsubscribed reader requires unsuppressing via Postmark's API before the next broadcast will reach them. 188 + ## Metering 189 + 190 + 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: 191 + 192 + - **Active subscribers per publication** — `count(*) from publication_email_subscribers where publication = $1 and state = 'confirmed'`. Queried on demand. 193 + - **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. 194 + 195 + No reporting cron for v1; surface both counters in the dashboard and let Stripe reporting layer on later. 280 196 281 - ## Open questions / deferred 197 + ## Open questions 282 198 283 - - **Preview / test send** — authors sending a test email to themselves before broadcasting. 284 - - **Scheduled / backdated sends** — does a backdated publish still trigger a send? Probably not; spec should nail down. 285 - - **Resubscribe UX** — specifically, what the subscribe form does when `state = 'unsubscribed'` (silent resubscribe vs re-confirm). Tied to the Postmark unsuppress call. 199 + - **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. 286 200 - **Confirmation rate limiting / CAPTCHA** — the anon subscribe endpoint is spammable. 287 201 - **Identity linking** — backfilling `identity_id` when a subscriber later logs in. 288 - - **Retention** — how long `unsubscribed` rows and the events log stick around (GDPR angle). 289 - - **Stripe metering** — where the usage-reporting cron reads subscriber counts from. 290 - - **Subscriber-side auth for manage/unsubscribe** — `unsubscribe_token` is sketched; confirm that's sufficient or if we want short-lived signed links too. 291 - - **Naming** — `publications.record` (the AT-Proto mirror) vs appview-only columns. Current plan: appview-only state never touches `record`. 202 + - **Retention** — how long `unsubscribed` rows and the events log stick around (GDPR). 203 + - **Subscriber-side auth for manage/unsubscribe** — is `unsubscribe_token` sufficient, or do we also want short-lived signed links?