logicspike/docs

Newsletter

Newsletter Service — Backlog: Issues, Gaps & Improvement Scope

Last Updated: 2026-06-28 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

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 (Cloudflare Cron Trigger) that deletes nl_subscriber_events rows older than 365 days for production, or exposes a DELETE /admin/subscriber-events/purge?olderThan= route for ops.


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) 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

Nothing fixed yet — initial backlog from 2026-06-28 analysis.

Newsletter