logicspike/docs

Newsletter

Newsletter Service — Backlog: Issues, Gaps & Improvement Scope

Last Updated: 2026-10-05 Status: Active Purpose: Known bugs, missing features, and improvement opportunities found during codebase analysis. Mark items ✅ with date when fixed. Do not delete fixed items.


Scorecard

Audited 2026-06-28 against apps/newsletter-service source. Overall: 8.0 / 10

The email pipeline (queue, DLQ, drain check, Durable Object scheduling) and multi-tenant isolation are excellent. The main gaps are in subscriber experience polish (customisable confirmation email, metadata-based segments, GDPR erasure automation) and missing MCP tooling that every other major service now has.

Parameter Score Notes
API Design 8.5 Clean REST, cursor pagination, good shapes; segment preview endpoint missing (B1)
Data Model 8.5 8 tables, append-only events, suppression list; subscriber_count drift (B2), no retention policy (G2)
Security & Auth 9.0 Gateway secret, permission-gated admin, HMAC tokens for confirm/unsubscribe, rate limiting
Multi-tenancy 10.0 Perfect isolation; cross-tenant suppression list correctly enforced for deliverability
Billing 8.5 Upfront charge + idempotency key design is solid; no refund on delivery failure (clear policy)
Email Pipeline 9.0 Queue-based, DLQ, drain check, provider-agnostic via comms; raw errors not persisted (B5)
Double Opt-In 7.0 HMAC public flow solid; manual/imported subscribers bypass it (B4); confirmation not customisable (B3)
Scheduling 8.5 Durable Object alarm-based is elegant; no campaign unschedule action (G7)
Templates 7.5 Snapshot semantics good; conflicting token format for blog subject (B6); no conditionals/loops
Segmentation 7.0 Tags + basic filters work; count drifts (B2); no metadata filtering (G5); preview broken (B1)
Blog Integration 8.0 Fire-and-forget, idempotent per post; token conflict (B6); no retry if newsletter errors
Testing 7.5 Some coverage; no evidence of comprehensive integration tests like blog-service has
Observability 6.0 Queue monitoring; raw errors not persisted (B5); no subscriber growth analytics (G12)
Feature Completeness 7.0 No MCP tools (G11); no A/B testing; no pause/resume (G13); no DLQ retry UI (G14)

Bugs (Broken or Subtly Wrong)

B1 — Segment preview endpoint not implemented MEDIUM

Where: apps/newsletter-service/src/routes/segments.route.ts

POST /segments/:id/preview is registered in index.ts routing but the handler in segments.route.ts is missing. Calling this endpoint returns an unexpected response (likely 404 or an unhandled error). The endpoint is listed in the API spec and referenced in the dashboard compose modal.

Fix: Implement the handler — execute the segment filter query against nl_subscribers, return paginated { data: Subscriber[], count: number }. Mirror the logic from fireCampaign()'s recipient resolution.


B2 — Segment subscriber_count drifts silently LOW

Where: apps/newsletter-service/src/db/schema.ts, segments.route.ts

nl_segments.subscriber_count is cached at segment save time. When subscribers bounce, unsubscribe, or are deleted after the segment was last saved, the count is stale. The dashboard shows the cached count, which can be significantly higher than the actual sendable audience.

Fix: Either: (a) compute count live on GET /segments/:id (cheap query, small lists), or (b) add a background sync that re-computes counts after bulk subscriber state changes (bounce, import, unsubscribe).


B3 — Confirmation email is hard-coded, non-customisable LOW

Where: apps/newsletter-service/src/routes/public.route.ts

The double opt-in confirmation email HTML is hard-coded in the route handler, not a template row. Tenants can't customise the confirmation email subject, copy, or branding. The sender name and from-email come from comms sender-settings, but the body is always the same boilerplate.

Fix: Add a confirm_template_id column to nl_blog_binding (or a new nl_tenant_settings table) so tenants can point to a template for the confirmation email. Fall back to the hard-coded HTML if null.


B4 — Double opt-in skipped for manual adds and imports LOW

Where: apps/newsletter-service/src/routes/subscribers.route.ts

POST /subscribers and POST /subscribers/import set confirmed_at = NOW() immediately. Comment in code says "skip double opt-in for now." This means manually imported subscribers bypass the confirmation flow, which can cause deliverability issues if the list quality is poor.

Fix (long-term): Add an optional requireConfirmation: boolean param to /subscribers/import that triggers confirmation emails for each imported address. Default false for backwards compat.


B5 — Raw provider errors not accessible LOW

Where: apps/newsletter-service/src/lib/campaign-send.ts

Comms-service errors are classified into coarse codes (provider_4xx, provider_5xx, provider_unreachable, provider_unknown) and stored in nl_campaign_sends.error. Raw error bodies (Resend error messages, stack traces) are logged to the Worker console but never persisted. Ops must tail live logs to debug specific failures.

Fix: Add a raw_error text column to nl_campaign_sends (truncated to 500 chars) to store the first line of the provider error message for dashboard-visible debugging without log access.


B6 — Two conflicting token formats for blog-to-newsletter subject MEDIUM

Where: nl_blog_binding.subject_template vs src/lib/render.ts

The blog binding subject_template uses {{title}} and {{excerpt}} (no dot-namespace, no spaces). The campaign body renderer uses {{ post.title }} and {{ post.excerpt }} (dot-namespaced, spaces). These are two separate substitution systems for the same data.

If a tenant writes {{ post.title }} in subject_template, it renders literally (unresolved). If they write {{title}} in the campaign body, it also renders literally. No error, no warning — silent garbage output in the email subject or body.

Fix: Unify both to the same token format. Either extend render.ts to also handle {{title}} in the subject field, or migrate subject_template to use {{ post.title }} and update the DO + internal-blog route to pass post.* into the render context for the subject.


B7 — Rate limiter silently falls back to in-memory in local dev MEDIUM

Where: apps/newsletter-service/src/lib/rate-limit.ts

Rate limit priority: native CF Workers binding → KV namespace → in-memory Map. In-memory is per-isolate — does not persist across CF Worker instances in production. In local dev (no native binding configured), the in-memory fallback is used silently with no warning log. Developers test under a false rate limit and may never discover the fallback is active.

Fix: Add a console.warn("Rate limiter: falling back to in-memory — not safe for production") log when the in-memory path is taken. Document in wrangler.toml dev stanza that NL_SUBSCRIBE_LIMITER must be configured for accurate rate limit testing.


Gaps (Missing Features)

G1 — No GDPR right-to-erasure automation HIGH (built 2026-10-22, data-lifecycle S2; not deployed)

Where: apps/newsletter-service/src/db/schema.ts

When a subscriber is deleted, subscriber_id is set to NULL in nl_subscriber_events but the email column remains (by design, for audit trail). There is no automated path to erase the email from audit logs on GDPR erasure request. Manual DB sweep is required.

nl_suppression_list also has no retention policy and keeps emails indefinitely.

Fix: Add a POST /subscribers/:id/erase (or hook into delete) that:

  1. Deletes the subscriber row
  2. Pseudonymises email in nl_subscriber_events to "erased@{hash}.gdpr" or similar
  3. Does NOT remove from suppression list (keeping bounce/complaint protection without PII)

G2 — No retention policy on append-only tables MEDIUM

Where: nl_subscriber_events table

The events table is explicitly append-only with a comment noting "retention policy can be enforced later by a separate sweeper." For high-volume tenants with active lists, this table can grow unbounded. No purge schedule or size cap exists.

Fix: Add a sweeper cron that deletes nl_subscriber_events rows older than 365 days. Superseded 2026-10-05: do not purge this table by age. It is the proof of consent for subscribers who still exist, and deleting it at 365 days would remove the evidence that justifies mailing them. The decided design (SES-readiness gap plan, E1/E3): the rows leave with their subscriber (the address is replaced by a keyed hash, so "we erased X" stays provable without keeping X) or with the workspace. No cron is needed, and the account has none to spare. Tracked as G35 in docs/communication/ses-production-access/gap-log.md.


