Frequency capping + quiet hours
Two policy gates every send (campaign / journey / manual) goes through before hitting a provider.
Frequency capping
Per-tenant config:
{
"messaging": {
"frequency_caps": {
"email": { "per_day": 1, "per_week": 5 },
"sms": { "per_day": 1, "per_week": 3 },
"push": { "per_day": 3, "per_week": 14 }
}
}
}
Cap query counts existing message_sends rows per (player, channel, rolling_window) — only counts rows in (queued, sending, sent, delivered, opened, clicked, bounced) status. Suppressed rows (SKIPPED_*) don't count toward the cap (otherwise cumulative misses would lock you out).
When a campaign batch fan-outs, the cap check runs as a SQL filter directly in the segment query — capped players never enter the batch + don't get a message_sends row at all.
For journey send_message + manual sends (single-player), checkMessagingPolicies() returns the first breached cap.
Quiet hours
Per-tenant config + per-player players.timezone:
{
"messaging": {
"quiet_hours": {
"start": "22:00",
"end": "09:00",
"default_timezone": "Europe/Prague"
}
}
}
Effective timezone resolution: players.timezone (if set) → quiet_hours.default_timezone → UTC. Wrap-around supported (start=22:00 end=09:00 = quiet from 22:00 to 09:00 next day).
Implementation uses Intl.DateTimeFormat with timeZone option for DST-aware local-clock conversion.
When a campaign sends to a player in their quiet window, UCRM doesn't drop the message — it inserts the row with status=failed + error_code=SKIPPED_QUIET_HOURS so the campaign stats panel shows the suppression. Operator can re-target on next campaign run.
Test sends bypass
checkMessagingPolicies({ bypass: true }) skips both checks. The admin "Send test" button uses bypass so operators can test their template without fighting their own policies.
Audit trail
Every suppression writes the reason in message_sends.error_message:
"player in quiet-hours window (Europe/Prague)"
"player has no email for channel email"
"frequency cap email per_day=1 reached"
The recipients list (GET /v1/campaigns/:id/recipients?status=failed) shows them so operators can debug.