[{"data":1,"prerenderedAt":1990},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time":3,"surround:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time":1957,"ancestors:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time":1965,"children:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time":1970,"related:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time":1971},{"id":4,"title":5,"body":6,"dateModified":1923,"datePublished":1923,"description":1924,"extension":1925,"faq":1926,"keyword":1935,"kind":1936,"meta":1937,"navigation":123,"path":1946,"seo":1947,"slug":1950,"stem":1951,"topics":1952,"type":1955,"__hash__":1956},"docs\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time\u002Findex.md","Send Report Emails at Each Recipient's Local Time",{"type":7,"value":8,"toc":1911},"minimark",[9,24,29,60,77,81,264,274,365,369,754,761,765,1131,1152,1278,1282,1578,1581,1772,1776,1787,1791,1826,1830,1833,1837,1846,1855,1868,1874,1878,1907],[10,11,12,13,17,18,23],"p",{},"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 ",[14,15,16],"code",{},"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 ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","Emailing Excel Reports with smtplib",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl holidays\n","bash","",[14,37,38],{"__ignoreMap":35},[39,40,43,47,51,54,57],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"sMTad","pip",[39,48,50],{"class":49},"srMev"," install",[39,52,53],{"class":49}," pandas",[39,55,56],{"class":49}," openpyxl",[39,58,59],{"class":49}," holidays\n",[10,61,62,64,65,68,69,72,73,76],{},[14,63,16],{}," is in the standard library from Python 3.9; on Windows it also needs ",[14,66,67],{},"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 (",[14,70,71],{},"Europe\u002FLondon",", ",[14,74,75],{},"America\u002FNew_York","), country for public holidays and preferred local send hour.",[25,78,80],{"id":79},"step-1-validate-the-time-zones-in-the-list","Step 1 — Validate the time zones in the list",[30,82,86],{"className":83,"code":84,"language":85,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\nfrom zoneinfo import ZoneInfo, ZoneInfoNotFoundError\n\npeople = pd.read_excel(\"distribution.xlsx\")\nbad = []\nfor tz in people[\"Time zone\"].unique():\n    try:\n        ZoneInfo(tz)\n    except (ZoneInfoNotFoundError, ValueError):\n        bad.append(tz)\nassert not bad, f\"unknown time zones: {bad}\"\nprint(people.to_string(index=False))\n","python",[14,87,88,104,118,125,143,154,175,184,190,206,212,243],{"__ignoreMap":35},[39,89,90,94,98,101],{"class":41,"line":42},[39,91,93],{"class":92},"s-kum","import",[39,95,97],{"class":96},"skGVy"," pandas ",[39,99,100],{"class":92},"as",[39,102,103],{"class":96}," pd\n",[39,105,107,110,113,115],{"class":41,"line":106},2,[39,108,109],{"class":92},"from",[39,111,112],{"class":96}," zoneinfo ",[39,114,93],{"class":92},[39,116,117],{"class":96}," ZoneInfo, ZoneInfoNotFoundError\n",[39,119,121],{"class":41,"line":120},3,[39,122,124],{"emptyLinePlaceholder":123},true,"\n",[39,126,128,131,134,137,140],{"class":41,"line":127},4,[39,129,130],{"class":96},"people ",[39,132,133],{"class":92},"=",[39,135,136],{"class":96}," pd.read_excel(",[39,138,139],{"class":49},"\"distribution.xlsx\"",[39,141,142],{"class":96},")\n",[39,144,146,149,151],{"class":41,"line":145},5,[39,147,148],{"class":96},"bad ",[39,150,133],{"class":92},[39,152,153],{"class":96}," []\n",[39,155,157,160,163,166,169,172],{"class":41,"line":156},6,[39,158,159],{"class":92},"for",[39,161,162],{"class":96}," tz ",[39,164,165],{"class":92},"in",[39,167,168],{"class":96}," people[",[39,170,171],{"class":49},"\"Time zone\"",[39,173,174],{"class":96},"].unique():\n",[39,176,178,181],{"class":41,"line":177},7,[39,179,180],{"class":92},"    try",[39,182,183],{"class":96},":\n",[39,185,187],{"class":41,"line":186},8,[39,188,189],{"class":96},"        ZoneInfo(tz)\n",[39,191,193,196,199,203],{"class":41,"line":192},9,[39,194,195],{"class":92},"    except",[39,197,198],{"class":96}," (ZoneInfoNotFoundError, ",[39,200,202],{"class":201},"sP0c6","ValueError",[39,204,205],{"class":96},"):\n",[39,207,209],{"class":41,"line":208},10,[39,210,211],{"class":96},"        bad.append(tz)\n",[39,213,215,218,221,224,227,230,234,237,240],{"class":41,"line":214},11,[39,216,217],{"class":92},"assert",[39,219,220],{"class":92}," not",[39,222,223],{"class":96}," bad, ",[39,225,226],{"class":92},"f",[39,228,229],{"class":49},"\"unknown time zones: ",[39,231,233],{"class":232},"sSjpA","{",[39,235,236],{"class":96},"bad",[39,238,239],{"class":232},"}",[39,241,242],{"class":49},"\"\n",[39,244,246,249,252,256,258,261],{"class":41,"line":245},12,[39,247,248],{"class":201},"print",[39,250,251],{"class":96},"(people.to_string(",[39,253,255],{"class":254},"sa561","index",[39,257,133],{"class":92},[39,259,260],{"class":201},"False",[39,262,263],{"class":96},"))\n",[10,265,266,267,269,270,273],{},"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; ",[14,268,75],{}," 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 ",[14,271,272],{},"Europe\u002FLondn"," stops the run with a clear message instead of silently skipping one person forever.",[275,276,284,285,284,289,284,293,284,301,284,311,284,318,284,324,284,328,284,331,284,335,284,339,284,344,284,349,284,353,284,356,284,359,284,362],"svg",{"viewBox":277,"role":278,"ariaLabelledBy":279,"xmlns":282,"style":283},"4 0 752 199","img",[280,281],"tz-names-t","tz-names-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[286,287,288],"title",{"id":280},"What to store in the time zone column",[290,291,292],"desc",{"id":281},"Store IANA names such as Europe\u002FLondon and America\u002FNew_York, which follow daylight saving automatically. Do not store abbreviations like EST or BST, or fixed offsets like plus one hour, which are correct for only part of the year.",[294,295],"rect",{"x":296,"y":297,"width":298,"height":299,"fill":300},"4","0","752","199","#ffffff",[294,302],{"x":303,"y":304,"width":305,"height":306,"rx":307,"fill":308,"stroke":309,"style":310},"20.0","16.0","351.0","167.0","14","#d9f4f1","var(--teal,#0f9488)","stroke-width:2px",[312,313,317],"text",{"x":314,"y":315,"style":316},"195.5","44.0","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Store",[41,319],{"x1":320,"y1":321,"x2":322,"y2":321,"stroke":309,"style":323},"36.0","57.0","355.0","stroke-width:1px",[312,325,71],{"x":314,"y":326,"style":327},"77.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle",[312,329,75],{"x":314,"y":330,"style":327},"103.0",[312,332,334],{"x":314,"y":333,"style":327},"129.0","Asia\u002FSingapore",[312,336,338],{"x":314,"y":337,"style":327},"155.0","Australia\u002FSydney",[294,340],{"x":341,"y":304,"width":305,"height":306,"rx":307,"fill":342,"stroke":343,"style":310},"389.0","#fce9e9","var(--danger,#dc2626)",[312,345,348],{"x":346,"y":315,"style":347},"564.5","font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Avoid",[41,350],{"x1":351,"y1":321,"x2":352,"y2":321,"stroke":343,"style":323},"405.0","724.0",[312,354,355],{"x":346,"y":326,"style":327},"GMT or BST",[312,357,358],{"x":346,"y":330,"style":327},"EST or EDT",[312,360,361],{"x":346,"y":333,"style":327},"UTC+01:00",[312,363,364],{"x":346,"y":337,"style":327},"a city name typed freely",[25,366,368],{"id":367},"step-2-decide-who-is-due-at-a-given-moment","Step 2 — Decide who is due at a given moment",[30,370,372],{"className":83,"code":371,"language":85,"meta":35,"style":35},"from datetime import datetime, timezone\nimport holidays\n\nWEEKENDS = {\"SA\": {4, 5}}       # Friday and Saturday; everywhere else Saturday and Sunday\n_calendars = {}\ndef is_holiday(country, day):\n    if country not in _calendars:\n        _calendars[country] = holidays.country_holidays(country, years=range(2026, 2028))\n    return day in _calendars[country]\n\ndef due(person, now_utc):\n    \"\"\"True when it is the person's send hour on one of their local working days.\"\"\"\n    local = now_utc.astimezone(ZoneInfo(person[\"Time zone\"]))\n    if local.hour != person[\"Send hour\"]:\n        return False\n    weekend = WEEKENDS.get(person[\"Country\"], {5, 6})        # Monday is 0\n    return local.weekday() not in weekend and not is_holiday(person[\"Country\"], local.date())\n\nnow = datetime(2026, 10, 6, 7, 0, tzinfo=timezone.utc)\nprint(f\"{now:%a %d %b %H:%M} UTC ->\", [p[\"Name\"] for _, p in people.iterrows() if due(p, now)])\n",[14,373,374,386,392,396,427,437,449,466,497,510,514,524,529,545,565,574,607,635,640,681],{"__ignoreMap":35},[39,375,376,378,381,383],{"class":41,"line":42},[39,377,109],{"class":92},[39,379,380],{"class":96}," datetime ",[39,382,93],{"class":92},[39,384,385],{"class":96}," datetime, timezone\n",[39,387,388,390],{"class":41,"line":106},[39,389,93],{"class":92},[39,391,59],{"class":96},[39,393,394],{"class":41,"line":120},[39,395,124],{"emptyLinePlaceholder":123},[39,397,398,401,404,407,410,413,415,417,420,423],{"class":41,"line":127},[39,399,400],{"class":201},"WEEKENDS",[39,402,403],{"class":92}," =",[39,405,406],{"class":96}," {",[39,408,409],{"class":49},"\"SA\"",[39,411,412],{"class":96},": {",[39,414,296],{"class":201},[39,416,72],{"class":96},[39,418,419],{"class":201},"5",[39,421,422],{"class":96},"}}       ",[39,424,426],{"class":425},"s-wDw","# Friday and Saturday; everywhere else Saturday and Sunday\n",[39,428,429,432,434],{"class":41,"line":145},[39,430,431],{"class":96},"_calendars ",[39,433,133],{"class":92},[39,435,436],{"class":96}," {}\n",[39,438,439,442,446],{"class":41,"line":156},[39,440,441],{"class":92},"def",[39,443,445],{"class":444},"s_Opv"," is_holiday",[39,447,448],{"class":96},"(country, day):\n",[39,450,451,454,457,460,463],{"class":41,"line":177},[39,452,453],{"class":92},"    if",[39,455,456],{"class":96}," country ",[39,458,459],{"class":92},"not",[39,461,462],{"class":92}," in",[39,464,465],{"class":96}," _calendars:\n",[39,467,468,471,473,476,479,481,484,487,490,492,495],{"class":41,"line":186},[39,469,470],{"class":96},"        _calendars[country] ",[39,472,133],{"class":92},[39,474,475],{"class":96}," holidays.country_holidays(country, ",[39,477,478],{"class":254},"years",[39,480,133],{"class":92},[39,482,483],{"class":201},"range",[39,485,486],{"class":96},"(",[39,488,489],{"class":201},"2026",[39,491,72],{"class":96},[39,493,494],{"class":201},"2028",[39,496,263],{"class":96},[39,498,499,502,505,507],{"class":41,"line":192},[39,500,501],{"class":92},"    return",[39,503,504],{"class":96}," day ",[39,506,165],{"class":92},[39,508,509],{"class":96}," _calendars[country]\n",[39,511,512],{"class":41,"line":208},[39,513,124],{"emptyLinePlaceholder":123},[39,515,516,518,521],{"class":41,"line":214},[39,517,441],{"class":92},[39,519,520],{"class":444}," due",[39,522,523],{"class":96},"(person, now_utc):\n",[39,525,526],{"class":41,"line":245},[39,527,528],{"class":49},"    \"\"\"True when it is the person's send hour on one of their local working days.\"\"\"\n",[39,530,532,535,537,540,542],{"class":41,"line":531},13,[39,533,534],{"class":96},"    local ",[39,536,133],{"class":92},[39,538,539],{"class":96}," now_utc.astimezone(ZoneInfo(person[",[39,541,171],{"class":49},[39,543,544],{"class":96},"]))\n",[39,546,548,550,553,556,559,562],{"class":41,"line":547},14,[39,549,453],{"class":92},[39,551,552],{"class":96}," local.hour ",[39,554,555],{"class":92},"!=",[39,557,558],{"class":96}," person[",[39,560,561],{"class":49},"\"Send hour\"",[39,563,564],{"class":96},"]:\n",[39,566,568,571],{"class":41,"line":567},15,[39,569,570],{"class":92},"        return",[39,572,573],{"class":201}," False\n",[39,575,577,580,582,585,588,591,594,596,598,601,604],{"class":41,"line":576},16,[39,578,579],{"class":96},"    weekend ",[39,581,133],{"class":92},[39,583,584],{"class":201}," WEEKENDS",[39,586,587],{"class":96},".get(person[",[39,589,590],{"class":49},"\"Country\"",[39,592,593],{"class":96},"], {",[39,595,419],{"class":201},[39,597,72],{"class":96},[39,599,600],{"class":201},"6",[39,602,603],{"class":96},"})        ",[39,605,606],{"class":425},"# Monday is 0\n",[39,608,610,612,615,617,619,622,625,627,630,632],{"class":41,"line":609},17,[39,611,501],{"class":92},[39,613,614],{"class":96}," local.weekday() ",[39,616,459],{"class":92},[39,618,462],{"class":92},[39,620,621],{"class":96}," weekend ",[39,623,624],{"class":92},"and",[39,626,220],{"class":92},[39,628,629],{"class":96}," is_holiday(person[",[39,631,590],{"class":49},[39,633,634],{"class":96},"], local.date())\n",[39,636,638],{"class":41,"line":637},18,[39,639,124],{"emptyLinePlaceholder":123},[39,641,643,646,648,651,653,655,658,660,662,664,667,669,671,673,676,678],{"class":41,"line":642},19,[39,644,645],{"class":96},"now ",[39,647,133],{"class":92},[39,649,650],{"class":96}," datetime(",[39,652,489],{"class":201},[39,654,72],{"class":96},[39,656,657],{"class":201},"10",[39,659,72],{"class":96},[39,661,600],{"class":201},[39,663,72],{"class":96},[39,665,666],{"class":201},"7",[39,668,72],{"class":96},[39,670,297],{"class":201},[39,672,72],{"class":96},[39,674,675],{"class":254},"tzinfo",[39,677,133],{"class":92},[39,679,680],{"class":96},"timezone.utc)\n",[39,682,684,686,688,690,693,695,698,701,704,706,709,711,714,716,719,721,724,726,729,732,735,738,740,743,745,748,751],{"class":41,"line":683},20,[39,685,248],{"class":201},[39,687,486],{"class":96},[39,689,226],{"class":92},[39,691,692],{"class":49},"\"",[39,694,233],{"class":232},[39,696,697],{"class":96},"now:",[39,699,700],{"class":92},"%",[39,702,703],{"class":96},"a ",[39,705,700],{"class":92},[39,707,708],{"class":96},"d ",[39,710,700],{"class":92},[39,712,713],{"class":96},"b ",[39,715,700],{"class":92},[39,717,718],{"class":96},"H:",[39,720,700],{"class":92},[39,722,723],{"class":96},"M",[39,725,239],{"class":232},[39,727,728],{"class":49}," UTC ->\"",[39,730,731],{"class":96},", [p[",[39,733,734],{"class":49},"\"Name\"",[39,736,737],{"class":96},"] ",[39,739,159],{"class":92},[39,741,742],{"class":96}," _, p ",[39,744,165],{"class":92},[39,746,747],{"class":96}," people.iterrows() ",[39,749,750],{"class":92},"if",[39,752,753],{"class":96}," due(p, now)])\n",[10,755,756,757,760],{},"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 ",[14,758,759],{},"holidays"," package by country, so a report due on a bank holiday in one country still goes out on time everywhere else.",[25,762,764],{"id":763},"step-3-run-hourly-and-log-every-send","Step 3 — Run hourly and log every send",[30,766,768],{"className":83,"code":767,"language":85,"meta":35,"style":35},"import json\nfrom pathlib import Path\n\nLOG = Path(\"sent_log.jsonl\")\n\ndef already_sent(email, local_date):\n    if not LOG.exists():\n        return False\n    return any(json.loads(line)[\"email\"] == email and json.loads(line)[\"local_date\"] == local_date\n               for line in LOG.read_text().splitlines())\n\ndef run(now_utc, send):\n    sent = []\n    for _, p in people.iterrows():\n        local_date = now_utc.astimezone(ZoneInfo(p[\"Time zone\"])).date().isoformat()\n        if due(p, now_utc) and not already_sent(p[\"Email\"], local_date):\n            send(p)\n            with LOG.open(\"a\") as f:\n                f.write(json.dumps({\"email\": p[\"Email\"], \"local_date\": local_date,\n                                    \"sent_utc\": now_utc.isoformat()}) + \"\\n\")\n            sent.append(p[\"Name\"])\n    return sent\n\nLOG.unlink(missing_ok=True)\nfake_send = lambda p: None\nnow = datetime(2026, 10, 6, 7, 0, tzinfo=timezone.utc)\nprint(run(now, fake_send), run(now, fake_send))\n",[14,769,770,777,789,793,808,812,822,834,840,876,891,895,905,914,926,941,962,967,988,1008,1029,1040,1048,1053,1071,1088,1123],{"__ignoreMap":35},[39,771,772,774],{"class":41,"line":42},[39,773,93],{"class":92},[39,775,776],{"class":96}," json\n",[39,778,779,781,784,786],{"class":41,"line":106},[39,780,109],{"class":92},[39,782,783],{"class":96}," pathlib ",[39,785,93],{"class":92},[39,787,788],{"class":96}," Path\n",[39,790,791],{"class":41,"line":120},[39,792,124],{"emptyLinePlaceholder":123},[39,794,795,798,800,803,806],{"class":41,"line":127},[39,796,797],{"class":201},"LOG",[39,799,403],{"class":92},[39,801,802],{"class":96}," Path(",[39,804,805],{"class":49},"\"sent_log.jsonl\"",[39,807,142],{"class":96},[39,809,810],{"class":41,"line":145},[39,811,124],{"emptyLinePlaceholder":123},[39,813,814,816,819],{"class":41,"line":156},[39,815,441],{"class":92},[39,817,818],{"class":444}," already_sent",[39,820,821],{"class":96},"(email, local_date):\n",[39,823,824,826,828,831],{"class":41,"line":177},[39,825,453],{"class":92},[39,827,220],{"class":92},[39,829,830],{"class":201}," LOG",[39,832,833],{"class":96},".exists():\n",[39,835,836,838],{"class":41,"line":186},[39,837,570],{"class":92},[39,839,573],{"class":201},[39,841,842,844,847,850,853,855,858,861,863,866,869,871,873],{"class":41,"line":192},[39,843,501],{"class":92},[39,845,846],{"class":201}," any",[39,848,849],{"class":96},"(json.loads(line)[",[39,851,852],{"class":49},"\"email\"",[39,854,737],{"class":96},[39,856,857],{"class":92},"==",[39,859,860],{"class":96}," email ",[39,862,624],{"class":92},[39,864,865],{"class":96}," json.loads(line)[",[39,867,868],{"class":49},"\"local_date\"",[39,870,737],{"class":96},[39,872,857],{"class":92},[39,874,875],{"class":96}," local_date\n",[39,877,878,881,884,886,888],{"class":41,"line":208},[39,879,880],{"class":92},"               for",[39,882,883],{"class":96}," line ",[39,885,165],{"class":92},[39,887,830],{"class":201},[39,889,890],{"class":96},".read_text().splitlines())\n",[39,892,893],{"class":41,"line":214},[39,894,124],{"emptyLinePlaceholder":123},[39,896,897,899,902],{"class":41,"line":245},[39,898,441],{"class":92},[39,900,901],{"class":444}," run",[39,903,904],{"class":96},"(now_utc, send):\n",[39,906,907,910,912],{"class":41,"line":531},[39,908,909],{"class":96},"    sent ",[39,911,133],{"class":92},[39,913,153],{"class":96},[39,915,916,919,921,923],{"class":41,"line":547},[39,917,918],{"class":92},"    for",[39,920,742],{"class":96},[39,922,165],{"class":92},[39,924,925],{"class":96}," people.iterrows():\n",[39,927,928,931,933,936,938],{"class":41,"line":567},[39,929,930],{"class":96},"        local_date ",[39,932,133],{"class":92},[39,934,935],{"class":96}," now_utc.astimezone(ZoneInfo(p[",[39,937,171],{"class":49},[39,939,940],{"class":96},"])).date().isoformat()\n",[39,942,943,946,949,951,953,956,959],{"class":41,"line":576},[39,944,945],{"class":92},"        if",[39,947,948],{"class":96}," due(p, now_utc) ",[39,950,624],{"class":92},[39,952,220],{"class":92},[39,954,955],{"class":96}," already_sent(p[",[39,957,958],{"class":49},"\"Email\"",[39,960,961],{"class":96},"], local_date):\n",[39,963,964],{"class":41,"line":609},[39,965,966],{"class":96},"            send(p)\n",[39,968,969,972,974,977,980,983,985],{"class":41,"line":637},[39,970,971],{"class":92},"            with",[39,973,830],{"class":201},[39,975,976],{"class":96},".open(",[39,978,979],{"class":49},"\"a\"",[39,981,982],{"class":96},") ",[39,984,100],{"class":92},[39,986,987],{"class":96}," f:\n",[39,989,990,993,995,998,1000,1003,1005],{"class":41,"line":642},[39,991,992],{"class":96},"                f.write(json.dumps({",[39,994,852],{"class":49},[39,996,997],{"class":96},": p[",[39,999,958],{"class":49},[39,1001,1002],{"class":96},"], ",[39,1004,868],{"class":49},[39,1006,1007],{"class":96},": local_date,\n",[39,1009,1010,1013,1016,1019,1022,1025,1027],{"class":41,"line":683},[39,1011,1012],{"class":49},"                                    \"sent_utc\"",[39,1014,1015],{"class":96},": now_utc.isoformat()}) ",[39,1017,1018],{"class":92},"+",[39,1020,1021],{"class":49}," \"",[39,1023,1024],{"class":232},"\\n",[39,1026,692],{"class":49},[39,1028,142],{"class":96},[39,1030,1032,1035,1037],{"class":41,"line":1031},21,[39,1033,1034],{"class":96},"            sent.append(p[",[39,1036,734],{"class":49},[39,1038,1039],{"class":96},"])\n",[39,1041,1043,1045],{"class":41,"line":1042},22,[39,1044,501],{"class":92},[39,1046,1047],{"class":96}," sent\n",[39,1049,1051],{"class":41,"line":1050},23,[39,1052,124],{"emptyLinePlaceholder":123},[39,1054,1056,1058,1061,1064,1066,1069],{"class":41,"line":1055},24,[39,1057,797],{"class":201},[39,1059,1060],{"class":96},".unlink(",[39,1062,1063],{"class":254},"missing_ok",[39,1065,133],{"class":92},[39,1067,1068],{"class":201},"True",[39,1070,142],{"class":96},[39,1072,1074,1077,1079,1082,1085],{"class":41,"line":1073},25,[39,1075,1076],{"class":96},"fake_send ",[39,1078,133],{"class":92},[39,1080,1081],{"class":92}," lambda",[39,1083,1084],{"class":96}," p: ",[39,1086,1087],{"class":201},"None\n",[39,1089,1091,1093,1095,1097,1099,1101,1103,1105,1107,1109,1111,1113,1115,1117,1119,1121],{"class":41,"line":1090},26,[39,1092,645],{"class":96},[39,1094,133],{"class":92},[39,1096,650],{"class":96},[39,1098,489],{"class":201},[39,1100,72],{"class":96},[39,1102,657],{"class":201},[39,1104,72],{"class":96},[39,1106,600],{"class":201},[39,1108,72],{"class":96},[39,1110,666],{"class":201},[39,1112,72],{"class":96},[39,1114,297],{"class":201},[39,1116,72],{"class":96},[39,1118,675],{"class":254},[39,1120,133],{"class":92},[39,1122,680],{"class":96},[39,1124,1126,1128],{"class":41,"line":1125},27,[39,1127,248],{"class":201},[39,1129,1130],{"class":96},"(run(now, fake_send), run(now, fake_send))\n",[10,1132,1133,1134,1137,1138,1142,1143,1146,1147,1151],{},"The job runs at the top of every hour — ",[14,1135,1136],{},"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 ",[1139,1140,1141],"em",{},"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 ",[14,1144,1145],{},"send"," builds and sends the message as in ",[19,1148,1150],{"href":1149},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-each-recipient-their-own-excel-report\u002F","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.",[275,1153,284,1158,284,1161,284,1164,284,1167,284,1174,284,1180,284,1185,284,1191,284,1196,284,1200,284,1205,284,1208,284,1212,284,1215,284,1220,284,1226,284,1229,284,1233,284,1237,284,1241,284,1244,284,1247,284,1251,284,1254,284,1257,284,1260,284,1264,284,1267,284,1270,284,1275],{"viewBox":1154,"role":278,"ariaLabelledBy":1155,"xmlns":282,"style":283},"4 0 752 126",[1156,1157],"tz-run-t","tz-run-d",[286,1159,1160],{"id":1156},"What each hourly run does",[290,1162,1163],{"id":1157},"The job starts at the top of the hour in UTC, converts that moment to each recipient's time zone, checks the local send hour, weekend and holiday, checks the send log for the local date, then sends and records the send.",[294,1165],{"x":296,"y":297,"width":298,"height":1166,"fill":300},"126",[294,1168],{"x":303,"y":304,"width":1169,"height":1170,"rx":1171,"fill":1172,"stroke":1173,"style":310},"115.2","94.0","12","#e7ebef","var(--line,#cdd5e6)",[312,1175,1179],{"x":1176,"y":1177,"style":1178},"77.6","59.0","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Hourly start",[312,1181,1184],{"x":1176,"y":1182,"style":1183},"78.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","UTC now",[41,1186],{"x1":1187,"y1":1188,"x2":1189,"y2":1188,"stroke":1190,"style":310},"140.2","63.0","162.2","var(--brand,#5b5cf0)",[1192,1193],"polygon",{"points":1194,"fill":1195},"166.2,63.0 157.2,68.0 157.2,58.0","#5b5cf0",[294,1197],{"x":1198,"y":304,"width":1169,"height":1170,"rx":1171,"fill":1199,"stroke":1190,"style":310},"171.2","#f0f4ff",[312,1201,1204],{"x":1202,"y":1177,"style":1203},"228.8","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","To local time",[312,1206,1207],{"x":1202,"y":1182,"style":1183},"per recipient",[41,1209],{"x1":1210,"y1":1188,"x2":1211,"y2":1188,"stroke":1190,"style":310},"291.4","313.4",[1192,1213],{"points":1214,"fill":1195},"317.4,63.0 308.4,68.0 308.4,58.0",[294,1216],{"x":1217,"y":304,"width":1169,"height":1170,"rx":1171,"fill":1218,"stroke":1219,"style":310},"322.4","#fdefd8","var(--gold,#b4740a)",[312,1221,1225],{"x":1222,"y":1223,"style":1224},"380.0","41.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Send hour",[312,1227,1228],{"x":1222,"y":1177,"style":1224},"and working",[312,1230,1232],{"x":1222,"y":1231,"style":1224},"76.5","day?",[312,1234,1236],{"x":1222,"y":1235,"style":1183},"96.0","local rules",[41,1238],{"x1":1239,"y1":1188,"x2":1240,"y2":1188,"stroke":1190,"style":310},"442.6","464.6",[1192,1242],{"points":1243,"fill":1195},"468.6,63.0 459.6,68.0 459.6,58.0",[294,1245],{"x":1246,"y":304,"width":1169,"height":1170,"rx":1171,"fill":1218,"stroke":1219,"style":310},"473.6",[312,1248,1250],{"x":1249,"y":1223,"style":1224},"531.2","Not yet sent",[312,1252,1253],{"x":1249,"y":1177,"style":1224},"today?",[312,1255,1256],{"x":1249,"y":1182,"style":1183},"log by local",[312,1258,1259],{"x":1249,"y":1235,"style":1183},"date",[41,1261],{"x1":1262,"y1":1188,"x2":1263,"y2":1188,"stroke":1190,"style":310},"593.8","615.8",[1192,1265],{"points":1266,"fill":1195},"619.8,63.0 610.8,68.0 610.8,58.0",[294,1268],{"x":1269,"y":304,"width":1169,"height":1170,"rx":1171,"fill":308,"stroke":309,"style":310},"624.8",[312,1271,1274],{"x":1272,"y":1177,"style":1273},"682.4","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Send and log",[312,1276,1277],{"x":1272,"y":1182,"style":1183},"append one line",[25,1279,1281],{"id":1280},"step-4-prove-it-across-a-clock-change","Step 4 — Prove it across a clock change",[30,1283,1285],{"className":83,"code":1284,"language":85,"meta":35,"style":35},"from datetime import timedelta\n\nLOG.unlink(missing_ok=True)\ncounts = {}\nt0 = datetime(2026, 10, 23, 0, 0, tzinfo=timezone.utc)\nfor h in range(24 * 14):                        # two weeks, hour by hour\n    for name in run(t0 + timedelta(hours=h), fake_send):\n        counts[name] = counts.get(name, 0) + 1\nprint(counts)\nfor line in LOG.read_text().splitlines():\n    rec = json.loads(line)\n    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\"):\n        print(rec[\"email\"].split(\"@\")[0], rec[\"local_date\"], \"sent at\", rec[\"sent_utc\"][11:16], \"UTC\")\n",[14,1286,1287,1298,1302,1316,1325,1361,1390,1415,1434,1441,1454,1464,1522],{"__ignoreMap":35},[39,1288,1289,1291,1293,1295],{"class":41,"line":42},[39,1290,109],{"class":92},[39,1292,380],{"class":96},[39,1294,93],{"class":92},[39,1296,1297],{"class":96}," timedelta\n",[39,1299,1300],{"class":41,"line":106},[39,1301,124],{"emptyLinePlaceholder":123},[39,1303,1304,1306,1308,1310,1312,1314],{"class":41,"line":120},[39,1305,797],{"class":201},[39,1307,1060],{"class":96},[39,1309,1063],{"class":254},[39,1311,133],{"class":92},[39,1313,1068],{"class":201},[39,1315,142],{"class":96},[39,1317,1318,1321,1323],{"class":41,"line":127},[39,1319,1320],{"class":96},"counts ",[39,1322,133],{"class":92},[39,1324,436],{"class":96},[39,1326,1327,1330,1332,1334,1336,1338,1340,1342,1345,1347,1349,1351,1353,1355,1357,1359],{"class":41,"line":145},[39,1328,1329],{"class":96},"t0 ",[39,1331,133],{"class":92},[39,1333,650],{"class":96},[39,1335,489],{"class":201},[39,1337,72],{"class":96},[39,1339,657],{"class":201},[39,1341,72],{"class":96},[39,1343,1344],{"class":201},"23",[39,1346,72],{"class":96},[39,1348,297],{"class":201},[39,1350,72],{"class":96},[39,1352,297],{"class":201},[39,1354,72],{"class":96},[39,1356,675],{"class":254},[39,1358,133],{"class":92},[39,1360,680],{"class":96},[39,1362,1363,1365,1368,1370,1373,1375,1378,1381,1384,1387],{"class":41,"line":156},[39,1364,159],{"class":92},[39,1366,1367],{"class":96}," h ",[39,1369,165],{"class":92},[39,1371,1372],{"class":201}," range",[39,1374,486],{"class":96},[39,1376,1377],{"class":201},"24",[39,1379,1380],{"class":92}," *",[39,1382,1383],{"class":201}," 14",[39,1385,1386],{"class":96},"):                        ",[39,1388,1389],{"class":425},"# two weeks, hour by hour\n",[39,1391,1392,1394,1397,1399,1402,1404,1407,1410,1412],{"class":41,"line":177},[39,1393,918],{"class":92},[39,1395,1396],{"class":96}," name ",[39,1398,165],{"class":92},[39,1400,1401],{"class":96}," run(t0 ",[39,1403,1018],{"class":92},[39,1405,1406],{"class":96}," timedelta(",[39,1408,1409],{"class":254},"hours",[39,1411,133],{"class":92},[39,1413,1414],{"class":96},"h), fake_send):\n",[39,1416,1417,1420,1422,1425,1427,1429,1431],{"class":41,"line":186},[39,1418,1419],{"class":96},"        counts[name] ",[39,1421,133],{"class":92},[39,1423,1424],{"class":96}," counts.get(name, ",[39,1426,297],{"class":201},[39,1428,982],{"class":96},[39,1430,1018],{"class":92},[39,1432,1433],{"class":201}," 1\n",[39,1435,1436,1438],{"class":41,"line":192},[39,1437,248],{"class":201},[39,1439,1440],{"class":96},"(counts)\n",[39,1442,1443,1445,1447,1449,1451],{"class":41,"line":208},[39,1444,159],{"class":92},[39,1446,883],{"class":96},[39,1448,165],{"class":92},[39,1450,830],{"class":201},[39,1452,1453],{"class":96},".read_text().splitlines():\n",[39,1455,1456,1459,1461],{"class":41,"line":214},[39,1457,1458],{"class":96},"    rec ",[39,1460,133],{"class":92},[39,1462,1463],{"class":96}," json.loads(line)\n",[39,1465,1466,1468,1471,1473,1475,1477,1480,1483,1485,1488,1490,1492,1494,1496,1498,1500,1502,1505,1507,1510,1512,1515,1517,1520],{"class":41,"line":245},[39,1467,453],{"class":92},[39,1469,1470],{"class":96}," rec[",[39,1472,852],{"class":49},[39,1474,737],{"class":96},[39,1476,165],{"class":92},[39,1478,1479],{"class":96}," (",[39,1481,1482],{"class":49},"\"priya@example.com\"",[39,1484,72],{"class":96},[39,1486,1487],{"class":49},"\"dana@example.com\"",[39,1489,982],{"class":96},[39,1491,624],{"class":92},[39,1493,1470],{"class":96},[39,1495,868],{"class":49},[39,1497,737],{"class":96},[39,1499,165],{"class":92},[39,1501,1479],{"class":96},[39,1503,1504],{"class":49},"\"2026-10-23\"",[39,1506,72],{"class":96},[39,1508,1509],{"class":49},"\"2026-10-26\"",[39,1511,72],{"class":96},[39,1513,1514],{"class":49},"\"2026-10-30\"",[39,1516,72],{"class":96},[39,1518,1519],{"class":49},"\"2026-11-02\"",[39,1521,205],{"class":96},[39,1523,1524,1527,1530,1532,1535,1538,1541,1543,1546,1548,1550,1553,1556,1559,1562,1565,1568,1571,1573,1576],{"class":41,"line":531},[39,1525,1526],{"class":201},"        print",[39,1528,1529],{"class":96},"(rec[",[39,1531,852],{"class":49},[39,1533,1534],{"class":96},"].split(",[39,1536,1537],{"class":49},"\"@\"",[39,1539,1540],{"class":96},")[",[39,1542,297],{"class":201},[39,1544,1545],{"class":96},"], rec[",[39,1547,868],{"class":49},[39,1549,1002],{"class":96},[39,1551,1552],{"class":49},"\"sent at\"",[39,1554,1555],{"class":96},", rec[",[39,1557,1558],{"class":49},"\"sent_utc\"",[39,1560,1561],{"class":96},"][",[39,1563,1564],{"class":201},"11",[39,1566,1567],{"class":96},":",[39,1569,1570],{"class":201},"16",[39,1572,1002],{"class":96},[39,1574,1575],{"class":49},"\"UTC\"",[39,1577,142],{"class":96},[10,1579,1580],{},"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.",[275,1582,284,1588,284,1591,284,1594,284,1599,284,1604,284,1608,284,1614,284,1618,284,1622,284,1626,284,1630,284,1634,284,1638,284,1642,284,1646,284,1649,284,1653,284,1655,284,1658,284,1660,284,1663,284,1665,284,1668,284,1671,284,1675,284,1677,284,1680,284,1682,284,1685,284,1687,284,1690,284,1692,284,1695,284,1697,284,1701,284,1703,284,1705,284,1707,284,1710,284,1712,284,1716,284,1718,284,1721,284,1724,284,1727,284,1729,284,1732,284,1734,284,1737,284,1739,284,1742,284,1744,284,1747,284,1750,284,1753,284,1755,284,1757,284,1759,284,1762,284,1764,284,1767,284,1769],{"viewBox":1583,"role":278,"ariaLabelledBy":1584,"xmlns":282,"style":1587},"140 0 480 196",[1585,1586],"tz-shift-t","tz-shift-d","width:100%;max-width:480px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[286,1589,1590],{"id":1585},"Same local hour, different UTC time",[290,1592,1593],{"id":1586},"Priya in London receives the report at 07:00 UTC before the European clock change on 25 October and at 08:00 UTC after it. Dana in New York receives it at 12:00 UTC until the US change on 1 November and at 13:00 UTC afterwards. Both always receive it at 08:00 local time.",[294,1595],{"x":1596,"y":297,"width":1597,"height":1598,"fill":300},"140","480","196",[294,1600],{"x":1601,"y":304,"width":1602,"height":1603,"rx":297,"fill":1172,"stroke":1173,"style":323},"156.2","32.0","24.0",[294,1605],{"x":1606,"y":304,"width":1607,"height":1603,"rx":297,"fill":1172,"stroke":1173,"style":323},"188.2","134.3",[312,1609,1613],{"x":1610,"y":1611,"style":1612},"255.4","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[294,1615],{"x":1616,"y":304,"width":1617,"height":1603,"rx":297,"fill":1172,"stroke":1173,"style":323},"322.5","95.1",[312,1619,1621],{"x":1620,"y":1611,"style":1612},"370.1","B",[294,1623],{"x":1624,"y":304,"width":1625,"height":1603,"rx":297,"fill":1172,"stroke":1173,"style":323},"417.7","91.7",[312,1627,1629],{"x":1628,"y":1611,"style":1612},"463.5","C",[294,1631],{"x":1632,"y":304,"width":1633,"height":1603,"rx":297,"fill":1172,"stroke":1173,"style":323},"509.3","94.5",[312,1635,1637],{"x":1636,"y":1611,"style":1612},"556.6","D",[294,1639],{"x":1601,"y":1640,"width":1602,"height":1641,"rx":297,"fill":1172,"stroke":1173,"style":323},"40.0","28.0",[312,1643,1645],{"x":1644,"y":1177,"style":1612},"172.2","1",[294,1647],{"x":1606,"y":1640,"width":1607,"height":1641,"rx":297,"fill":1199,"stroke":1190,"style":1648},"stroke-width:1.5px",[312,1650,1652],{"x":1610,"y":1177,"style":1651},"font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Recipient",[294,1654],{"x":1616,"y":1640,"width":1617,"height":1641,"rx":297,"fill":1199,"stroke":1190,"style":1648},[312,1656,1657],{"x":1620,"y":1177,"style":1651},"Local date",[294,1659],{"x":1624,"y":1640,"width":1625,"height":1641,"rx":297,"fill":1199,"stroke":1190,"style":1648},[312,1661,1662],{"x":1628,"y":1177,"style":1651},"Sent (UTC)",[294,1664],{"x":1632,"y":1640,"width":1633,"height":1641,"rx":297,"fill":1199,"stroke":1190,"style":1648},[312,1666,1667],{"x":1636,"y":1177,"style":1651},"Local time",[294,1669],{"x":1601,"y":1670,"width":1602,"height":1641,"rx":297,"fill":1172,"stroke":1173,"style":323},"68.0",[312,1672,1674],{"x":1644,"y":1673,"style":1612},"87.0","2",[294,1676],{"x":1606,"y":1670,"width":1607,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1678,1679],{"x":1610,"y":1673,"style":327},"Priya — London",[294,1681],{"x":1616,"y":1670,"width":1617,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1683,1684],{"x":1620,"y":1673,"style":327},"Fri 23 Oct",[294,1686],{"x":1624,"y":1670,"width":1625,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1688,1689],{"x":1628,"y":1673,"style":327},"07:00",[294,1691],{"x":1632,"y":1670,"width":1633,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1693,1694],{"x":1636,"y":1673,"style":327},"08:00 BST",[294,1696],{"x":1601,"y":1235,"width":1602,"height":1641,"rx":297,"fill":1172,"stroke":1173,"style":323},[312,1698,1700],{"x":1644,"y":1699,"style":1612},"115.0","3",[294,1702],{"x":1606,"y":1235,"width":1607,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1704,1679],{"x":1610,"y":1699,"style":327},[294,1706],{"x":1616,"y":1235,"width":1617,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1708,1709],{"x":1620,"y":1699,"style":327},"Mon 26 Oct",[294,1711],{"x":1624,"y":1235,"width":1625,"height":1641,"rx":297,"fill":308,"stroke":309,"style":1648},[312,1713,1715],{"x":1628,"y":1699,"style":1714},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","08:00",[294,1717],{"x":1632,"y":1235,"width":1633,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1719,1720],{"x":1636,"y":1699,"style":327},"08:00 GMT",[294,1722],{"x":1601,"y":1723,"width":1602,"height":1641,"rx":297,"fill":1172,"stroke":1173,"style":323},"124.0",[312,1725,296],{"x":1644,"y":1726,"style":1612},"143.0",[294,1728],{"x":1606,"y":1723,"width":1607,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1730,1731],{"x":1610,"y":1726,"style":327},"Dana — New York",[294,1733],{"x":1616,"y":1723,"width":1617,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1735,1736],{"x":1620,"y":1726,"style":327},"Fri 30 Oct",[294,1738],{"x":1624,"y":1723,"width":1625,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1740,1741],{"x":1628,"y":1726,"style":327},"12:00",[294,1743],{"x":1632,"y":1723,"width":1633,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1745,1746],{"x":1636,"y":1726,"style":327},"08:00 EDT",[294,1748],{"x":1601,"y":1749,"width":1602,"height":1641,"rx":297,"fill":1172,"stroke":1173,"style":323},"152.0",[312,1751,419],{"x":1644,"y":1752,"style":1612},"171.0",[294,1754],{"x":1606,"y":1749,"width":1607,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1756,1731],{"x":1610,"y":1752,"style":327},[294,1758],{"x":1616,"y":1749,"width":1617,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1760,1761],{"x":1620,"y":1752,"style":327},"Mon 2 Nov",[294,1763],{"x":1624,"y":1749,"width":1625,"height":1641,"rx":297,"fill":308,"stroke":309,"style":1648},[312,1765,1766],{"x":1628,"y":1752,"style":1714},"13:00",[294,1768],{"x":1632,"y":1749,"width":1633,"height":1641,"rx":297,"fill":300,"stroke":1173,"style":323},[312,1770,1771],{"x":1636,"y":1752,"style":327},"08:00 EST",[25,1773,1775],{"id":1774},"missed-hours-and-catching-up","Missed hours and catching up",[10,1777,1778,1779,1782,1783,1786],{},"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 ",[14,1780,1781],{},"due()"," and ",[14,1784,1785],{},"run()"," with a few lines, and the replay test is the way to check them.",[25,1788,1790],{"id":1789},"common-pitfalls","Common pitfalls",[1792,1793,1794,1802,1808,1814,1820],"ul",{},[1795,1796,1797,1801],"li",{},[1798,1799,1800],"strong",{},"Abbreviations or offsets in the list."," Store IANA names; they follow daylight saving.",[1795,1803,1804,1807],{},[1798,1805,1806],{},"Checking weekends in server time."," Convert to the recipient's zone first.",[1795,1809,1810,1813],{},[1798,1811,1812],{},"Logging by UTC date."," Key the send log on the recipient's local date.",[1795,1815,1816,1819],{},[1798,1817,1818],{},"One cron line per zone."," One hourly job scales and survives clock changes.",[1795,1821,1822,1825],{},[1798,1823,1824],{},"Testing against the real clock."," Replay simulated hours across a clock change.",[25,1827,1829],{"id":1828},"conclusion","Conclusion",[10,1831,1832],{},"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.",[25,1834,1836],{"id":1835},"frequently-asked-questions","Frequently asked questions",[10,1838,1839,1842,1843,1845],{},[1798,1840,1841],{},"How do I send an email at a specific local time for each recipient?","\nRun a job hourly, convert the current UTC time to each recipient's time zone with ",[14,1844,16],{},", and send when the local hour matches their send hour.",[10,1847,1848,1851,1852,1854],{},[1798,1849,1850],{},"Does zoneinfo handle daylight saving time?","\nYes; with an IANA name such as ",[14,1853,71],{}," the conversion uses the correct offset for each date, so the local hour stays the same when the clocks change.",[10,1856,1857,1860,1861,1864,1865,1867],{},[1798,1858,1859],{},"Why do I need tzdata on Windows?","\nWindows has no IANA time zone database; the ",[14,1862,1863],{},"tzdata"," package provides one that ",[14,1866,16],{}," uses automatically.",[10,1869,1870,1873],{},[1798,1871,1872],{},"How do I stop a rerun from sending the report twice?","\nRecord each send with the recipient and their local date, and skip anyone already in the log for that date.",[25,1875,1877],{"id":1876},"related","Related",[1792,1879,1880,1886,1893,1900],{},[1795,1881,1882,1883,1885],{},"Up one level: ",[19,1884,22],{"href":21}," — sending reports by email.",[1795,1887,1888,1892],{},[19,1889,1891],{"href":1890},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python\u002F","Send an Excel Report to Multiple Recipients in Python"," — one report, many people.",[1795,1894,1895,1899],{},[19,1896,1898],{"href":1897},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails\u002F","Queue and Retry Failed Report Emails"," — retries after failures.",[1795,1901,1902,1906],{},[19,1903,1905],{"href":1904},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fflag-public-holidays-in-excel-data-with-python\u002F","Flag Public Holidays in Excel Data with Python"," — the holiday calendars.",[1908,1909,1910],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}",{"title":35,"searchDepth":106,"depth":106,"links":1912},[1913,1914,1915,1916,1917,1918,1919,1920,1921,1922],{"id":27,"depth":106,"text":28},{"id":79,"depth":106,"text":80},{"id":367,"depth":106,"text":368},{"id":763,"depth":106,"text":764},{"id":1280,"depth":106,"text":1281},{"id":1774,"depth":106,"text":1775},{"id":1789,"depth":106,"text":1790},{"id":1828,"depth":106,"text":1829},{"id":1835,"depth":106,"text":1836},{"id":1876,"depth":106,"text":1877},"2026-10-02","Deliver a report email at 08:00 in each recipient's own time zone from one scheduled Python job: keep an IANA time zone per recipient in the distribution list, run the job hourly, decide who is due with zoneinfo, skip local weekends and holidays, record sends so nobody gets the report twice, and check the logic across daylight saving changes.","md",[1927,1929,1931,1933],{"q":1841,"a":1928},"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.",{"q":1850,"a":1930},"Yes; with an IANA name such as Europe\u002FLondon the conversion uses the correct offset for each date, so the local hour stays the same when the clocks change.",{"q":1859,"a":1932},"Windows has no IANA time zone database; the tzdata package provides one that zoneinfo uses automatically.",{"q":1872,"a":1934},"Record each send with the recipient and their local date, and skip anyone already in the log for that date.","send email at recipient local time python",null,{"breadcrumb":1938},[1939,1942,1945],{"name":1940,"item":1941},"Home","\u002F",{"name":1943,"item":1944},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time",{"title":1948,"description":1949},"Send Report Emails at Each Recipient's Local Time in Python","One hourly job that emails each recipient at 08:00 their time: IANA time zones with zoneinfo, local weekends and holidays, a send log against duplicates, and daylight saving changes tested.","send-report-emails-at-each-recipients-local-time","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time\u002Findex",[1953,1954],"email-delivery","scheduling","how-to","ckuy3RK7XYsT9Rz70OAlXwNWQZ9SBkkvg062tMC6UeI",[1958,1961],{"title":1891,"path":1959,"stem":1960,"children":-1},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python\u002Findex",{"title":1962,"path":1963,"stem":1964,"children":-1},"Test Report Emails with a Local SMTP Server","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server\u002Findex",[1966,1968],{"path":1967,"title":22},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib",{"path":1969,"title":1943},"\u002Fautomating-reporting-workflows",[],[1972,1975,1978,1981,1984,1987],{"path":1973,"title":1974,"kind":1955},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-credit-control-dunning-letter-run","Build a Credit Control Dunning Letter Run",{"path":1976,"title":1977,"kind":1955},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fattach-multiple-excel-files-to-one-email-in-python","Attach Multiple Excel Files to One Email in Python",{"path":1979,"title":1980,"kind":1955},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox","Download Excel Attachments from an Email Inbox with Python",{"path":1982,"title":1983,"kind":1955},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-a-download-link-instead-of-an-excel-attachment","Email a Download Link Instead of an Excel Attachment",{"path":1985,"title":1986,"kind":1955},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-an-excel-report-with-an-html-summary-body","Email an Excel Report with an HTML Summary Body",{"path":1988,"title":1989,"kind":1955},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fembed-a-chart-image-in-the-body-of-a-report-email","Embed a Chart Image in the Body of a Report Email",1790972129949]