G3 — Template thumbnail generation not implemented MEDIUM

Where: nl_templates.thumbnail_url column

The column exists (reserved for Phase E) but is always null. The dashboard falls back to in-browser iframe rendering of the template preview, which is slow and inconsistent across screen sizes.

Fix (Phase E): Add a server-side thumbnail renderer (Puppeteer or react-email + Cloudflare Worker compatible renderer) that fires after template save and stores a PNG/WebP screenshot URL in thumbnail_url.


G4 — nl_template_sections thumbnail not implemented LOW

Same as G3 but for reusable sections. thumbnail_url column exists in nl_template_sections, always null.


G5 — No metadata-based subscriber filtering MEDIUM

Where: apps/newsletter-service/src/routes/segments.route.ts, segment-filter.ts

Subscriber metadata is a JSONB column supporting arbitrary custom fields ({{ subscriber.metadata.* }}). However, the segment filter rules only support tags, status, and subscribedAfter. There is no way to create a segment that filters by metadata fields (e.g. metadata.plan = "pro").

Fix: Add a metadata key to filter_rules: { "metadata": { "plan": "pro", "country": "IN" } }. Implement the query using Postgres JSONB operators: metadata @> '{"plan": "pro"}'.


G6 — No A/B subject lines LOW

Not in scope for v1 (explicitly noted in templates.md). No path to test subject-line variants against a split audience. All recipients in a campaign get the same subject.

Track here for v2.


G7 — Scheduled campaign body lock has no "re-schedule" UX LOW

Where: campaigns.route.ts

Once a campaign is status=scheduled, its body is locked (only name, scheduled_at, and status can change). To edit the content, a user must cancel the scheduled campaign, edit as draft, and re-schedule — there is no single "unschedule and edit" action.

Fix: Add POST /campaigns/:id/unschedule that sets status = 'draft', clears scheduled_at, and cancels the DO alarm. This mirrors the blog-service schedule/unschedule pattern.


G8 — No campaign duplication LOW

Where: campaigns.route.ts

Templates have POST /templates/:id/duplicate but campaigns do not. There is no way to clone a sent campaign to re-use it as a draft without manually copying fields.

Fix: Add POST /campaigns/:id/duplicate — creates a new status=draft campaign with the same body, name (appended " (copy)"), but empty stats and a new ID.


G9 — Suppression list has no ops UI MEDIUM

Where: nl_suppression_list table

The suppression list is managed only via /internal/bounce. There is no admin UI or API route to:

  • List suppressed addresses
  • Check whether a specific email is suppressed
  • Manually add an address
  • Remove a mistakenly suppressed address

Fix: Add ops routes (internal-key gated): GET /internal/suppression, POST /internal/suppression, DELETE /internal/suppression/:email.


G10 — Rate limiter falls back to in-memory in dev LOW

Where: apps/newsletter-service/src/lib/rate-limit.ts

Rate limit priority: native CF Workers binding → KV namespace → in-memory Map. The in-memory fallback is per-isolate and does not persist across CF Worker instances in production. In local dev (no native binding configured), the in-memory fallback is used silently.

Fix: Add a dev warning log when falling back to in-memory. Document that in-memory rate limiting is development-only and must not be relied upon in production.


G11 — No MCP tools MEDIUM

The newsletter service has zero MCP tools. AI agents (Claude, Cursor, etc.) cannot manage subscribers, check campaign stats, fire campaigns, or query segment counts. Every other major service (blog, forms, billing) has MCP tooling. This gap grows more significant as AI-driven campaign management matures.

Fix: Wire an /mcp route group. Suggested initial set: list-subscribers, get-campaign-stats, create-campaign, fire-campaign, preview-segment, get-subscriber-count, update-blog-binding.


G12 — No subscriber growth analytics MEDIUM

Where: nl_subscriber_events (data exists), no aggregation layer

Campaign stats (total_opened, open_rate, etc.) exist on nl_campaigns. But there is no endpoint for subscriber growth — new signups per day/week, churn rate, net growth. The nl_subscriber_events table has all the data; it just isn't aggregated anywhere.

Fix: Add GET /subscribers/analytics?days=30 — groups nl_subscriber_events by day and kind, returns confirmed/unsubscribed/bounced counts and a net-growth time series.


G13 — No pause/resume for in-flight campaigns LOW

Once fireCampaign() is called, thousands of queue messages are enqueued. There is no mechanism to pause mid-send if a content error is spotted after send starts. The campaign must run to completion.

Track here for v2.


G14 — No dead-letter retry from dashboard MEDIUM

Where: apps/newsletter-service/src/consumer.ts, nl_campaign_sends

When a send job exhausts all 3 queue retries, it is marked dead_letter in nl_campaign_sends. There is no admin route or UI to retry these. The only remediation is a full manual re-send, which risks double-sends.

Fix: Add POST /campaigns/:id/retry-failed — re-enqueues all dead_letter send records (capped at 1000/call). Guard with a second idempotency key to prevent double-charge.


G15 — No unsubscribe reason collection LOW

Where: routes/public.route.ts (POST /public/unsubscribe)

Subscribers are immediately unsubscribed on link click with no reason collected ("Too frequent", "Not relevant", "Never signed up"). Standard exit reasons are essential for churn analysis and deliverability improvement.

Fix: Add optional ?reason= query param to the unsubscribe link. Store it in nl_subscriber_events.detail. Hosted unsubscribe page can show a short radio form before confirming.


G16 — Blog subject template tokens are limited and inconsistent LOW

Where: nl_blog_binding.subject_template vs lib/render.ts

subject_template only supports {{title}} and {{excerpt}} — no author name, no post URL. The body uses {{ post.title }} (dot-namespaced, spaces). Two substitution systems for the same data — see also B6.

Fix: Extend subject template substitution to all {{ post.* }} tokens, then deprecate the flat {{title}}/{{excerpt}} format in favour of the dot-namespaced form used in the body.


G17 — No per-timezone send scheduling LOW

All subscribers in a campaign receive the email at the same wall-clock moment. nl_subscribers.metadata could store a timezone; the scheduling layer doesn't use it.

Track here for v2.


G18 — Template preview uses canned mock data only LOW

Where: apps/seller-dashboard (template editor)

Template authors cannot preview how their email renders with specific subscriber names, metadata values, or post variables without sending a test email. Preview always uses hardcoded canned values.

Fix: Add previewData: Record<string, string> input to the template editor (stored in localStorage). Pass it through the same render.ts context for in-browser preview and test-send rendering.


Improvement Scope

Priority 1 — Fix now (correctness / compliance)

Item Effort Links
GDPR right-to-erasure automation (/subscribers/:id/erase) built 2026-10-22 as the delete itself (S2) Medium G1
Implement segment preview endpoint Small B1
Align conflicting token formats: subject {{title}} vs body {{ post.title }} Small B6

Priority 2 — Ops and trust

Item Effort Links
Persist raw provider error to nl_campaign_sends.raw_error Small B5
Retention sweeper for nl_subscriber_events Small G2
Suppression list management routes (GET/DELETE /internal/suppression) Small G9
Dead-letter retry endpoint (POST /campaigns/:id/retry-failed) Medium G14
Dev warning log when rate limiter falls to in-memory Tiny G10

Priority 3 — Missing features, high user value

Item Effort Links
MCP tool surface Medium G11
Campaign unschedule action (POST /campaigns/:id/unschedule) Small G7
Campaign duplication (POST /campaigns/:id/duplicate) Tiny G8
Subscriber growth analytics (GET /subscribers/analytics) Medium G12
Metadata-based segment filtering (filter_rules.metadata) Medium G5
Customisable confirmation email via template reference Medium B3
Live segment subscriber_count (not cached) Small B2

Priority 4 — Future / larger scope

