Skip to content

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:

  1. Mailer layerUserMailer (Rails ActionMailer) handles all email generation and SMTP delivery via Mailtrap
  2. 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):

ruby
Crono.perform(Social::WeeklyDigestEnqueueJob).every 1.hour

Flow:

  1. Iterate all users (with includes(:details) to avoid N+1)
  2. Skip if not opted in
  3. Skip if delivery day or hour don't match user's timezone
  4. Check cohort denominator: if outside cohort, log weekly_digest_suppressed (reason: cohort_excluded)
  5. Check sufficient activity: if below threshold, log weekly_digest_suppressed (reason: low_activity)
  6. Enqueue Social::WeeklyDigestSendJob for matching users

Per-user timezone logic:

ruby
# 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 == 9

Cohort gate (DIGEST_ROLLOUT_DENOMINATOR ENV):

ENV valueBehavior
0Emergency off — zero digests sent
1100% rollout — all eligible users sent
1010% 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):

ruby
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_excluded or low_activity)
  • Fired when: User is opted in and matches delivery day/hour, but is excluded by cohort or lacks activity

Error handling:

  • Catches StandardError at 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):

ruby
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
end

The 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):

ruby
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_data hash

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):

ruby
UserMailer.weekly_digest(user, result.details[:digest_data]).deliver_now

Renders 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):

ruby
def mark_sent!(user)
  return unless user.details
  
  user.details.update!(last_digest_sent_at: Time.current.iso8601)
end

Records 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):

ruby
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

ruby
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}")
end

Key fields in digest_data:

  • insight_text — 1–2 sentence personalized insight (AI-generated or fallback)
  • streak_count — number of active habits on streak
  • weekly_xp — total XP earned in the past week
  • week_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:

ruby
@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:

ruby
@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):

ruby
result.details[:digest_data] = {
  insight_text: String,
  streak_count: Integer,
  weekly_xp: Integer,
  week_label: String,
  cta_url: String,
  allies: Array<AllyObject>
}

Insight generation:

  1. 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)
  2. 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):

    ruby
    PosthogNotifier.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:

  1. Email contains a link like: /email/click?token=eyJfX3RhZ…&url=https%3A%2F%2Fapp.objectuve.com%2Fdash
  2. User clicks in email client
  3. Controller decodes token (30-day TTL, contains user_public_id and link_kind)
  4. Controller validates url against whitelist (HTTPS, allowed hosts only)
  5. Controller fires PostHog event: weekly_digest_clicked with link_kind
  6. Controller redirects to url

Allowed hosts (line 7):

ruby
ALLOWED_HOSTS = %w[app.objectuve.com objectuve.com].freeze

Whitelist validation (lines 46–51):

ruby
def allowed_host?(url)
  uri = URI.parse(url)
  ALLOWED_HOSTS.include?(uri.host) && uri.scheme == 'https'
rescue URI::InvalidURIError
  false
end

Requires HTTPS and an allowed host. Non-whitelisted URLs return 422 and log a Sentry warning.

PostHog telemetry (line 29–33):

ruby
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:

FieldTypeResolverNotes
hasDigestEnabledBooleanobject.details&.weekly_digest_enabled != falsenil = true (opt-in by default)
lastDigestSentAtString (ISO 8601)object.details&.last_digest_sent_atnil if never sent

GraphQL query example:

graphql
query {
  me {
    id
    hasDigestEnabled
    lastDigestSentAt
  }
}

PostHog Sync

After every SYNC_USER mutation, the frontend calls syncDigestPropertiesToPosthog():

File: ionic_frontend/src/helpers/userToPosthog.ts

