Notifications & Delivery Guide¶
How Podcast Scraper notifies users — digest emails, push nudges, native iOS push, the daily recap, and the in-app inbox. This is the end-to-end map: who produces a notification, how consent gates it, how it crosses the app↔infra seam, and how the last-mile worker actually sends it.
Design authority: ADR-144 (self-hosted queue, outsourced last-mile), ADR-145 (channel-agnostic outbox seam), RFC-110 (full spec), RFC-122 (daily recap).
Where the code lives: the producer half (subscriptions, consent, generation, the outbox seam) is in this repo under
src/podcast_scraper/server/. The delivery worker (last-mile sender) currently lives in the homelab infra repo atagentic-ai-homelab/infra/delivery/. Its packaging is being reevaluated in podcast_scraper#2077 — that only moves where the files live, not how any of this works.
The shape in one picture¶
The system is a two-stage, channel-agnostic boundary. The app produces structured envelopes into an outbox; a stateless worker drains, renders, and sends them, then reports terminal status back so the app can suppress bad recipients.
PRODUCER (this repo, learning-player API) SEAM LAST-MILE (homelab worker)
───────────────────────────────────────── ───────────────── ──────────────────────────────
subscription ─┐ GET /internal/ pull pending ──▶ render template
consent matrix ─┼─▶ hourly scheduler ──▶ outbox/pending? │ (Jinja, per channel)
finished today ─┘ assemble + enqueue channel=email|push │ │
DeliveryEnvelope ──▶ ┌─ outbox store ──────────▶│ send via:
(idempotent id) │ (file per envelope) │ • Resend (email)
│ │ • Web Push (VAPID)
consent write-back ◀── _suppress() ◀───────┴─ POST /internal/ ◀──────┘ • APNs (native iOS)
(bounce/complaint/unsub) outbox/{id}/status ◀── terminal status ──┘
Key properties
- Channel-agnostic + frozen contract. The envelope
payloadis structured JSON (never HTML). All rendering and protocol detail lives on the worker side. The schema is versioned (schema_version: "1"). - Idempotent everywhere. Every envelope has a deterministic
id(period-keyed), so re-running the scheduler or re-posting a status is a no-op. - Consent stays with the app. The worker never reads or writes the consent store; the app filters at enqueue and re-checks at pull, and applies suppression from delivery feedback.
- Tailnet-only, egress-only. The seam is a tailnet-internal HTTP API gated by a shared token; the worker only makes outbound calls (Resend / push over 443).
Part 1 — Producing notifications (this repo)¶
1.1 Subscriptions¶
A user opts in to push per device. Two kinds share one endpoint and one store.
Web Push (browser, VAPID) — web/learning-player/src/composables/usePushSubscription.ts
(enablePush()):
Notification.requestPermission()GET /api/app/push/vapid-key→ the server'sAPP_VAPID_PUBLIC_KEY(503 if unset)pushManager.subscribe({ applicationServerKey })POST /api/app/push/subscribewith the W3CPushSubscription(endpoint + keys)
Native iOS (Capacitor, APNs) — enablePushNative() (the #2068/#2072 work):
PushNotifications.requestPermissions()(Capacitor plugin)- listen for the
registrationevent → the APNs device token POST /api/app/push/subscribewith{ endpoint: "apns://<token>", kind: "apns", platform, token }
Both land in the same per-user store
(src/podcast_scraper/server/app_push_store.py → <data_dir>/users/<id>/push_subscriptions.json),
deduped on the endpoint string. The PushSubscription schema is
extra="allow", which is why the APNs kind/platform/token fields ride
through without a schema change. Removing the last subscription auto-disables
push everywhere for that user.
Why native APNs at all? An iOS Capacitor WKWebView has no ServiceWorker / PushManager, so Web Push silently fails there. Native APNs (device token →
apns://subscription) is the iOS path; the worker sends it over APNs HTTP/2.
1.2 Consent — a type × channel matrix¶
src/podcast_scraper/server/app_comms_store.py →
<data_dir>/users/<id>/comms.json.
- Types:
digest,daily_recap,new_episodes,product - Channels:
email(opt-in, default off),push(opt-in, default off),in_app(default on) - Schedules:
digest_schedule(cadenceweekly|daily,day_of_week,hour,paused) anddaily_recap_schedule(hour,paused) - Timezone: IANA string; all "due" checks use the user's local hour (empty = UTC fallback)
unsubscribe_ref: an opaque per-user string minted on first save, used by one-click unsubscribe links
Managed via GET/PUT /api/app/comms. channel_enabled(comms, type, channel)
is the boolean gate enforced everywhere an envelope is generated.
1.3 Generation — the four templates¶
Every generator assembles a structured, graph-carrying payload (each item
carries graph_refs + a deep_link; bridge-only, no audio) and returns an
envelope only if consent is met and there is content.
| Template | Type | Channel | Source | Cadence |
|---|---|---|---|---|
your-week-digest.v1 |
digest |
app_digest_personal.py (revisit + new-in-follows/interests + trending) |
weekly or daily | |
resurface-nudge.v1 |
digest |
push | app_digest_personal.py (one per subscription) |
daily |
daily-recap.v1 |
daily_recap |
app_digest_daily_recap.py (episodes you finished today) |
daily | |
recommendations-digest.v1 |
digest |
app_digest_recommendations.py (discovery only) |
monthly |
Consent gates (examples):
- Email digest:
digest.emailenabled · not paused ·email_verified - Push nudge:
digest.pushenabled · ≥1 subscription · graph-carrying content - Daily recap:
daily_recap.emailenabled · not paused ·email_verified· finished ≥1 episode today (local day)
1.4 Scheduling & enqueue¶
An hourly scheduler job (src/podcast_scraper/server/scheduler.py,
kind="digest") fans out to the three orchestrators:
enqueue_due_digests(...) # your-week (email) + resurface (push)
enqueue_due_recommendations(...) # monthly discovery digest
enqueue_due_daily_recaps(...) # daily recap
Each checks the user's local due-slot, then writes envelopes to the outbox
store (app_outbox_store.py → <data_dir>/outbox/<sha256(id)>.json). The
id is period-keyed (dgst_<period>_<uid>, drcp_<YYYYMMDD>_<uid>, …), so
enqueue() is a no-op if the id already exists — re-runs are safe.
Timezone note (not-yet-per-user at fire time): the app enqueues at a fixed UTC hour today; per-user local timezone at the schedule level is tracked separately. See the recap/digest scheduler for current behavior.
Part 2 — The seam (app ↔ infra)¶
The delivery worker pulls from a small, token-gated HTTP API served by the
learning-player API. Routes: src/podcast_scraper/server/routes/internal_outbox.py.
2.1 Auth¶
Every /internal/outbox/* call presents X-Internal-Token, compared against
app.state.internal_outbox_token (from the INTERNAL_OUTBOX_TOKEN env var,
app.py). Unset → 503 (the endpoints are hard-disabled), mismatch →
401. The worker's matching value is PODCAST_INTERNAL_OUTBOX_TOKEN (same
secret, a different variable name on the worker side).
2.2 Endpoints¶
GET /internal/outbox/pending?channel=email|push&limit=N → { envelopes: [...] }
- pending-only, oldest-first, capped at
limit - re-checks current consent (live
comms, not the frozenconsent_snapshot) — a user who unsubscribed or paused since enqueue is dropped here - excludes past-
expires_atenvelopes (no stale flush after an outage)
POST /internal/outbox/{id}/status with { status, detail? }
- terminal statuses:
delivered,bounced,complaint,suppressed,failed - idempotent per id — a repeated status returns the stored one, no-op
- suppression write-back: on
bounced/complaint/suppressed, the app flips the matching consent cell (_suppress()→set_channel(..., False); a bad push endpoint →disable_push_everywhere). This is how the app stops producing for a dead recipient without the worker ever touching consent.
2.3 The envelope¶
DeliveryEnvelope (schema: docs/api/delivery-envelope.schema.json, golden
fixtures under tests/fixtures/delivery/, contract test
tests/unit/server/test_delivery_envelope_contract.py):
{
"schema_version": "1",
"id": "dgst_2026W37_u123", // idempotency key (period + user)
"user_id": "u123",
"channel": "email", // email | push
"type": "digest", // informational
"template": "your-week-digest.v1",
"recipient": { "email": "…", "email_verified": true },
// or { "push_subscription": <W3C sub> } / apns sub
"consent_snapshot": { "digest_enabled": true, "cadence": "weekly",
"unsubscribe_ref": "…" }, // informational (live consent re-checked at pull)
"payload": { "sections": [ /* graph-carrying items, NOT HTML */ ] },
"not_before": "…", "expires_at": "…", "created_at": "…"
}
Part 3 — Delivering (the homelab worker)¶
agentic-ai-homelab/infra/delivery/ — a standalone Python package with no
podcast_scraper dependency; it consumes only the vendored seam schema.
See that repo's README.md and HANDOVER-homelab.md.
3.1 Three long-running loops¶
delivery-email, delivery-push, delivery-events (one worker per tenant per
channel). Each loop: GET …/pending?channel=… → render the template (Jinja)
→ send → POST …/status.
- email → Resend HTTP API (443, reputation relay). Templates:
your-week-digest,daily-recap,recommendations-digest. - push → dispatched by subscription
kind: webpush→ self-hosted VAPID / RFC 8291 POST to the subscription endpointapns→ APNs token-based ES256-JWT over HTTP/2 (native iOS). Prod uses the production APNs host (apns_sandbox: false); dev-signed builds need a sandbox tenant (see §4).- events → polls Resend's events API (cursor-based) and posts
bounced/complaintback to the seam.
3.2 Tenants¶
tenants.yaml in the worker: one worker set per tenant.
podcast— prod. Drains the prod player-API outbox over the tailnet; APNsapns_sandbox: false.podcast-dev— dev twin. Its outbox URL is env-overridable (PODCAST_DEV_OUTBOX_URL) so it follows wherever you run the dev API; APNsapns_sandbox: truefor dev-signed device builds.
3.3 Reliability & observability¶
- Retry + dead-letter: transient failures retry with backoff;
failedis terminal (dead-lettered after N). - Idempotency: keyed on envelope
idend-to-end. - o11y: all three channels emit logs (JSONL → VictoriaLogs), metrics
(Prometheus
/metrics→ VictoriaMetrics, labeled by channel), and traces (OTEL spans → VictoriaTraces,service.name=delivery-worker). Errors → GlitchTip.
Part 4 — Operating it¶
4.1 Local dev testing (email + push, without TestFlight)¶
The dev API (make serve-api, port :8000) serves /internal/outbox. It now
binds 0.0.0.0 by default, so it's reachable over the tailnet with no extra
flags:
# laptop: run the dev API (token comes from .env → INTERNAL_OUTBOX_TOKEN)
make serve-app-dev
# mini: point the delivery worker's podcast-dev tenant at your laptop, once
~/agentic-ai-homelab/infra/delivery/bin/dev-outbox.sh laptop
Then a dev-signed iOS build registers its sandbox APNs token, a digest
enqueues, and the podcast-dev worker (sandbox) delivers to your device.
Email needs nothing extra — the podcast-dev email worker sends via Resend
(real mail from the shared mail_from, so use safe recipients).
4.2 Prod¶
- Server: deploy the app (token-registration + enqueue code) to the prod VPS via the normal player deploy.
- iOS: a TestFlight / App Store build → production APNs tokens (matches
the prod
podcasttenant'sapns_sandbox: false). - Worker: already runs on the homelab mini for the
podcasttenant.
4.3 Config / env (both sides)¶
| Where | Var | Purpose |
|---|---|---|
| app | INTERNAL_OUTBOX_TOKEN |
gate /internal/outbox/* (503 unset / 401 mismatch) |
| app | APP_VAPID_PUBLIC_KEY |
browser Web Push subscription |
| worker | PODCAST_INTERNAL_OUTBOX_TOKEN |
same value as the app's token |
| worker | RESEND_API_KEY |
email last-mile |
| worker | PODCAST_VAPID_PRIVATE_KEY |
Web Push signing (pairs the app's public key) |
| worker | PODCAST_APNS_AUTH_KEY (+ key/team/bundle ids in tenants.yaml) |
native iOS APNs |
| worker | PODCAST_DEV_OUTBOX_URL |
dev: point podcast-dev at the active dev API |
In-app inbox (the in_app channel)¶
Separate from the outbound worker: app_notifications_store.py writes to
<data_dir>/users/<id>/notifications.json (bounded, newest-first), gated by the
same in_app consent cell. Read via GET /api/app/notifications +
.../read / .../read-all. This never leaves the app — no seam, no worker.
One-click unsubscribe¶
Emails carry an RFC 8058 List-Unsubscribe-Post header and a link
…/api/app/comms/unsubscribe?ref=<unsubscribe_ref>&type=<type>. GET renders
a confirm page (no mutation, guards email prefetch); POST resolves the ref →
user and disables that one email type. Type-aware, so unsubscribing from the
daily recap doesn't touch the weekly digest.
See also¶
- ADR-144 — Self-hosted delivery queue, outsourced last-mile
- ADR-145 — Channel-agnostic outbox seam
- RFC-110 — Outbound delivery and seam
- RFC-122 — Post-episode recap
- Development Guide —
make serve/serve-api - Server Guide — the
/api/*surface - podcast_scraper#2077 — reevaluating where the delivery worker's code lives