Item Effort Links
Template + section thumbnail generation (Phase E) Large G3, G4
A/B subject lines (v2) Large G6
Pause/resume in-flight campaign Large G13
Per-timezone send scheduling Large G17
Unsubscribe reason collection Small G15
Blog subject template token unification Small G16
Double opt-in for manual imports (requireConfirmation param) Small B4
Template preview test-data overrides Small G18

Fixed Items

2026-10-22 — Two alarm loops that never stopped (found mapping S5)

Not deployed. RepermissionRunner left its 60-second safety alarm armed after a run finished, so every run ever started re-ticked forever, querying Postgres each time and keeping the newsletter database awake around the clock (the G78 cost pattern). CampaignScheduler returned early when its campaign row was gone (deleted, or later purged) and polled every 5 minutes forever. Both now call deleteAlarm() when there is nothing left to do: a finished, missing or non-running run; a missing campaign; an instance with no stored pointer. Tests in repermission-runner.test.ts and scheduler.test.ts; 4 deliberate breaks caught. An instance already looping stops on its first alarm after the deploy.

2026-10-22 — Subscriber erase (data-lifecycle S2, G1)

Branch data/s2-subscriber-erase, not deployed. DELETE /subscribers/:id, the MCP remove-subscriber tool and the ops "Erase a subscriber" page (/internal/admin/subscribers/erase) all run lib/erase.ts: the subscriber row, its send rows, link clicks and re-permission targets are deleted; every activity-log row for the address (including deleted rows from the old delete) gets an HMAC fingerprint instead of the address, with subscriber_id and detail cleared; one erased row records it; comms deletes its copies of the newsletter mail (/internal/erase-recipient). The global suppression list is untouched, and an unsubscribed or complained subscriber's opt-out is kept as a comms suppression entry for the workspace; if comms can't take it, the erase is refused (503) instead of losing the opt-out. Tests: test/erase.test.ts (24), comms test/recipient-erase.test.ts (9), internal-api test/subscriber-erase.test.ts (10); 29 deliberate breaks across the three services caught.

2026-09-14 — Mailbox + Newsletter audit, Phase 0 (security & money)

Branch audit/phase-0-security. Finding ids refer to the audit report (NL- newsletter worker, NF- newsletter dashboard, CM- comms transport).

