Skip to main content

Newsletter Service

File: src/cofounder_agent/services/newsletter_service.py Tested by: src/cofounder_agent/tests/unit/services/test_newsletter_service.py Last reviewed: 2026-04-30

What it does

newsletter_service.send_post_newsletter() sends a “we just published a new article” email to every active, verified subscriber in newsletter_subscribers whenever a blog post publishes. It supports two providers, picked by newsletter_provider:
  • Resend (default) — calls the resend SDK; free tier covers 100/day, 3,000/month.
  • SMTP — uses aiosmtplib; works against any SMTP server (self-hosted, Postmark, SendGrid, etc.).
Sends are batched (default 50 per batch with a 2-second delay between batches) so the call respects provider rate limits. Every attempt — success or failure — is logged to campaign_email_logs for delivery tracking. The whole operation is fire-and-forget from publish_service’s point of view; failures don’t block the publish. The HTML body is a minimal, inlined-CSS template with the post title, excerpt, a CTA button, and a List-Unsubscribe link. Personalization is limited to first-name greeting if the subscriber row has it.

Public API

  • await send_post_newsletter(pool, title, excerpt, slug) -> dict — the only public function. pool is an asyncpg connection pool (the caller in publish_service passes db_service.cloud_pool or db_service.pool). Returns {"sent": int, "failed": int, "skipped": int, "total_subscribers": int, "skipped_reason": str?}skipped_reason is set when the function exits early (disabled, no_api_key, no_smtp_host).
The internal helpers (_cfg, _get_active_subscribers, _build_html, _send_via_resend, _send_via_smtp, _log_send) are private.

Key behaviors / invariants

  • Disabled by default. newsletter_enabled defaults to false. Until an operator flips it, every call returns {"skipped_reason": "disabled"} immediately. No DB read of subscribers, no provider check.
  • Provider config is gated. Resend without an API key, or SMTP without a host, returns early with the matching skipped_reason — no half-attempts that would partial-fail.
  • smtp_password and resend_api_key are read via get_secret vs get. In the baseline schema, smtp_password is flagged is_secret=true, which means it’s filtered out of the in-memory SiteConfig cache and MUST be fetched via the async get_secret() path. The current code uses get_secret for smtp_password but plain get for resend_api_key — see Status callout below.
  • Subscriber filter: unsubscribed_at IS NULL AND verified = TRUE, ordered by id for determinism.
  • site_url is required. _site_url() calls site_config.require("site_url"), which raises RuntimeError if unset. The HTML body would otherwise contain broken /posts/... links — the loud failure is intentional.
  • List-Unsubscribe header on SMTP only. The SMTP path adds List-Unsubscribe: <{site_url}/newsletter/unsubscribe>. The Resend path does NOT — it relies on Resend’s account-level unsubscribe handling.
  • Per-recipient send is sequential. Inside each batch, sends are awaited one at a time. There’s no concurrency within a batch — if you have 5,000 subscribers and a 2s SMTP latency, the whole job takes ~10,000s plus batch delays. The batch_delay sleep happens between batches, not within them.
  • Logging swallows DB errors. _log_send catches and logs at DEBUG — a failed campaign_email_logs insert never aborts the send loop.

Status: secret-flag inconsistency

smtp_password is fetched via get_secret (correct, post-migration 0121). resend_api_key is fetched via plain get, which means it must NOT be flagged is_secret=true in app_settings or SiteConfig will filter it out of the cache and the value will silently be empty. Check the row’s is_secret value before assuming Resend will work:
If is_secret=true, either flip it to false or update newsletter_service._cfg() to use await site_config.get_secret(...) for the Resend key the same way it does for SMTP.

Configuration

All from app_settings via services.site_config:
  • newsletter_enabled (default false) — global on/off switch.
  • newsletter_provider (default "resend") — "resend" or "smtp".
  • newsletter_from_email (required when enabled) — sender address.
  • newsletter_from_name (default empty) — display name for the From: header.
  • resend_api_key (required when provider is Resend) — Resend API key. See Status callout — must NOT be is_secret=true today.
  • smtp_host (required when provider is SMTP) — SMTP server host.
  • smtp_port (default 587) — SMTP port.
  • smtp_user (default empty) — SMTP auth user, optional.
  • smtp_password (default empty, is_secret=true in the baseline) — SMTP auth password.
  • smtp_use_tls (default true) — STARTTLS toggle.
  • newsletter_batch_size (default 50) — emails per batch.
  • newsletter_batch_delay_seconds (default 2) — sleep between batches.
  • site_url (required, fail-loud) — used for /posts/<slug> and /newsletter/unsubscribe URLs.
  • company_name, site_name — used in the HTML body header and footer text.

Dependencies

  • Reads from:
    • newsletter_subscribers — active+verified subscriber list.
    • services.site_config.site_config — all configuration.
  • Writes to:
    • campaign_email_logs — one row per send attempt with delivery_status (delivered / failed) and optional delivery_error.
  • External APIs:
    • Resend: resend.Emails.send (sync SDK, run in executor).
    • SMTP: aiosmtplib.send against the configured host.
  • Callers:
    • services.publish_service (post-publish hook section “11f. Newsletter to subscribers”). Spawned via _spawn_background — fire-and-forget; failures logged at DEBUG and don’t block the publish.

Failure modes

  • Disabled — early return {"skipped_reason": "disabled"}.
  • Resend chosen but API key missing/empty — early return {"skipped_reason": "no_api_key"}.
  • SMTP chosen but host missing — early return {"skipped_reason": "no_smtp_host"}.
  • site_url not configured_site_url() raises RuntimeError. The publish-service caller catches this in its outer try/except (logger.debug("[NEWSLETTER] Failed (non-fatal): %s", e)) so the publish still succeeds, but no newsletter is sent.
  • Per-recipient send fails — the provider helper (_send_via_resend / _send_via_smtp) catches the SDK exception, logs a warning, returns False. The outer loop counts it as failed and writes delivery_status='failed' to campaign_email_logs. The next subscriber is attempted normally.
  • Resend API rate-limit hit (429) — caught the same way as any other Resend exception; logged and counted as failed. No back-off; subsequent calls in the same batch will likely also fail. Tune newsletter_batch_size / newsletter_batch_delay_seconds to stay under the documented Resend limits.
  • resend or aiosmtplib package missingImportError at send-time inside _send_via_*, caught as the generic Exception, logged as warning. All sends will fail until the dependency is installed. Confirm pyproject.toml includes both.
  • No active subscribers — early return after the SELECT, with total_subscribers=0. Not an error.

Common ops

  • Enable newsletters:
  • Switch to SMTP:
  • Tune batch size for a slower provider:
  • Inspect recent send results:
  • Trigger manually for an existing post (one-off):
  • Find active subscriber count:

See also

  • docs/architecture/services/publish_service.md — the caller that triggers newsletter sends.
  • 0000_baseline.py — seeds smtp_password with is_secret=true (originally migration 0121_flip_smtp_password_secret, folded into the baseline by the Phase F squash (2026-06-22)).
  • feedback_no_silent_defaults (operator design note) — why _site_url() raises rather than defaulting.