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:
- Deletes the subscriber row
- Pseudonymises
emailinnl_subscriber_eventsto"erased@{hash}.gdpr"or similar - 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.