Email Delivery Architecture
The email subsystem handles lifecycle-triggered outreach: weekly digests, notifications, and transactional messages. This document covers the first lifecycle email channel (weekly digest), the infrastructure that powers it, and the observability surface.
Status: Phase 89 complete (v3.9.178+). Full rollout, content tuned, prompt v2 live.
Overview
The email system has two layers:
- Mailer layer —
UserMailer(Rails ActionMailer) handles all email generation and SMTP delivery via Mailtrap - Job layer — Sidekiq jobs handle async send scheduling, cohort gating, idempotency, and telemetry
The first email channel shipping is the weekly digest — a summarized email of the user's progress sent once per week at a user-selected time (Sunday, Saturday, or Monday morning). It includes a personalized insight, streak summary, XP earned, and a call-to-action link (tracked).
Job Topology
Two coordinated jobs handle weekly digest delivery:
Social::WeeklyDigestEnqueueJob (hourly Crono trigger)
Schedule: Runs hourly via config/cronotab.rb (no fixed minute — Crono rejects at: on sub-day periods; the job matches each user's local hour internally)
Purpose: Identify which users should receive a digest in this hour, subject to:
- Opt-in status (
user.details.weekly_digest_enabled) - Delivery day match (Sunday, Saturday, or Monday per
user.details.weekly_digest_delivery_day) - Local hour match (09:00 in user's timezone)
- Cohort gate (
DIGEST_ROLLOUT_DENOMINATOR) - Sufficient activity (≥3 events in past 30 days)
Queue: :default (standard Sidekiq queue, 3-retry policy)
Implementation (rails_api/app/jobs/social/weekly_digest_enqueue_job.rb):
Crono.perform(Social::WeeklyDigestEnqueueJob).every 1.hourFlow:
- Iterate all users (with
includes(:details)to avoid N+1) - Skip if not opted in
- Skip if delivery day or hour don't match user's timezone
- Check cohort denominator: if outside cohort, log
weekly_digest_suppressed(reason:cohort_excluded) - Check sufficient activity: if below threshold, log
weekly_digest_suppressed(reason:low_activity) - Enqueue
Social::WeeklyDigestSendJobfor matching users
Per-user timezone logic:
# Convert current UTC time to user's timezone
user_tz_now = Time.current.in_time_zone(user.timezone_or_default)
# Check if delivery day (Sunday/Saturday/Monday) matches today
user_day = user.details&.weekly_digest_delivery_day || 'sun' # 3-letter code
local_day = user_tz_now.to_date.strftime('%a').downcase # Sun, Mon, etc.
return unless user_day == local_day
# Check if local hour is 9:00 AM
return unless user_tz_now.hour == 9Cohort gate (DIGEST_ROLLOUT_DENOMINATOR ENV):
| ENV value | Behavior |
|---|---|
0 | Emergency off — zero digests sent |
1 | 100% rollout — all eligible users sent |
10 | 10% cohort — send to users where user.id % 10 == 0 |
Internal allowlist: Users in WEEKLY_DIGEST_INTERNAL_ALLOWLIST (comma-separated email list in ENV) bypass the cohort gate and receive digests regardless of denominator value. Used for early access testing. The allowlist and DIGEST_ROLLOUT_DENOMINATOR are independent controls — an empty allowlist does not require a non-zero denominator, and vice versa. WEEKLY_DIGEST_INTERNAL_ALLOWLIST is intentionally left unset in both deploy/crono.staging.yaml and deploy/crono.production.yaml; populating it would defeat the cohort gate it's meant to bypass.
Historical note — Phase-87/88 guard contradiction (fixed 2026-08-29, OBJ-2982): from Phase 87 (37804afd8) until this fix, perform's guard read return if allowlist.empty? — an unconditional abort with no DIGEST_ROLLOUT_DENOMINATOR check at all. That guard predated Phase 88's cohort gate and was never reconciled with it: Phase 88's own commit message (4ce9326c5) designated the allowlist as a bypass sitting beside the cohort gate ("allowlist short-circuits; denominator=0 emergency rollback"), but its diff started below the Phase-87 guard line and never touched it. The result was that an empty allowlist voided the cohort gate entirely, making a cohort-only rollout unreachable by construction — the weekly digest never sent to anyone, in any environment, from the day Phase 87 shipped until OBJ-2982 landed. The corrected guard (weekly_digest_enqueue_job.rb:13):
return if allowlist.empty? && ENV.fetch('DIGEST_ROLLOUT_DENOMINATOR', 0).to_i.zero?Preserves the cheap early-exit when the feature is fully off (both controls at their "off" value) while letting the denominator gate stand on its own, as Phase 88 intended. Full evidence chain (commit archaeology, corrected root-cause narrative, rollout sequencing): OBJ-2982.
PostHog telemetry (app/jobs/social/weekly_digest_enqueue_job.rb:25 and 36):
- Event:
weekly_digest_suppressed - Properties:
user_public_id,reason(enum:cohort_excludedorlow_activity) - Fired when: User is opted in and matches delivery day/hour, but is excluded by cohort or lacks activity
Error handling:
- Catches
StandardErrorat line 41, logs via Sentry and Rails logger, then re-raises - Sidekiq retries 3 times before abandoning the job
- If the job fails entirely, no user receives a digest that hour (but it will retry next hour)
Social::WeeklyDigestSendJob (async mailer queue)
Queue: :mailers (dedicated for email jobs; separate from default Sidekiq work)
Purpose: Assemble the digest payload, render and send the email, and record telemetry
Implementation (rails_api/app/jobs/social/weekly_digest_send_job.rb):
Idempotency guard (lines 49–55):
SENT_GUARD_DAYS = 6 # prevent double-send within same digest week
def already_sent_this_week?(user)
last_sent = user.details&.last_digest_sent_at
return false if last_sent.blank?
Time.parse(last_sent) > SENT_GUARD_DAYS.days.ago
endThe system records last_digest_sent_at as a timestamp on UserDetail after each send. If the timestamp is ≤6 days old, the job bails early (no double-send). This is intentionally 6 days (not 7) to allow for time-zone and scheduling edge cases without exceeding the one-per-week guarantee. (See Phase 87 carry-forward note below.)
Payload assembly (line 14):
result = WeeklyDigest::AssembleDigestData.call(user_id: user.public_id)WeeklyDigest::AssembleDigestData is an interaction that:
- Fetches user's goals and recent events
- Calls AI Coach prompt (v2, tuned for insight quality) to generate a personalized insight
- Falls back to a deterministic pool of brand-voice insights if AI fails
- Assembles all data into a
digest_datahash
If assembly fails, the job:
- Captures exception to Sentry with job context (line 16–20)
- Logs error to Rails logger
- Returns early (does NOT queue the send, does NOT mark as sent)
- Sidekiq retries the entire job 3 times
This "capture and swallow" pattern for assembly failures prevents a single bad data fetch from blocking all downstream digests.
SMTP delivery (line 25):
UserMailer.weekly_digest(user, result.details[:digest_data]).deliver_nowRenders the email template and sends immediately via Mailtrap. If SMTP fails with Net::SMTPServerBusy (rate limit), the exception is captured to Sentry and the job re-raises (Sidekiq retries). Other SMTP errors are also captured and re-raised.
Denormalized last_digest_sent_at write (lines 58–62):
def mark_sent!(user)
return unless user.details
user.details.update!(last_digest_sent_at: Time.current.iso8601)
endRecords ISO 8601 timestamp. This allows the next-hour enqueue job to skip users who already got a digest this week. The timestamp is also exposed via GraphQL and synced to PostHog for activation analysis.
PostHog telemetry (line 28–35):
PosthogNotifier.capture(
distinct_id: user.public_id,
event: 'weekly_digest_sent',
properties: {
user_public_id: user.public_id,
sent_for_week_starting: 7.days.ago.to_date.iso8601
}
)- Event:
weekly_digest_sent - Properties:
user_public_id,sent_for_week_starting(the ISO date of the week the digest covers) - Fired once per successful send
Error handling:
Net::SMTPServerBusy→ capture to Sentry + re-raise (Sidekiq retries)- Other
StandardError→ capture to Sentry + re-raise (Sidekiq retries) - Assembly failures → capture to Sentry + early return (no send, no mark_sent)
- Retries: 3x per Sidekiq policy
Mailer Implementation
File: rails_api/app/mailers/user_mailer.rb
def weekly_digest(user, digest_data)
@user = user
@insight_text = digest_data.fetch(:insight_text)
@streak_count = digest_data.fetch(:streak_count)
@weekly_xp = digest_data.fetch(:weekly_xp)
@week_label = digest_data.fetch(:week_label)
@cta_url = click_redirect_url_for(user, digest_data.fetch(:cta_url), :cta)
@allies = digest_data.fetch(:allies, [])
@unsubscribe_url = unsubscribe_url_for(user)
mail(to: @user.email, subject: "Your week with Objectuve — #{@week_label}")
endKey fields in digest_data:
insight_text— 1–2 sentence personalized insight (AI-generated or fallback)streak_count— number of active habits on streakweekly_xp— total XP earned in the past weekweek_label— human-readable week range (e.g., "May 12 – 18")cta_url— destination link (deep link to in-app screen)allies— list of accountability partners (for kudos section)
CTA URL signing:
@cta_url = click_redirect_url_for(user, digest_data.fetch(:cta_url), :cta)The CTA link is signed with a 30-day TTL via click_redirect verifier (see Click Tracking below). The original URL is never exposed in the email; instead, the email contains a signed /email/click?token=…&url=… redirect.
Unsubscribe URL signing:
@unsubscribe_url = unsubscribe_url_for(user)Token is signed with 30-day TTL via weekly_digest_unsubscribe verifier, appended with &confirm=yes for one-tap UX (see Unsubscribe Flow).
Template: rails_api/app/views/user_mailer/weekly_digest.html.erb (HTML-only; Rails auto-renders .text.erb from HTML via html_part.body)
Payload Assembly
File: rails_api/app/interactions/weekly_digest/assemble_digest_data.rb
Input: user_id (public_id)
Output (on success):
result.details[:digest_data] = {
insight_text: String,
streak_count: Integer,
weekly_xp: Integer,
week_label: String,
cta_url: String,
allies: Array<AllyObject>
}Insight generation:
AI Coach prompt (v2) —
Ai::Prompts::WeeklyDigest::VERSION = 2- Takes user's weekly activity events, goal descriptions, and recent insights
- System prompt enforces: no causation claims, hard 2-sentence limit, no affirmation openings, sparse-data fallback instruction
- Returns 1–2 sentences per Phase 89 tuning (Penny audit of 400–800 phase-window sends)
Fallback pool — If AI fails or returns invalid output:
Ai::Fallbacks::Insights::WEEKLY_DIGEST_COPY— 10 brand-voice lines × 6 personas- Selection is deterministic:
Digest::MD5.hexdigest("#{user.public_id}-#{year}-#{week}")→ selects one line per (user, ISO-week) tuple - Ensures same user sees same fallback within a week, but different users see different fallbacks
PostHog telemetry (
app/interactions/weekly_digest/assemble_digest_data.rb:55):rubyPosthogNotifier.capture( distinct_id: user.public_id, event: 'weekly_digest_insight_fallback', properties: { user_public_id: user.public_id, reason: 'ai_failure' } )- Event:
weekly_digest_insight_fallback - Fired when AI Coach returns no insight or assembly fails
- Helps monitor AI reliability against the baseline fallback rate
Allies assembly:
- Queries accountability partners with recent encouragement/reaction activity
- Limits to recent 3–5 allies (to keep email template compact)
- Includes kudos text (e.g., "Sarah encouraged you yesterday")
Click Tracking
File: rails_api/app/controllers/email/click_redirects_controller.rb
Route: GET /email/click?token={signed_token}&url={target_url}
Flow:
- Email contains a link like:
/email/click?token=eyJfX3RhZ…&url=https%3A%2F%2Fapp.objectuve.com%2Fdash - User clicks in email client
- Controller decodes
token(30-day TTL, containsuser_public_idandlink_kind) - Controller validates
urlagainst whitelist (HTTPS, allowed hosts only) - Controller fires PostHog event:
weekly_digest_clickedwithlink_kind - Controller redirects to
url
Allowed hosts (line 7):
ALLOWED_HOSTS = %w[app.objectuve.com objectuve.com].freezeWhitelist validation (lines 46–51):
def allowed_host?(url)
uri = URI.parse(url)
ALLOWED_HOSTS.include?(uri.host) && uri.scheme == 'https'
rescue URI::InvalidURIError
false
endRequires HTTPS and an allowed host. Non-whitelisted URLs return 422 and log a Sentry warning.
PostHog telemetry (line 29–33):
PosthogNotifier.capture(
distinct_id: payload[:user_public_id],
event: 'weekly_digest_clicked',
properties: { user_public_id: payload[:user_public_id], link_kind: payload[:link_kind] }
)- Event:
weekly_digest_clicked - Properties:
user_public_id,link_kind(e.g.,'cta'for the primary CTA) - Fired on every click (whether or not the redirect succeeds)
Important: The unsubscribe link does NOT go through click-redirect. It uses its own weekly_digest_unsubscribe verifier without click tracking, protecting user privacy for unsubscribe intent.
User Properties (GraphQL + PostHog Sync)
GraphQL Schema
New fields on UserType:
| Field | Type | Resolver | Notes |
|---|---|---|---|
hasDigestEnabled | Boolean | object.details&.weekly_digest_enabled != false | nil = true (opt-in by default) |
lastDigestSentAt | String (ISO 8601) | object.details&.last_digest_sent_at | nil if never sent |
GraphQL query example:
query {
me {
id
hasDigestEnabled
lastDigestSentAt
}
}PostHog Sync
After every SYNC_USER mutation, the frontend calls syncDigestPropertiesToPosthog():
File: ionic_frontend/src/helpers/userToPosthog.ts
export function syncDigestPropertiesToPosthog(userData: UserType) {
try {
posthog.identify(userData.publicId, {
has_digest_enabled: userData.hasDigestEnabled ?? true,
last_digest_sent_at: userData.lastDigestSentAt ?? null
})
} catch (error) {
if (import.meta.env.DEV) console.warn('PostHog sync failed:', error)
}
}This ensures user properties are always in sync with the backend, enabling activation cohort analysis and churn prediction based on digest engagement.
Unsubscribe & Resubscription
See docs/features/weekly-digest-email.md#unsubscribe-flow for the full unsubscribe page states and flow.
Key points:
- Unsubscribe link includes
&confirm=yesparameter (one-tap from email) - Bare unsubscribe URL shows confirmation page (protects against accidental unsubscribes and iframe-based prefetch)
- Both use
Rails.application.message_verifier(:weekly_digest_unsubscribe)with 30-day TTL - Controller:
rails_api/app/controllers/unsubscribes_controller.rb
Mailtrap Setup
Service: Mailtrap (inbox testing + production mail relay)
Per-environment delivery matrix
| Environment | Config source | SMTP host | Where mail lands |
|---|---|---|---|
| Production | config/environments/production.rb:100-109 | live.smtp.mailtrap.io | Real recipient mailboxes |
| Staging | config/environments/staging.rb — require_relative 'production' inherits the production block above, then overrides action_mailer.smtp_settings (OBJ-2822) | sandbox.smtp.mailtrap.io | Mailtrap sandbox inbox (captured, not delivered) |
| Development | config/environments/development.rb:42 — delivery_method = :letter_opener | n/a | Opens each send as a browser preview; never sent over the network |
| Test | config/environments/test.rb:40 — delivery_method = :test | n/a | Held in-memory in ActionMailer::Base.deliveries; never sent |
Why staging needed an explicit override:
staging.rbbegins withrequire_relative 'production', which runs production's entireRails.application.configureblock — including the live SMTP settings — before staging's own block executes.- Before OBJ-2822, nothing overrode
action_mailer.smtp_settingsafterward, so every staging send — including Playwright smoke-test magic-code emails to non-existent+clerk_test_*@objectuve.comaddresses — went out the live relay and bounced, drowning real production delivery signal in Mailtrap's dashboard stats. - None of the
tests/smoke-playwright/specs depend on a real mailbox receiving mail: the sign-up spec (specs/auth-onboarding/clerk-sign-up.spec.ts) verifies with Clerk's fixed test OTP424242, and every other spec authenticates viastorageStateor the fallback token. Routing staging mail to sandbox has no smoke-suite impact.
Production configuration (rails_api/config/environments/production.rb:100-109):
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: 'live.smtp.mailtrap.io',
port: 587,
domain: 'objectuve.com',
user_name: 'api',
password: ENV['MAILTRAP_API_TOKEN'],
authentication: :plain,
enable_starttls: true
}Staging override (rails_api/config/environments/staging.rb):
config.action_mailer.smtp_settings = config.action_mailer.smtp_settings.merge(
address: 'sandbox.smtp.mailtrap.io',
user_name: ENV['MAILTRAP_SANDBOX_SMTP_USERNAME'],
password: ENV['MAILTRAP_SANDBOX_SMTP_PASSWORD']
)Environment variables:
MAILTRAP_API_TOKEN— Mailtrap API token, production SMTP auth (Cloud Run production secrets)MAILTRAP_SANDBOX_SMTP_USERNAME/MAILTRAP_SANDBOX_SMTP_PASSWORD— staging-only Mailtrap sandbox inbox SMTP credentials (OBJ-2822)- Provisioned 2026-08-25 — see OBJ-2893 (the staging SMTP-AUTH incident that surfaced the gap and confirmed the fix) for the history
- If either value is ever unset, staging SMTP auth fails the same way a missing
MAILTRAP_API_TOKENwould (seedocs/operations/deployment.md) — fails closed (mail queues/errors) rather than falling back to the live relay
Monitoring:
- Mailtrap dashboard shows real-time send volume, bounces, complaints, and SMTP errors (retention window observed as 3 days on the current plan as of 2026-08-21 — plan-tier-dependent, confirm the live value in the Mailtrap dashboard; see below) — the live dashboard now reflects production traffic only
- Alert thresholds documented in
docs/features/weekly-digest-email.md#production-deployment - Sentry does not currently capture SMTP failures — see the Observability section below
Observability & Telemetry
PostHog Events
Four events surface digest activity. Each carries enough context for PostHog funnels, retention cohorts, and churn analysis.
| Event | Fire site | Properties | When fired |
|---|---|---|---|
weekly_digest_suppressed | app/jobs/social/weekly_digest_enqueue_job.rb:25, 36 | user_public_id, reason (enum: cohort_excluded, low_activity) | User is opted in & matches day/hour, but excluded by cohort or lacks activity |
weekly_digest_sent | app/jobs/social/weekly_digest_send_job.rb:30 | user_public_id, sent_for_week_starting | Email successfully sent |
weekly_digest_insight_fallback | app/interactions/weekly_digest/assemble_digest_data.rb:55 | user_public_id, reason (e.g., 'ai_nil_response', or exception class name like 'Net::OpenTimeout') | AI Coach returned no insight or raised an error; fallback pool used |
weekly_digest_clicked | app/controllers/email/click_redirects_controller.rb:31 | user_public_id, link_kind (e.g., 'cta') | User clicked a CTA link in the email |
Sentry Logging
Sentry capture is live, in both staging and production, as of OBJ-2821's blank-SENTRY_DSN fix (2026-08-22) — rails_api/config/initializers/sentry.rb only warns and skips config.dsn= when the env var is blank; it no longer silently no-ops the whole initializer. This project (objectuve-api) is confirmed actively ingesting real events: OBJECTUVE-API-2W (8 events) and OBJECTUVE-API-2S (2 events, resolved) are live Sentry issues from the email-canary jobs' identical Sentry.capture_exception/capture_message pattern — see Delivery Canary below. As of 2026-08-29, a Sentry issue search for digest against objectuve-api (90-day window) returns zero results — none of the four weekly-digest code paths below has fired in production or staging yet. That's an absence of failures, not evidence capture is broken.
- Assembly failures (
WeeklyDigestSendJob#perform,rails_api/app/jobs/social/weekly_digest_send_job.rb:17) — captured as exceptions with job context, logged to Rails logger, job returns early (no send) - SMTP failures (same job,
:38-44) — captured, logged, and job re-raises (Sidekiq retries up to 3,sidekiq_options retry: 3) - Enqueue job errors (
WeeklyDigestEnqueueJob#perform,rails_api/app/jobs/social/weekly_digest_enqueue_job.rb:42) — captured, logged, and re-raised - Non-whitelisted click-redirect (
Email::ClickRedirectsController#show,rails_api/app/controllers/email/click_redirects_controller.rb:21) — captured as a:warn-level message
Where email failures are actually observable today:
- SMTP-level errors (connection refused, auth failure, rate limiting, etc.) —
config.action_mailer.raise_delivery_errors = trueis set inproduction.rb, so a failed send raises insideWeeklyDigestSendJob, which lands the exception in both Sentry (see above) and Rails logger output that reaches Cloud Run logs. - Post-acceptance bounces — Mailtrap accepting a message over SMTP ("Delivered mail" in Rails logs) is proof of acceptance, not proof the recipient's server actually took it. Real bounces happen after acceptance and are visible only in the Mailtrap dashboard/API's Email Logs — never in Rails logs or Sentry — for a retention window observed as 3 days on the current plan as of 2026-08-21; confirm the live value in the Mailtrap dashboard, since it's plan-tier-dependent and can change.
- A synthetic delivery canary (OBJ-2820) now closes part of this gap with an automated, daily, positive proof of end-to-end delivery — see Delivery Canary below. It does not cover the window between daily runs, and it does not prove inbox placement (see the spam-foldering note below).
Delivery Canary
A daily synthetic send-and-verify cycle that proves the real transactional mail path — not a synthetic one — actually reaches an inbox, closing the observability gap named above.
Pipeline:
Email::DeliveryCanarySendJob(rails_api/app/jobs/email/delivery_canary_send_job.rb) — Crono, daily at 07:00, runs inline in the Crono container (rails_api/config/cronotab.rb:92). Generates a random token, sendsOpsMailer#delivery_canarytoEMAIL_CANARY_RECIPIENT, and writes a send record (email_canary:send:#{token}) plusemail_canary:last_send:#{environment}toRails.cache(24h TTL). EnqueuesEmail::DeliveryCanaryVerifyJobwith a 10-minute delay so Mailtrap has time to log the send.OpsMailer#delivery_canary(rails_api/app/mailers/ops_mailer.rb) — the first mailer in the repo addressed to us, not a user. Same layout, HTML+text multipart, andfromas user-facing mail, so a green canary proves the actual path users' mail travels. Sets theX-Objectuve-Canary-Tokenheader and embeds the token in the subject line — only the subject is read today (see the subject-scan row below); the header is unused since OBJ-2951 removed the RFC-Message-ID filter route it fed.Email::DeliveryCanaryVerifyJob(rails_api/app/jobs/email/delivery_canary_verify_job.rb) — Sidekiq, worker container (not Crono, unlike the send job). Resolves the canary's Mailtrap message viaMailtrap::EmailLogs.find_message_id, then reads the delivery verdict viaMailtrap::EmailLogs.status_for. On a confirmed:deliveredstatus it writesemail_canary:last_success:#{environment}(48h TTL) — this is the key/health'semail_canarycheck reads for staleness. On any other terminal outcome it alerts; see docs/operations/alerting.md § Application-Level Alerts for the two alert kinds, the/healthcheck's three states, and theemail_canary:live_firerake task that re-proves this path. On staging, both calls resolve differently — see below.
EMAIL_CANARY_RECIPIENT — the inbox the canary sends to. Currently canary@objectuve.com, set as a plain (non-secret) value: in all six deploy/*.yaml manifests (crono.staging.yaml, crono.production.yaml, worker.staging.yaml, worker.production.yaml, service.staging.yaml, service.production.yaml) — the address itself carries no sensitive information. If unset, DeliveryCanarySendJob logs and returns without sending; it does not raise.
Verify path: sandbox vs. sending API
Mailtrap::EmailLogs (rails_api/app/services/mailtrap/email_logs.rb) reads two structurally different Mailtrap products depending on environment, via an explicit sandbox_mode? split (Rails.env.staging?, OBJ-2939):
Staging (sandbox_mode? true) | Production (sandbox_mode? false) | |
|---|---|---|
| Mailtrap product read | Email Testing sandbox inbox | Email Sending (sending-logs) API |
| List endpoint | GET /api/accounts/{MAILTRAP_CANARY_ACCOUNT_ID}/inboxes/{MAILTRAP_CANARY_INBOX_ID}/messages | GET /api/email_logs |
find_message_id resolution | Subject-token scan only (find_by_subject_scan) | Subject-token scan only (find_by_subject_scan) — the prior server-side message_id-filter route (find_by_rfc_message_id) was removed under OBJ-2951: Mailtrap's docs confirm message_id/rfc_message_id were never filterable fields, so that request ran fully unfiltered on every call anyway |
| List-entry id field | id | message_id — a separate field name from the sandbox product, not just a separate response shape (OBJ-2951; see the Mailtrap sandbox vs. sending-API gotcha) |
status_for verdict | Short-circuits to :delivered once find_message_id resolves a match — sandbox messages carry no status field, so STATUS_MAP has nothing to map; inbox presence is the delivery verdict | Real GET /api/email_logs/{message_id} call, mapped through STATUS_MAP (delivered / not_delivered → :bounced / enqueued → :pending / opted_out → :bounced) |
This exists because staging sends canary mail through Mailtrap's Email Testing sandbox (see the delivery matrix above), which never appears in the sending-logs endpoints production reads — see the Mailtrap sandbox vs. sending-API gotcha for what breaks if a mailer-routing change invalidates this assumption.
Two new env vars, staging-only (deploy/worker.staging.yaml — the manifest running Email::DeliveryCanaryVerifyJob):
MAILTRAP_CANARY_ACCOUNT_ID— Mailtrap account id owning the sandbox inbox (2654109)MAILTRAP_CANARY_INBOX_ID— the sandbox inbox id itself (4475141)
Both are plain (non-secret) identifiers, read with .presence (configured? / sandbox_configured? degrade to :not_configured/cannot-verify rather than raising if unset), and must never be wired into a production manifest — per Josh's OBJ-2893 constraint, doing so would repoint production's canary at staging's sandbox inbox instead of production's own delivery reality. parsed_list accepts two different response envelopes, one per product (both live-probed 2026-08-29, OBJ-2958): the sending-logs endpoint returns a Hash ({"messages": [...], "total_count": N, "next_page_cursor": ...}); the sandbox inbox returns a bare JSON array of message hashes with no envelope. OBJ-2939 hardened the parser to Hash-only off the sending-logs shape, which made every staging lookup return nil at that type guard — the root cause of OBJECTUVE-API-2W, and the reason OBJ-2956's search-param fix (correct on its own terms: search does filter the sandbox inbox server-side) did not turn staging green. The count in the zero-match warning is labelled per envelope for the same reason — total_count=N is a server-reported grand total that can exceed the returned page; returned_count=N is only that page's size, because the bare array carries no total at all.
The spam-foldering blind spot. Mailtrap's Email Logs API reports delivered once the receiving mail server accepts the message — that is not the same as the message reaching the inbox. A message a recipient's provider routes to spam still reports delivered. A green canary is proof of acceptance, not proof of inbox placement, and a future sender-reputation regression that gets Objectuve's mail spam-foldered at a major provider would not turn this canary red. That is the honest limit of what this milestone delivers — do not read ok as "email definitely lands in the inbox."
A hard bounce silently suppresses every later send — a named risk class. Mailtrap maintains a suppression list per recipient: one genuine hard bounce (a 550-class rejection, e.g. a non-existent mailbox) puts that address on the list, and every later send to it is silently short-circuited before any SMTP attempt is even made — no distinct error, no log line marking it "suppressed" rather than "sent and bounced again." Root-caused 2026-08-29 (OBJ-2967): canary@objectuve.com was never a real mailbox, so its first send hard-bounced, and the following two days' sends both silently no-op'd against the suppression list — three not_delivered rows in the Mailtrap sending log that looked like three independent failures but were one real bounce plus two local suppressions. The fix needs both halves: provisioning the recipient as a real, delivering mailbox (here, a Google Workspace alias on josh@objectuve.com) and clearing the suppression-list entry in the Mailtrap UI — either alone leaves the address silently dead. This risk class applies to any alerting/monitoring recipient, not just the canary.
Sentry alert fingerprinting (OBJ-2971). DeliveryCanaryVerifyJob#send_alert and DeliveryCanarySendJob#alert_send_failure pass an explicit fingerprint: ['email-canary', kind, environment] (plus level: :error/:warning) to Sentry.capture_message — previously Sentry's default message-based grouping folded every alert kind and both environments into one issue, so a production delivery failure and a staging cannot-verify were indistinguishable. See docs/operations/alerting.md § Email Delivery Canary for the full kind/level/fingerprint mapping and the watchdog autopilot that backstops this canary's own liveness.
Rails Logging
All jobs log to Rails logger via Rails.logger.error() and Rails.logger.info() calls. Logs are structured via Lograge and auto-parsed by GCP Cloud Logging.
Example log entries:
[Social::WeeklyDigestEnqueueJob] Enqueued WeeklyDigestSendJob for user_id=abc123
[Social::WeeklyDigestSendJob] sent user_id=abc123
[Social::WeeklyDigestSendJob] Assembly failed user_id=abc123: AI request timeoutAsymmetry: 6-Day Guard vs. 7-Day Analytics
Implementation note (Phase 87 carry-forward): The last_digest_sent_at idempotency guard is 6 days, not 7. This is intentional.
- Why 6 days: Allows for timezone and scheduling edge cases without violating the "one per week" contract. If a user's timezone crosses midnight between enqueue and send, or if a send is delayed by a few hours, the 6-day window provides buffer room.
- Why analytics use 7 days: The
sent_for_week_startingproperty inweekly_digest_sentevent uses7.days.ago.to_date.iso8601to label which "week" the digest covers. This aligns with calendar week semantics (Monday–Sunday or Sunday–Saturday).
Both values are correct for their purpose; the 1-day difference is not a bug.
Queue Topology
:defaultqueue — Used bySocial::WeeklyDigestEnqueueJob(low-priority; okay to compete with other background work):mailersqueue — Used bySocial::WeeklyDigestSendJob(dedicated for SMTP work; higher priority, isolated from default jobs)
In production, the :mailers queue should have at least 5–10 Sidekiq workers to handle email throughput. Monitor queue depth and latency in Sentry or your Sidekiq dashboard.
Rollback Procedure
Emergency Rollback
Set DIGEST_ROLLOUT_DENOMINATOR=0 in production secrets. Takes effect on the next Crono hourly tick (~1 min). No code deploy required; no data loss.
Document this in runbooks for on-call operators.
Revert Prompt Tuning
If Ai::Prompts::WeeklyDigest::VERSION=2 exhibits unexpected drift:
- Revert the commit that bumped VERSION and updated the system prompt
- Redeploy
- VERSION field in
weekly_digest_insight_fallbackPostHog events identifies which prompt fired
No migration or data backfill needed.
Revert Content Drop
If Penny's content (subject line, body copy, fallback pool) needs to be rolled back:
- Revert the commit that replaced
WEEKLY_DIGEST_COPYand mailer view - Redeploy
- Ensure no
TODO(content)markers are reintroduced
Testing
Files with test coverage:
spec/jobs/social/weekly_digest_enqueue_job_spec.rb— cohort gate, allowlist bypass, PostHog events, timezone logic (the empty-allowlist case is split into denominator-zero-still-suppressed and denominator-nonzero-now-enqueues per OBJ-2982, replacing a single case that had asserted the Phase-87/88 contradiction as correct behavior)spec/jobs/social/weekly_digest_send_job_spec.rb— idempotency guard, assembly success/failure, mark_sent, PostHog eventspec/interactions/weekly_digest/assemble_digest_data_spec.rb— AI success/fallback, allies assemblyspec/mailers/user_mailer_spec.rb— TTL, token generation, template renderingspec/requests/email/click_redirects_spec.rb— token decoding, whitelist validation, PostHog eventspec/requests/unsubscribes_spec.rb— all 4 states, token expiry, resubscription
Coverage target: ≥80% line / ≥75% branch on all new/modified files
Smoke test: tests/smoke/specs/14-weekly-digest-unsubscribe.cy.ts (runs post-deploy on staging)
Related Artifacts
- Feature guide: docs/features/weekly-digest-email.md
- Milestone narrative: docs/milestones/v1.22-weekly-digest-email.md
- Phase 89 task package:
.planning/milestones/v1.22-weekly-digest-email-MILESTONE-AUDIT.md(task 89.5) - Mailer helper:
rails_api/app/mailers/user_mailer.rb(private methods:unsubscribe_url_forat line 41,click_redirect_url_forat line 46) - Crono schedule:
rails_api/config/cronotab.rb(Social::WeeklyDigestEnqueueJobline;Email::DeliveryCanarySendJobline 92) - Delivery canary:
rails_api/app/jobs/email/delivery_canary_{send,verify}_job.rb,rails_api/app/mailers/ops_mailer.rb,rails_api/app/services/mailtrap/email_logs.rb— alerting behavior documented in docs/operations/alerting.md § Application-Level Alerts
Last updated: 2026-08-30 (OBJ-2958: corrected the falsified "both Mailtrap endpoints return a Hash, never a bare array" claim — the sandbox inbox returns a bare array)