Finding Fix Test
Gateway forwarded /newsletter/internal/* (bounce, event, blog fire, suppress) Proxy answers 404 before auth; x-internal-key stripped from forwarded requests apps/gateway/test/internal-surface.test.ts
NF-01 read-only users got success toasts on refused writes API wrappers throw NewsletterApiError on any non-2xx; mutation buttons wrapped in PermissionGate seller-dashboard/.../newsletter.api.test.ts
NL-17 public subscribe took visitor tags + unbounded metadata Visitor tags ignored (logged); metadata flat, ≤20 fields, ≤200-char strings, ≤4 KB test/public-hardening.test.ts
NL-20 href="{{ var }}" could render a javascript: link Renderer drops non-http(s)/mailto values when a token is a whole URL attribute test/render.test.ts
NL-10 suppression-status disclosed other tenants' subscriber rows tenantId required; only the caller's own status returned test/internal-suppression-status.test.ts
NL-14 test-send charged once per minute but delivered every click Charge and comms send share one idempotency key that fingerprints the template content, so a double-click dedups but an edited re-send still goes out test/templates.test.ts
Test-send reported "sent" for a suppressed address Dialog shows the suppression warning —

2026-09-15 — Mailbox + Newsletter audit, Phase 1 (correctness hotfixes)

Branch audit/phase-1, committed not yet deployed. 9 PRs (P1–P9b); rows below cover the newsletter-service and newsletter-dashboard findings. Mailbox and comms findings are in their own backlog docs.

Finding Fix Test
NL-02 retries double-counted into total_failed; drain flipped early Counters derived from nl_campaign_sends via reconcileCampaign; tryFlipSent guards a one-time sent flip with RETURNING test/queue-consumer.test.ts
NL-03 a scheduled campaign that failed to fire was stranded with no alarm Non-retryable failure becomes a draft with last_error/failed_at; retryable failure backs off 2/10/30/60/120 min; newsletter.campaign.failed Flows event + owner email test/scheduler.test.ts
NL-05 (partial) settings loaded and pacing computed after the charge, so an invalid timezone threw post-charge on every alarm retry getNewsletterSettings + pacing resolved before the charge; refund clears the ledger key correctly test/campaign-send.test.ts
NL-07 sends-list cursor lost failed/skipped rows (NULL sentAt sorts first) Cursor on (created_at, id); left-join on subscribers for email/name test/validation-p5.test.ts
NL-11 scheduled_at rejected a timezone offset .datetime({ offset: true }) test/validation-p5.test.ts
NL-12 an unvalidated cursor reached Postgres as an invalid date zod-validated keyset cursor, 400 INVALID_CURSOR test/validation-p5.test.ts
NL-13 template PUT's truthiness checks silently ignored an explicit empty string; text derived stale !== undefined checks; text derives from the current HTML when omitted test/validation-p5.test.ts
NL-24 an invalid timezone threw RangeError after the charge settings.route.ts validates against Intl.supportedValuesOf("timeZone") test/validation-p5.test.ts
NL-26 route-coverage test only checked mutations, not reads findUngatedMutations extended to GET routes test/route-coverage.test.ts
NL-28 mixed-case suppression email comparison missed matches lower(email) join test/validation-p5.test.ts
NL-30 MCP routes validated more loosely than REST MCP reuses the REST zod schemas; invalidArgument returns structured 400 test/validation-p5.test.ts
CM-23 emailSettingsToDTO rendered the alias on the wrong domain Mapper uses ALIAS_DOMAIN from @repo/core-types core-integrations/test/mappers.test.ts
NF-02 saved campaign/template HTML didn't carry the brand kit the preview showed One shared useBrandKit load feeds every preview and every save hooks/useBrandKit.test.ts
NF-03 switching to raw HTML kept the stale block tree; next autosave overwrote hand-edited HTML Raw-mode save sends blocks: null composer/useCampaignDraft.test.tsx
NF-04 / P1-N2 editing a scheduled campaign always failed; a failed load could blank the campaign on save Draft state machine (loadState, status, readOnly); scheduled campaigns open read-only with Change-time/Unschedule; a failed load blocks every save composer/useCampaignDraft.test.tsx
NF-08 "Use in campaign" carried only a template id, landing on an empty Step 3 template-loader.ts never rejects; prefills blocks/subject/preview/width composer/template-loader.test.ts
NF-11 / MF-05 comms-backed screens (brand, stats, logs) 403'd for a newsletter-only tenant Gateway commsEntitlementFor widens sender-setup/stats/logs to mailbox+newsletter; see docs/communication/backlog.md apps/gateway/test/comms-entitlement.test.ts
NF-12 StatsPage crashed in render on a refused response res.ok/status checked before use; 403 renders a permission-denied state manual
NF-32 embed snippet interpolated the honeypot field name unescaped escapeAttr on the field name; CSP note added to the panel components/NewsletterEmbedSnippet.test.ts
P1-D2 Members can send campaigns but can't save the org postal address the send pre-flight requires New narrow newsletter:settings.write permission, granted to Members automatically; comms accepts it on brand/org fields only packages/core-access/test/rbac.manager.test.ts, apps/communication/test/p6-comms.test.ts
Campaign list didn't show why a schedule failed or was retrying Health chip + message + fix-it link from lib/fire-errors.ts; failed/skipped counts pages/CampaignsPage.test.tsx

2026-09-29 — Newsletter dashboard Low-list (ad hoc, one item)

Branch audit/phase-5. The newsletter dashboard's own Phase 5 track (PR8–PR13: typed client, subscribers paging, campaigns/templates paging, page splits, editor history, test sweep) has not been started — this single fix was an opportunistic pickup while wrapping up the mailbox side of the same phase.

Finding Fix Test
NF-41 BrandKitPage.tsx was unreachable dead code — no route mounted it, nothing imported it Deleted (309 lines); fully superseded by Settings → Brand tab (BrandTab.tsx) tsc clean (confirms nothing imported it); newsletter dashboard suite 270

Still open from this backlog: Phases 2–6 of the audit report (.claude/plans/take-your-time-bubbly-hejlsberg.md); the newsletter dashboard's own Phase 5 PR8–PR13 track (PR8 done, see below; PR9–PR13 not started).

2026-09-29 — Newsletter dashboard Phase 5 Track N PR8 (typed client)

Branch audit/phase-5, commit 41c5b79f. First PR of the newsletter dashboard's own Phase 5 track (mailbox's Track M, PR1–PR7, is fully done). Scope was narrower than the master plan's original framing: the error-handling half of "typed client" (one NewsletterApiError, no raw fetch().json()) had already shipped in the Phase 0 pass — jsonOrThrow and NewsletterApiError already existed. What was actually missing was response-shape validation and the duplicated page-local interfaces it should have replaced.

Finding Fix Test
jsonOrThrow returned untyped Promise<any>; 4 real unchecked as X data-shape casts (settings ×2, sender summary, campaign sends) New newsletter.schemas.ts (zod, mirrors mailbox's mailbox.schemas.ts); jsonOrThrow<T>(res, schema) validates every 2xx body, throws NewsletterApiError code BAD_RESPONSE on a mismatch; every exported function typed from its schema newsletter.api.test.ts (new BAD_RESPONSE test); mutation-checked (removed the safeParse gate, confirmed the test fails, restored)
Duplicated Campaign/Template/Segment/SavedSection interfaces across 8 files, diverging on optionality and even field count (Segment was 7 fields in SegmentsPage, 3 in Step1Recipients, same name) Deleted every local duplicate; every page imports its type from newsletter.api.ts tsc clean (would fail on any real shape mismatch); full dashboard suite 633
Signup-form writes went through a separate signupFormJson helper (plain Error, no status/code) instead of the shared parser Routed through jsonOrThrow like every other call existing newsletter.api.test.ts cases still cover the 4 functions
NF-27 — Step4Review/PreviewOverlay hardcoded "Vlozi <hello@vlozi.app>" unconditionally (no fetch at all); Step2Identity fetched real settings but never read fromEmail, so its From address was permanently the hardcoded default even for a tenant with one configured New lib/sender-identity.ts (resolveSenderIdentity) + hooks/useSenderIdentity.ts (one cached load, mirrors useBrandKit's shape); all three surfaces now share it sender-identity.test.ts, useSenderIdentity.test.ts (9 new tests); mutation-checked (removed the senderName fallback, confirmed 3 tests fail, restored)

Not done in this PR: Stat/BigStat/FieldLabel primitive duplication, RECIPIENTS_PER_CREDIT duplication (both listed in the master plan's PR11, page splits). NF-22 (subscriber list has no confirmedAt, so pending subscribers show as active) confirmed still open during this PR's schema work — it's PR9's scope, not touched here.

Review fix pass, commit c74537c3: an independent review of 41c5b79f found no Critical/High issues; fixed the 3 Medium/Low findings it did raise — useSenderIdentity's hook itself had zero test coverage (only its module-level cache functions did), so the settled-vs-loaded!==null distinction the hook exists for was unverified; added 3 renderHook cases mirroring mailbox's useUnreadCounts.test.ts precedent (mutation-checked: reverted the check, confirmed the new "mounts after a failed load" test fails, restored). fetchBlogBinding/upsertBlogBinding/deleteBlogBinding still hand-rolled their own error handling instead of the shared jsonOrThrow path everything else uses — folded in (fetchBlogBinding's 404→null branch stays ahead of it), plus 6 new tests (these 3 functions had none before). Removed dead unused schema exports (SignupFormDetailSchema, CampaignCountsResponse) that nothing called yet — they'll come back defined against a real call site when PR9/PR10 actually need them. Dashboard suite 279→287, full suite 642 green.

Still open: PR9 (subscribers: server search, cursor paging, pending status, chunked import), PR10 (campaigns/templates paging, honest Overview, segments, settings), PR11 (page splits + shared primitives), PR12 (editor undo/redo history + canvas fixes), PR13 (test sweep + docs).

2026-09-30 — Newsletter dashboard Phase 5 Track N PR9 (subscribers: search, cursor, pending, chunked import)

Branch audit/phase-5, four commits: backend 6a72d173, typed client 8ae2bd5f, parse-contacts fixes 66d105c1, page wiring 657b9224.

Finding Fix Test
GET /subscribers had no search — the dashboard filtered client-side over whatever single hardcoded 100-row page had loaded; a match outside it was invisible New q param (newsletter-service), escaped ILIKE over email/name (mirrors mailbox's MB-22 fix); dashboard debounces 350ms and sends it server-side subscribers.test.ts (2 new: literal _/% escaping, mutation-checked separately since either wildcard can coincidentally still match); SubscribersPage.test.tsx (debounce timing, mutation-checked)
GET /subscribers had no cursor pagination — offset/limit only, list capped at 100 with no way to reach the rest Reused lib/keyset-cursor.ts (already used by campaigns, unused by this route) — pages by (created_at, id); offset stays supported unchanged; dashboard's "Load more" appends via nextCursor subscribers.test.ts (whole-list walk with no gaps/repeats, mutation-checked against a disabled filter); SubscribersPage.test.tsx (appends not replaces)
POST /subscribers/import was whole-batch-or-nothing — one bad row 422'd the entire request before the handler ran (array-level zod parse) Per-row normalizedEmail.safeParse; new rejected: [{index, email, reason}] response field, added+skipped+rejected.length always equals total; dashboard's new runChunkedImport sends in sequential 500-row chunks with one retry per chunk and a downloadable CSV report for anything rejected subscribers.test.ts (4 new: valid+invalid mixed, in-batch duplicate vs. already-exists distinction, case/whitespace normalization, all-invalid); import-runner.test.ts (11 new, mutation-checked: chunk-to-global index offset, retry bound)
NF-22 — subscriber list DTO had no confirmedAt, so there was no way to show per-row pending state, only the aggregate /stats count; the status filter <select> had no Pending option at all confirmedAt added to the list select and the client schema; new effectiveStatus() helper (active + no confirmedAt → "pending", every other status passes through) drives both the row pill and the new filter option — the real status DB column is untouched subscriber-ui.test.ts (3 new, mutation-checked); SubscribersPage.test.tsx (Pending pill scoped with within, since "Pending" also appears as a stat label and a select option)
NF-31 (parse-contacts.ts) — invented a display name from the email local-part when no name column matched; no First/Last Name column support (Mailchimp/Google/Apple exports); UTF-16 "CSV UTF-16" Excel exports decoded as garbage (always assumed UTF-8) deriveName and its generic-mailbox list removed entirely — an unmatched name is just empty, never a guess; First/Given Name + Last/Surname/Family Name column and JSON-key patterns added, combined via combineName(); decodeTextFile() sniffs a UTF-16 BOM before choosing a TextDecoder; client email validation now uses the exact same zod schema the backend's normalizedEmail uses parse-contacts.test.ts (7 new: first/last combos, a full-Name column still wins, UTF-16LE-with-BOM decode vs. plain UTF-8, mutation-checked on both the BOM sniff and the name join)
"Company goes to metadata only unless tag-by-company is ticked" (master plan item) — company was unconditionally tagged New checkbox in the import panel, shown only when the parsed file has a Company column, default on (matches prior behavior) covered via the chunked-import test's payload shape

Also added (not a finding, a UX gap the runner surfaces): a Stop button during an in-progress chunked import, checked between chunks — chunks already sent still complete, the rest aren't.

Not done in this PR: apps/newsletter-service/scripts/lib/contacts-transform.mjs (the ops CLI) still derives names from email and lacks first/last-name columns — intentionally divergent from the dashboard path now, bringing it in line is a separate follow-up. Dead code found and left alone: fetchBlogBinding (still exported, genuinely has no caller anywhere in the dashboard — noticed while touching the same file in the prior PR8 review-fix pass, out of scope here).

Tests: newsletter-service suite 988 → 1019; dashboard suite 287 → 314. Full dashboard suite (newsletter + mailbox + lib) 667 green throughout. tsc clean on both apps at every step; eslint zero new findings on the dashboard (newsletter-service has no lint setup).

Review fix pass, commit c9aa28a1: an independent review of the four PR9 commits found one High and one Medium issue, both fixed.

  • High — GET /subscribers's total silently shrank on every cursor page instead of staying the whole filtered list's size (a count(*) OVER () window function was scoped to the same WHERE clause as the cursor's keysetBefore predicate, so it only ever counted rows remaining after the cursor — contradicting the route's own comment, the dashboard schema's comment, and the commit's own design claim). Fixed by splitting into a cursor-free count() query (always runs, in parallel with the row fetch) scoped to tenant+status+tag+q only. New regression test asserts total stays at 8 across three consecutive cursor pages; mutation-checked by reproducing the exact reported shape of the bug (total 8→6 instead of staying 8).
  • Medium — a paste-box line without an @ was counted in the toast but never reached the persistent rejected-rows report (gated only on the server run's own array) — a paste-only run with malformed lines would toast "N rejected" and then auto-close the panel, leaving no way to see or download which line failed. Fixed by giving paste-rejected lines a synthetic {index, email: <raw line>, reason: "no_at_sign"} entry merged with the server's rejected array before deciding whether to keep the panel open. 2 new tests (mixed paste+server rejections merging into one report; an all-paste-rejected case that previously auto-closed), both mutation-checked.
  • Low (folded into the Medium fix) — the original "chunked import" test mocked the runner's return value unconditionally and never verified the real paste→rows pipeline despite its name claiming to; rewritten to assert on the runner's actual call arguments.

newsletter-service suite 1019 → 1020; dashboard SubscribersPage.test.tsx 5 → 6 (one test rewritten, one added), full dashboard suite 671 green.

2026-10-02 — Newsletter dashboard Phase 5 Track N PR10 (campaigns/templates paging, honest Overview, segments, settings)

Branch audit/phase-5, eight commits: backend 8e17edc2, typed client 6166993b, CampaignsPage wiring b6f9ced6, templates+picker paging 38f9d0e7, Overview 99d515bc, Segments ed9e489e, blog-post variables d4a66905, timezone select e3fdc0f2, SenderDefaultsForm extraction a56e3a14.

Finding Fix Test
NF-28/campaigns — status tab counts and the status filter were computed client-side over whatever single ≤50-row page fetchCampaigns() had loaded, the same correctness bug PR9 fixed for subscribers New GET /campaigns/counts (grouped count, registered before /:id); GET /campaigns takes a status query param instead of filtering after the fact; CampaignsPage re-fetches on tab change instead of re-filtering the loaded page, tab counts come from the new endpoint, a "Load more" cursor button replaces the implicit page-1 cap, and the live-send poll merges by id (limit: 100) instead of replacing the array outright (which would have discarded anything Load-more had appended) campaigns-route.test.ts (7 new: status scoping, status+cursor composition, invalid status 400, counts scoped per tenant, counts not capped by page size — 60 draft campaigns asserts draft: 60); CampaignsPage.test.tsx (4 new, mutation-checked: counts-from-endpoint, tab-click re-fetches with status, Load-more appends, cancel removes a now-filtered-out row)
NF-28/templates — same bug on the templates gallery and the compose "Start from a saved template" picker's Saved tab: both just rendered the first page with no way to reach the rest GET /templates gained the same keyset-cursor pagination campaigns already had; TemplatesPage and StartFromPicker's Saved tab both got a "Load more" button that appends (deduped by id) instead of replacing templates.test.ts (5 new); TemplatesPage.test.tsx, StartFromPicker.test.tsx (new, 2 each, mutation-checked: reverting append-with-dedupe to a plain replace breaks each surface's own test)
NF-29 — Overview's "Delivered" was Σ totalSent over the same capped campaign page, mislabelled (it was never actually delivery data); open/click rate was an unweighted average of each campaign's own rate, so a 10-recipient and a 10,000-recipient campaign counted equally; Pipeline counts (Sent/Drafts/Scheduled) were .filter().length over that same page Pipeline now reads fetchCampaignCounts(); Engagement now reads fetchCampaignStats(30) — the same server-weighted endpoint StatsPage/Deliverability already use. "Delivered" is gone, replaced by an honest "Sent" count plus a "Delivery rate" that shows "—" with a "no delivery reports yet" hint instead of a misleading 0%; added a seq.current guard mirroring StatsPage.tsx:53-73 New OverviewPage.test.tsx (none existed before; 8 cases, mutation-checked: counts-not-filtered-list, null-deliveryRate dash fallback)
NF-23 — the segment builder's "all" status option/summary claimed it includes unsubscribed/bounced subscribers; segment-filter.ts's status="all" branch is notInArray(status, NEVER_MAIL_STATUSES), which always excludes them (identically to status="active" — there's no third mailable status today, so the two options are currently behaviourally identical and only the UI's claim was wrong). Separately, buildFilterRules built date-range boundaries as new Date(dateOnlyString).toISOString(), parsing a bare day as UTC midnight — for any tenant east of UTC this silently excluded the first few hours of "subscribed after <day>" and cut "subscribed before <day>" off before the day was over; the read path used iso.slice(0, 10), which is the UTC calendar date, not the picked local one. Also: dead previewCount/onPreviewCountChange plumbing threaded into SegmentEditor and immediately discarded with void previewCount; void onPreviewCountChange Extracted lib/segment-filter-rules.ts: honest copy for both status options; local-midnight-based date boundaries (subscribed_after = start of the picked local day, subscribed_before = start of the local day after, paired with the backend's <=); round-trip helpers so the edit form and rules-preview summary recover the exact picked day; removed the dead preview-count state and props entirely (the real "refresh a saved segment's count" feature, previewSegment(id), is untouched) New segment-filter-rules.test.ts (14 cases, using Pacific/Kiritimati UTC+14 via process.env.TZ to exercise the boundary bug concretely; mutation-checked: reverting to UTC-midnight parsing breaks 3 boundary tests with the exact expected mismatch, reverting the copy breaks the honesty test)
NF-07/P5-A17 — {{post.title/excerpt/slug/cover_url/author_name}} tokens, resolved by internal-blog.route.ts for a blog auto-campaign's template body, had no entry at all in the dashboard's variable contract (lib/variables.ts) — the picker couldn't offer them and the checks-chip lint flagged a legitimately-used {{ post.title }} as unknown Added a "post" VariableGroup with the five paths + mocks; the binding's own subject field uses bare {{title}}/{{excerpt}} (a deliberate, separate mini-template, not a renderer pass) — rather than adding those as global variables, explained the distinction in the help text on both surfaces that field exists (AutomationTab.tsx, NewsletterDeliveryCard.tsx) variables.test.ts (4 new, mutation-checked: removing the five entries breaks both the not-unknown and mock-substitution tests)
NF-24 — the Schedule tab's timezone field was free text; an invalid IANA name only surfaced at fire time (Intl.DateTimeFormat throwing inside pacing.ts), after the campaign was already charged Replaced the TextInput with a SelectInput from Intl.supportedValuesOf("timeZone") (fallback list for an environment lacking it), mirroring the sibling Window-start/end hour fields' existing pattern New ScheduleTab.test.tsx (none existed before; 7 cases, mutation-checked: reverting to the free-text input breaks the "is a select" test)
NF-25/NL-22 — the auto-check toggle, default template, default segment, and subject-template fields were independently implemented in AutomationTab.tsx (useSettingsForm/Field/SelectInput/TextInput/ToggleRow) and the blog module's NewsletterDeliveryCard.tsx (four useStates, a raw <select>, shadcn Input/Label/Switch), and had drifted: different auto-check copy (AutomationTab's "Auto-send blog posts" implied the toggle controls whether delivery happens at all; it only sets the publish modal's starting checkbox state — NewsletterDeliveryCard's wording was the accurate one), segment options silently dropped the subscriber count on one surface but not the other, and the post.*/subject-token help text (added earlier in this same PR) was duplicated verbatim New shared components/SenderDefaultsForm.tsx, rendered by both; NewsletterDeliveryCard keeps its four useStates (least invasive given handleSave/hydrateFromBinding/disconnect all reference them directly) but wires them through one combined value/onChange adapter. Per this session's scope-ambiguity resolution, no from-name field was added — both callers still submit defaultFromName: null unconditionally, exactly as before; sender identity stays unified via Communications New SenderDefaultsForm.test.tsx (8 cases, mutation-checked: reverting the copy breaks the unified-copy test, dropping subscriber-count formatting breaks the segment-label test)

