Scheduled sends with timezone

/api/v1/emails and /api/v1/sms accept three mutually exclusive ways to schedule a send:

Shape Example
No scheduling fields (sends immediately)
ISO UTC "scheduled_at": "2026-05-01T13:00:00.000Z"
Local wall-clock + IANA zone "scheduled_local": "2026-05-01 09:00", "scheduled_at_tz": "America/New_York"

When scheduled_local is supplied, scheduled_at_tz is required. resolveScheduledAt (src/lib/api/schedule.ts) converts the wall-clock to the correct UTC instant by measuring the offset the target timezone has at that instant using Intl.DateTimeFormat.

Cross-DST example: 2026-03-08 02:30 America/New_York falls inside the spring-forward gap. The helper resolves it to the first existing instant (handled consistently by Intl), so the send doesn't silently drop.

Behavior

  • Scheduled rows land in messages with status="scheduled" and scheduled_at set to the UTC instant.
  • The send-scheduled cron runs every minute and dispatches rows whose scheduled_at <= now().
  • You can cancel with DELETE /api/v1/{emails,sms}/:id while the row is still scheduled.

Precedence

  • scheduled_at wins if both it and scheduled_local are supplied.
  • Missing scheduled_at_tz with scheduled_local returns a 422 validation error.
  • scheduled_local must be a full date and time, YYYY-MM-DD HH:MM or YYYY-MM-DD HH:MM:SS (a T separator also works). A bare time such as "09:00", or a date or time that does not exist (2027-02-30, month 13, 24:00, minute 75), returns 422 VALIDATION_ERROR with the message invalid scheduled_local. It is never rolled forward into the next day or month.