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
resendSDK; free tier covers 100/day, 3,000/month. - SMTP — uses
aiosmtplib; works against any SMTP server (self-hosted, Postmark, SendGrid, etc.).
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.poolis an asyncpg connection pool (the caller inpublish_servicepassesdb_service.cloud_pool or db_service.pool). Returns{"sent": int, "failed": int, "skipped": int, "total_subscribers": int, "skipped_reason": str?}—skipped_reasonis set when the function exits early (disabled,no_api_key,no_smtp_host).
_cfg, _get_active_subscribers,
_build_html, _send_via_resend, _send_via_smtp, _log_send)
are private.
Key behaviors / invariants
- Disabled by default.
newsletter_enableddefaults tofalse. 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_passwordandresend_api_keyare read viaget_secretvsget. In the baseline schema,smtp_passwordis flaggedis_secret=true, which means it’s filtered out of the in-memorySiteConfigcache and MUST be fetched via the asyncget_secret()path. The current code usesget_secretforsmtp_passwordbut plaingetforresend_api_key— see Status callout below.- Subscriber filter:
unsubscribed_at IS NULL AND verified = TRUE, ordered byidfor determinism. site_urlis required._site_url()callssite_config.require("site_url"), which raisesRuntimeErrorif 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_sendcatches and logs at DEBUG — a failedcampaign_email_logsinsert 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:
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 fromapp_settings via services.site_config:
newsletter_enabled(defaultfalse) — 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 beis_secret=truetoday.smtp_host(required when provider is SMTP) — SMTP server host.smtp_port(default587) — SMTP port.smtp_user(default empty) — SMTP auth user, optional.smtp_password(default empty,is_secret=truein the baseline) — SMTP auth password.smtp_use_tls(defaulttrue) — STARTTLS toggle.newsletter_batch_size(default50) — emails per batch.newsletter_batch_delay_seconds(default2) — sleep between batches.site_url(required, fail-loud) — used for/posts/<slug>and/newsletter/unsubscribeURLs.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 withdelivery_status(delivered/failed) and optionaldelivery_error.
- External APIs:
- Resend:
resend.Emails.send(sync SDK, run in executor). - SMTP:
aiosmtplib.sendagainst the configured host.
- Resend:
- 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_urlnot configured —_site_url()raisesRuntimeError. 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, returnsFalse. The outer loop counts it asfailedand writesdelivery_status='failed'tocampaign_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. Tunenewsletter_batch_size/newsletter_batch_delay_secondsto stay under the documented Resend limits. resendoraiosmtplibpackage missing —ImportErrorat send-time inside_send_via_*, caught as the genericException, logged as warning. All sends will fail until the dependency is installed. Confirmpyproject.tomlincludes 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— seedssmtp_passwordwithis_secret=true(originally migration0121_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.