Not done in this PR: the campaign list's source filter (manual vs. from-blog) stays client-side — there's no backend sourceKind query param and the master plan doesn't ask for one.

Tests: newsletter-service suite 1020 → 1030 (backend commit). Dashboard suite grew from 314 (after PR9) across all seven dashboard commits; full dashboard suite (newsletter + mailbox + blog + lib) 671 → 731 green throughout this PR. tsc clean on both apps at every step; eslint zero new findings.

Review fix pass, commit 8ad96ff6: an independent review of all nine PR10 commits found no Critical/High/Medium issues and three Low ones. Fixed the one that is a real race: StartFromPicker's Saved tab reloads page 1 on every entry, but a "Load more" still in flight from the previous visit had no guard — leave for Layouts and come back while it's pending and its rows landed on top of the fresh page 1 with its (now meaningless) cursor replacing the fresh one. A page-walk sequence ref now drops it (1 new test, mutation-checked). The other two are accepted residuals, not fixed: (a) GET /templates pages over the mutable updated_at (by design — "recently updated first"), so a template edited mid-walk jumps past the cursor and is skipped until a reload; never duplicated. (b) segment-filter-rules.ts encodes a date boundary as the setting browser's local midnight, so an editor in a different IANA zone sees the picked day off by one in the edit form (and would re-anchor it to their own midnight if they save untouched) — strictly less wrong than the UTC-slice behaviour it replaced, and only affects teams spanning zones. Full dashboard suite 731 → 732.

