intermediate
digest
email
cadence

Digest Emails & Cadence

How the hourly compliance-digest cron walks city_contacts, picks who is due, builds the email payload, and updates last_digest_sent_at.

Levy Fleets TeamMay 18, 202610 min read

Digest Emails & Cadence

City contacts choose how often they want a compliance digest — daily, weekly, or monthly. An hourly cron walks every city_contacts row, checks whether their cadence window has elapsed, and (if so) builds and sends the digest.

Cadence options

digest_frequencyEmail landsBest for
dailyEach morning after the previous day closes (jurisdiction-local time)Operations-focused city contacts
weeklyMonday morning, covering the previous Mon-SunLead compliance officers
monthlyFirst of the month, covering the previous calendar monthPermit reviewers, auditors

Cadence is per-contact, not per-jurisdiction. Common pattern: lead compliance officer gets weekly, an ops analyst gets daily, an auditor gets monthly.

The hourly cron

/api/cron/compliance-digest runs every hour (0 * * * * in vercel.json). It:

  1. Walks city_contacts rows where portal_access = true.
  2. For each row, checks the cadence window:
    • Daily: last_digest_sent_at < (today 00:00 jurisdiction-local time)
    • Weekly: last_digest_sent_at < (this Monday 00:00 jurisdiction-local time) and it is now Monday
    • Monthly: last_digest_sent_at < (1st of this month 00:00) and it is now the 1st
  3. For each contact that is due, calls buildComplianceReport({jurisdictionId, period, date}) to compute the payload.
  4. Calls buildDigestEmail(report, contact.locale) to format subject + HTML + text.
  5. Hands the email to the pluggable complianceDigestSender — wired to the existing transactional email infra in production.
  6. Updates last_digest_sent_at on the contact row.
  7. Persists the payload to city_compliance_reports with the audit row.

If any step throws, the contact is logged but the cron moves on — one failed contact does not block the rest of the batch.

The pluggable sender

src/lib/compliance/digest-email.ts exports setComplianceDigestSender(fn). The default is a no-op sender that logs to console; production sets it to the operator-app email infra at startup. This indirection lets tests run the digest pipeline without sending mail.

Email shape

SectionContents
SubjectLevy Compliance Digest — Boulder, CO — Daily Report for 2026-05-17
HeaderOperator name, jurisdiction name, period
Compliance scoreboardPass/fail per condition with current value vs threshold
Today's snapshotTrips, vehicles deployed, complaints opened/closed
Enforcement summarySpeed-limit commands sent, locks issued, hours within slow-zones
Open issuesFailing conditions, open complaints over SLA
FooterLink to the city portal, contact info for support

For monthly digests we attach a one-page PDF. Daily and weekly are HTML + text only.

HTML escaping

buildDigestEmail HTML-escapes every user-controlled string (jurisdiction name, complaint titles, vehicle numbers). Tested in digest-email.test.ts to be safe against injected <script> tags in policy names — even if a city publishes a policy with weird characters in the name, the digest renders correctly without breaking the layout.

Localization

The email is rendered in city_contacts.locale (defaults to en). Launch supports en and es. Additional locales fall back to English with a note in the footer ("This report is available in English only").

The compliance scoreboard's pass/fail labels and the condition names use the contact's locale; the operator name and city name come through unchanged.

last_digest_sent_at and idempotency

If the cron retries (e.g., a deploy mid-cycle, a Vercel timeout), last_digest_sent_at is the guard against duplicate sends. The cron updates it inside the same transaction as the city_compliance_reports insert, so either both happen or neither does.

If you ever need to force a re-send (a contact says they didn't receive Monday's digest), set last_digest_sent_at to an earlier value via the dashboard's "Re-send digest" action. The next cron run picks the contact up as due and re-delivers.

Disabling digests temporarily

To pause digests for a contact without removing them:

  • Portal-only access: set portal_access = true and digest_frequency = null. The contact can still log in to the portal but receives no email.
  • Both paused: set portal_access = false. Magic-link auth fails; no digests sent.

Persisted reports

Every digest run writes a row to city_compliance_reports:

ColumnWhat it holds
idUUID
jurisdiction_idFK
report_typedaily / weekly / monthly
period_start / period_endTime window covered
computed_atWhen the report was built
payloadFull JSON snapshot
pdf_urlFor monthly reports
delivered_to_contact_idThe city_contacts row that received it

This table is the audit trail. If a city auditor asks "did you send a digest on April 14?", the row in city_compliance_reports is the answer. Reports are retained indefinitely.

Common questions

"My contact says they didn't get the email." Check spam, then check city_compliance_reports for the expected row. If the row exists and delivered_to_contact_id matches, the email was dispatched — investigate at the email provider. If the row doesn't exist, the cron either skipped the contact (cadence window not yet elapsed) or hit an error (Sentry).

"Can the same contact get both daily and monthly?" No — digest_frequency is a single value. Create two contact rows for the same email if you want both cadences.

"Can I CC someone on the digest?" Not via the contact row. Create a separate city_contacts row for the CC recipient. Each gets their own magic-link access and their own last_digest_sent_at.

What's next