[{"data":1,"prerenderedAt":2163},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails":3,"surround:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails":2129,"ancestors:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails":2138,"children:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails":2143,"related:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails":2144},{"id":4,"title":5,"body":6,"dateModified":2095,"datePublished":2095,"description":2096,"extension":2097,"faq":2098,"keyword":2107,"kind":2108,"meta":2109,"navigation":120,"path":2118,"seo":2119,"slug":2122,"stem":2123,"topics":2124,"type":2127,"__hash__":2128},"docs\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails\u002Findex.md","Queue and Retry Failed Report Emails",{"type":7,"value":8,"toc":2084},"minimark",[9,24,29,62,77,81,84,451,462,611,615,618,1409,1423,1550,1554,1557,1761,1764,1856,1860,1863,1950,1969,1973,2012,2016,2019,2023,2029,2035,2041,2047,2051,2080],[10,11,12,13,17,18,23],"p",{},"A report job that builds a workbook and emails it in the same breath has a fragile last step. If the mail server is unreachable for ten minutes, the email is simply lost — or, if the job is wrapped in a retry, the whole report is rebuilt and some recipients get it twice. If one address on the list has been deleted, the server's rejection can abort delivery to everyone after it. And the next morning nobody can say who actually received the report. The pattern that solves all three is old and simple: an ",[14,15,16],"em",{},"outbox",". The job writes one row per recipient into a small database and finishes; a separate step delivers whatever is due, records the result for each recipient, retries temporary failures later and gives up cleanly on permanent ones. This page builds that with SQLite and smtplib and tests it against a local SMTP server that misbehaves on purpose. 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 openpyxl aiosmtpd   # aiosmtpd only for the local test server\n","bash","",[37,38,39],"code",{"__ignoreMap":35},[40,41,44,48,52,55,58],"span",{"class":42,"line":43},"line",1,[40,45,47],{"class":46},"sMTad","pip",[40,49,51],{"class":50},"srMev"," install",[40,53,54],{"class":50}," openpyxl",[40,56,57],{"class":50}," aiosmtpd",[40,59,61],{"class":60},"s-wDw","   # aiosmtpd only for the local test server\n",[10,63,64,65,68,69,72,73,23],{},"SQLite and smtplib are in the standard library. The outputs below come from a local aiosmtpd server on port 8025 that accepts most recipients, rejects one address permanently (",[37,66,67],{},"550 User unknown",") and makes another wait (",[37,70,71],{},"451 try again later",") twice before accepting it — the mix of responses a real mail server produces. Setting up such a server is covered in ",[19,74,76],{"href":75},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server\u002F","Test Report Emails with a Local SMTP Server",[25,78,80],{"id":79},"step-1-the-outbox-table","Step 1 — The outbox table",[10,82,83],{},"One row per message and recipient, with its delivery state:",[30,85,89],{"className":86,"code":87,"language":88,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","import sqlite3\nfrom datetime import datetime, timedelta\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS outbox (\n    id           INTEGER PRIMARY KEY,\n    recipient    TEXT NOT NULL,\n    subject      TEXT NOT NULL,\n    body         TEXT NOT NULL,\n    attachment   TEXT,\n    status       TEXT NOT NULL DEFAULT 'pending',     -- pending, sent, failed\n    attempts     INTEGER NOT NULL DEFAULT 0,\n    next_attempt TEXT NOT NULL,\n    last_error   TEXT,\n    sent_at      TEXT,\n    UNIQUE (recipient, subject)\n)\"\"\"\n\ndb = sqlite3.connect(\"outbox.db\")\ndb.execute(SCHEMA)\n\ndef enqueue(db, recipients, subject, body, attachment, now):\n    with db:\n        cur = db.executemany(\n            \"INSERT OR IGNORE INTO outbox (recipient, subject, body, attachment, next_attempt) VALUES (?, ?, ?, ?, ?)\",\n            [(r, subject, body, attachment, now.isoformat()) for r in recipients])\n    return cur.rowcount\n\nt0 = datetime(2026, 10, 2, 7, 0)\nteam = [\"finance@example.com\", \"ops@example.com\", \"old.manager@example.com\", \"ceo@example.com\"]\nsubject = \"Sales report — September 2026\"\nprint(\"queued:\", enqueue(db, team, subject, \"The September sales report is attached.\", \"sales_2026-09.xlsx\", t0))\nprint(\"queued on rerun:\", enqueue(db, team, subject, \"The September sales report is attached.\", \"sales_2026-09.xlsx\", t0))\n","python",[37,90,91,101,115,122,135,141,147,153,159,165,171,177,183,189,195,201,207,213,218,236,246,251,264,273,284,293,311,320,325,362,394,405,431],{"__ignoreMap":35},[40,92,93,97],{"class":42,"line":43},[40,94,96],{"class":95},"s-kum","import",[40,98,100],{"class":99},"skGVy"," sqlite3\n",[40,102,104,107,110,112],{"class":42,"line":103},2,[40,105,106],{"class":95},"from",[40,108,109],{"class":99}," datetime ",[40,111,96],{"class":95},[40,113,114],{"class":99}," datetime, timedelta\n",[40,116,118],{"class":42,"line":117},3,[40,119,121],{"emptyLinePlaceholder":120},true,"\n",[40,123,125,129,132],{"class":42,"line":124},4,[40,126,128],{"class":127},"sP0c6","SCHEMA",[40,130,131],{"class":95}," =",[40,133,134],{"class":50}," \"\"\"\n",[40,136,138],{"class":42,"line":137},5,[40,139,140],{"class":50},"CREATE TABLE IF NOT EXISTS outbox (\n",[40,142,144],{"class":42,"line":143},6,[40,145,146],{"class":50},"    id           INTEGER PRIMARY KEY,\n",[40,148,150],{"class":42,"line":149},7,[40,151,152],{"class":50},"    recipient    TEXT NOT NULL,\n",[40,154,156],{"class":42,"line":155},8,[40,157,158],{"class":50},"    subject      TEXT NOT NULL,\n",[40,160,162],{"class":42,"line":161},9,[40,163,164],{"class":50},"    body         TEXT NOT NULL,\n",[40,166,168],{"class":42,"line":167},10,[40,169,170],{"class":50},"    attachment   TEXT,\n",[40,172,174],{"class":42,"line":173},11,[40,175,176],{"class":50},"    status       TEXT NOT NULL DEFAULT 'pending',     -- pending, sent, failed\n",[40,178,180],{"class":42,"line":179},12,[40,181,182],{"class":50},"    attempts     INTEGER NOT NULL DEFAULT 0,\n",[40,184,186],{"class":42,"line":185},13,[40,187,188],{"class":50},"    next_attempt TEXT NOT NULL,\n",[40,190,192],{"class":42,"line":191},14,[40,193,194],{"class":50},"    last_error   TEXT,\n",[40,196,198],{"class":42,"line":197},15,[40,199,200],{"class":50},"    sent_at      TEXT,\n",[40,202,204],{"class":42,"line":203},16,[40,205,206],{"class":50},"    UNIQUE (recipient, subject)\n",[40,208,210],{"class":42,"line":209},17,[40,211,212],{"class":50},")\"\"\"\n",[40,214,216],{"class":42,"line":215},18,[40,217,121],{"emptyLinePlaceholder":120},[40,219,221,224,227,230,233],{"class":42,"line":220},19,[40,222,223],{"class":99},"db ",[40,225,226],{"class":95},"=",[40,228,229],{"class":99}," sqlite3.connect(",[40,231,232],{"class":50},"\"outbox.db\"",[40,234,235],{"class":99},")\n",[40,237,239,242,244],{"class":42,"line":238},20,[40,240,241],{"class":99},"db.execute(",[40,243,128],{"class":127},[40,245,235],{"class":99},[40,247,249],{"class":42,"line":248},21,[40,250,121],{"emptyLinePlaceholder":120},[40,252,254,257,261],{"class":42,"line":253},22,[40,255,256],{"class":95},"def",[40,258,260],{"class":259},"s_Opv"," enqueue",[40,262,263],{"class":99},"(db, recipients, subject, body, attachment, now):\n",[40,265,267,270],{"class":42,"line":266},23,[40,268,269],{"class":95},"    with",[40,271,272],{"class":99}," db:\n",[40,274,276,279,281],{"class":42,"line":275},24,[40,277,278],{"class":99},"        cur ",[40,280,226],{"class":95},[40,282,283],{"class":99}," db.executemany(\n",[40,285,287,290],{"class":42,"line":286},25,[40,288,289],{"class":50},"            \"INSERT OR IGNORE INTO outbox (recipient, subject, body, attachment, next_attempt) VALUES (?, ?, ?, ?, ?)\"",[40,291,292],{"class":99},",\n",[40,294,296,299,302,305,308],{"class":42,"line":295},26,[40,297,298],{"class":99},"            [(r, subject, body, attachment, now.isoformat()) ",[40,300,301],{"class":95},"for",[40,303,304],{"class":99}," r ",[40,306,307],{"class":95},"in",[40,309,310],{"class":99}," recipients])\n",[40,312,314,317],{"class":42,"line":313},27,[40,315,316],{"class":95},"    return",[40,318,319],{"class":99}," cur.rowcount\n",[40,321,323],{"class":42,"line":322},28,[40,324,121],{"emptyLinePlaceholder":120},[40,326,328,331,333,336,339,342,345,347,350,352,355,357,360],{"class":42,"line":327},29,[40,329,330],{"class":99},"t0 ",[40,332,226],{"class":95},[40,334,335],{"class":99}," datetime(",[40,337,338],{"class":127},"2026",[40,340,341],{"class":99},", ",[40,343,344],{"class":127},"10",[40,346,341],{"class":99},[40,348,349],{"class":127},"2",[40,351,341],{"class":99},[40,353,354],{"class":127},"7",[40,356,341],{"class":99},[40,358,359],{"class":127},"0",[40,361,235],{"class":99},[40,363,365,368,370,373,376,378,381,383,386,388,391],{"class":42,"line":364},30,[40,366,367],{"class":99},"team ",[40,369,226],{"class":95},[40,371,372],{"class":99}," [",[40,374,375],{"class":50},"\"finance@example.com\"",[40,377,341],{"class":99},[40,379,380],{"class":50},"\"ops@example.com\"",[40,382,341],{"class":99},[40,384,385],{"class":50},"\"old.manager@example.com\"",[40,387,341],{"class":99},[40,389,390],{"class":50},"\"ceo@example.com\"",[40,392,393],{"class":99},"]\n",[40,395,397,400,402],{"class":42,"line":396},31,[40,398,399],{"class":99},"subject ",[40,401,226],{"class":95},[40,403,404],{"class":50}," \"Sales report — September 2026\"\n",[40,406,408,411,414,417,420,423,425,428],{"class":42,"line":407},32,[40,409,410],{"class":127},"print",[40,412,413],{"class":99},"(",[40,415,416],{"class":50},"\"queued:\"",[40,418,419],{"class":99},", enqueue(db, team, subject, ",[40,421,422],{"class":50},"\"The September sales report is attached.\"",[40,424,341],{"class":99},[40,426,427],{"class":50},"\"sales_2026-09.xlsx\"",[40,429,430],{"class":99},", t0))\n",[40,432,434,436,438,441,443,445,447,449],{"class":42,"line":433},33,[40,435,410],{"class":127},[40,437,413],{"class":99},[40,439,440],{"class":50},"\"queued on rerun:\"",[40,442,419],{"class":99},[40,444,422],{"class":50},[40,446,341],{"class":99},[40,448,427],{"class":50},[40,450,430],{"class":99},[10,452,453,454,457,458,461],{},"The ",[37,455,456],{},"UNIQUE (recipient, subject)"," constraint with ",[37,459,460],{},"INSERT OR IGNORE"," makes enqueueing idempotent: if the report job runs twice — a manual rerun, a scheduler hiccup — the second run adds nothing, so nobody gets the report twice. Include the reporting period in the subject (or add a separate report-key column) so next month's report is a new message. The attachment is stored as a path, not as bytes: the workbook stays on disk until delivery, and the table stays small. The report job's work ends here; it does not wait for the mail server.",[463,464,472,473,472,477,472,481,472,488,472,498,472,509,472,516,472,521,472,527,472,532,472,536,472,543,472,548,472,551,472,555,472,558,472,563,472,570,472,576,472,580,472,584,472,588,472,591,472,596,472,603,472,608],"svg",{"viewBox":465,"role":466,"ariaLabelledBy":467,"xmlns":470,"style":471},"4 0 752 142","img",[468,469],"ob-flow-t","ob-flow-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  ",[474,475,476],"title",{"id":468},"An outbox between the report and the mail server",[478,479,480],"desc",{"id":469},"The report job builds the workbook and inserts one outbox row per recipient, then finishes. A separate delivery step sends rows that are due, marks each as sent, schedules a retry for temporary failures, or marks it failed for permanent ones.",[482,483],"rect",{"x":484,"y":359,"width":485,"height":486,"fill":487},"4","752","142","#ffffff",[482,489],{"x":490,"y":491,"width":492,"height":493,"rx":494,"fill":495,"stroke":496,"style":497},"20.0","16.0","153.0","110.5","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[499,500,502],"g",{"transform":501},"translate(84.5,37.8) scale(1.000)",[503,504],"path",{"d":505,"fill":506,"stroke":507,"style":508},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[510,511,515],"text",{"x":512,"y":513,"style":514},"96.5","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Report job",[510,517,520],{"x":512,"y":518,"style":519},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","builds the workbook",[42,522],{"x1":523,"y1":524,"x2":525,"y2":524,"stroke":526,"style":497},"178.0","71.2","200.0","var(--brand,#5b5cf0)",[528,529],"polygon",{"points":530,"fill":531},"204.0,71.2 195.0,76.2 195.0,66.2","#5b5cf0",[482,533],{"x":534,"y":491,"width":492,"height":493,"rx":494,"fill":535,"stroke":526,"style":497},"209.0","#f0f4ff",[499,537,539],{"transform":538},"translate(273.5,37.8) scale(1.000)",[503,540],{"d":541,"fill":506,"stroke":542,"style":508},"M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3z M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6 M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3","var(--brand-strong,#4338ca)",[510,544,547],{"x":545,"y":513,"style":546},"285.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Enqueue",[510,549,550],{"x":545,"y":518,"style":519},"one row per recipient",[42,552],{"x1":553,"y1":524,"x2":554,"y2":524,"stroke":526,"style":497},"367.0","389.0",[528,556],{"points":557,"fill":531},"393.0,71.2 384.0,76.2 384.0,66.2",[482,559],{"x":560,"y":491,"width":492,"height":493,"rx":494,"fill":561,"stroke":562,"style":497},"398.0","#d9f4f1","var(--teal,#0f9488)",[499,564,566],{"transform":565},"translate(462.5,29.0) scale(1.000)",[503,567],{"d":568,"fill":506,"stroke":569,"style":508},"M3 11l18-8-8 18-2-8z","var(--teal-ink,#0b6157)",[510,571,575],{"x":572,"y":573,"style":574},"474.5","75.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Deliver due rows",[510,577,579],{"x":572,"y":578,"style":519},"95.0","separate step, any",[510,581,583],{"x":572,"y":582,"style":519},"112.5","time",[42,585],{"x1":586,"y1":524,"x2":587,"y2":524,"stroke":526,"style":497},"556.0","578.0",[528,589],{"points":590,"fill":531},"582.0,71.2 573.0,76.2 573.0,66.2",[482,592],{"x":593,"y":491,"width":492,"height":493,"rx":494,"fill":594,"stroke":595,"style":497},"587.0","#fdefd8","var(--gold,#b4740a)",[499,597,599],{"transform":598},"translate(651.5,37.8) scale(1.000)",[503,600],{"d":601,"fill":506,"stroke":602,"style":508},"M5 12.5l4.5 4.5L19 7","var(--gold-ink,#7a4e06)",[510,604,607],{"x":605,"y":513,"style":606},"663.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Record outcome",[510,609,610],{"x":605,"y":518,"style":519},"sent, retry later, failed",[25,612,614],{"id":613},"step-2-deliver-what-is-due-one-recipient-at-a-time","Step 2 — Deliver what is due, one recipient at a time",[10,616,617],{},"The delivery step opens one SMTP connection, sends each due row separately, and classifies every failure:",[30,619,621],{"className":86,"code":620,"language":88,"meta":35,"style":35},"import smtplib\nfrom email.message import EmailMessage\nfrom pathlib import Path\n\nMAX_ATTEMPTS, BASE_DELAY = 5, timedelta(minutes=5)\n\ndef build(row):\n    msg = EmailMessage()\n    msg[\"From\"], msg[\"To\"], msg[\"Subject\"] = \"reports@example.com\", row[\"recipient\"], row[\"subject\"]\n    msg.set_content(row[\"body\"])\n    if row[\"attachment\"]:\n        path = Path(row[\"attachment\"])\n        msg.add_attachment(path.read_bytes(), maintype=\"application\",\n                           subtype=\"vnd.openxmlformats-officedocument.spreadsheetml.sheet\", filename=path.name)\n    return msg\n\ndef defer(db, row, error, now):\n    attempts = row[\"attempts\"] + 1\n    status = \"failed\" if attempts >= MAX_ATTEMPTS else \"pending\"\n    db.execute(\"UPDATE outbox SET attempts=?, status=?, next_attempt=?, last_error=? WHERE id=?\",\n               (attempts, status, (now + BASE_DELAY * 2 ** (attempts - 1)).isoformat(), error, row[\"id\"]))\n\ndef deliver_due(db, now, host=\"127.0.0.1\", port=8025):\n    db.row_factory = sqlite3.Row\n    due = db.execute(\"SELECT * FROM outbox WHERE status='pending' AND next_attempt\u003C=? ORDER BY id\", (now.isoformat(),)).fetchall()\n    if not due:\n        return {}\n    outcome = {}\n    try:\n        with smtplib.SMTP(host, port, timeout=30) as smtp, db:\n            for row in due:\n                try:\n                    smtp.send_message(build(row))\n                    db.execute(\"UPDATE outbox SET status='sent', attempts=attempts+1, sent_at=? WHERE id=?\", (now.isoformat(), row[\"id\"]))\n                    outcome[row[\"recipient\"]] = \"sent\"\n                except smtplib.SMTPRecipientsRefused as e:\n                    code, text = e.recipients[row[\"recipient\"]]\n                    if code >= 500:\n                        db.execute(\"UPDATE outbox SET status='failed', attempts=attempts+1, last_error=? WHERE id=?\",\n                                   (f\"{code} {text.decode()}\", row[\"id\"]))\n                        outcome[row[\"recipient\"]] = f\"failed ({code})\"\n                    else:\n                        defer(db, row, f\"{code} {text.decode()}\", now)\n                        outcome[row[\"recipient\"]] = f\"retry later ({code})\"\n    except (OSError, smtplib.SMTPException) as e:\n        with db:\n            for row in due:\n                if row[\"recipient\"] not in outcome:\n                    defer(db, row, f\"server: {e}\", now)\n                    outcome[row[\"recipient\"]] = \"retry later (server)\"\n    return outcome\n\nprint(deliver_due(db, t0))\n",[37,622,623,630,642,654,658,687,691,701,711,752,763,777,791,806,826,833,837,847,867,895,905,942,946,972,982,998,1008,1016,1025,1033,1058,1070,1077,1082,1098,1114,1128,1144,1160,1171,1208,1235,1243,1270,1294,1313,1320,1331,1352,1374,1388,1396,1401],{"__ignoreMap":35},[40,624,625,627],{"class":42,"line":43},[40,626,96],{"class":95},[40,628,629],{"class":99}," smtplib\n",[40,631,632,634,637,639],{"class":42,"line":103},[40,633,106],{"class":95},[40,635,636],{"class":99}," email.message ",[40,638,96],{"class":95},[40,640,641],{"class":99}," EmailMessage\n",[40,643,644,646,649,651],{"class":42,"line":117},[40,645,106],{"class":95},[40,647,648],{"class":99}," pathlib ",[40,650,96],{"class":95},[40,652,653],{"class":99}," Path\n",[40,655,656],{"class":42,"line":124},[40,657,121],{"emptyLinePlaceholder":120},[40,659,660,663,665,668,670,673,676,680,682,685],{"class":42,"line":137},[40,661,662],{"class":127},"MAX_ATTEMPTS",[40,664,341],{"class":99},[40,666,667],{"class":127},"BASE_DELAY",[40,669,131],{"class":95},[40,671,672],{"class":127}," 5",[40,674,675],{"class":99},", timedelta(",[40,677,679],{"class":678},"sa561","minutes",[40,681,226],{"class":95},[40,683,684],{"class":127},"5",[40,686,235],{"class":99},[40,688,689],{"class":42,"line":143},[40,690,121],{"emptyLinePlaceholder":120},[40,692,693,695,698],{"class":42,"line":149},[40,694,256],{"class":95},[40,696,697],{"class":259}," build",[40,699,700],{"class":99},"(row):\n",[40,702,703,706,708],{"class":42,"line":155},[40,704,705],{"class":99},"    msg ",[40,707,226],{"class":95},[40,709,710],{"class":99}," EmailMessage()\n",[40,712,713,716,719,722,725,727,730,733,735,738,741,744,747,750],{"class":42,"line":161},[40,714,715],{"class":99},"    msg[",[40,717,718],{"class":50},"\"From\"",[40,720,721],{"class":99},"], msg[",[40,723,724],{"class":50},"\"To\"",[40,726,721],{"class":99},[40,728,729],{"class":50},"\"Subject\"",[40,731,732],{"class":99},"] ",[40,734,226],{"class":95},[40,736,737],{"class":50}," \"reports@example.com\"",[40,739,740],{"class":99},", row[",[40,742,743],{"class":50},"\"recipient\"",[40,745,746],{"class":99},"], row[",[40,748,749],{"class":50},"\"subject\"",[40,751,393],{"class":99},[40,753,754,757,760],{"class":42,"line":167},[40,755,756],{"class":99},"    msg.set_content(row[",[40,758,759],{"class":50},"\"body\"",[40,761,762],{"class":99},"])\n",[40,764,765,768,771,774],{"class":42,"line":173},[40,766,767],{"class":95},"    if",[40,769,770],{"class":99}," row[",[40,772,773],{"class":50},"\"attachment\"",[40,775,776],{"class":99},"]:\n",[40,778,779,782,784,787,789],{"class":42,"line":179},[40,780,781],{"class":99},"        path ",[40,783,226],{"class":95},[40,785,786],{"class":99}," Path(row[",[40,788,773],{"class":50},[40,790,762],{"class":99},[40,792,793,796,799,801,804],{"class":42,"line":185},[40,794,795],{"class":99},"        msg.add_attachment(path.read_bytes(), ",[40,797,798],{"class":678},"maintype",[40,800,226],{"class":95},[40,802,803],{"class":50},"\"application\"",[40,805,292],{"class":99},[40,807,808,811,813,816,818,821,823],{"class":42,"line":191},[40,809,810],{"class":678},"                           subtype",[40,812,226],{"class":95},[40,814,815],{"class":50},"\"vnd.openxmlformats-officedocument.spreadsheetml.sheet\"",[40,817,341],{"class":99},[40,819,820],{"class":678},"filename",[40,822,226],{"class":95},[40,824,825],{"class":99},"path.name)\n",[40,827,828,830],{"class":42,"line":197},[40,829,316],{"class":95},[40,831,832],{"class":99}," msg\n",[40,834,835],{"class":42,"line":203},[40,836,121],{"emptyLinePlaceholder":120},[40,838,839,841,844],{"class":42,"line":209},[40,840,256],{"class":95},[40,842,843],{"class":259}," defer",[40,845,846],{"class":99},"(db, row, error, now):\n",[40,848,849,852,854,856,859,861,864],{"class":42,"line":215},[40,850,851],{"class":99},"    attempts ",[40,853,226],{"class":95},[40,855,770],{"class":99},[40,857,858],{"class":50},"\"attempts\"",[40,860,732],{"class":99},[40,862,863],{"class":95},"+",[40,865,866],{"class":127}," 1\n",[40,868,869,872,874,877,880,883,886,889,892],{"class":42,"line":220},[40,870,871],{"class":99},"    status ",[40,873,226],{"class":95},[40,875,876],{"class":50}," \"failed\"",[40,878,879],{"class":95}," if",[40,881,882],{"class":99}," attempts ",[40,884,885],{"class":95},">=",[40,887,888],{"class":127}," MAX_ATTEMPTS",[40,890,891],{"class":95}," else",[40,893,894],{"class":50}," \"pending\"\n",[40,896,897,900,903],{"class":42,"line":238},[40,898,899],{"class":99},"    db.execute(",[40,901,902],{"class":50},"\"UPDATE outbox SET attempts=?, status=?, next_attempt=?, last_error=? WHERE id=?\"",[40,904,292],{"class":99},[40,906,907,910,912,915,918,921,924,927,930,933,936,939],{"class":42,"line":248},[40,908,909],{"class":99},"               (attempts, status, (now ",[40,911,863],{"class":95},[40,913,914],{"class":127}," BASE_DELAY",[40,916,917],{"class":95}," *",[40,919,920],{"class":127}," 2",[40,922,923],{"class":95}," **",[40,925,926],{"class":99}," (attempts ",[40,928,929],{"class":95},"-",[40,931,932],{"class":127}," 1",[40,934,935],{"class":99},")).isoformat(), error, row[",[40,937,938],{"class":50},"\"id\"",[40,940,941],{"class":99},"]))\n",[40,943,944],{"class":42,"line":253},[40,945,121],{"emptyLinePlaceholder":120},[40,947,948,950,953,956,958,961,964,966,969],{"class":42,"line":266},[40,949,256],{"class":95},[40,951,952],{"class":259}," deliver_due",[40,954,955],{"class":99},"(db, now, host",[40,957,226],{"class":95},[40,959,960],{"class":50},"\"127.0.0.1\"",[40,962,963],{"class":99},", port",[40,965,226],{"class":95},[40,967,968],{"class":127},"8025",[40,970,971],{"class":99},"):\n",[40,973,974,977,979],{"class":42,"line":275},[40,975,976],{"class":99},"    db.row_factory ",[40,978,226],{"class":95},[40,980,981],{"class":99}," sqlite3.Row\n",[40,983,984,987,989,992,995],{"class":42,"line":286},[40,985,986],{"class":99},"    due ",[40,988,226],{"class":95},[40,990,991],{"class":99}," db.execute(",[40,993,994],{"class":50},"\"SELECT * FROM outbox WHERE status='pending' AND next_attempt\u003C=? ORDER BY id\"",[40,996,997],{"class":99},", (now.isoformat(),)).fetchall()\n",[40,999,1000,1002,1005],{"class":42,"line":295},[40,1001,767],{"class":95},[40,1003,1004],{"class":95}," not",[40,1006,1007],{"class":99}," due:\n",[40,1009,1010,1013],{"class":42,"line":313},[40,1011,1012],{"class":95},"        return",[40,1014,1015],{"class":99}," {}\n",[40,1017,1018,1021,1023],{"class":42,"line":322},[40,1019,1020],{"class":99},"    outcome ",[40,1022,226],{"class":95},[40,1024,1015],{"class":99},[40,1026,1027,1030],{"class":42,"line":327},[40,1028,1029],{"class":95},"    try",[40,1031,1032],{"class":99},":\n",[40,1034,1035,1038,1041,1044,1046,1049,1052,1055],{"class":42,"line":364},[40,1036,1037],{"class":95},"        with",[40,1039,1040],{"class":99}," smtplib.SMTP(host, port, ",[40,1042,1043],{"class":678},"timeout",[40,1045,226],{"class":95},[40,1047,1048],{"class":127},"30",[40,1050,1051],{"class":99},") ",[40,1053,1054],{"class":95},"as",[40,1056,1057],{"class":99}," smtp, db:\n",[40,1059,1060,1063,1066,1068],{"class":42,"line":396},[40,1061,1062],{"class":95},"            for",[40,1064,1065],{"class":99}," row ",[40,1067,307],{"class":95},[40,1069,1007],{"class":99},[40,1071,1072,1075],{"class":42,"line":407},[40,1073,1074],{"class":95},"                try",[40,1076,1032],{"class":99},[40,1078,1079],{"class":42,"line":433},[40,1080,1081],{"class":99},"                    smtp.send_message(build(row))\n",[40,1083,1085,1088,1091,1094,1096],{"class":42,"line":1084},34,[40,1086,1087],{"class":99},"                    db.execute(",[40,1089,1090],{"class":50},"\"UPDATE outbox SET status='sent', attempts=attempts+1, sent_at=? WHERE id=?\"",[40,1092,1093],{"class":99},", (now.isoformat(), row[",[40,1095,938],{"class":50},[40,1097,941],{"class":99},[40,1099,1101,1104,1106,1109,1111],{"class":42,"line":1100},35,[40,1102,1103],{"class":99},"                    outcome[row[",[40,1105,743],{"class":50},[40,1107,1108],{"class":99},"]] ",[40,1110,226],{"class":95},[40,1112,1113],{"class":50}," \"sent\"\n",[40,1115,1117,1120,1123,1125],{"class":42,"line":1116},36,[40,1118,1119],{"class":95},"                except",[40,1121,1122],{"class":99}," smtplib.SMTPRecipientsRefused ",[40,1124,1054],{"class":95},[40,1126,1127],{"class":99}," e:\n",[40,1129,1131,1134,1136,1139,1141],{"class":42,"line":1130},37,[40,1132,1133],{"class":99},"                    code, text ",[40,1135,226],{"class":95},[40,1137,1138],{"class":99}," e.recipients[row[",[40,1140,743],{"class":50},[40,1142,1143],{"class":99},"]]\n",[40,1145,1147,1150,1153,1155,1158],{"class":42,"line":1146},38,[40,1148,1149],{"class":95},"                    if",[40,1151,1152],{"class":99}," code ",[40,1154,885],{"class":95},[40,1156,1157],{"class":127}," 500",[40,1159,1032],{"class":99},[40,1161,1163,1166,1169],{"class":42,"line":1162},39,[40,1164,1165],{"class":99},"                        db.execute(",[40,1167,1168],{"class":50},"\"UPDATE outbox SET status='failed', attempts=attempts+1, last_error=? WHERE id=?\"",[40,1170,292],{"class":99},[40,1172,1174,1177,1180,1183,1187,1189,1192,1195,1198,1200,1202,1204,1206],{"class":42,"line":1173},40,[40,1175,1176],{"class":99},"                                   (",[40,1178,1179],{"class":95},"f",[40,1181,1182],{"class":50},"\"",[40,1184,1186],{"class":1185},"sSjpA","{",[40,1188,37],{"class":99},[40,1190,1191],{"class":1185},"}",[40,1193,1194],{"class":1185}," {",[40,1196,1197],{"class":99},"text.decode()",[40,1199,1191],{"class":1185},[40,1201,1182],{"class":50},[40,1203,740],{"class":99},[40,1205,938],{"class":50},[40,1207,941],{"class":99},[40,1209,1211,1214,1216,1218,1220,1223,1226,1228,1230,1232],{"class":42,"line":1210},41,[40,1212,1213],{"class":99},"                        outcome[row[",[40,1215,743],{"class":50},[40,1217,1108],{"class":99},[40,1219,226],{"class":95},[40,1221,1222],{"class":95}," f",[40,1224,1225],{"class":50},"\"failed (",[40,1227,1186],{"class":1185},[40,1229,37],{"class":99},[40,1231,1191],{"class":1185},[40,1233,1234],{"class":50},")\"\n",[40,1236,1238,1241],{"class":42,"line":1237},42,[40,1239,1240],{"class":95},"                    else",[40,1242,1032],{"class":99},[40,1244,1246,1249,1251,1253,1255,1257,1259,1261,1263,1265,1267],{"class":42,"line":1245},43,[40,1247,1248],{"class":99},"                        defer(db, row, ",[40,1250,1179],{"class":95},[40,1252,1182],{"class":50},[40,1254,1186],{"class":1185},[40,1256,37],{"class":99},[40,1258,1191],{"class":1185},[40,1260,1194],{"class":1185},[40,1262,1197],{"class":99},[40,1264,1191],{"class":1185},[40,1266,1182],{"class":50},[40,1268,1269],{"class":99},", now)\n",[40,1271,1273,1275,1277,1279,1281,1283,1286,1288,1290,1292],{"class":42,"line":1272},44,[40,1274,1213],{"class":99},[40,1276,743],{"class":50},[40,1278,1108],{"class":99},[40,1280,226],{"class":95},[40,1282,1222],{"class":95},[40,1284,1285],{"class":50},"\"retry later (",[40,1287,1186],{"class":1185},[40,1289,37],{"class":99},[40,1291,1191],{"class":1185},[40,1293,1234],{"class":50},[40,1295,1297,1300,1303,1306,1309,1311],{"class":42,"line":1296},45,[40,1298,1299],{"class":95},"    except",[40,1301,1302],{"class":99}," (",[40,1304,1305],{"class":127},"OSError",[40,1307,1308],{"class":99},", smtplib.SMTPException) ",[40,1310,1054],{"class":95},[40,1312,1127],{"class":99},[40,1314,1316,1318],{"class":42,"line":1315},46,[40,1317,1037],{"class":95},[40,1319,272],{"class":99},[40,1321,1323,1325,1327,1329],{"class":42,"line":1322},47,[40,1324,1062],{"class":95},[40,1326,1065],{"class":99},[40,1328,307],{"class":95},[40,1330,1007],{"class":99},[40,1332,1334,1337,1339,1341,1343,1346,1349],{"class":42,"line":1333},48,[40,1335,1336],{"class":95},"                if",[40,1338,770],{"class":99},[40,1340,743],{"class":50},[40,1342,732],{"class":99},[40,1344,1345],{"class":95},"not",[40,1347,1348],{"class":95}," in",[40,1350,1351],{"class":99}," outcome:\n",[40,1353,1355,1358,1360,1363,1365,1368,1370,1372],{"class":42,"line":1354},49,[40,1356,1357],{"class":99},"                    defer(db, row, ",[40,1359,1179],{"class":95},[40,1361,1362],{"class":50},"\"server: ",[40,1364,1186],{"class":1185},[40,1366,1367],{"class":99},"e",[40,1369,1191],{"class":1185},[40,1371,1182],{"class":50},[40,1373,1269],{"class":99},[40,1375,1377,1379,1381,1383,1385],{"class":42,"line":1376},50,[40,1378,1103],{"class":99},[40,1380,743],{"class":50},[40,1382,1108],{"class":99},[40,1384,226],{"class":95},[40,1386,1387],{"class":50}," \"retry later (server)\"\n",[40,1389,1391,1393],{"class":42,"line":1390},51,[40,1392,316],{"class":95},[40,1394,1395],{"class":99}," outcome\n",[40,1397,1399],{"class":42,"line":1398},52,[40,1400,121],{"emptyLinePlaceholder":120},[40,1402,1404,1406],{"class":42,"line":1403},53,[40,1405,410],{"class":127},[40,1407,1408],{"class":99},"(deliver_due(db, t0))\n",[10,1410,1411,1414,1415,1418,1419,1422],{},[37,1412,1413],{},"SMTPRecipientsRefused"," carries the server's code for the refused address. Codes in the 400s are ",[14,1416,1417],{},"temporary"," — mailbox busy, greylisting, rate limits — and are worth retrying. Codes in the 500s are ",[14,1420,1421],{},"permanent"," — the address does not exist, the domain refuses mail — and retrying only produces the same rejection, so the row is marked failed at once. Sending each recipient separately keeps one bad address from affecting the others, and lets each row's state be recorded exactly. If the connection itself fails — server down, DNS failure, timeout — every row that was not yet delivered is deferred together. The first run delivers to finance and the CEO, fails the deleted address permanently and schedules ops for a retry in five minutes.",[463,1424,472,1429,472,1432,472,1435,472,1438,472,1442,472,1448,472,1452,472,1456,472,1460,472,1463,472,1466,472,1473,472,1478,472,1483,472,1488,472,1492,472,1496,472,1498,472,1501,472,1505,472,1508,472,1511,472,1517,472,1520,472,1523,472,1525,472,1528,472,1532,472,1535,472,1538,472,1544,472,1547],{"viewBox":1425,"role":466,"ariaLabelledBy":1426,"xmlns":470,"style":471},"4 0 752 272",[1427,1428],"ob-classify-t","ob-classify-d",[474,1430,1431],{"id":1427},"What happens to each recipient",[478,1433,1434],{"id":1428},"An accepted message is marked sent and never sent again. A 4xx reply or a connection failure schedules a retry with a doubling delay, up to five attempts. A 5xx reply marks the row failed immediately because retrying will not help.",[482,1436],{"x":484,"y":359,"width":485,"height":1437,"fill":487},"272",[482,1439],{"x":1440,"y":491,"width":1441,"height":573,"rx":494,"fill":535,"stroke":526,"style":497},"234.4","291.2",[499,1443,1445],{"transform":1444},"translate(368.0,29.0) scale(1.000)",[503,1446],{"d":1447,"fill":506,"stroke":542,"style":508},"M3 6h18v12H3z M3 7l9 6 9-6",[510,1449,1451],{"x":1450,"y":573,"style":546},"380.0","Server's answer for this recipient",[42,1453],{"x1":1450,"y1":1454,"x2":1450,"y2":1455,"stroke":526,"style":497},"91.5","111.5",[42,1457],{"x1":1458,"y1":1455,"x2":1459,"y2":1455,"stroke":526,"style":497},"134.7","625.3",[42,1461],{"x1":1458,"y1":1455,"x2":1458,"y2":1462,"stroke":526,"style":497},"157.5",[528,1464],{"points":1465,"fill":531},"134.7,161.5 129.7,152.5 139.7,152.5",[482,1467],{"x":1468,"y":1469,"width":1470,"height":1471,"rx":344,"fill":487,"stroke":496,"style":1472},"114.0","121.5","41.3","21.0","stroke-width:1.2px",[510,1474,1477],{"x":1458,"y":1475,"style":1476},"136.0","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","250",[482,1479],{"x":490,"y":1480,"width":1481,"height":1482,"rx":494,"fill":561,"stroke":562,"style":497},"163.5","229.3","93.0",[499,1484,1486],{"transform":1485},"translate(122.7,176.5) scale(1.000)",[503,1487],{"d":601,"fill":506,"stroke":569,"style":508},[510,1489,1491],{"x":1458,"y":1490,"style":574},"223.0","sent",[510,1493,1495],{"x":1458,"y":1494,"style":519},"242.5","never resent",[42,1497],{"x1":1450,"y1":1455,"x2":1450,"y2":1462,"stroke":526,"style":497},[528,1499],{"points":1500,"fill":531},"380.0,161.5 375.0,152.5 385.0,152.5",[482,1502],{"x":1503,"y":1469,"width":1504,"height":1471,"rx":344,"fill":487,"stroke":496,"style":1472},"312.6","134.8",[510,1506,1507],{"x":1450,"y":1475,"style":1476},"4xx \u002F no connection",[482,1509],{"x":1510,"y":1480,"width":1481,"height":1482,"rx":494,"fill":594,"stroke":595,"style":497},"265.3",[499,1512,1514],{"transform":1513},"translate(368.0,176.5) scale(1.000)",[503,1515],{"d":1516,"fill":506,"stroke":602,"style":508},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2",[510,1518,1519],{"x":1450,"y":1490,"style":606},"retry later",[510,1521,1522],{"x":1450,"y":1494,"style":519},"5, 10, 20 … min",[42,1524],{"x1":1459,"y1":1455,"x2":1459,"y2":1462,"stroke":526,"style":497},[528,1526],{"points":1527,"fill":531},"625.3,161.5 620.3,152.5 630.3,152.5",[482,1529],{"x":1530,"y":1469,"width":1531,"height":1471,"rx":344,"fill":487,"stroke":496,"style":1472},"605.6","39.5",[510,1533,1534],{"x":1459,"y":1475,"style":1476},"5xx",[482,1536],{"x":1537,"y":1480,"width":1481,"height":1482,"rx":494,"fill":535,"stroke":526,"style":497},"510.7",[499,1539,1541],{"transform":1540},"translate(613.3,176.5) scale(1.000)",[503,1542],{"d":1543,"fill":506,"stroke":542,"style":508},"M6 6l12 12 M18 6L6 18",[510,1545,1546],{"x":1459,"y":1490,"style":546},"failed now",[510,1548,1549],{"x":1459,"y":1494,"style":519},"report it",[25,1551,1553],{"id":1552},"step-3-run-the-delivery-step-on-a-schedule","Step 3 — Run the delivery step on a schedule",[10,1555,1556],{},"Delivery runs every few minutes from cron or a scheduler; each run sends only what is due. Here the clock is moved forward by hand to show the backoff:",[30,1558,1560],{"className":86,"code":1559,"language":88,"meta":35,"style":35},"for minutes in [1, 6, 12, 20]:\n    now = t0 + timedelta(minutes=minutes)\n    print(f\"t+{minutes:>2} min:\", deliver_due(db, now) or \"nothing due\")\n\nfor row in db.execute(\"SELECT recipient, status, attempts, last_error FROM outbox ORDER BY id\"):\n    print(f\"  {row['recipient']:\u003C26} {row['status']:\u003C8} attempts={row['attempts']}  {row['last_error'] or ''}\")\nprint(\"server received:\", sorted(handler.delivered))\n",[37,1561,1562,1592,1614,1649,1653,1668,1744],{"__ignoreMap":35},[40,1563,1564,1566,1569,1571,1573,1576,1578,1581,1583,1585,1587,1590],{"class":42,"line":43},[40,1565,301],{"class":95},[40,1567,1568],{"class":99}," minutes ",[40,1570,307],{"class":95},[40,1572,372],{"class":99},[40,1574,1575],{"class":127},"1",[40,1577,341],{"class":99},[40,1579,1580],{"class":127},"6",[40,1582,341],{"class":99},[40,1584,494],{"class":127},[40,1586,341],{"class":99},[40,1588,1589],{"class":127},"20",[40,1591,776],{"class":99},[40,1593,1594,1597,1599,1602,1604,1607,1609,1611],{"class":42,"line":103},[40,1595,1596],{"class":99},"    now ",[40,1598,226],{"class":95},[40,1600,1601],{"class":99}," t0 ",[40,1603,863],{"class":95},[40,1605,1606],{"class":99}," timedelta(",[40,1608,679],{"class":678},[40,1610,226],{"class":95},[40,1612,1613],{"class":99},"minutes)\n",[40,1615,1616,1619,1621,1623,1626,1628,1630,1633,1635,1638,1641,1644,1647],{"class":42,"line":117},[40,1617,1618],{"class":127},"    print",[40,1620,413],{"class":99},[40,1622,1179],{"class":95},[40,1624,1625],{"class":50},"\"t+",[40,1627,1186],{"class":1185},[40,1629,679],{"class":99},[40,1631,1632],{"class":95},":>2",[40,1634,1191],{"class":1185},[40,1636,1637],{"class":50}," min:\"",[40,1639,1640],{"class":99},", deliver_due(db, now) ",[40,1642,1643],{"class":95},"or",[40,1645,1646],{"class":50}," \"nothing due\"",[40,1648,235],{"class":99},[40,1650,1651],{"class":42,"line":124},[40,1652,121],{"emptyLinePlaceholder":120},[40,1654,1655,1657,1659,1661,1663,1666],{"class":42,"line":137},[40,1656,301],{"class":95},[40,1658,1065],{"class":99},[40,1660,307],{"class":95},[40,1662,991],{"class":99},[40,1664,1665],{"class":50},"\"SELECT recipient, status, attempts, last_error FROM outbox ORDER BY id\"",[40,1667,971],{"class":99},[40,1669,1670,1672,1674,1676,1679,1681,1684,1687,1690,1693,1695,1697,1699,1702,1704,1707,1709,1712,1714,1716,1719,1721,1723,1726,1728,1731,1733,1735,1738,1740,1742],{"class":42,"line":143},[40,1671,1618],{"class":127},[40,1673,413],{"class":99},[40,1675,1179],{"class":95},[40,1677,1678],{"class":50},"\"  ",[40,1680,1186],{"class":1185},[40,1682,1683],{"class":99},"row[",[40,1685,1686],{"class":50},"'recipient'",[40,1688,1689],{"class":99},"]",[40,1691,1692],{"class":95},":\u003C26",[40,1694,1191],{"class":1185},[40,1696,1194],{"class":1185},[40,1698,1683],{"class":99},[40,1700,1701],{"class":50},"'status'",[40,1703,1689],{"class":99},[40,1705,1706],{"class":95},":\u003C8",[40,1708,1191],{"class":1185},[40,1710,1711],{"class":50}," attempts=",[40,1713,1186],{"class":1185},[40,1715,1683],{"class":99},[40,1717,1718],{"class":50},"'attempts'",[40,1720,1689],{"class":99},[40,1722,1191],{"class":1185},[40,1724,1725],{"class":1185},"  {",[40,1727,1683],{"class":99},[40,1729,1730],{"class":50},"'last_error'",[40,1732,732],{"class":99},[40,1734,1643],{"class":95},[40,1736,1737],{"class":50}," ''",[40,1739,1191],{"class":1185},[40,1741,1182],{"class":50},[40,1743,235],{"class":99},[40,1745,1746,1748,1750,1753,1755,1758],{"class":42,"line":149},[40,1747,410],{"class":127},[40,1749,413],{"class":99},[40,1751,1752],{"class":50},"\"server received:\"",[40,1754,341],{"class":99},[40,1756,1757],{"class":127},"sorted",[40,1759,1760],{"class":99},"(handler.delivered))\n",[10,1762,1763],{},"At one minute nothing is due. At six minutes ops is tried again and deferred a second time — now for ten minutes, the doubled delay. At twelve minutes nothing is due; at twenty, ops is accepted. The final table shows exactly who received the report, after how many attempts, and why the deleted address failed; ops keeps its last error as a record of the earlier deferrals. The server received three messages, one per valid recipient, with no duplicates. With five attempts and a doubling delay starting at five minutes, a message is retried for about 75 minutes before being given up, which rides out most mail server restarts and network blips; adjust both numbers to the report's deadline.",[463,1765,472,1770,472,1773,472,1776,472,1779,472,1786,472,1790,472,1796,472,1801,472,1805,472,1809,472,1813,472,1818,472,1822,472,1824,472,1826,472,1829,472,1834,472,1837,472,1839,472,1841,472,1844,472,1849,472,1852,472,1854],{"viewBox":1766,"role":466,"ariaLabelledBy":1767,"xmlns":470,"style":471},"4 0 752 211",[1768,1769],"ob-ops-t","ob-ops-d",[474,1771,1772],{"id":1768},"One recipient's path through the outbox",[478,1774,1775],{"id":1769},"The ops address is deferred with a 451 reply at 07:00 and retried after five minutes. At 07:06 it is deferred again and the delay doubles to ten minutes. At 07:20 the server accepts it and the row is marked sent after three attempts.",[482,1777],{"x":484,"y":359,"width":485,"height":1778,"fill":487},"211",[42,1780],{"x1":1781,"y1":1782,"x2":1783,"y2":1782,"stroke":1784,"style":1785},"94.0","105.5","666.0","var(--muted,#5b6780)","stroke-width:2.5px",[482,1787],{"x":490,"y":491,"width":1788,"height":1789,"rx":494,"fill":594,"stroke":595,"style":497},"172.0","57.5",[510,1791,1795],{"x":1792,"y":1793,"style":1794},"106.0","40.5","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","07:00",[510,1797,1800],{"x":1792,"y":1798,"style":1799},"59.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","451, retry in 5 min",[42,1802],{"x1":1792,"y1":1803,"x2":1792,"y2":1782,"stroke":1784,"style":1804},"73.5","stroke-width:1.5px",[1806,1807],"circle",{"cx":1792,"cy":1782,"r":1808,"fill":595,"stroke":487,"style":497},"7.0",[482,1810],{"x":1811,"y":1812,"width":1788,"height":1789,"rx":494,"fill":594,"stroke":595,"style":497},"202.7","137.5",[510,1814,1817],{"x":1815,"y":1816,"style":1794},"288.7","162.0","07:06",[510,1819,1821],{"x":1815,"y":1820,"style":1799},"180.5","451, retry in 10 min",[42,1823],{"x1":1815,"y1":1782,"x2":1815,"y2":1812,"stroke":1784,"style":1804},[1806,1825],{"cx":1815,"cy":1782,"r":1808,"fill":595,"stroke":487,"style":497},[482,1827],{"x":1828,"y":491,"width":1788,"height":1789,"rx":494,"fill":495,"stroke":496,"style":497},"385.3",[510,1830,1833],{"x":1831,"y":1793,"style":1832},"471.3","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","07:12",[510,1835,1836],{"x":1831,"y":1798,"style":1799},"not yet due",[42,1838],{"x1":1831,"y1":1803,"x2":1831,"y2":1782,"stroke":1784,"style":1804},[1806,1840],{"cx":1831,"cy":1782,"r":1808,"fill":1784,"stroke":487,"style":497},[482,1842],{"x":1843,"y":1812,"width":1788,"height":1789,"rx":494,"fill":561,"stroke":562,"style":497},"568.0",[510,1845,1848],{"x":1846,"y":1816,"style":1847},"654.0","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","07:20",[510,1850,1851],{"x":1846,"y":1820,"style":1799},"accepted, sent",[42,1853],{"x1":1846,"y1":1782,"x2":1846,"y2":1812,"stroke":1784,"style":1804},[1806,1855],{"cx":1846,"cy":1782,"r":1808,"fill":562,"stroke":487,"style":497},[25,1857,1859],{"id":1858},"step-4-survive-a-server-outage","Step 4 — Survive a server outage",[10,1861,1862],{},"Stop the mail server, queue next month's report and run delivery:",[30,1864,1866],{"className":86,"code":1865,"language":88,"meta":35,"style":35},"controller.stop()\nenqueue(db, [\"finance@example.com\"], \"Sales report — October 2026\", \"The October sales report is attached.\", \"sales_2026-09.xlsx\", t0 + timedelta(hours=1))\nprint(deliver_due(db, t0 + timedelta(hours=1)))\nprint(db.execute(\"SELECT status, attempts, next_attempt FROM outbox WHERE subject LIKE '%October%'\").fetchone()[:])\ndb.close()\n",[37,1867,1868,1873,1912,1932,1945],{"__ignoreMap":35},[40,1869,1870],{"class":42,"line":43},[40,1871,1872],{"class":99},"controller.stop()\n",[40,1874,1875,1878,1880,1883,1886,1888,1891,1893,1895,1898,1900,1902,1905,1907,1909],{"class":42,"line":103},[40,1876,1877],{"class":99},"enqueue(db, [",[40,1879,375],{"class":50},[40,1881,1882],{"class":99},"], ",[40,1884,1885],{"class":50},"\"Sales report — October 2026\"",[40,1887,341],{"class":99},[40,1889,1890],{"class":50},"\"The October sales report is attached.\"",[40,1892,341],{"class":99},[40,1894,427],{"class":50},[40,1896,1897],{"class":99},", t0 ",[40,1899,863],{"class":95},[40,1901,1606],{"class":99},[40,1903,1904],{"class":678},"hours",[40,1906,226],{"class":95},[40,1908,1575],{"class":127},[40,1910,1911],{"class":99},"))\n",[40,1913,1914,1916,1919,1921,1923,1925,1927,1929],{"class":42,"line":117},[40,1915,410],{"class":127},[40,1917,1918],{"class":99},"(deliver_due(db, t0 ",[40,1920,863],{"class":95},[40,1922,1606],{"class":99},[40,1924,1904],{"class":678},[40,1926,226],{"class":95},[40,1928,1575],{"class":127},[40,1930,1931],{"class":99},")))\n",[40,1933,1934,1936,1939,1942],{"class":42,"line":124},[40,1935,410],{"class":127},[40,1937,1938],{"class":99},"(db.execute(",[40,1940,1941],{"class":50},"\"SELECT status, attempts, next_attempt FROM outbox WHERE subject LIKE '%October%'\"",[40,1943,1944],{"class":99},").fetchone()[:])\n",[40,1946,1947],{"class":42,"line":137},[40,1948,1949],{"class":99},"db.close()\n",[10,1951,1952,1953,1956,1957,1960,1961,1965,1966,1968],{},"The connection is refused, so the October row stays ",[37,1954,1955],{},"pending"," with one attempt recorded and a retry scheduled five minutes later. Nothing is lost and nothing needs rebuilding: when the server is back, the next scheduled delivery sends it. Rows that end up ",[37,1958,1959],{},"failed"," should be reported — an alert, or a daily summary to the report owner listing undeliverable addresses — as described in ",[19,1962,1964],{"href":1963},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails\u002F","Send an Alert When an Excel Report Job Fails",". Deleting old ",[37,1967,1491],{}," rows after a retention period keeps the table small while preserving a delivery record for as long as you need it.",[25,1970,1972],{"id":1971},"common-pitfalls","Common pitfalls",[1974,1975,1976,1984,1990,1996,2004],"ul",{},[1977,1978,1979,1983],"li",{},[1980,1981,1982],"strong",{},"Sending inline from the report job."," A mail outage loses the report or forces a rebuild.",[1977,1985,1986,1989],{},[1980,1987,1988],{},"Retrying 5xx rejections."," They are permanent; mark them failed and report them.",[1977,1991,1992,1995],{},[1980,1993,1994],{},"One message to all recipients."," One bad address can block the rest; send per recipient.",[1977,1997,1998,2001,2002,23],{},[1980,1999,2000],{},"Duplicate sends on rerun."," Use a unique key and ",[37,2003,460],{},[1977,2005,2006,2009,2010,23],{},[1980,2007,2008],{},"Silent failures."," Alert on rows that end in ",[37,2011,1959],{},[25,2013,2015],{"id":2014},"conclusion","Conclusion",[10,2017,2018],{},"Decouple report generation from delivery with an outbox: the job inserts one row per recipient with a unique key, so reruns cannot duplicate, and a scheduled delivery step sends each due row on its own. Mark accepted rows sent, retry 4xx and connection failures with a doubling delay up to a limit, mark 5xx rejections failed immediately, and alert on failures. The result survives mail outages, isolates bad addresses and leaves a record of who received what.",[25,2020,2022],{"id":2021},"frequently-asked-questions","Frequently asked questions",[10,2024,2025,2028],{},[1980,2026,2027],{},"How do I retry failed emails in Python?","\nStore each message in a database table with a status and next-attempt time, and run a delivery step on a schedule that sends due rows and reschedules temporary failures with a growing delay.",[10,2030,2031,2034],{},[1980,2032,2033],{},"Which SMTP errors should be retried?","\n4xx replies and connection failures are temporary and worth retrying; 5xx replies such as 550 User unknown are permanent and should be marked failed.",[10,2036,2037,2040],{},[1980,2038,2039],{},"How do I avoid sending a report email twice?","\nGive each message a unique key, such as recipient plus subject or report period, and insert with INSERT OR IGNORE so a rerun of the job adds nothing.",[10,2042,2043,2046],{},[1980,2044,2045],{},"Why send to each recipient separately?","\nSo one refused address cannot stop delivery to the others, and each recipient's delivery state can be recorded exactly.",[25,2048,2050],{"id":2049},"related","Related",[1974,2052,2053,2059,2066,2073],{},[1977,2054,2055,2056,2058],{},"Up one level: ",[19,2057,22],{"href":21}," — sending reports from Python.",[1977,2060,2061,2065],{},[19,2062,2064],{"href":2063},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a Failed Excel Report Job in Python"," — in-process retries for other steps.",[1977,2067,2068,2072],{},[19,2069,2071],{"href":2070},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-each-recipient-their-own-excel-report\u002F","Send Each Recipient Their Own Excel Report"," — per-recipient attachments.",[1977,2074,2075,2079],{},[19,2076,2078],{"href":2077},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun\u002F","Make an Excel Report Job Safe to Rerun"," — idempotence for the whole job.",[2081,2082,2083],"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 pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":35,"searchDepth":103,"depth":103,"links":2085},[2086,2087,2088,2089,2090,2091,2092,2093,2094],{"id":27,"depth":103,"text":28},{"id":79,"depth":103,"text":80},{"id":613,"depth":103,"text":614},{"id":1552,"depth":103,"text":1553},{"id":1858,"depth":103,"text":1859},{"id":1971,"depth":103,"text":1972},{"id":2014,"depth":103,"text":2015},{"id":2021,"depth":103,"text":2022},{"id":2049,"depth":103,"text":2050},"2026-10-02","Stop losing report emails when the mail server has a bad moment: write each message to a small SQLite outbox instead of sending it inline, deliver from the outbox with per-recipient status, retry temporary SMTP failures with backoff, mark permanent rejections as failed instead of retrying them forever, and never send the same report twice when the job is rerun.","md",[2099,2101,2103,2105],{"q":2027,"a":2100},"Store each message in a database table with a status and next-attempt time, and run a delivery step on a schedule that sends due rows and reschedules temporary failures with a growing delay.",{"q":2033,"a":2102},"4xx replies and connection failures are temporary and worth retrying; 5xx replies such as 550 User unknown are permanent and should be marked failed.",{"q":2039,"a":2104},"Give each message a unique key, such as recipient plus subject or report period, and insert with INSERT OR IGNORE so a rerun of the job adds nothing.",{"q":2045,"a":2106},"So one refused address cannot stop delivery to the others, and each recipient's delivery state can be recorded exactly.","queue and retry failed report emails",null,{"breadcrumb":2110},[2111,2114,2117],{"name":2112,"item":2113},"Home","\u002F",{"name":2115,"item":2116},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails",{"title":2120,"description":2121},"Queue and Retry Failed Excel Report Emails with an Outbox","A durable SQLite outbox for emailed Excel reports: idempotent enqueueing, per-recipient delivery, SMTP 4xx retried with backoff, 5xx marked failed, server outages survived, tested with aiosmtpd.","queue-and-retry-failed-report-emails","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails\u002Findex",[2125,2126],"email-delivery","reliable-jobs","how-to","Q_wkNDakdQakObac6DweOzwnl8jLMm4loa33z0W02_4",[2130,2134],{"title":2131,"path":2132,"stem":2133,"children":-1},"Personalise Report Emails with Jinja2 Templates","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates\u002Findex",{"title":2135,"path":2136,"stem":2137,"children":-1},"Send a Weekly Digest of Several Reports in One Email","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-a-weekly-digest-of-several-reports-in-one-email","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-a-weekly-digest-of-several-reports-in-one-email\u002Findex",[2139,2141],{"path":2140,"title":22},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib",{"path":2142,"title":2115},"\u002Fautomating-reporting-workflows",[],[2145,2148,2151,2154,2157,2160],{"path":2146,"title":2147,"kind":2127},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-credit-control-dunning-letter-run","Build a Credit Control Dunning Letter Run",{"path":2149,"title":2150,"kind":2127},"\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":2152,"title":2153,"kind":2127},"\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":2155,"title":2156,"kind":2127},"\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":2158,"title":2159,"kind":2127},"\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":2161,"title":2162,"kind":2127},"\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",1790972129725]