2026-10-03 — Newsletter dashboard Phase 5 Track N PR11 (page splits, shared primitives)

Branch audit/phase-5, ten commits: primitives bd39c64d, then structural splits a2aba8d4 (SubscribersPage), a92da8d3 (CampaignsPage), 9bcbd396 (TemplateEditor), 56c2d73b (SegmentsPage), a850e933 (SignupFormsPage, OverviewPage), 6c8c7459 (TemplatesPage, CampaignReportPage), bc2422bc (newsletter.api.ts/.schemas.ts by domain), cce6b10e (useCampaignDraft, block-prop-editors, BlockCanvas, EmailComposer), 0784812f (SubscribersPage 449 → 393). No behaviour change in any of them.

Finding Fix Test
Seven pages re-declared their own Stat/BigStat and two re-declared FieldLabel, with drifting min-widths and padding; the compose steps hardcoded RECIPIENTS_PER_CREDIT = 5 while the worker derives it Shared StatStrip/BigStat (components/ui/stat-strip) and FieldLabel (components/ui/settings-form); local copies deleted; the steps read CREDIT_COSTS.EMAIL_RECIPIENTS_PER_CREDIT, the registry the worker's charge uses, so the estimate cannot drift from what is charged Existing suites unchanged
11 non-test files over 400 lines (largest newsletter.api.ts 787, SubscribersPage 948, TemplateEditor 888, CampaignsPage 895) Same pattern each time: presentational pieces to pages/<name>/, state and actions to a hook, helpers to a small -ui.ts. newsletter.api.ts is now a barrel over client.ts, workspace.api, subscribers.api, templates.api, campaigns.api, audience.api, integrations.api, so every existing import and vi.mock("../api/newsletter.api") still works The existing page, hook and api suites pass unchanged at every step (732 green throughout)

Result: no non-test file under modules/newsletter exceeded 400 lines at the end of PR11b (largest 393). It was broken again by PR12's own additions and restored in PR13 (see below).

2026-10-05 — Newsletter dashboard Phase 5 Track N PR12 (editor history and canvas)

Branch audit/phase-5, eight commits: reducer 5eeede95, canvas 939ea205, clearing fields e0dca980, double submit 10cd2a79, column operations in email-blocks d1ea90c6, columns UI ef94f7fe, Custom HTML sizing 3c3483ac, saved sections 4b35b57c.

