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_frequency | Email lands | Best for |
|---|---|---|
daily | Each morning after the previous day closes (jurisdiction-local time) | Operations-focused city contacts |
weekly | Monday morning, covering the previous Mon-Sun | Lead compliance officers |
monthly | First of the month, covering the previous calendar month | Permit 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:
- Walks
city_contactsrows whereportal_access = true. - 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
- Daily:
- For each contact that is due, calls
buildComplianceReport({jurisdictionId, period, date})to compute the payload. - Calls
buildDigestEmail(report, contact.locale)to format subject + HTML + text. - Hands the email to the pluggable
complianceDigestSender— wired to the existing transactional email infra in production. - Updates
last_digest_sent_aton the contact row. - Persists the payload to
city_compliance_reportswith 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
| Section | Contents |
|---|---|
| Subject | Levy Compliance Digest — Boulder, CO — Daily Report for 2026-05-17 |
| Header | Operator name, jurisdiction name, period |
| Compliance scoreboard | Pass/fail per condition with current value vs threshold |
| Today's snapshot | Trips, vehicles deployed, complaints opened/closed |
| Enforcement summary | Speed-limit commands sent, locks issued, hours within slow-zones |
| Open issues | Failing conditions, open complaints over SLA |
| Footer | Link 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 = trueanddigest_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:
| Column | What it holds |
|---|---|
id | UUID |
jurisdiction_id | FK |
report_type | daily / weekly / monthly |
period_start / period_end | Time window covered |
computed_at | When the report was built |
payload | Full JSON snapshot |
pdf_url | For monthly reports |
delivered_to_contact_id | The 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
- City Portal & Magic-Link Auth — where contacts log in to see the same data live.
- Permit-Condition Reports — what the scoreboard inside the digest is measuring.