Scheduling email digests with cron

Daily or weekly digest sends, timezone-aware, with delivery failures treated as separate from generation.

The problem

Digest emails have two independent failure points that get treated as one: generating the content (querying what happened, rendering the email) and delivering it (SMTP or an email API call). A script that logs "digest sent" the moment it hands off to the mail server, without checking the send actually succeeded, will report success on days the email never left the building.

For a digest meant to land at a consistent local time for a distributed audience, cron's system-clock timezone becomes a real design decision. A server on UTC sending "0 8 * * *" delivers at 8am UTC, which is not 8am for any particular user, and it drifts further out across daylight saving changes if you assume otherwise.

Recommended schedule

0 8 * * *

Daily at 08:00 server time. If your audience is in one timezone, work out the schedule in server time rather than relying on the system default. A TZ= prefix on the command only changes the timezone the script sees, not when cron fires it. /tools/cron-expression-parser can confirm the resulting UTC offset.

Example crontab entry

# norc: email-digest Daily activity digest
0 8 * * * /usr/bin/python3 /opt/digest/send.py >> /var/log/digest.log 2>&1
result = send_digest_emails()
if result.failed_count > 0:
    raise SystemExit(f"{result.failed_count} digest sends failed")  # nonzero exit, not just a log line

Failure modes to watch for

  • Send failures logged but not surfaced as a nonzero exit code, so the job "succeeds" from cron's perspective on a day half the digests bounced.
  • Timezone assumed rather than set explicitly, delivering at a fixed UTC time that reads as a different local hour after a DST transition.
  • No idempotency guard: a cron job that's manually re-run to recover from a partial failure re-sends digests to recipients who already got theirs.
  • Rate limits from the email provider hit partway through a large recipient batch, silently truncating the send with no per-recipient retry.

How norc helps

norc's run history separates a run that never happened, a run that failed and a run that exited cleanly, so a script that exits nonzero when a send fails shows up as a failed run instead of a trickle of support tickets. A script that catches the error and exits 0 still looks like a success, so make it fail loudly. You can fix the schedule from your desktop, terminal or phone.

Request an invite

Leave your email and we'll send an invite when a spot opens.