[{"data":1,"prerenderedAt":2330},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox":3,"surround:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox":2300,"ancestors:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox":2309,"children:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox":2314,"related:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox":2315},{"id":4,"title":5,"body":6,"dateModified":2267,"datePublished":2267,"description":2268,"extension":2269,"faq":2270,"keyword":2279,"kind":2280,"meta":2281,"navigation":278,"path":2289,"seo":2290,"slug":2293,"stem":2294,"topics":2295,"type":2298,"__hash__":2299},"docs\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox\u002Findex.md","Download Excel Attachments from an Email Inbox with Python",{"type":7,"value":8,"toc":2254},"minimark",[9,28,193,198,207,231,238,242,248,470,489,493,500,640,654,658,674,1109,1133,1247,1251,1258,1612,1619,1793,1796,1800,1803,1953,1960,2088,2096,2100,2152,2156,2171,2175,2187,2191,2200,2206,2212,2218,2222,2250],[10,11,12,13,17,18,21,22,27],"p",{},"Many reporting pipelines start with a person emailing a spreadsheet: a supplier's weekly stock file, a branch's sales return, a bank's statement export. Someone then saves the attachment into a folder where a script can find it — which works until they are on holiday. Python's standard library can do that step itself: ",[14,15,16],"code",{},"imaplib"," searches the mailbox and fetches messages, and the ",[14,19,20],{},"email"," package pulls out the attachments. The care is in what to trust — file names from strangers, attachments that are not really workbooks, messages seen twice. This page is part of ",[23,24,26],"a",{"href":25},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","Emailing Excel Reports with smtplib",", which covers the sending side.",[29,30,38,39,38,43,38,47,38,55,38,65,38,76,38,83,38,88,38,94,38,99,38,102,38,108,38,112,38,115,38,119,38,122,38,125,38,131,38,135,38,138,38,142,38,145,38,148,38,154,38,159,38,163,38,167,38,171,38,174,38,177,38,183,38,187,38,190],"svg",{"viewBox":31,"role":32,"ariaLabelledBy":33,"xmlns":36,"style":37},"4 0 752 142","img",[34,35],"imap-flow-t","imap-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  ",[40,41,42],"title",{"id":34},"From inbox to a folder of checked workbooks",[44,45,46],"desc",{"id":35},"Five steps. Search the mailbox over IMAP for messages from known senders. Fetch each message without marking it read. Keep only attachments that are Excel files and really open as workbooks. Save them under a safe, unique name. Record the Message-ID and move the message to a Processed folder.",[48,49],"rect",{"x":50,"y":51,"width":52,"height":53,"fill":54},"4","0","752","142","#ffffff",[48,56],{"x":57,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"20.0","16.0","115.2","110.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[66,67,69],"g",{"transform":68},"translate(65.6,37.8) scale(1.000)",[70,71],"path",{"d":72,"fill":73,"stroke":74,"style":75},"M11 4a7 7 0 1 0 .01 0z M16 16l5 5","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[77,78,82],"text",{"x":79,"y":80,"style":81},"77.6","84.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Search",[77,84,87],{"x":79,"y":85,"style":86},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","FROM, SINCE",[89,90],"line",{"x1":91,"y1":92,"x2":93,"y2":92,"stroke":63,"style":64},"140.2","71.2","162.2",[95,96],"polygon",{"points":97,"fill":98},"166.2,71.2 157.2,76.2 157.2,66.2","#5b5cf0",[48,100],{"x":101,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"171.2",[66,103,105],{"transform":104},"translate(216.8,37.8) scale(1.000)",[70,106],{"d":107,"fill":73,"stroke":74,"style":75},"M3 6h18v12H3z M3 7l9 6 9-6",[77,109,111],{"x":110,"y":80,"style":81},"228.8","Fetch",[77,113,114],{"x":110,"y":85,"style":86},"BODY.PEEK[]",[89,116],{"x1":117,"y1":92,"x2":118,"y2":92,"stroke":63,"style":64},"291.4","313.4",[95,120],{"points":121,"fill":98},"317.4,71.2 308.4,76.2 308.4,66.2",[48,123],{"x":124,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"322.4",[66,126,128],{"transform":127},"translate(368.0,37.8) scale(1.000)",[70,129],{"d":130,"fill":73,"stroke":74,"style":75},"M3 5h18l-7 8v6l-4 2v-8z",[77,132,134],{"x":133,"y":80,"style":81},"380.0","Filter",[77,136,137],{"x":133,"y":85,"style":86},"real Excel files",[89,139],{"x1":140,"y1":92,"x2":141,"y2":92,"stroke":63,"style":64},"442.6","464.6",[95,143],{"points":144,"fill":98},"468.6,71.2 459.6,76.2 459.6,66.2",[48,146],{"x":147,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"473.6",[66,149,151],{"transform":150},"translate(519.2,29.0) scale(1.000)",[70,152],{"d":153,"fill":73,"stroke":74,"style":75},"M3 6h6l2 2h10v11H3z",[77,155,158],{"x":156,"y":157,"style":81},"531.2","75.5","Save",[77,160,162],{"x":156,"y":161,"style":86},"95.0","safe unique",[77,164,166],{"x":156,"y":165,"style":86},"112.5","name",[89,168],{"x1":169,"y1":92,"x2":170,"y2":92,"stroke":63,"style":64},"593.8","615.8",[95,172],{"points":173,"fill":98},"619.8,71.2 610.8,76.2 610.8,66.2",[48,175],{"x":176,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"624.8",[66,178,180],{"transform":179},"translate(670.4,29.0) scale(1.000)",[70,181],{"d":182,"fill":73,"stroke":74,"style":75},"M5 12.5l4.5 4.5L19 7",[77,184,186],{"x":185,"y":157,"style":81},"682.4","Mark done",[77,188,189],{"x":185,"y":161,"style":86},"Message-ID,",[77,191,192],{"x":185,"y":165,"style":86},"move",[194,195,197],"h2",{"id":196},"prerequisites","Prerequisites",[10,199,200,201,203,204,206],{},"Nothing to install for the mail handling — ",[14,202,16],{}," and ",[14,205,20],{}," are in the standard library. openpyxl is used to check the saved files:",[208,209,214],"pre",{"className":210,"code":211,"language":212,"meta":213,"style":213},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install openpyxl\n","bash","",[14,215,216],{"__ignoreMap":213},[217,218,220,224,228],"span",{"class":89,"line":219},1,[217,221,223],{"class":222},"sMTad","pip",[217,225,227],{"class":226},"srMev"," install",[217,229,230],{"class":226}," openpyxl\n",[10,232,233,234,237],{},"You need an IMAP-enabled mailbox and credentials, ideally a dedicated address such as ",[14,235,236],{},"reports@"," rather than a person's inbox. The examples below parse four sample messages: a supplier's stock file with a logo image alongside it, a statement with a German file name, an attachment whose name tries to escape the download folder, and a \"workbook\" that is not one.",[194,239,241],{"id":240},"step-1-search-and-fetch-messages-over-imap","Step 1 — Search and fetch messages over IMAP",[10,243,244,245,247],{},"Connect over TLS, select the mailbox and search. IMAP search criteria are strings; combine sender and date so the job only looks at recent mail from expected senders. Fetch with ",[14,246,114],{},", which downloads the message without marking it as read:",[208,249,253],{"className":250,"code":251,"language":252,"meta":213,"style":213},"language-python shiki shiki-themes github-light github-dark-high-contrast","import imaplib\nimport os\n\nHOST = \"imap.example.com\"\nwith imaplib.IMAP4_SSL(HOST) as imap:\n    imap.login(os.environ[\"IMAP_USER\"], os.environ[\"IMAP_PASSWORD\"])\n    imap.select(\"INBOX\")\n    status, data = imap.uid(\"SEARCH\", None, '(FROM \"northline.example\" SINCE \"20-Sep-2026\")')\n    uids = data[0].split()\n    raw_messages = []\n    for uid in uids:\n        status, parts = imap.uid(\"FETCH\", uid, \"(BODY.PEEK[])\")\n        raw_messages.append(parts[0][1])\nprint(len(raw_messages), \"messages fetched\")\n","python",[14,254,255,265,273,280,293,313,331,343,371,387,398,413,434,450],{"__ignoreMap":213},[217,256,257,261],{"class":89,"line":219},[217,258,260],{"class":259},"s-kum","import",[217,262,264],{"class":263},"skGVy"," imaplib\n",[217,266,268,270],{"class":89,"line":267},2,[217,269,260],{"class":259},[217,271,272],{"class":263}," os\n",[217,274,276],{"class":89,"line":275},3,[217,277,279],{"emptyLinePlaceholder":278},true,"\n",[217,281,283,287,290],{"class":89,"line":282},4,[217,284,286],{"class":285},"sP0c6","HOST",[217,288,289],{"class":259}," =",[217,291,292],{"class":226}," \"imap.example.com\"\n",[217,294,296,299,302,304,307,310],{"class":89,"line":295},5,[217,297,298],{"class":259},"with",[217,300,301],{"class":263}," imaplib.IMAP4_SSL(",[217,303,286],{"class":285},[217,305,306],{"class":263},") ",[217,308,309],{"class":259},"as",[217,311,312],{"class":263}," imap:\n",[217,314,316,319,322,325,328],{"class":89,"line":315},6,[217,317,318],{"class":263},"    imap.login(os.environ[",[217,320,321],{"class":226},"\"IMAP_USER\"",[217,323,324],{"class":263},"], os.environ[",[217,326,327],{"class":226},"\"IMAP_PASSWORD\"",[217,329,330],{"class":263},"])\n",[217,332,334,337,340],{"class":89,"line":333},7,[217,335,336],{"class":263},"    imap.select(",[217,338,339],{"class":226},"\"INBOX\"",[217,341,342],{"class":263},")\n",[217,344,346,349,352,355,358,361,364,366,369],{"class":89,"line":345},8,[217,347,348],{"class":263},"    status, data ",[217,350,351],{"class":259},"=",[217,353,354],{"class":263}," imap.uid(",[217,356,357],{"class":226},"\"SEARCH\"",[217,359,360],{"class":263},", ",[217,362,363],{"class":285},"None",[217,365,360],{"class":263},[217,367,368],{"class":226},"'(FROM \"northline.example\" SINCE \"20-Sep-2026\")'",[217,370,342],{"class":263},[217,372,374,377,379,382,384],{"class":89,"line":373},9,[217,375,376],{"class":263},"    uids ",[217,378,351],{"class":259},[217,380,381],{"class":263}," data[",[217,383,51],{"class":285},[217,385,386],{"class":263},"].split()\n",[217,388,390,393,395],{"class":89,"line":389},10,[217,391,392],{"class":263},"    raw_messages ",[217,394,351],{"class":259},[217,396,397],{"class":263}," []\n",[217,399,401,404,407,410],{"class":89,"line":400},11,[217,402,403],{"class":259},"    for",[217,405,406],{"class":263}," uid ",[217,408,409],{"class":259},"in",[217,411,412],{"class":263}," uids:\n",[217,414,416,419,421,423,426,429,432],{"class":89,"line":415},12,[217,417,418],{"class":263},"        status, parts ",[217,420,351],{"class":259},[217,422,354],{"class":263},[217,424,425],{"class":226},"\"FETCH\"",[217,427,428],{"class":263},", uid, ",[217,430,431],{"class":226},"\"(BODY.PEEK[])\"",[217,433,342],{"class":263},[217,435,437,440,442,445,448],{"class":89,"line":436},13,[217,438,439],{"class":263},"        raw_messages.append(parts[",[217,441,51],{"class":285},[217,443,444],{"class":263},"][",[217,446,447],{"class":285},"1",[217,449,330],{"class":263},[217,451,453,456,459,462,465,468],{"class":89,"line":452},14,[217,454,455],{"class":285},"print",[217,457,458],{"class":263},"(",[217,460,461],{"class":285},"len",[217,463,464],{"class":263},"(raw_messages), ",[217,466,467],{"class":226},"\"messages fetched\"",[217,469,342],{"class":263},[10,471,472,473,476,477,203,480,483,484,488],{},"Use ",[14,474,475],{},"imap.uid(...)"," rather than plain ",[14,478,479],{},"search",[14,481,482],{},"fetch",": UIDs stay stable for a message, while sequence numbers shift whenever a message is deleted or moved. Keep credentials in environment variables or a secrets store, never in the script. Microsoft 365 no longer accepts passwords for IMAP and requires OAuth; there, ",[23,485,487],{"href":486},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api\u002F","the Microsoft Graph API"," is usually the easier route, and Gmail needs an app password or OAuth.",[194,490,492],{"id":491},"step-2-parse-messages-and-list-their-attachments","Step 2 — Parse messages and list their attachments",[10,494,495,496,499],{},"Parse each message with the modern email policy. Without ",[14,497,498],{},"policy=default",", file names written in RFC 2231 or encoded-word form come back as raw encoded strings:",[208,501,503],{"className":250,"code":502,"language":252,"meta":213,"style":213},"from email import message_from_bytes\nfrom email.policy import default\n\nfor raw in raw_messages:\n    msg = message_from_bytes(raw, policy=default)\n    print(msg[\"From\"], \"|\", msg[\"Subject\"], \"|\", msg[\"Message-ID\"])\n    for part in msg.iter_attachments():\n        print(\"   \", repr(part.get_filename()), part.get_content_type(), len(part.get_content()), \"bytes\")\n",[14,504,505,518,530,534,547,566,600,612],{"__ignoreMap":213},[217,506,507,510,513,515],{"class":89,"line":219},[217,508,509],{"class":259},"from",[217,511,512],{"class":263}," email ",[217,514,260],{"class":259},[217,516,517],{"class":263}," message_from_bytes\n",[217,519,520,522,525,527],{"class":89,"line":267},[217,521,509],{"class":259},[217,523,524],{"class":263}," email.policy ",[217,526,260],{"class":259},[217,528,529],{"class":263}," default\n",[217,531,532],{"class":89,"line":275},[217,533,279],{"emptyLinePlaceholder":278},[217,535,536,539,542,544],{"class":89,"line":282},[217,537,538],{"class":259},"for",[217,540,541],{"class":263}," raw ",[217,543,409],{"class":259},[217,545,546],{"class":263}," raw_messages:\n",[217,548,549,552,554,557,561,563],{"class":89,"line":295},[217,550,551],{"class":263},"    msg ",[217,553,351],{"class":259},[217,555,556],{"class":263}," message_from_bytes(raw, ",[217,558,560],{"class":559},"sa561","policy",[217,562,351],{"class":259},[217,564,565],{"class":263},"default)\n",[217,567,568,571,574,577,580,583,586,589,591,593,595,598],{"class":89,"line":315},[217,569,570],{"class":285},"    print",[217,572,573],{"class":263},"(msg[",[217,575,576],{"class":226},"\"From\"",[217,578,579],{"class":263},"], ",[217,581,582],{"class":226},"\"|\"",[217,584,585],{"class":263},", msg[",[217,587,588],{"class":226},"\"Subject\"",[217,590,579],{"class":263},[217,592,582],{"class":226},[217,594,585],{"class":263},[217,596,597],{"class":226},"\"Message-ID\"",[217,599,330],{"class":263},[217,601,602,604,607,609],{"class":89,"line":333},[217,603,403],{"class":259},[217,605,606],{"class":263}," part ",[217,608,409],{"class":259},[217,610,611],{"class":263}," msg.iter_attachments():\n",[217,613,614,617,619,622,624,627,630,632,635,638],{"class":89,"line":345},[217,615,616],{"class":285},"        print",[217,618,458],{"class":263},[217,620,621],{"class":226},"\"   \"",[217,623,360],{"class":263},[217,625,626],{"class":285},"repr",[217,628,629],{"class":263},"(part.get_filename()), part.get_content_type(), ",[217,631,461],{"class":285},[217,633,634],{"class":263},"(part.get_content()), ",[217,636,637],{"class":226},"\"bytes\"",[217,639,342],{"class":263},[10,641,642,645,646,649,650,653],{},[14,643,644],{},"iter_attachments()"," yields each attached part, skipping the message body. The German file name ",[14,647,648],{},"Übersicht September.xlsx"," arrives decoded, ready to use. The third message's attachment is named ",[14,651,652],{},"..\u002F..\u002Finvoice.xlsx"," — a file name chosen by whoever sent the mail, which could point anywhere on disk if used as it is.",[194,655,657],{"id":656},"step-3-keep-only-real-excel-files-under-safe-names","Step 3 — Keep only real Excel files, under safe names",[10,659,660,661,665,666,669,670,673],{},"Decide which attachments to keep by extension ",[662,663,664],"em",{},"and"," by content: the extension and content type are claims made by the sender's mail client, and a ",[14,667,668],{},".xlsx"," that is really something else will break the next step of the pipeline. An xlsx file is a zip archive, so a valid one starts with the bytes ",[14,671,672],{},"PK",". Build the saved file name yourself, from a sanitised version of the original, and never let it contain a path:",[208,675,677],{"className":250,"code":676,"language":252,"meta":213,"style":213},"import re\nfrom pathlib import Path\n\nEXCEL = {\".xlsx\", \".xlsm\", \".xls\"}\ninbox = Path(\"incoming\"); inbox.mkdir(exist_ok=True)\n\ndef safe_name(name):\n    base = Path(name.replace(\"\\\\\", \"\u002F\")).name\n    base = re.sub(r\"[^\\w.\\- ]\", \"_\", base).strip(\" .\")\n    return base or \"attachment.xlsx\"\n\ndef excel_attachments(msg):\n    for part in msg.iter_attachments():\n        name = part.get_filename() or \"\"\n        ext = Path(name).suffix.lower()\n        data = part.get_content()\n        if ext not in EXCEL:\n            continue\n        looks_valid = data[:2] == b\"PK\" if ext in {\".xlsx\", \".xlsm\"} else data[:4] == b\"\\xd0\\xcf\\x11\\xe0\"\n        yield safe_name(name), data, looks_valid\n\nfor raw in raw_messages:\n    msg = message_from_bytes(raw, policy=default)\n    for name, data, ok in excel_attachments(msg):\n        print(f\"{name:28} {'ok' if ok else 'REJECTED: not a real workbook'}\")\n",[14,678,679,686,698,702,728,754,758,770,797,845,859,863,873,883,898,909,920,941,947,1012,1021,1026,1037,1052,1065],{"__ignoreMap":213},[217,680,681,683],{"class":89,"line":219},[217,682,260],{"class":259},[217,684,685],{"class":263}," re\n",[217,687,688,690,693,695],{"class":89,"line":267},[217,689,509],{"class":259},[217,691,692],{"class":263}," pathlib ",[217,694,260],{"class":259},[217,696,697],{"class":263}," Path\n",[217,699,700],{"class":89,"line":275},[217,701,279],{"emptyLinePlaceholder":278},[217,703,704,707,709,712,715,717,720,722,725],{"class":89,"line":282},[217,705,706],{"class":285},"EXCEL",[217,708,289],{"class":259},[217,710,711],{"class":263}," {",[217,713,714],{"class":226},"\".xlsx\"",[217,716,360],{"class":263},[217,718,719],{"class":226},"\".xlsm\"",[217,721,360],{"class":263},[217,723,724],{"class":226},"\".xls\"",[217,726,727],{"class":263},"}\n",[217,729,730,733,735,738,741,744,747,749,752],{"class":89,"line":295},[217,731,732],{"class":263},"inbox ",[217,734,351],{"class":259},[217,736,737],{"class":263}," Path(",[217,739,740],{"class":226},"\"incoming\"",[217,742,743],{"class":263},"); inbox.mkdir(",[217,745,746],{"class":559},"exist_ok",[217,748,351],{"class":259},[217,750,751],{"class":285},"True",[217,753,342],{"class":263},[217,755,756],{"class":89,"line":315},[217,757,279],{"emptyLinePlaceholder":278},[217,759,760,763,767],{"class":89,"line":333},[217,761,762],{"class":259},"def",[217,764,766],{"class":765},"s_Opv"," safe_name",[217,768,769],{"class":263},"(name):\n",[217,771,772,775,777,780,783,787,789,791,794],{"class":89,"line":345},[217,773,774],{"class":263},"    base ",[217,776,351],{"class":259},[217,778,779],{"class":263}," Path(name.replace(",[217,781,782],{"class":226},"\"",[217,784,786],{"class":785},"sSjpA","\\\\",[217,788,782],{"class":226},[217,790,360],{"class":263},[217,792,793],{"class":226},"\"\u002F\"",[217,795,796],{"class":263},")).name\n",[217,798,799,801,803,806,809,811,814,817,820,823,827,830,832,834,837,840,843],{"class":89,"line":373},[217,800,774],{"class":263},[217,802,351],{"class":259},[217,804,805],{"class":263}," re.sub(",[217,807,808],{"class":259},"r",[217,810,782],{"class":226},[217,812,813],{"class":285},"[",[217,815,816],{"class":259},"^",[217,818,819],{"class":285},"\\w",[217,821,822],{"class":785},".",[217,824,826],{"class":825},"s_b0D","\\-",[217,828,829],{"class":285}," ]",[217,831,782],{"class":226},[217,833,360],{"class":263},[217,835,836],{"class":226},"\"_\"",[217,838,839],{"class":263},", base).strip(",[217,841,842],{"class":226},"\" .\"",[217,844,342],{"class":263},[217,846,847,850,853,856],{"class":89,"line":389},[217,848,849],{"class":259},"    return",[217,851,852],{"class":263}," base ",[217,854,855],{"class":259},"or",[217,857,858],{"class":226}," \"attachment.xlsx\"\n",[217,860,861],{"class":89,"line":400},[217,862,279],{"emptyLinePlaceholder":278},[217,864,865,867,870],{"class":89,"line":415},[217,866,762],{"class":259},[217,868,869],{"class":765}," excel_attachments",[217,871,872],{"class":263},"(msg):\n",[217,874,875,877,879,881],{"class":89,"line":436},[217,876,403],{"class":259},[217,878,606],{"class":263},[217,880,409],{"class":259},[217,882,611],{"class":263},[217,884,885,888,890,893,895],{"class":89,"line":452},[217,886,887],{"class":263},"        name ",[217,889,351],{"class":259},[217,891,892],{"class":263}," part.get_filename() ",[217,894,855],{"class":259},[217,896,897],{"class":226}," \"\"\n",[217,899,901,904,906],{"class":89,"line":900},15,[217,902,903],{"class":263},"        ext ",[217,905,351],{"class":259},[217,907,908],{"class":263}," Path(name).suffix.lower()\n",[217,910,912,915,917],{"class":89,"line":911},16,[217,913,914],{"class":263},"        data ",[217,916,351],{"class":259},[217,918,919],{"class":263}," part.get_content()\n",[217,921,923,926,929,932,935,938],{"class":89,"line":922},17,[217,924,925],{"class":259},"        if",[217,927,928],{"class":263}," ext ",[217,930,931],{"class":259},"not",[217,933,934],{"class":259}," in",[217,936,937],{"class":285}," EXCEL",[217,939,940],{"class":263},":\n",[217,942,944],{"class":89,"line":943},18,[217,945,946],{"class":259},"            continue\n",[217,948,950,953,955,958,961,964,967,970,973,976,978,980,982,984,986,988,991,994,996,998,1000,1002,1004,1006,1009],{"class":89,"line":949},19,[217,951,952],{"class":263},"        looks_valid ",[217,954,351],{"class":259},[217,956,957],{"class":263}," data[:",[217,959,960],{"class":285},"2",[217,962,963],{"class":263},"] ",[217,965,966],{"class":259},"==",[217,968,969],{"class":259}," b",[217,971,972],{"class":226},"\"PK\"",[217,974,975],{"class":259}," if",[217,977,928],{"class":263},[217,979,409],{"class":259},[217,981,711],{"class":263},[217,983,714],{"class":226},[217,985,360],{"class":263},[217,987,719],{"class":226},[217,989,990],{"class":263},"} ",[217,992,993],{"class":259},"else",[217,995,957],{"class":263},[217,997,50],{"class":285},[217,999,963],{"class":263},[217,1001,966],{"class":259},[217,1003,969],{"class":259},[217,1005,782],{"class":226},[217,1007,1008],{"class":785},"\\xd0\\xcf\\x11\\xe0",[217,1010,1011],{"class":226},"\"\n",[217,1013,1015,1018],{"class":89,"line":1014},20,[217,1016,1017],{"class":259},"        yield",[217,1019,1020],{"class":263}," safe_name(name), data, looks_valid\n",[217,1022,1024],{"class":89,"line":1023},21,[217,1025,279],{"emptyLinePlaceholder":278},[217,1027,1029,1031,1033,1035],{"class":89,"line":1028},22,[217,1030,538],{"class":259},[217,1032,541],{"class":263},[217,1034,409],{"class":259},[217,1036,546],{"class":263},[217,1038,1040,1042,1044,1046,1048,1050],{"class":89,"line":1039},23,[217,1041,551],{"class":263},[217,1043,351],{"class":259},[217,1045,556],{"class":263},[217,1047,560],{"class":559},[217,1049,351],{"class":259},[217,1051,565],{"class":263},[217,1053,1055,1057,1060,1062],{"class":89,"line":1054},24,[217,1056,403],{"class":259},[217,1058,1059],{"class":263}," name, data, ok ",[217,1061,409],{"class":259},[217,1063,1064],{"class":263}," excel_attachments(msg):\n",[217,1066,1068,1070,1072,1075,1077,1080,1082,1085,1088,1090,1093,1095,1098,1100,1103,1105,1107],{"class":89,"line":1067},25,[217,1069,616],{"class":285},[217,1071,458],{"class":263},[217,1073,1074],{"class":259},"f",[217,1076,782],{"class":226},[217,1078,1079],{"class":785},"{",[217,1081,166],{"class":263},[217,1083,1084],{"class":259},":28",[217,1086,1087],{"class":785},"}",[217,1089,711],{"class":785},[217,1091,1092],{"class":226},"'ok'",[217,1094,975],{"class":259},[217,1096,1097],{"class":263}," ok ",[217,1099,993],{"class":259},[217,1101,1102],{"class":226}," 'REJECTED: not a real workbook'",[217,1104,1087],{"class":785},[217,1106,782],{"class":226},[217,1108,342],{"class":263},[10,1110,1111,1114,1115,1117,1118,1121,1122,1124,1125,1128,1129,1132],{},[14,1112,1113],{},"Path(...).name"," drops any directory part, so ",[14,1116,652],{}," becomes ",[14,1119,1120],{},"invoice.xlsx","; the regular expression replaces characters that are unsafe on Windows or in shell commands, while ",[14,1123,819],{}," keeps letters such as ",[14,1126,1127],{},"Ü",". The logo is skipped by extension, and the fourth message's \"workbook\" fails the signature check. Legacy ",[14,1130,1131],{},".xls"," files start with a different signature, which the check also handles.",[29,1134,38,1139,38,1142,38,1145,38,1148,38,1154,38,1159,38,1165,38,1168,38,1172,38,1177,38,1183,38,1185,38,1189,38,1191,38,1194,38,1197,38,1201,38,1203,38,1207,38,1209,38,1212,38,1215,38,1219,38,1221,38,1225,38,1227,38,1229,38,1232,38,1236,38,1238,38,1242,38,1244],{"viewBox":1135,"role":32,"ariaLabelledBy":1136,"xmlns":36,"style":37},"4 0 752 208",[1137,1138],"imap-checks-t","imap-checks-d",[40,1140,1141],{"id":1137},"What each check protects against",[44,1143,1144],{"id":1138},"A table of attachment checks. The extension check skips images and signatures, such as logo.png. The file signature check rejects files that claim to be workbooks but are not, such as a text file named stock_w39.xlsx. The safe name check strips directory parts and unsafe characters, turning ..\u002F..\u002Finvoice.xlsx into invoice.xlsx. The Message-ID record skips messages already processed.",[48,1146],{"x":50,"y":51,"width":52,"height":1147,"fill":54},"208",[48,1149],{"x":57,"y":58,"width":1150,"height":1151,"rx":51,"fill":54,"stroke":1152,"style":1153},"151.5","40.0","var(--line,#cdd5e6)","stroke-width:1px",[48,1155],{"x":1156,"y":58,"width":1157,"height":1151,"rx":51,"fill":62,"stroke":63,"style":1158},"171.5","284.3","stroke-width:1.2px",[77,1160,1164],{"x":1161,"y":1162,"style":1163},"313.6","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Stops",[48,1166],{"x":1167,"y":58,"width":1157,"height":1151,"rx":51,"fill":62,"stroke":63,"style":1158},"455.7",[77,1169,1171],{"x":1170,"y":1162,"style":1163},"597.9","Example",[48,1173],{"x":57,"y":1174,"width":1150,"height":1175,"rx":51,"fill":1176,"stroke":1152,"style":1153},"56.0","34.0","#e7ebef",[77,1178,1182],{"x":1179,"y":1180,"style":1181},"32.0","78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","Extension",[48,1184],{"x":1156,"y":1174,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1186,1188],{"x":1161,"y":1187,"style":86},"77.0","non-Excel attachments",[48,1190],{"x":1167,"y":1174,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1192,1193],{"x":1170,"y":1187,"style":86},"logo.png",[48,1195],{"x":57,"y":1196,"width":1150,"height":1175,"rx":51,"fill":1176,"stroke":1152,"style":1153},"90.0",[77,1198,1200],{"x":1179,"y":1199,"style":1181},"112.0","File signature",[48,1202],{"x":1156,"y":1196,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1204,1206],{"x":1161,"y":1205,"style":86},"111.0","fake workbooks",[48,1208],{"x":1167,"y":1196,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1210,1211],{"x":1170,"y":1205,"style":86},"text named .xlsx",[48,1213],{"x":57,"y":1214,"width":1150,"height":1175,"rx":51,"fill":1176,"stroke":1152,"style":1153},"124.0",[77,1216,1218],{"x":1179,"y":1217,"style":1181},"146.0","Safe name",[48,1220],{"x":1156,"y":1214,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1222,1224],{"x":1161,"y":1223,"style":86},"145.0","path tricks and bad characters",[48,1226],{"x":1167,"y":1214,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1228,652],{"x":1170,"y":1223,"style":86},[48,1230],{"x":57,"y":1231,"width":1150,"height":1175,"rx":51,"fill":1176,"stroke":1152,"style":1153},"158.0",[77,1233,1235],{"x":1179,"y":1234,"style":1181},"180.0","Message-ID record",[48,1237],{"x":1156,"y":1231,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1239,1241],{"x":1161,"y":1240,"style":86},"179.0","processing twice",[48,1243],{"x":1167,"y":1231,"width":1157,"height":1175,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1245,1246],{"x":1170,"y":1240,"style":86},"the same mail next run",[194,1248,1250],{"id":1249},"step-4-save-without-overwriting-and-skip-messages-already-handled","Step 4 — Save without overwriting, and skip messages already handled",[10,1252,1253,1254,1257],{},"The same supplier sends ",[14,1255,1256],{},"stock_w39.xlsx"," every week, sometimes twice. Prefix each saved file with the message date so names never collide, and keep a record of processed Message-IDs so a message is handled exactly once, however many times the job runs:",[208,1259,1261],{"className":250,"code":1260,"language":252,"meta":213,"style":213},"import json\nfrom email.utils import parsedate_to_datetime\n\nseen_path = Path(\"processed_ids.json\")\nseen = set(json.loads(seen_path.read_text())) if seen_path.exists() else set()\n\nsaved, rejected = [], []\nfor raw in raw_messages:\n    msg = message_from_bytes(raw, policy=default)\n    msg_id = msg[\"Message-ID\"]\n    if msg_id in seen:\n        continue\n    stamp = parsedate_to_datetime(msg[\"Date\"]).strftime(\"%Y%m%d-%H%M%S\")\n    for name, data, ok in excel_attachments(msg):\n        if not ok:\n            rejected.append((msg[\"From\"], name))\n            continue\n        target = inbox \u002F f\"{stamp}_{name}\"\n        n = 1\n        while target.exists():\n            target = inbox \u002F f\"{stamp}_{n}_{name}\"; n += 1\n        target.write_bytes(data)\n        saved.append(target.name)\n    seen.add(msg_id)\nseen_path.write_text(json.dumps(sorted(seen)))\nprint(\"saved:\", saved)\nprint(\"rejected:\", rejected)\n",[14,1262,1263,1270,1282,1286,1300,1326,1330,1340,1350,1364,1379,1392,1397,1424,1434,1444,1454,1458,1494,1504,1512,1560,1565,1570,1575,1586,1599],{"__ignoreMap":213},[217,1264,1265,1267],{"class":89,"line":219},[217,1266,260],{"class":259},[217,1268,1269],{"class":263}," json\n",[217,1271,1272,1274,1277,1279],{"class":89,"line":267},[217,1273,509],{"class":259},[217,1275,1276],{"class":263}," email.utils ",[217,1278,260],{"class":259},[217,1280,1281],{"class":263}," parsedate_to_datetime\n",[217,1283,1284],{"class":89,"line":275},[217,1285,279],{"emptyLinePlaceholder":278},[217,1287,1288,1291,1293,1295,1298],{"class":89,"line":282},[217,1289,1290],{"class":263},"seen_path ",[217,1292,351],{"class":259},[217,1294,737],{"class":263},[217,1296,1297],{"class":226},"\"processed_ids.json\"",[217,1299,342],{"class":263},[217,1301,1302,1305,1307,1310,1313,1316,1319,1321,1323],{"class":89,"line":295},[217,1303,1304],{"class":263},"seen ",[217,1306,351],{"class":259},[217,1308,1309],{"class":285}," set",[217,1311,1312],{"class":263},"(json.loads(seen_path.read_text())) ",[217,1314,1315],{"class":259},"if",[217,1317,1318],{"class":263}," seen_path.exists() ",[217,1320,993],{"class":259},[217,1322,1309],{"class":285},[217,1324,1325],{"class":263},"()\n",[217,1327,1328],{"class":89,"line":315},[217,1329,279],{"emptyLinePlaceholder":278},[217,1331,1332,1335,1337],{"class":89,"line":333},[217,1333,1334],{"class":263},"saved, rejected ",[217,1336,351],{"class":259},[217,1338,1339],{"class":263}," [], []\n",[217,1341,1342,1344,1346,1348],{"class":89,"line":345},[217,1343,538],{"class":259},[217,1345,541],{"class":263},[217,1347,409],{"class":259},[217,1349,546],{"class":263},[217,1351,1352,1354,1356,1358,1360,1362],{"class":89,"line":373},[217,1353,551],{"class":263},[217,1355,351],{"class":259},[217,1357,556],{"class":263},[217,1359,560],{"class":559},[217,1361,351],{"class":259},[217,1363,565],{"class":263},[217,1365,1366,1369,1371,1374,1376],{"class":89,"line":389},[217,1367,1368],{"class":263},"    msg_id ",[217,1370,351],{"class":259},[217,1372,1373],{"class":263}," msg[",[217,1375,597],{"class":226},[217,1377,1378],{"class":263},"]\n",[217,1380,1381,1384,1387,1389],{"class":89,"line":400},[217,1382,1383],{"class":259},"    if",[217,1385,1386],{"class":263}," msg_id ",[217,1388,409],{"class":259},[217,1390,1391],{"class":263}," seen:\n",[217,1393,1394],{"class":89,"line":415},[217,1395,1396],{"class":259},"        continue\n",[217,1398,1399,1402,1404,1407,1410,1413,1416,1419,1422],{"class":89,"line":436},[217,1400,1401],{"class":263},"    stamp ",[217,1403,351],{"class":259},[217,1405,1406],{"class":263}," parsedate_to_datetime(msg[",[217,1408,1409],{"class":226},"\"Date\"",[217,1411,1412],{"class":263},"]).strftime(",[217,1414,1415],{"class":226},"\"%Y%m",[217,1417,1418],{"class":785},"%d",[217,1420,1421],{"class":226},"-%H%M%S\"",[217,1423,342],{"class":263},[217,1425,1426,1428,1430,1432],{"class":89,"line":452},[217,1427,403],{"class":259},[217,1429,1059],{"class":263},[217,1431,409],{"class":259},[217,1433,1064],{"class":263},[217,1435,1436,1438,1441],{"class":89,"line":900},[217,1437,925],{"class":259},[217,1439,1440],{"class":259}," not",[217,1442,1443],{"class":263}," ok:\n",[217,1445,1446,1449,1451],{"class":89,"line":911},[217,1447,1448],{"class":263},"            rejected.append((msg[",[217,1450,576],{"class":226},[217,1452,1453],{"class":263},"], name))\n",[217,1455,1456],{"class":89,"line":922},[217,1457,946],{"class":259},[217,1459,1460,1463,1465,1468,1471,1474,1476,1478,1481,1483,1486,1488,1490,1492],{"class":89,"line":943},[217,1461,1462],{"class":263},"        target ",[217,1464,351],{"class":259},[217,1466,1467],{"class":263}," inbox ",[217,1469,1470],{"class":259},"\u002F",[217,1472,1473],{"class":259}," f",[217,1475,782],{"class":226},[217,1477,1079],{"class":785},[217,1479,1480],{"class":263},"stamp",[217,1482,1087],{"class":785},[217,1484,1485],{"class":226},"_",[217,1487,1079],{"class":785},[217,1489,166],{"class":263},[217,1491,1087],{"class":785},[217,1493,1011],{"class":226},[217,1495,1496,1499,1501],{"class":89,"line":949},[217,1497,1498],{"class":263},"        n ",[217,1500,351],{"class":259},[217,1502,1503],{"class":285}," 1\n",[217,1505,1506,1509],{"class":89,"line":1014},[217,1507,1508],{"class":259},"        while",[217,1510,1511],{"class":263}," target.exists():\n",[217,1513,1514,1517,1519,1521,1523,1525,1527,1529,1531,1533,1535,1537,1540,1542,1544,1546,1548,1550,1552,1555,1558],{"class":89,"line":1023},[217,1515,1516],{"class":263},"            target ",[217,1518,351],{"class":259},[217,1520,1467],{"class":263},[217,1522,1470],{"class":259},[217,1524,1473],{"class":259},[217,1526,782],{"class":226},[217,1528,1079],{"class":785},[217,1530,1480],{"class":263},[217,1532,1087],{"class":785},[217,1534,1485],{"class":226},[217,1536,1079],{"class":785},[217,1538,1539],{"class":263},"n",[217,1541,1087],{"class":785},[217,1543,1485],{"class":226},[217,1545,1079],{"class":785},[217,1547,166],{"class":263},[217,1549,1087],{"class":785},[217,1551,782],{"class":226},[217,1553,1554],{"class":263},"; n ",[217,1556,1557],{"class":259},"+=",[217,1559,1503],{"class":285},[217,1561,1562],{"class":89,"line":1028},[217,1563,1564],{"class":263},"        target.write_bytes(data)\n",[217,1566,1567],{"class":89,"line":1039},[217,1568,1569],{"class":263},"        saved.append(target.name)\n",[217,1571,1572],{"class":89,"line":1054},[217,1573,1574],{"class":263},"    seen.add(msg_id)\n",[217,1576,1577,1580,1583],{"class":89,"line":1067},[217,1578,1579],{"class":263},"seen_path.write_text(json.dumps(",[217,1581,1582],{"class":285},"sorted",[217,1584,1585],{"class":263},"(seen)))\n",[217,1587,1589,1591,1593,1596],{"class":89,"line":1588},26,[217,1590,455],{"class":285},[217,1592,458],{"class":263},[217,1594,1595],{"class":226},"\"saved:\"",[217,1597,1598],{"class":263},", saved)\n",[217,1600,1602,1604,1606,1609],{"class":89,"line":1601},27,[217,1603,455],{"class":285},[217,1605,458],{"class":263},[217,1607,1608],{"class":226},"\"rejected:\"",[217,1610,1611],{"class":263},", rejected)\n",[10,1613,1614,1615,1618],{},"Running the same loop again saves nothing, because every Message-ID is now recorded. Searching only for ",[14,1616,1617],{},"UNSEEN"," mail is a tempting alternative but a fragile one: anyone who opens the mailbox in a mail client marks messages as seen, and the job silently skips them.",[29,1620,38,1626,38,1629,38,1632,38,1637,38,1641,38,1645,38,1651,38,1655,38,1659,38,1661,38,1664,38,1667,38,1669,38,1671,38,1674,38,1677,38,1681,38,1683,38,1686,38,1689,38,1692,38,1694,38,1696,38,1699,38,1703,38,1705,38,1707,38,1710,38,1714,38,1717,38,1719,38,1722,38,1724,38,1726,38,1728,38,1730,38,1732,38,1735,38,1737,38,1739,38,1741,38,1744,38,1746,38,1748,38,1750,38,1753,38,1755,38,1757,38,1759,38,1762,38,1768,38,1772,38,1777,38,1780,38,1787],{"viewBox":1621,"role":32,"ariaLabelledBy":1622,"xmlns":36,"style":1625},"97 0 566 220",[1623,1624],"imap-names-t","imap-names-d","width:100%;max-width:566px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[40,1627,1628],{"id":1623},"Attachment names as sent, and as saved",[44,1630,1631],{"id":1624},"Left, the attachment names as the senders wrote them: stock_w39.xlsx, Übersicht September.xlsx, ..\u002F..\u002Finvoice.xlsx and logo.png. Right, what is saved: the stock file and the statement with a date prefix, the invoice with its directory part removed, and the logo skipped because it is not an Excel file.",[48,1633],{"x":1634,"y":51,"width":1635,"height":1636,"fill":54},"97","566","220",[48,1638],{"x":1639,"y":1151,"width":1179,"height":1640,"rx":51,"fill":1176,"stroke":1152,"style":1153},"113.1","24.0",[48,1642],{"x":1643,"y":1151,"width":1644,"height":1640,"rx":51,"fill":1176,"stroke":1152,"style":1153},"145.1","143.1",[77,1646,1650],{"x":1647,"y":1648,"style":1649},"216.6","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[48,1652],{"x":1639,"y":1653,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},"64.0","28.0",[77,1656,447],{"x":1657,"y":1658,"style":1649},"129.1","83.0",[48,1660],{"x":1643,"y":1653,"width":1644,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1662,166],{"x":1647,"y":1658,"style":1663},"font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle",[48,1665],{"x":1639,"y":1666,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},"92.0",[77,1668,960],{"x":1657,"y":1205,"style":1649},[48,1670],{"x":1643,"y":1666,"width":1644,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1672,1256],{"x":1647,"y":1205,"style":1673},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle",[48,1675],{"x":1639,"y":1676,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},"120.0",[77,1678,1680],{"x":1657,"y":1679,"style":1649},"139.0","3",[48,1682],{"x":1643,"y":1676,"width":1644,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1684,1685],{"x":1647,"y":1679,"style":1673},"Übersicht Sept.xlsx",[48,1687],{"x":1639,"y":1688,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},"148.0",[77,1690,50],{"x":1657,"y":1691,"style":1649},"167.0",[48,1693],{"x":1643,"y":1688,"width":1644,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1695,652],{"x":1647,"y":1691,"style":1673},[48,1697],{"x":1639,"y":1698,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},"176.0",[77,1700,1702],{"x":1657,"y":1701,"style":1649},"195.0","5",[48,1704],{"x":1643,"y":1698,"width":1644,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1706,1193],{"x":1647,"y":1701,"style":1673},[48,1708],{"x":1709,"y":1151,"width":1179,"height":1640,"rx":51,"fill":1176,"stroke":1152,"style":1153},"425.9",[48,1711],{"x":1712,"y":1151,"width":1713,"height":1640,"rx":51,"fill":1176,"stroke":1152,"style":1153},"457.9","189.0",[77,1715,1650],{"x":1716,"y":1648,"style":1649},"552.4",[48,1718],{"x":1709,"y":1653,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},[77,1720,447],{"x":1721,"y":1658,"style":1649},"441.9",[48,1723],{"x":1712,"y":1653,"width":1713,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1725,166],{"x":1716,"y":1658,"style":1663},[48,1727],{"x":1709,"y":1666,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},[77,1729,960],{"x":1721,"y":1205,"style":1649},[48,1731],{"x":1712,"y":1666,"width":1713,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1733,1734],{"x":1716,"y":1205,"style":1673},"20260921_stock_w39.xlsx",[48,1736],{"x":1709,"y":1676,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},[77,1738,1680],{"x":1721,"y":1679,"style":1649},[48,1740],{"x":1712,"y":1676,"width":1713,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1742,1743],{"x":1716,"y":1679,"style":1673},"20260921_Übersicht…",[48,1745],{"x":1709,"y":1688,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},[77,1747,50],{"x":1721,"y":1691,"style":1649},[48,1749],{"x":1712,"y":1688,"width":1713,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1751,1752],{"x":1716,"y":1691,"style":1673},"20260921_invoice.xlsx",[48,1754],{"x":1709,"y":1698,"width":1179,"height":1654,"rx":51,"fill":1176,"stroke":1152,"style":1153},[77,1756,1702],{"x":1721,"y":1701,"style":1649},[48,1758],{"x":1712,"y":1698,"width":1713,"height":1654,"rx":51,"fill":54,"stroke":1152,"style":1153},[77,1760,1761],{"x":1716,"y":1701,"style":1673},"(skipped)",[77,1763,1767],{"x":1764,"y":1765,"style":1766},"200.6","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","before",[77,1769,1771],{"x":1770,"y":1765,"style":1766},"536.4","after",[89,1773],{"x1":1774,"y1":1775,"x2":1776,"y2":1775,"stroke":63,"style":64},"298.2","122.0","411.9",[95,1778],{"points":1779,"fill":98},"415.9,122.0 406.9,127.0 406.9,117.0",[48,1781],{"x":1782,"y":1783,"width":1784,"height":1785,"rx":1786,"fill":54,"stroke":1152,"style":1158},"317.9","89.0","78.2","22.0","11",[77,1788,1792],{"x":1789,"y":1790,"style":1791},"357.0","104.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","sanitise",[10,1794,1795],{},"The date prefix in the saved name also gives the folder a natural order: sorting by name sorts by the time the mail was sent. An allowlist of senders adds a further guard — the third message comes from an address nobody expects, and in production its attachment would be set aside for a person to check rather than fed into a report.",[194,1797,1799],{"id":1798},"step-5-check-the-workbooks-open-then-move-the-mail","Step 5 — Check the workbooks open, then move the mail",[10,1801,1802],{},"A file that passes the signature check can still be damaged or password-protected. Open each saved file before handing it to the rest of the pipeline:",[208,1804,1806],{"className":250,"code":1805,"language":252,"meta":213,"style":213},"from openpyxl import load_workbook\n\nfor name in saved:\n    try:\n        wb = load_workbook(inbox \u002F name, read_only=True)\n        print(f\"{name}: sheets {wb.sheetnames}\")\n        wb.close()\n    except Exception as exc:\n        print(f\"{name}: cannot open ({type(exc).__name__})\")\n",[14,1807,1808,1820,1824,1836,1843,1867,1897,1902,1916],{"__ignoreMap":213},[217,1809,1810,1812,1815,1817],{"class":89,"line":219},[217,1811,509],{"class":259},[217,1813,1814],{"class":263}," openpyxl ",[217,1816,260],{"class":259},[217,1818,1819],{"class":263}," load_workbook\n",[217,1821,1822],{"class":89,"line":267},[217,1823,279],{"emptyLinePlaceholder":278},[217,1825,1826,1828,1831,1833],{"class":89,"line":275},[217,1827,538],{"class":259},[217,1829,1830],{"class":263}," name ",[217,1832,409],{"class":259},[217,1834,1835],{"class":263}," saved:\n",[217,1837,1838,1841],{"class":89,"line":282},[217,1839,1840],{"class":259},"    try",[217,1842,940],{"class":263},[217,1844,1845,1848,1850,1853,1855,1858,1861,1863,1865],{"class":89,"line":295},[217,1846,1847],{"class":263},"        wb ",[217,1849,351],{"class":259},[217,1851,1852],{"class":263}," load_workbook(inbox ",[217,1854,1470],{"class":259},[217,1856,1857],{"class":263}," name, ",[217,1859,1860],{"class":559},"read_only",[217,1862,351],{"class":259},[217,1864,751],{"class":285},[217,1866,342],{"class":263},[217,1868,1869,1871,1873,1875,1877,1879,1881,1883,1886,1888,1891,1893,1895],{"class":89,"line":315},[217,1870,616],{"class":285},[217,1872,458],{"class":263},[217,1874,1074],{"class":259},[217,1876,782],{"class":226},[217,1878,1079],{"class":785},[217,1880,166],{"class":263},[217,1882,1087],{"class":785},[217,1884,1885],{"class":226},": sheets ",[217,1887,1079],{"class":785},[217,1889,1890],{"class":263},"wb.sheetnames",[217,1892,1087],{"class":785},[217,1894,782],{"class":226},[217,1896,342],{"class":263},[217,1898,1899],{"class":89,"line":333},[217,1900,1901],{"class":263},"        wb.close()\n",[217,1903,1904,1907,1910,1913],{"class":89,"line":345},[217,1905,1906],{"class":259},"    except",[217,1908,1909],{"class":285}," Exception",[217,1911,1912],{"class":259}," as",[217,1914,1915],{"class":263}," exc:\n",[217,1917,1918,1920,1922,1924,1926,1928,1930,1932,1935,1937,1940,1943,1946,1948,1951],{"class":89,"line":373},[217,1919,616],{"class":285},[217,1921,458],{"class":263},[217,1923,1074],{"class":259},[217,1925,782],{"class":226},[217,1927,1079],{"class":785},[217,1929,166],{"class":263},[217,1931,1087],{"class":785},[217,1933,1934],{"class":226},": cannot open (",[217,1936,1079],{"class":785},[217,1938,1939],{"class":285},"type",[217,1941,1942],{"class":263},"(exc).",[217,1944,1945],{"class":285},"__name__",[217,1947,1087],{"class":785},[217,1949,1950],{"class":226},")\"",[217,1952,342],{"class":263},[10,1954,1955,1956,1959],{},"Finally, move processed messages out of the inbox, so a person looking at it sees only what still needs attention. ",[14,1957,1958],{},"MOVE"," is an IMAP extension most servers support; without it, copy and then flag the original as deleted:",[208,1961,1963],{"className":250,"code":1962,"language":252,"meta":213,"style":213},"with imaplib.IMAP4_SSL(HOST) as imap:\n    imap.login(os.environ[\"IMAP_USER\"], os.environ[\"IMAP_PASSWORD\"])\n    imap.select(\"INBOX\")\n    for uid in uids:\n        if imap.uid(\"MOVE\", uid, \"Processed\")[0] != \"OK\":\n            imap.uid(\"COPY\", uid, \"Processed\")\n            imap.uid(\"STORE\", uid, \"+FLAGS\", r\"(\\Deleted)\")\n    imap.expunge()\n",[14,1964,1965,1979,1991,1999,2009,2038,2052,2083],{"__ignoreMap":213},[217,1966,1967,1969,1971,1973,1975,1977],{"class":89,"line":219},[217,1968,298],{"class":259},[217,1970,301],{"class":263},[217,1972,286],{"class":285},[217,1974,306],{"class":263},[217,1976,309],{"class":259},[217,1978,312],{"class":263},[217,1980,1981,1983,1985,1987,1989],{"class":89,"line":267},[217,1982,318],{"class":263},[217,1984,321],{"class":226},[217,1986,324],{"class":263},[217,1988,327],{"class":226},[217,1990,330],{"class":263},[217,1992,1993,1995,1997],{"class":89,"line":275},[217,1994,336],{"class":263},[217,1996,339],{"class":226},[217,1998,342],{"class":263},[217,2000,2001,2003,2005,2007],{"class":89,"line":282},[217,2002,403],{"class":259},[217,2004,406],{"class":263},[217,2006,409],{"class":259},[217,2008,412],{"class":263},[217,2010,2011,2013,2015,2018,2020,2023,2026,2028,2030,2033,2036],{"class":89,"line":295},[217,2012,925],{"class":259},[217,2014,354],{"class":263},[217,2016,2017],{"class":226},"\"MOVE\"",[217,2019,428],{"class":263},[217,2021,2022],{"class":226},"\"Processed\"",[217,2024,2025],{"class":263},")[",[217,2027,51],{"class":285},[217,2029,963],{"class":263},[217,2031,2032],{"class":259},"!=",[217,2034,2035],{"class":226}," \"OK\"",[217,2037,940],{"class":263},[217,2039,2040,2043,2046,2048,2050],{"class":89,"line":315},[217,2041,2042],{"class":263},"            imap.uid(",[217,2044,2045],{"class":226},"\"COPY\"",[217,2047,428],{"class":263},[217,2049,2022],{"class":226},[217,2051,342],{"class":263},[217,2053,2054,2056,2059,2061,2064,2066,2068,2070,2073,2076,2079,2081],{"class":89,"line":333},[217,2055,2042],{"class":263},[217,2057,2058],{"class":226},"\"STORE\"",[217,2060,428],{"class":263},[217,2062,2063],{"class":226},"\"+FLAGS\"",[217,2065,360],{"class":263},[217,2067,808],{"class":259},[217,2069,782],{"class":226},[217,2071,2072],{"class":285},"(\\D",[217,2074,2075],{"class":226},"eleted",[217,2077,2078],{"class":285},")",[217,2080,782],{"class":226},[217,2082,342],{"class":263},[217,2084,2085],{"class":89,"line":345},[217,2086,2087],{"class":263},"    imap.expunge()\n",[10,2089,2090,2091,2095],{},"Send a short reply or alert for rejected attachments, so the sender knows the file did not arrive; ",[23,2092,2094],{"href":2093},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","Error Handling and Logging in Excel Automation"," covers alerts that people read.",[194,2097,2099],{"id":2098},"common-pitfalls","Common pitfalls",[2101,2102,2103,2111,2117,2125,2131,2140,2146],"ul",{},[2104,2105,2106,2110],"li",{},[2107,2108,2109],"strong",{},"Trusting attachment file names."," Strip directories and unsafe characters; build the name yourself.",[2104,2112,2113,2116],{},[2107,2114,2115],{},"Trusting the extension."," Check the file signature and try opening the workbook.",[2104,2118,2119,2124],{},[2107,2120,2121,2122,822],{},"Parsing without ",[14,2123,498],{}," International file names stay encoded.",[2104,2126,2127,2130],{},[2107,2128,2129],{},"Relying on UNSEEN."," Anyone reading the mailbox breaks it; record Message-IDs instead.",[2104,2132,2133,2136,2137,822],{},[2107,2134,2135],{},"Sequence numbers instead of UIDs."," They change when mail is moved; use ",[14,2138,2139],{},"imap.uid",[2104,2141,2142,2145],{},[2107,2143,2144],{},"Accepting mail from anyone."," Check senders against an allowlist before saving.",[2104,2147,2148,2151],{},[2107,2149,2150],{},"Passwords in code."," Use environment variables, app passwords or OAuth.",[194,2153,2155],{"id":2154},"performance-notes","Performance notes",[10,2157,2158,2159,2162,2163,2166,2167,2170],{},"Fetching whole messages downloads every attachment, including large images. For busy mailboxes, fetch ",[14,2160,2161],{},"BODYSTRUCTURE"," first to see which parts are spreadsheets, then fetch only those parts with ",[14,2164,2165],{},"BODY.PEEK[n]",". Polling every few minutes is plenty; IMAP ",[14,2168,2169],{},"IDLE"," gives near-instant pickup if it matters.",[194,2172,2174],{"id":2173},"conclusion","Conclusion",[10,2176,2177,2178,2180,2181,2183,2184,2186],{},"Search the mailbox with ",[14,2179,16],{}," using UIDs and specific criteria, fetch with ",[14,2182,114],{},", and parse with ",[14,2185,498],{}," so file names are decoded. Keep only attachments with an Excel extension and a matching file signature, save them under sanitised, date-prefixed names, and record each Message-ID so nothing is processed twice. Check that each workbook opens, then move the handled messages to a Processed folder.",[194,2188,2190],{"id":2189},"frequently-asked-questions","Frequently asked questions",[10,2192,2193,2196,2197,2199],{},[2107,2194,2195],{},"How do I download email attachments with Python?","\nConnect with imaplib.IMAP4_SSL, log in, select the mailbox, search for the messages you want, fetch each one with BODY.PEEK",[217,2198],{},", parse it with email.message_from_bytes using the default policy, and save the payload of each part returned by iter_attachments().",[10,2201,2202,2205],{},[2107,2203,2204],{},"How do I get the file name of an attachment with accents or non-Latin characters?","\nParse the message with policy=email.policy.default. Then part.get_filename() decodes both RFC 2231 and encoded-word file names, returning \"Übersicht September.xlsx\" rather than \"=?utf-8?q?...\".",[10,2207,2208,2211],{},[2107,2209,2210],{},"How do I avoid downloading the same attachment twice?","\nRecord each processed message's Message-ID and skip messages already in the record, and move handled messages to a separate folder. Searching only UNSEEN is fragile, because anyone reading the mailbox marks messages as seen.",[10,2213,2214,2217],{},[2107,2215,2216],{},"Can I use a normal password for Gmail or Microsoft 365?","\nUsually not any more. Microsoft 365 has retired basic authentication for IMAP, and Gmail needs an app password or OAuth. For Microsoft 365 the Graph API is often simpler than IMAP with OAuth.",[194,2219,2221],{"id":2220},"related","Related",[2101,2223,2224,2230,2237,2244],{},[2104,2225,2226,2227,2229],{},"Up one level: ",[23,2228,26],{"href":25}," — sending the reports out.",[2104,2231,2232,2236],{},[23,2233,2235],{"href":2234},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fwatch-a-folder-and-process-new-excel-files\u002F","Watch a Folder and Process New Excel Files"," — the next step after saving.",[2104,2238,2239,2243],{},[23,2240,2242],{"href":2241},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fvalidate-an-uploaded-excel-file-in-a-web-app\u002F","Validate an Uploaded Excel File in a Web App"," — the same distrust for uploads.",[2104,2245,2246,2249],{},[23,2247,2248],{"href":486},"Send an Excel Report with the Microsoft Graph API"," — Microsoft 365 without IMAP.",[2251,2252,2253],"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 .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s_b0D, html code.shiki .s_b0D{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold}",{"title":213,"searchDepth":267,"depth":267,"links":2255},[2256,2257,2258,2259,2260,2261,2262,2263,2264,2265,2266],{"id":196,"depth":267,"text":197},{"id":240,"depth":267,"text":241},{"id":491,"depth":267,"text":492},{"id":656,"depth":267,"text":657},{"id":1249,"depth":267,"text":1250},{"id":1798,"depth":267,"text":1799},{"id":2098,"depth":267,"text":2099},{"id":2154,"depth":267,"text":2155},{"id":2173,"depth":267,"text":2174},{"id":2189,"depth":267,"text":2190},{"id":2220,"depth":267,"text":2221},"2026-09-25","Pick up the spreadsheets suppliers and colleagues email in, without anyone saving them by hand. Search a mailbox over IMAP, save only genuine Excel attachments under safe file names, decode international file names, skip messages already processed, and move handled mail to a folder so the job can run every few minutes.","md",[2271,2273,2275,2277],{"q":2195,"a":2272},"Connect with imaplib.IMAP4_SSL, log in, select the mailbox, search for the messages you want, fetch each one with BODY.PEEK[], parse it with email.message_from_bytes using the default policy, and save the payload of each part returned by iter_attachments().",{"q":2204,"a":2274},"Parse the message with policy=email.policy.default. Then part.get_filename() decodes both RFC 2231 and encoded-word file names, returning \"Übersicht September.xlsx\" rather than \"=?utf-8?q?...\".",{"q":2210,"a":2276},"Record each processed message's Message-ID and skip messages already in the record, and move handled messages to a separate folder. Searching only UNSEEN is fragile, because anyone reading the mailbox marks messages as seen.",{"q":2216,"a":2278},"Usually not any more. Microsoft 365 has retired basic authentication for IMAP, and Gmail needs an app password or OAuth. For Microsoft 365 the Graph API is often simpler than IMAP with OAuth.","download excel attachments from an email inbox",null,{"breadcrumb":2282},[2283,2285,2288],{"name":2284,"item":1470},"Home",{"name":2286,"item":2287},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":26,"item":25},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox",{"title":2291,"description":2292},"Download Excel Attachments from Email with Python (IMAP)","Save Excel attachments from an inbox with imaplib and the email package: IMAP search by sender and date, BODY.PEEK, iter_attachments, encoded file names, content-type and extension checks, safe names, duplicate detection by Message-ID and moving processed mail.","download-excel-attachments-from-an-email-inbox","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox\u002Findex",[2296,2297],"email-delivery","data-sources","how-to","IUSfYEbmWQzwrPRoS8g5s4ClLHBWpU245-kx08jB3_g",[2301,2305],{"title":2302,"path":2303,"stem":2304,"children":-1},"Attach Multiple Excel Files to One Email in Python","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fattach-multiple-excel-files-to-one-email-in-python","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fattach-multiple-excel-files-to-one-email-in-python\u002Findex",{"title":2306,"path":2307,"stem":2308,"children":-1},"Email a Download Link Instead of an Excel Attachment","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-a-download-link-instead-of-an-excel-attachment","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-a-download-link-instead-of-an-excel-attachment\u002Findex",[2310,2312],{"path":2311,"title":26},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib",{"path":2313,"title":2286},"\u002Fautomating-reporting-workflows",[],[2316,2319,2320,2321,2324,2327],{"path":2317,"title":2318,"kind":2298},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-credit-control-dunning-letter-run","Build a Credit Control Dunning Letter Run",{"path":2303,"title":2302,"kind":2298},{"path":2307,"title":2306,"kind":2298},{"path":2322,"title":2323,"kind":2298},"\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":2325,"title":2326,"kind":2298},"\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",{"path":2328,"title":2329,"kind":2298},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates","Personalise Report Emails with Jinja2 Templates",1790972129583]