Finding Fix Test
NF-13/14 — the editor's undo and redo stacks lived in React state and were mutated from inside other setters' updaters. React may run an updater twice (StrictMode does, in dev), so one edit pushed two undo entries; merely clicking into a text block pushed a snapshot and cleared redo The whole state machine is one pure reducer (blockTreeReducer) behind useReducer. An inline edit session is ONE undo step (the pre-session tree is pushed on the first real change, so startEdit alone adds no history and keeps redo); adding a text block starts an already-dirty session; history capped at 50. Anything random (new ids, clones, and now duplicate's copy via a caller-minted seed) is created before dispatching, so every transition is a pure function of (state, action) blockTreeReducer.test.ts (37), useBlockTree.test.tsx under <StrictMode> (10); 8 of 8 mutants killed on the first batch, 28 of 28 across the later move-mode additions. A purity test over every action caught duplicate minting ids inside the reducer
NF-15 — an empty column was a dead end (no insert affordance, and insertAfter needs a sibling); no block could cross between the top level and a column, or between the two columns email-blocks: locateBlock, insertInto, moveBetween (+ ColumnRef/Placement types), with no cycle check needed (taking the block out removes every container inside it). Canvas: insert gaps now render inside columns including empty ones (renderGap carries the slot to, because afterId=null inside a column would mean "top of the page"); a move mode (movingId in the reducer, so the desktop toolbar, settings panel and mobile Commands sheet can all start it) replaces the gaps with "Move here" targets, hides the no-op slots, offers none inside a Columns block that is itself being moved, and ends on Cancel/Escape/undo/redo/delete. One undo step per move. The settings panel hides while moving. Drag and drop was not built: a pick-the-destination flow works the same on desktop and touch email-blocks operations.test.ts (+19, 41 in file, 103 in package, 8 of 8 mutants); reducer (+16), BlockCanvas.test.tsx (16), BlockPropPanel.test.tsx (3), composer and mobile-sheet cases; 28 of 28 mutants
NF-16 — the Custom HTML block's preview was stuck at 40px: its sandbox="" frame has an opaque origin, so contentDocument is null and measure() could never read a height The frame reports its own height by postMessage, which needs allow-scripts; granted only together with a hash-pinned CSP placed BEFORE the author's (unsanitised) markup that allows exactly our height script, and never with allow-same-origin. Without crypto.subtle it withholds scripts and keeps a readable fallback height. The mechanism was extracted from the mailbox viewer into src/lib/iframe-height.ts and both use it; the block resets its height per block id (not per HTML) so typing does not flicker iframe-height.test.tsx (15), CustomHtmlEdit.test.tsx (10), the viewer's 8 auto-height tests unchanged; 23 of 23 mutants on the security-relevant lines
NF-17 — the mobile toolbar's onTouchStart={preventDefault} "cancelled horizontal scrolling" The premise was wrong. React 19 registers touchstart as a passive listener, so preventDefault() there was always ignored; its only effect was a browser warning. Removed as dead code; what keeps the keyboard up when a control is tapped is the wrapper's onMouseDown preventDefault, which stays. No behaviour change InlineTextEditor.test.tsx pins the mousedown path and focus retention. A touchstart assertion would pass with or without the handler, so none was written
NF-18 — the slash menu's caret probe did not mirror text-align/direction, so on centred, right-aligned or RTL text the menu anchored to the left edge Both added to the mirrored properties textarea-caret.test.ts (5, mutation-checked)
NF-19 — the canvas used pre-wrap for every text variant, but the sent email keeps typed line breaks in paragraphs only, so a heading with a line break previewed as two lines and sent as one pre-wrap for paragraphs, normal for h1–h3 canvas-edits.test.tsx (5, mutation-checked both ways)
NF-20 — useCampaignDraft guarded save/send/schedule/reschedule/unschedule with if (saving), state read from the render closure; two clicks before a re-render both ran, and with no draft id that is two POSTs and two draft rows Ref-guarded beginSave/endSave; saving stays as state for the UI. The callbacks no longer depend on it 4 new cases (same-tick double save creates one draft, a send mid-save is refused, the guard releases after a failure, saving still reports); 6 of 6 mutants
NF-21 — on a template update, `text_body undefinedandpreview_text
NF-43 — useSavedSections.rename/remove existed but nothing called them Saved-section rows in the Insert menu (all four places it appears) get inline rename (Enter saves, Escape/blur cancels without closing the menu) and a two-step inline delete confirm; controls disabled while in flight. The unreachable data.error branches were removed (the api throws on any non-2xx and the sections routes answer errors with 4xx); rename now toasts like the others. Two component-level busy guards were written and then removed: they read a stale closure so protected nothing; the disabled controls and the hook's per-section in-flight set do InsertMenu.test.tsx (14), useSavedSections.test.tsx (11), 3 composer cases; 26 mutants across the menu, hook and every wiring point (one escaped, which is what exposed the redundant guard), then 2 re-checked after removing it: all killed

Also: the repo-wide vitest.setup.ts now polyfills CSS.escape (jsdom lacks it; use-anchored-rect calls it once a block is selected on desktop, which made a test throw from a requestAnimationFrame callback). insertAfter with a missing anchor used to select and start editing a block that was never inserted; it and the new insertAt now return the state untouched.

Not covered by an automated test, to be checked on a device: the exit criterion "a block moves into an empty column on iOS Safari". The flow uses only click targets, so there is no gesture behaviour to differ, but it has not been run on a phone.

2026-10-05 — Newsletter dashboard Phase 5 Track N PR13 (test sweep, axe gate, docs)

Branch audit/phase-5, six commits: editor accessibility fixes c423562c, axe gate 16d2b787, schema tolerance and raw-switch safety 88136567, slash/dark-inbox unit tests 31ad4c07, back under 400 lines 476525d4, and these docs.

Finding Fix Test
No accessibility gate existed (no axe-core, no script, zero axe tests) axe-core dev dependency and pnpm --filter seller-dashboard test:a11y (runs every *.a11y.test.tsx). It was already in the lockfile transitively (eslint-plugin-jsx-a11y), so the dependency asks for ^4.11.1 and the lockfile change is exactly 3 lines; asking for ^4.13.0 re-resolved 78 unrelated hunks and was reverted. src/test/axe.ts audits the whole document, fails only serious/critical, and switches off color-contrast and region (jsdom has no layout or colour). One narrow exclusion, input[type=file].hidden; a blanket .hidden exclusion was rejected because it also skips hidden md:flex toolbars that are visible on desktop axe.a11y.test.tsx is a canary proving axe reports image-alt, button-name, label and aria-required-parent under jsdom, so a green run is not a no-op. inbox.a11y.test.tsx (thread list, message card with bounced recipients, reply box) and editor.a11y.test.tsx (canvas, move mode, Insert menu with rename/delete, settings sheet actions). 11 of 11 mutants killed on real components
The gate found critical ARIA errors in the Insert menu: role="menu" holding a search box, role="menuitem" buttons wrapped in ul/li, aria-haspopup="menu" — a screen reader announced a menu whose arrow keys did nothing (aria-required-children, aria-required-parent) The popover is a labelled group, the rows are plain buttons, aria-haspopup removed, the "Add block" bar gains aria-expanded editor.a11y.test.tsx; the 4 role-based queries in my own tests changed to button, anchored (/^hero banner/) because "Rename Hero banner" also matches. The unanchored form passed in isolation and failed in the full run
Every "Move here" target had the same accessible name, so with several on screen a screen-reader user could not tell them apart "Move here, into the left column", "Move here, after the Divider block in the email", "Move here, at the bottom of the email" (starts with the visible text, as label-in-name requires) BlockCanvas.test.tsx (+2) and 5 mutants on the wording, column side and neighbour
documentOpts.maxWidth was typed as 600 | 680 | 720 and campaigns' sourceKind as enum(["blog_post"]). The worker only writes those, but one older row with another width, or the first second source kind, failed the whole templates/campaigns response with BAD_RESPONSE; it also made the template loader's "ignore an unsupported width" branch unreachable in production Any number and any string; the consumers already narrow them newsletter.schemas.test.ts (17, fixtures shaped like the worker's responses incl. older workers, erased subscribers, pre-tracking fires, tracking off); 12 of 12 mutants
"Edit raw HTML" compiles the blocks to HTML and drops the tree. If the compile threw, the catch dropped the tree anyway and kept the last-loaded html_body, which can be older than unsaved edits, silently losing work right after the author confirmed A failed compile now aborts with an error and changes nothing TemplateEditor.raw-switch.test.tsx (5: warning, cancel, confirm saves compiled HTML with blocks: null and no render_version, one-way, failure path); 5 of 6 mutants (the 6th is equivalent: the payload takes blocks: null from the editor mode)
email-slash-items and dark-inbox had no tests Tests, including drift guards: every slash insert type exists in BLOCK_CATALOGUE and together with text covers every block; the dark-inbox shim only works while the renderer emits the colours it targets, so one test renders a block and asserts they are still there email-slash-items.test.ts (12), dark-inbox.test.ts (13); 14 of 14 mutants
PR12 pushed BlockCanvas.tsx (521) and InsertMenu.tsx (448) back over 400 lines Extracted editor/canvas/move-mode.tsx, editor/canvas/BlockChrome.tsx and editor/SectionItem.tsx, byte-for-byte; now 319 and 316; largest non-test file in the module is 377 Existing suites unchanged: 945 passed, same as immediately before

Plan items not built as written: useAutosave was not extracted (autosave stays inline in TemplateEditor; its behaviour is covered through the TemplateEditor tests, including the autosave path for NF-21, and extracting a hook only to test it would be churn); the plan's slash-commands.test.ts is email-slash-items.test.ts; buildFilterRules.test.ts is segment-filter-rules.test.ts (PR10).

Open findings from the new gate (not fixed here, deliberately):

  • NF-46 — block settings controls have no programmatic labels. Field in prop-fields.tsx renders its label as a <div> that is not associated with the control, so every range, colour picker and text input in the block settings panel has no accessible name (axe label, critical). The sheet's own action row (Move/Duplicate/Delete) is named; the fields under it are not. Fixing it means labelling about 20 controls across prop-fields.tsx and block-prop-editors.tsx and needs care (wrapping Field in a <label> would make clicking a field's title press its first button, e.g. the alignment picker's "left"). Recorded as it.todo in editor.a11y.test.tsx.
  • NF-47 — VariableInsertButton uses the same role="menu"/role="menuitem" pattern the Insert menu had. Not yet audited with axe; likely the same two errors.

Pre-existing, noted while linting the whole module (untouched by this phase): eslint src/modules/newsletter reports three react-hooks/set-state-in-effect errors in components/TemplateThumbnail.tsx, hooks/useCompiledEmail.ts and pages/SettingsPage.tsx; they and the lint config are unchanged since before PR11.

Tests: dashboard suite 732 (after PR11) → 945 passed + 1 todo across 92 files; email-blocks package 84 → 103. tsc clean at every step; eslint zero new findings.

2026-10-05 — Review fix pass for PR11–PR13

Three independent read-only reviews ran after PR13 (PR11 page splits; editor logic for PR12/13; sandboxed iframe, schema tolerance, raw switch, axe gate and the dependency for PR13). Each finding was checked against the code, and where it concerned layout or browser behaviour, in real Chrome (the repo's playwright-core against the installed Chrome), before anything was changed. Nine commits on audit/phase-5: cd2dcced, 7980f626, 7076c906, 0e0e115f, 81440865, b090bbef, 9ba5d4c3, 6499924e, e0ef4db4. Every fix has tests and was mutation-checked: 69 mutants, all killed in the end; 2 escaped on their first run and each led to a better test (noted in the table).

Finding Fix Test
PR11 review, Low — the Add and Import panels were mounted only while open, so the toolbar toggle threw away a half-typed form or pasted list, and toggling away from a RUNNING import orphaned it: the run carried on with Stop unreachable and a second import could start over it Both stay mounted and render nothing while closed (open prop), so their state survives exactly as it did before the split. Cancel/Close and a finished clean run reset the paste, the picked file and the rejected-rows report; a successful add clears the form 9 new SubscribersPage tests incl. an import in progress surviving a toggle with Stop still reaching it; 11 of 11 mutants
High — "Save as section" always reported failure although the section was created: the worker answers 201 {section:{id,name}} and the dashboard validated a full stored row, so BAD_RESPONSE, no library refresh, and a retry made a duplicate (introduced in PR8; nothing caught it because the hook tests mock the API layer and the schema test used a full row) The schema validates what the worker sends; save() returns {id,name} instead of casting to a row it never was api tests parse bodies copied from sections.route.ts; 3 of 3 mutants
Medium — clicks in the in-column "Add block" menu bubbled to the Columns block's chrome and re-selected it: its settings opened beside the menu, picking Text lost the new block's edit, and on mobile a second dialog opened The menu owns a click boundary (desktop wrapper; a display: contents wrapper around trigger, sheet and overlay on mobile) menu + composer tests that observe the symptom; 3 of 3 mutants
Medium — undo, redo, remove and replace left selectedId / editingId / media target / moving block pointing at blocks that no longer existed: toolbar Insert silently did nothing after an undo; deleting the block being edited kept a dirty session that hid the settings panel and the mobile bottom bar One reconcile() after those actions; remove also moves the selection when it went with a deleted ancestor 11 reducer cases; 15 of 15 mutants (the one that escaped, a pointer cleared while the selection survives, now has its test)
Low — a no-op update (clicking the already-active alignment) recorded an undo step and cleared redo; insertSection with a missing anchor selected a block that was never inserted; a click on the inline format toolbar re-started the edit and split one text edit into two undo steps Each fixed in the reducer reducer cases; mutants killed
Low — move mode was not modal (Escape also deselected; toolbar and mobile Insert, and "Choose image", still acted), its banner's sticky never stuck (nearest scroller was the canvas's overflow-auto wrapper, which never scrolls), and target names repeated for adjacent same-type blocks and for every Columns block Reducer refuses add/pick actions while moving and the two Insert buttons disable; the settings panel ignores Escape while moving; the banner is a sibling above the wrapper (checked in real Chrome: scrolled away at -823px before, stuck at 0px after, with the page or an inner scroller as the scroller); names carry the block number and, with more than one Columns block, which one 19 tests; 16 of 16 mutants. Wording changed: "Move here, after the Divider block (block 2) in the email", "…the left column of Columns block 2"
Low — a refused section rename discarded what was typed; Escape in the rename box closed the whole mobile sheet (Radix handles Escape in the capture phase, so the earlier stopPropagation only ever worked on desktop); the Enter that confirms an IME composition saved a half-typed name rename resolves false on refusal and the input stays open with the text and focus; the sheet declines an Escape aimed at a rename row; composing keydowns (isComposing, Safari's keyCode 229) are ignored in the input and in the menu's Escape handler 8 tests; 12 of 12 mutants
Medium — iframe auto-height only ever grew: documentElement.scrollHeight is never less than the viewport (the frame's own height), so a one-line block stayed at 120px and content edited down kept its tallest height Reports max(body.scrollHeight, html's getBoundingClientRect().height). Checked in real Chrome across short, tall, shrunk, margin, overflow, float and live-shrink (ResizeObserver) cases. The mailbox viewer shares the script and gets the same fix a pin test (jsdom has no layout; the behaviour is the browser check); 2 of 2 mutants
Medium — the CSP does not make the content's script "exactly ours and nothing else": it governs the srcdoc only. Content that gets the frame to load a REMOTE document inherits allow-scripts with no CSP (the old sandbox="" could run none of it) Added frame-src/object-src/form-action 'none'; measured: <object> and <embed> payloads ran before and are blocked after. <meta refresh> and a link click still get through (CSP has no navigate-to). The headers now say exactly that. See the residuals below 3 of 3 mutants on the directives
resetKey was unknown (a fresh object each render would loop); the viewer passed the sanitized object, which also reset the height when the frame did not reload and so never re-reported string | number; the viewer passes its HTML string compile-time; the 8 viewer auto-height tests unchanged
The axe suite's header said "run by CI with the rest" No workflow runs seller-dashboard tests, so it is a gate only for whoever runs them; the comment says so —
"Edit raw HTML" with every block deleted opened the raw editor on the stale loaded HTML (and Save could store it) An empty tree compiles to the empty shell; html_body is the source only with no tree 1 test; mutant killed
(Out of the reviewed slice, pre-existing) the template editor's header said "Unsaved" for good after a save that worked: the snapshot held the compiled html/text, which the form never takes on The snapshot is the form as saved 4 tests incl. one checked immediately after an explicit Save (the first version waited long enough for the 2s autosave to hide the bug, which is how a mutant escaped); 2 of 2 mutants
documentOpts.maxWidth: null would still fail the whole response (the tolerance rule says no stricter than the worker) nullable().optional() 2 cases; mutant killed

Also: StatStrip's doc comment moved back above StatStrip; the two TemplateEditor test files got a 15s test timeout (the first raw-switch test had exceeded the 5s default in a loaded full run).

Verified clean by the reviewers (no change needed): reducer purity and StrictMode, edit-session accounting, the history cap, moveBetween/insertInto/locateBlock semantics including cycles, move-mode state transitions, the useCampaignDraft save guard, NF-21 semantics on all three call sites, 476525d4 being a pure move, the sandbox flags (allow-scripts only, never allow-same-origin), the hash gating (sandbox, CSP and script derive from one value), readHeightReport, the viewer refactor, the lockfile diff (exactly the 3-line importer entry), and the Insert menu's ARIA.

Accepted residuals, not fixed:

  • A Custom HTML preview can still reach a remote document with scripts through <meta refresh> or a click on a link. It stays confined to an opaque origin (no cookies, storage, parent or top access, popups or modals; all checked in Chrome), can send requests and post data to the parent, and can move the frame's height within its clamp. Only the template author's own team sees the preview. The full fix is to never give the content allow-scripts: measure it from a trusted outer frame through an inner script-less frame (same-origin through the opaque origin). Larger than a fix pass; a decision for Phase 6, or sooner if previews ever render content from outside the team.
  • The a11y gate runs only where someone runs it. There is no CI workflow for seller-dashboard and no pre-commit hook. Adding the workflow is what would make it a gate.
  • switchToRaw: edits made in the few milliseconds the compile is in flight can be dropped (the dialog is closed while it awaits).
  • Hand-crafted block trees with duplicate ids break the move/insert invariants (verified: a block inserted into both of two Columns blocks sharing an id). Layouts and saved sections mint fresh ids and no production source of duplicates was found; trees authored through the MCP or API were not checked.
  • Legacy widths are normalised to 680 on the next save of a template or campaign that had another width (expected from the tolerance rule).
  • Not checked on a device: keyboard focus after a move starts or ends (the activated toolbar button becomes hidden and the target unmounts, so focus probably falls to the page), and the empty-column move on iOS Safari. Firefox and Safari were not tested for the iframe behaviours.
  • NF-46 and NF-47 stay open as above.

Still open: the rollout (see docs/mailbox/backlog.md for the Track M closing notes).

Newsletter