Send Report Emails at Each Recipient's Local Time
A report that lands at 08:00 London time arrives at 03:00 in New York and 16:00 in Singapore. For a team spread across time zones, a single send time is wrong for most of them: the report is buried under a night's email for some and arrives after the working day for others. The fix is not one cron entry per time zone — that multiplies as the list grows and breaks twice a year when the clocks change — but a single job that runs every hour and, each time, sends to exactly the recipients for whom it is now the start of their working day. This page builds that job: a distribution list with a time zone for each person, the "is it 08:00 for them now?" check with Python's zoneinfo, local weekends and holidays, a send log so a rerun never duplicates, and a test that walks the clock through the autumn change to prove nobody gets the report twice or not at all. It belongs to Emailing Excel Reports with smtplib.
Prerequisites
pip install pandas openpyxl holidays
zoneinfo is in the standard library from Python 3.9; on Windows it also needs pip install tzdata, because Windows does not ship the time zone database. The distribution list is a small Excel sheet with each recipient's name, email, IANA time zone (Europe/London, America/New_York), country for public holidays and preferred local send hour.
Step 1 — Validate the time zones in the list
import pandas as pd
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
people = pd.read_excel("distribution.xlsx")
bad = []
for tz in people["Time zone"].unique():
try:
ZoneInfo(tz)
except (ZoneInfoNotFoundError, ValueError):
bad.append(tz)
assert not bad, f"unknown time zones: {bad}"
print(people.to_string(index=False))
The time zone column must hold IANA names, not abbreviations or offsets. "EST" or "+01:00" look tidy but are wrong for half the year, because they do not know about daylight saving; America/New_York carries the full history of the city's clock changes and switches between UTC−5 and UTC−4 on the right dates by itself. Checking every name before the job does anything means a typo such as Europe/Londn stops the run with a clear message instead of silently skipping one person forever.
Step 2 — Decide who is due at a given moment
from datetime import datetime, timezone
import holidays
WEEKENDS = {"SA": {4, 5}} # Friday and Saturday; everywhere else Saturday and Sunday
_calendars = {}
def is_holiday(country, day):
if country not in _calendars:
_calendars[country] = holidays.country_holidays(country, years=range(2026, 2028))
return day in _calendars[country]
def due(person, now_utc):
"""True when it is the person's send hour on one of their local working days."""
local = now_utc.astimezone(ZoneInfo(person["Time zone"]))
if local.hour != person["Send hour"]:
return False
weekend = WEEKENDS.get(person["Country"], {5, 6}) # Monday is 0
return local.weekday() not in weekend and not is_holiday(person["Country"], local.date())
now = datetime(2026, 10, 6, 7, 0, tzinfo=timezone.utc)
print(f"{now:%a %d %b %H:%M} UTC ->", [p["Name"] for _, p in people.iterrows() if due(p, now)])
The decision is made entirely in the recipient's local time. The job takes the current moment in UTC, converts it to each person's zone, and asks two questions: is it their send hour, and is today a working day for them? Because the conversion uses the zone's rules for that exact instant, daylight saving is handled without any special code. Weekends are local too — the working week differs by country — and public holidays come from the holidays package by country, so a report due on a bank holiday in one country still goes out on time everywhere else.
Step 3 — Run hourly and log every send
import json
from pathlib import Path
LOG = Path("sent_log.jsonl")
def already_sent(email, local_date):
if not LOG.exists():
return False
return any(json.loads(line)["email"] == email and json.loads(line)["local_date"] == local_date
for line in LOG.read_text().splitlines())
def run(now_utc, send):
sent = []
for _, p in people.iterrows():
local_date = now_utc.astimezone(ZoneInfo(p["Time zone"])).date().isoformat()
if due(p, now_utc) and not already_sent(p["Email"], local_date):
send(p)
with LOG.open("a") as f:
f.write(json.dumps({"email": p["Email"], "local_date": local_date,
"sent_utc": now_utc.isoformat()}) + "\n")
sent.append(p["Name"])
return sent
LOG.unlink(missing_ok=True)
fake_send = lambda p: None
now = datetime(2026, 10, 6, 7, 0, tzinfo=timezone.utc)
print(run(now, fake_send), run(now, fake_send))
The job runs at the top of every hour — 0 * * * * in cron, or an hourly trigger in Task Scheduler — and sends to whoever is due. The log records each send against the recipient's local date, which is the right key: "has Dana had today's report?" means today in New York. Running the same hour twice, as the second call shows, sends nothing the second time, so a retried or overlapping run is harmless. In the real job send builds and sends the message as in Send Each Recipient Their Own Excel Report with Python; a send that raises is not logged, so the next hourly run tries again — but only while the local hour still matches, which is the subject of the catch-up rule below.
Step 4 — Prove it across a clock change
from datetime import timedelta
LOG.unlink(missing_ok=True)
counts = {}
t0 = datetime(2026, 10, 23, 0, 0, tzinfo=timezone.utc)
for h in range(24 * 14): # two weeks, hour by hour
for name in run(t0 + timedelta(hours=h), fake_send):
counts[name] = counts.get(name, 0) + 1
print(counts)
for line in LOG.read_text().splitlines():
rec = json.loads(line)
if rec["email"] in ("priya@example.com", "dana@example.com") and rec["local_date"] in ("2026-10-23", "2026-10-26", "2026-10-30", "2026-11-02"):
print(rec["email"].split("@")[0], rec["local_date"], "sent at", rec["sent_utc"][11:16], "UTC")
Time zone code is easy to get almost right, so it deserves a test that does not depend on the real clock. This one replays two weeks hour by hour, from 23 October to 5 November 2026 — a fortnight that contains the end of summer time in Europe (25 October) and in the United States (1 November), while Sydney, which moved its clocks forward on 4 October, is already on summer time throughout. Each person should receive exactly one report per local working day, and the log shows the UTC send time shifting by an hour when their clocks change while the local time stays at 08:00: Priya's report goes at 07:00 UTC before 25 October and 08:00 UTC after, Dana's at 12:00 UTC until 1 November and 13:00 UTC after. The counts confirm that nobody was skipped or doubled: ten working days for everyone, including Aisha in Riyadh, whose working week runs from Sunday to Thursday, and Sam in Sydney, whose 08:00 falls at 21:00 UTC on the previous calendar day. That last case is a useful reminder of why everything is decided in local time — in UTC terms Sam's Monday report goes out on Sunday evening.
Missed hours and catching up
The simple rule — send when the local hour equals the send hour — has one weakness: if the job does not run in that hour, because the server was down or the run failed, the recipient gets nothing that day. Two refinements fix it. The first is a window: treat a person as due from their send hour until, say, noon local time, and rely on the log to stop duplicates; a run at 10:00 then catches up anyone missed at 08:00, and a report that arrives at 10:00 is far better than none. The second is an alert: if a recipient's local working day has passed noon with no line in the log, report it, because something is wrong that the window could not cover. Both fit into due() and run() with a few lines, and the replay test is the way to check them.
Common pitfalls
- Abbreviations or offsets in the list. Store IANA names; they follow daylight saving.
- Checking weekends in server time. Convert to the recipient's zone first.
- Logging by UTC date. Key the send log on the recipient's local date.
- One cron line per zone. One hourly job scales and survives clock changes.
- Testing against the real clock. Replay simulated hours across a clock change.
Conclusion
To deliver a report at each recipient's local morning, keep an IANA time zone, country and send hour per person, run one job every hour, convert the current UTC time to each person's zone and send when it is their hour on a local working day, and log sends by local date so reruns never duplicate. Replay a fortnight that contains a clock change to prove every person receives exactly one report per working day.
Frequently asked questions
How do I send an email at a specific local time for each recipient?
Run a job hourly, convert the current UTC time to each recipient's time zone with zoneinfo, and send when the local hour matches their send hour.
Does zoneinfo handle daylight saving time?
Yes; with an IANA name such as Europe/London the conversion uses the correct offset for each date, so the local hour stays the same when the clocks change.
Why do I need tzdata on Windows?
Windows has no IANA time zone database; the tzdata package provides one that zoneinfo uses automatically.
How do I stop a rerun from sending the report twice? Record each send with the recipient and their local date, and skip anyone already in the log for that date.
Related
- Up one level: Emailing Excel Reports with smtplib — sending reports by email.
- Send an Excel Report to Multiple Recipients in Python — one report, many people.
- Queue and Retry Failed Report Emails — retries after failures.
- Flag Public Holidays in Excel Data with Python — the holiday calendars.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toDownload Excel Attachments from an Email Inbox with Python
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toEmail an Excel Report with an HTML Summary Body
- How-toEmbed a Chart Image in the Body of a Report Email