typescript
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=yes parameter (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

EnvironmentConfig sourceSMTP hostWhere mail lands
Productionconfig/environments/production.rb:100-109live.smtp.mailtrap.ioReal recipient mailboxes
Stagingconfig/environments/staging.rbrequire_relative 'production' inherits the production block above, then overrides action_mailer.smtp_settings (OBJ-2822)sandbox.smtp.mailtrap.ioMailtrap sandbox inbox (captured, not delivered)
Developmentconfig/environments/development.rb:42delivery_method = :letter_openern/aOpens each send as a browser preview; never sent over the network
Testconfig/environments/test.rb:40delivery_method = :testn/aHeld in-memory in ActionMailer::Base.deliveries; never sent

Why staging needed an explicit override:

  • staging.rb begins with require_relative 'production', which runs production's entire Rails.application.configure block — including the live SMTP settings — before staging's own block executes.
  • Before OBJ-2822, nothing overrode action_mailer.smtp_settings afterward, so every staging send — including Playwright smoke-test magic-code emails to non-existent +clerk_test_*@objectuve.com addresses — 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 OTP 424242, and every other spec authenticates via storageState or the fallback token. Routing staging mail to sandbox has no smoke-suite impact.

Production configuration (rails_api/config/environments/production.rb:100-109):

ruby
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):

ruby
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_TOKEN would (see docs/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.

EventFire sitePropertiesWhen fired
weekly_digest_suppressedapp/jobs/social/weekly_digest_enqueue_job.rb:25, 36user_public_id, reason (enum: cohort_excluded, low_activity)User is opted in & matches day/hour, but excluded by cohort or lacks activity
weekly_digest_sentapp/jobs/social/weekly_digest_send_job.rb:30user_public_id, sent_for_week_startingEmail successfully sent
weekly_digest_insight_fallbackapp/interactions/weekly_digest/assemble_digest_data.rb:55user_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_clickedapp/controllers/email/click_redirects_controller.rb:31user_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 = true is set in production.rb, so a failed send raises inside WeeklyDigestSendJob, 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:

  1. 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, sends OpsMailer#delivery_canary to EMAIL_CANARY_RECIPIENT, and writes a send record (email_canary:send:#{token}) plus email_canary:last_send:#{environment} to Rails.cache (24h TTL). Enqueues Email::DeliveryCanaryVerifyJob with a 10-minute delay so Mailtrap has time to log the send.
  2. 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, and from as user-facing mail, so a green canary proves the actual path users' mail travels. Sets the X-Objectuve-Canary-Token header 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.
  3. 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 via Mailtrap::EmailLogs.find_message_id, then reads the delivery verdict via Mailtrap::EmailLogs.status_for. On a confirmed :delivered status it writes email_canary:last_success:#{environment} (48h TTL) — this is the key /health's email_canary check reads for staleness. On any other terminal outcome it alerts; see docs/operations/alerting.md § Application-Level Alerts for the two alert kinds, the /health check's three states, and the email_canary:live_fire rake 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 readEmail Testing sandbox inboxEmail Sending (sending-logs) API
List endpointGET /api/accounts/{MAILTRAP_CANARY_ACCOUNT_ID}/inboxes/{MAILTRAP_CANARY_INBOX_ID}/messagesGET /api/email_logs
find_message_id resolutionSubject-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 fieldidmessage_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 verdictShort-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 verdictReal 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 timeout

Asymmetry: 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_starting property in weekly_digest_sent event uses 7.days.ago.to_date.iso8601 to 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

  • :default queue — Used by Social::WeeklyDigestEnqueueJob (low-priority; okay to compete with other background work)
  • :mailers queue — Used by Social::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:

  1. Revert the commit that bumped VERSION and updated the system prompt
  2. Redeploy
  3. VERSION field in weekly_digest_insight_fallback PostHog 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:

  1. Revert the commit that replaced WEEKLY_DIGEST_COPY and mailer view
  2. Redeploy
  3. 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 event
  • spec/interactions/weekly_digest/assemble_digest_data_spec.rb — AI success/fallback, allies assembly
  • spec/mailers/user_mailer_spec.rb — TTL, token generation, template rendering
  • spec/requests/email/click_redirects_spec.rb — token decoding, whitelist validation, PostHog event
  • spec/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)


  • 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_for at line 41, click_redirect_url_for at line 46)
  • Crono schedule: rails_api/config/cronotab.rb (Social::WeeklyDigestEnqueueJob line; Email::DeliveryCanarySendJob line 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)

Loading…