[{"data":1,"prerenderedAt":2629},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api":3,"surround:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api":2622},{"id":4,"title":5,"body":6,"dateModified":2595,"datePublished":2595,"description":2596,"extension":2597,"faq":2598,"meta":2607,"navigation":258,"path":2615,"seo":2616,"slug":2618,"stem":2619,"type":2620,"__hash__":2621},"docs\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api\u002Findex.md","Send an Excel Report with the Microsoft Graph API",{"type":7,"value":8,"toc":2580},"minimark",[9,19,118,123,151,163,221,225,232,470,473,477,480,850,861,865,967,970,1629,1636,1640,1643,1833,1848,1852,1855,2097,2100,2104,2107,2157,2167,2171,2279,2283,2289,2296,2299,2303,2402,2408,2481,2493,2497,2510,2514,2520,2526,2532,2538,2542,2576],[10,11,12,13,18],"p",{},"Basic authentication for SMTP is switched off in most Microsoft 365 tenants, which retires the\nusername-and-password approach that scheduled report scripts have relied on for years. The\nreplacement is the Graph API: an application registration, a client credential, and an HTTP call that\nsends the message. This guide, part of\n",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","Emailing Excel Reports with smtplib",",\ncovers the registration, the send, and the larger-attachment path.",[20,21,29,30,29,34,29,38,29,45,29,52,29,62,29,68,29,73,29,79,29,84,29,87,29,90,29,93,29,97,29,100,29,105,29,110,29,113],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"0 0 760 232","img",[25,26],"gr-flow-t","gr-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:760px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[31,32,33],"title",{"id":25},"What replaces the SMTP password",[35,36,37],"desc",{"id":26},"An application registration exchanges a client secret for a token at the identity service, and that token authorises a single HTTPS call that hands Microsoft the whole message.",[39,40],"rect",{"x":41,"y":41,"width":42,"height":43,"fill":44},"0","760","232","#ffffff",[46,47,51],"text",{"x":48,"y":49,"style":50},"380.0","32","font-size:13px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","client credentials",[39,53],{"x":54,"y":55,"width":56,"height":57,"rx":58,"fill":59,"stroke":60,"style":61},"24.0","74","208.0","96","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[46,63,67],{"x":64,"y":65,"style":66},"128.0","114","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","app registration",[46,69,72],{"x":64,"y":70,"style":71},"136","font-size:11.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle","client id and secret",[74,75],"line",{"x1":76,"y1":77,"x2":78,"y2":77,"stroke":60,"style":61},"237.0","122.0","269.0",[80,81],"polygon",{"points":82,"fill":83},"269.0,122.0 260.0,117.0 260.0,127.0","#5b5cf0",[39,85],{"x":86,"y":55,"width":56,"height":57,"rx":58,"fill":59,"stroke":60,"style":61},"276.0",[46,88,89],{"x":48,"y":65,"style":66},"token endpoint",[46,91,92],{"x":48,"y":70,"style":71},"client-credentials flow",[74,94],{"x1":95,"y1":77,"x2":96,"y2":77,"stroke":60,"style":61},"489.0","521.0",[80,98],{"points":99,"fill":83},"521.0,122.0 512.0,117.0 512.0,127.0",[39,101],{"x":102,"y":55,"width":56,"height":57,"rx":58,"fill":103,"stroke":104,"style":61},"528.0","#d9f4f1","var(--teal,#0f9488)",[46,106,109],{"x":107,"y":65,"style":108},"632.0","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","sendMail",[46,111,112],{"x":107,"y":70,"style":71},"one HTTPS call",[46,114,117],{"x":48,"y":115,"style":116},"210","font-size:12.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle","no mailbox password, and nothing for a tenant policy to disable",[119,120,122],"h2",{"id":121},"prerequisites","Prerequisites",[124,125,130],"pre",{"className":126,"code":127,"language":128,"meta":129,"style":129},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install msal requests\n","bash","",[131,132,133],"code",{"__ignoreMap":129},[134,135,137,141,145,148],"span",{"class":74,"line":136},1,[134,138,140],{"class":139},"sMTad","pip",[134,142,144],{"class":143},"srMev"," install",[134,146,147],{"class":143}," msal",[134,149,150],{"class":143}," requests\n",[10,152,153,154,157,158,162],{},"You also need an application registration in Entra ID with the ",[131,155,156],{},"Mail.Send"," ",[159,160,161],"strong",{},"application","\npermission and admin consent granted, plus its tenant id, client id and a client secret. Store the\nsecret in the environment rather than the script:",[124,164,166],{"className":126,"code":165,"language":128,"meta":129,"style":129},"export GRAPH_TENANT_ID=\"...\"\nexport GRAPH_CLIENT_ID=\"...\"\nexport GRAPH_CLIENT_SECRET=\"...\"\nexport GRAPH_SENDER=\"reports@example.com\"\n",[131,167,168,184,196,208],{"__ignoreMap":129},[134,169,170,174,178,181],{"class":74,"line":136},[134,171,173],{"class":172},"s-kum","export",[134,175,177],{"class":176},"skGVy"," GRAPH_TENANT_ID",[134,179,180],{"class":172},"=",[134,182,183],{"class":143},"\"...\"\n",[134,185,187,189,192,194],{"class":74,"line":186},2,[134,188,173],{"class":172},[134,190,191],{"class":176}," GRAPH_CLIENT_ID",[134,193,180],{"class":172},[134,195,183],{"class":143},[134,197,199,201,204,206],{"class":74,"line":198},3,[134,200,173],{"class":172},[134,202,203],{"class":176}," GRAPH_CLIENT_SECRET",[134,205,180],{"class":172},[134,207,183],{"class":143},[134,209,211,213,216,218],{"class":74,"line":210},4,[134,212,173],{"class":172},[134,214,215],{"class":176}," GRAPH_SENDER",[134,217,180],{"class":172},[134,219,220],{"class":143},"\"reports@example.com\"\n",[119,222,224],{"id":223},"get-a-token","Get a token",[10,226,227,228,231],{},"The client-credentials flow needs no user interaction, which is what makes it suitable for a\nscheduled job. ",[131,229,230],{},"msal"," caches the token internally, so acquiring one per run is cheap.",[124,233,237],{"className":234,"code":235,"language":236,"meta":129,"style":129},"language-python shiki shiki-themes github-light github-dark-high-contrast","import os\nimport msal\n\ndef graph_token() -> str:\n    app = msal.ConfidentialClientApplication(\n        client_id=os.environ[\"GRAPH_CLIENT_ID\"],\n        client_credential=os.environ[\"GRAPH_CLIENT_SECRET\"],\n        authority=f\"https:\u002F\u002Flogin.microsoftonline.com\u002F{os.environ['GRAPH_TENANT_ID']}\",\n    )\n    result = app.acquire_token_for_client(scopes=[\"https:\u002F\u002Fgraph.microsoft.com\u002F.default\"])\n    if \"access_token\" not in result:\n        raise RuntimeError(f\"{result.get('error')}: {result.get('error_description')}\")\n    return result[\"access_token\"]\n","python",[131,238,239,247,254,260,279,290,308,323,358,364,389,407,455],{"__ignoreMap":129},[134,240,241,244],{"class":74,"line":136},[134,242,243],{"class":172},"import",[134,245,246],{"class":176}," os\n",[134,248,249,251],{"class":74,"line":186},[134,250,243],{"class":172},[134,252,253],{"class":176}," msal\n",[134,255,256],{"class":74,"line":198},[134,257,259],{"emptyLinePlaceholder":258},true,"\n",[134,261,262,265,269,272,276],{"class":74,"line":210},[134,263,264],{"class":172},"def",[134,266,268],{"class":267},"s_Opv"," graph_token",[134,270,271],{"class":176},"() -> ",[134,273,275],{"class":274},"sP0c6","str",[134,277,278],{"class":176},":\n",[134,280,282,285,287],{"class":74,"line":281},5,[134,283,284],{"class":176},"    app ",[134,286,180],{"class":172},[134,288,289],{"class":176}," msal.ConfidentialClientApplication(\n",[134,291,293,297,299,302,305],{"class":74,"line":292},6,[134,294,296],{"class":295},"sa561","        client_id",[134,298,180],{"class":172},[134,300,301],{"class":176},"os.environ[",[134,303,304],{"class":143},"\"GRAPH_CLIENT_ID\"",[134,306,307],{"class":176},"],\n",[134,309,311,314,316,318,321],{"class":74,"line":310},7,[134,312,313],{"class":295},"        client_credential",[134,315,180],{"class":172},[134,317,301],{"class":176},[134,319,320],{"class":143},"\"GRAPH_CLIENT_SECRET\"",[134,322,307],{"class":176},[134,324,326,329,331,334,337,341,343,346,349,352,355],{"class":74,"line":325},8,[134,327,328],{"class":295},"        authority",[134,330,180],{"class":172},[134,332,333],{"class":172},"f",[134,335,336],{"class":143},"\"https:\u002F\u002Flogin.microsoftonline.com\u002F",[134,338,340],{"class":339},"sSjpA","{",[134,342,301],{"class":176},[134,344,345],{"class":143},"'GRAPH_TENANT_ID'",[134,347,348],{"class":176},"]",[134,350,351],{"class":339},"}",[134,353,354],{"class":143},"\"",[134,356,357],{"class":176},",\n",[134,359,361],{"class":74,"line":360},9,[134,362,363],{"class":176},"    )\n",[134,365,367,370,372,375,378,380,383,386],{"class":74,"line":366},10,[134,368,369],{"class":176},"    result ",[134,371,180],{"class":172},[134,373,374],{"class":176}," app.acquire_token_for_client(",[134,376,377],{"class":295},"scopes",[134,379,180],{"class":172},[134,381,382],{"class":176},"[",[134,384,385],{"class":143},"\"https:\u002F\u002Fgraph.microsoft.com\u002F.default\"",[134,387,388],{"class":176},"])\n",[134,390,392,395,398,401,404],{"class":74,"line":391},11,[134,393,394],{"class":172},"    if",[134,396,397],{"class":143}," \"access_token\"",[134,399,400],{"class":172}," not",[134,402,403],{"class":172}," in",[134,405,406],{"class":176}," result:\n",[134,408,410,413,416,419,421,423,425,428,431,434,436,439,441,443,446,448,450,452],{"class":74,"line":409},12,[134,411,412],{"class":172},"        raise",[134,414,415],{"class":274}," RuntimeError",[134,417,418],{"class":176},"(",[134,420,333],{"class":172},[134,422,354],{"class":143},[134,424,340],{"class":339},[134,426,427],{"class":176},"result.get(",[134,429,430],{"class":143},"'error'",[134,432,433],{"class":176},")",[134,435,351],{"class":339},[134,437,438],{"class":143},": ",[134,440,340],{"class":339},[134,442,427],{"class":176},[134,444,445],{"class":143},"'error_description'",[134,447,433],{"class":176},[134,449,351],{"class":339},[134,451,354],{"class":143},[134,453,454],{"class":176},")\n",[134,456,458,461,464,467],{"class":74,"line":457},13,[134,459,460],{"class":172},"    return",[134,462,463],{"class":176}," result[",[134,465,466],{"class":143},"\"access_token\"",[134,468,469],{"class":176},"]\n",[10,471,472],{},"Raising with the description rather than a generic message matters here, because the two most common\nfailures — a secret that has expired and a permission that was never consented — produce very\ndifferent descriptions and identical symptoms otherwise.",[119,474,476],{"id":475},"send-the-report-as-an-attachment","Send the report as an attachment",[10,478,479],{},"Graph takes the whole message as JSON, with the attachment base64-encoded inline.",[124,481,483],{"className":234,"code":482,"language":236,"meta":129,"style":129},"import base64\nimport pathlib\nimport requests\n\ndef send_report(path: str, to: list[str], subject: str, html_body: str) -> None:\n    data = pathlib.Path(path).read_bytes()\n    message = {\n        \"message\": {\n            \"subject\": subject,\n            \"body\": {\"contentType\": \"HTML\", \"content\": html_body},\n            \"toRecipients\": [{\"emailAddress\": {\"address\": address}} for address in to],\n            \"attachments\": [{\n                \"@odata.type\": \"#microsoft.graph.fileAttachment\",\n                \"name\": pathlib.Path(path).name,\n                \"contentType\": \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n                \"contentBytes\": base64.b64encode(data).decode(),\n            }],\n        },\n        \"saveToSentItems\": True,\n    }\n    sender = os.environ[\"GRAPH_SENDER\"]\n    response = requests.post(\n        f\"https:\u002F\u002Fgraph.microsoft.com\u002Fv1.0\u002Fusers\u002F{sender}\u002FsendMail\",\n        headers={\"Authorization\": f\"Bearer {graph_token()}\",\n                 \"Content-Type\": \"application\u002Fjson\"},\n        json=message,\n        timeout=60,\n    )\n    response.raise_for_status()          # 202 Accepted on success\n",[131,484,485,492,499,505,509,544,554,564,572,580,605,636,644,656,665,678,687,693,699,712,718,734,745,766,797,811,822,835,840],{"__ignoreMap":129},[134,486,487,489],{"class":74,"line":136},[134,488,243],{"class":172},[134,490,491],{"class":176}," base64\n",[134,493,494,496],{"class":74,"line":186},[134,495,243],{"class":172},[134,497,498],{"class":176}," pathlib\n",[134,500,501,503],{"class":74,"line":198},[134,502,243],{"class":172},[134,504,150],{"class":176},[134,506,507],{"class":74,"line":210},[134,508,259],{"emptyLinePlaceholder":258},[134,510,511,513,516,519,521,524,526,529,531,534,536,539,542],{"class":74,"line":281},[134,512,264],{"class":172},[134,514,515],{"class":267}," send_report",[134,517,518],{"class":176},"(path: ",[134,520,275],{"class":274},[134,522,523],{"class":176},", to: list[",[134,525,275],{"class":274},[134,527,528],{"class":176},"], subject: ",[134,530,275],{"class":274},[134,532,533],{"class":176},", html_body: ",[134,535,275],{"class":274},[134,537,538],{"class":176},") -> ",[134,540,541],{"class":274},"None",[134,543,278],{"class":176},[134,545,546,549,551],{"class":74,"line":292},[134,547,548],{"class":176},"    data ",[134,550,180],{"class":172},[134,552,553],{"class":176}," pathlib.Path(path).read_bytes()\n",[134,555,556,559,561],{"class":74,"line":310},[134,557,558],{"class":176},"    message ",[134,560,180],{"class":172},[134,562,563],{"class":176}," {\n",[134,565,566,569],{"class":74,"line":325},[134,567,568],{"class":143},"        \"message\"",[134,570,571],{"class":176},": {\n",[134,573,574,577],{"class":74,"line":360},[134,575,576],{"class":143},"            \"subject\"",[134,578,579],{"class":176},": subject,\n",[134,581,582,585,588,591,593,596,599,602],{"class":74,"line":366},[134,583,584],{"class":143},"            \"body\"",[134,586,587],{"class":176},": {",[134,589,590],{"class":143},"\"contentType\"",[134,592,438],{"class":176},[134,594,595],{"class":143},"\"HTML\"",[134,597,598],{"class":176},", ",[134,600,601],{"class":143},"\"content\"",[134,603,604],{"class":176},": html_body},\n",[134,606,607,610,613,616,618,621,624,627,630,633],{"class":74,"line":391},[134,608,609],{"class":143},"            \"toRecipients\"",[134,611,612],{"class":176},": [{",[134,614,615],{"class":143},"\"emailAddress\"",[134,617,587],{"class":176},[134,619,620],{"class":143},"\"address\"",[134,622,623],{"class":176},": address}} ",[134,625,626],{"class":172},"for",[134,628,629],{"class":176}," address ",[134,631,632],{"class":172},"in",[134,634,635],{"class":176}," to],\n",[134,637,638,641],{"class":74,"line":409},[134,639,640],{"class":143},"            \"attachments\"",[134,642,643],{"class":176},": [{\n",[134,645,646,649,651,654],{"class":74,"line":457},[134,647,648],{"class":143},"                \"@odata.type\"",[134,650,438],{"class":176},[134,652,653],{"class":143},"\"#microsoft.graph.fileAttachment\"",[134,655,357],{"class":176},[134,657,659,662],{"class":74,"line":658},14,[134,660,661],{"class":143},"                \"name\"",[134,663,664],{"class":176},": pathlib.Path(path).name,\n",[134,666,668,671,673,676],{"class":74,"line":667},15,[134,669,670],{"class":143},"                \"contentType\"",[134,672,438],{"class":176},[134,674,675],{"class":143},"\"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"",[134,677,357],{"class":176},[134,679,681,684],{"class":74,"line":680},16,[134,682,683],{"class":143},"                \"contentBytes\"",[134,685,686],{"class":176},": base64.b64encode(data).decode(),\n",[134,688,690],{"class":74,"line":689},17,[134,691,692],{"class":176},"            }],\n",[134,694,696],{"class":74,"line":695},18,[134,697,698],{"class":176},"        },\n",[134,700,702,705,707,710],{"class":74,"line":701},19,[134,703,704],{"class":143},"        \"saveToSentItems\"",[134,706,438],{"class":176},[134,708,709],{"class":274},"True",[134,711,357],{"class":176},[134,713,715],{"class":74,"line":714},20,[134,716,717],{"class":176},"    }\n",[134,719,721,724,726,729,732],{"class":74,"line":720},21,[134,722,723],{"class":176},"    sender ",[134,725,180],{"class":172},[134,727,728],{"class":176}," os.environ[",[134,730,731],{"class":143},"\"GRAPH_SENDER\"",[134,733,469],{"class":176},[134,735,737,740,742],{"class":74,"line":736},22,[134,738,739],{"class":176},"    response ",[134,741,180],{"class":172},[134,743,744],{"class":176}," requests.post(\n",[134,746,748,751,754,756,759,761,764],{"class":74,"line":747},23,[134,749,750],{"class":172},"        f",[134,752,753],{"class":143},"\"https:\u002F\u002Fgraph.microsoft.com\u002Fv1.0\u002Fusers\u002F",[134,755,340],{"class":339},[134,757,758],{"class":176},"sender",[134,760,351],{"class":339},[134,762,763],{"class":143},"\u002FsendMail\"",[134,765,357],{"class":176},[134,767,769,772,774,776,779,781,783,786,788,791,793,795],{"class":74,"line":768},24,[134,770,771],{"class":295},"        headers",[134,773,180],{"class":172},[134,775,340],{"class":176},[134,777,778],{"class":143},"\"Authorization\"",[134,780,438],{"class":176},[134,782,333],{"class":172},[134,784,785],{"class":143},"\"Bearer ",[134,787,340],{"class":339},[134,789,790],{"class":176},"graph_token()",[134,792,351],{"class":339},[134,794,354],{"class":143},[134,796,357],{"class":176},[134,798,800,803,805,808],{"class":74,"line":799},25,[134,801,802],{"class":143},"                 \"Content-Type\"",[134,804,438],{"class":176},[134,806,807],{"class":143},"\"application\u002Fjson\"",[134,809,810],{"class":176},"},\n",[134,812,814,817,819],{"class":74,"line":813},26,[134,815,816],{"class":295},"        json",[134,818,180],{"class":172},[134,820,821],{"class":176},"message,\n",[134,823,825,828,830,833],{"class":74,"line":824},27,[134,826,827],{"class":295},"        timeout",[134,829,180],{"class":172},[134,831,832],{"class":274},"60",[134,834,357],{"class":176},[134,836,838],{"class":74,"line":837},28,[134,839,363],{"class":176},[134,841,843,846],{"class":74,"line":842},29,[134,844,845],{"class":176},"    response.raise_for_status()          ",[134,847,849],{"class":848},"s-wDw","# 202 Accepted on success\n",[10,851,852,853,856,857,860],{},"The MIME type is the one for ",[131,854,855],{},".xlsx","; getting it wrong does not stop delivery but does change how\nsome clients present the attachment. ",[131,858,859],{},"saveToSentItems"," is worth leaving on — a copy in the sending\nmailbox is the cheapest possible audit trail for a report that goes out unattended.",[119,862,864],{"id":863},"large-attachments-need-an-upload-session","Large attachments need an upload session",[20,866,29,871,29,874,29,877,29,880,29,887,29,893,29,899,29,904,29,908,29,912,29,917,29,922,29,926,29,929,29,932,29,935,29,943,29,948,29,953,29,956,29,960,29,963],{"viewBox":867,"role":23,"ariaLabelledBy":868,"xmlns":27,"style":28},"0 0 760 201",[869,870],"gr-size-t","gr-size-d",[31,872,873],{"id":869},"Two attachment paths, decided by size",[35,875,876],{"id":870},"A file under roughly three megabytes after encoding travels inline in the sendMail payload, while a larger one needs a draft message, a chunked upload session and an explicit send.",[39,878],{"x":41,"y":41,"width":42,"height":879,"fill":44},"201",[39,881],{"x":882,"y":883,"width":884,"height":885,"rx":886,"fill":103,"stroke":104,"style":61},"20","28","270.0","139","14",[46,888,892],{"x":889,"y":890,"style":891},"155.0","54","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","inline attachment",[74,894],{"x1":895,"y1":896,"x2":897,"y2":896,"stroke":104,"style":898},"36","64","274.0","stroke-width:1px",[46,900,903],{"x":889,"y":901,"style":902},"86","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","under ~3 MB encoded",[46,905,907],{"x":889,"y":906,"style":902},"109","one request",[46,909,911],{"x":889,"y":910,"style":902},"132","contentBytes field",[39,913],{"x":914,"y":883,"width":884,"height":885,"rx":886,"fill":915,"stroke":916,"style":61},"470.0","#fdefd8","var(--gold,#b4740a)",[46,918,921],{"x":919,"y":890,"style":920},"605.0","font-size:13px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","upload session",[74,923],{"x1":924,"y1":896,"x2":925,"y2":896,"stroke":916,"style":898},"486.0","724.0",[46,927,928],{"x":919,"y":901,"style":902},"create a draft",[46,930,931],{"x":919,"y":906,"style":902},"PUT chunks of 4 MB",[46,933,934],{"x":919,"y":910,"style":902},"then send the draft",[39,936],{"x":937,"y":938,"width":939,"height":940,"rx":941,"fill":942,"stroke":60},"316.0","78.5","128","38","19","#ebebfd",[46,944,947],{"x":48,"y":945,"style":946},"102.5","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","3 MB",[74,949],{"x1":950,"y1":951,"x2":952,"y2":951,"stroke":60,"style":61},"295.0","97.5","309.0",[80,954],{"points":955,"fill":83},"309.0,97.5 300.0,92.5 300.0,102.5",[74,957],{"x1":958,"y1":951,"x2":959,"y2":951,"stroke":60,"style":61},"449.0","463.0",[80,961],{"points":962,"fill":83},"463.0,97.5 454.0,92.5 454.0,102.5",[46,964,966],{"x":48,"y":965,"style":116},"187","the limit is on the encoded size, not the file",[10,968,969],{},"Anything much over 3 MB after encoding is rejected. The larger path is to create a draft, upload the\nfile in chunks against it, and then send the draft.",[124,971,973],{"className":234,"code":972,"language":236,"meta":129,"style":129},"def send_large_report(path: str, to: list[str], subject: str, html_body: str) -> None:\n    token = graph_token()\n    headers = {\"Authorization\": f\"Bearer {token}\", \"Content-Type\": \"application\u002Fjson\"}\n    sender = os.environ[\"GRAPH_SENDER\"]\n    base = f\"https:\u002F\u002Fgraph.microsoft.com\u002Fv1.0\u002Fusers\u002F{sender}\u002Fmessages\"\n\n    draft = requests.post(base, headers=headers, timeout=60, json={\n        \"subject\": subject,\n        \"body\": {\"contentType\": \"HTML\", \"content\": html_body},\n        \"toRecipients\": [{\"emailAddress\": {\"address\": a}} for a in to],\n    })\n    draft.raise_for_status()\n    message_id = draft.json()[\"id\"]\n\n    file = pathlib.Path(path)\n    size = file.stat().st_size\n    session = requests.post(\n        f\"{base}\u002F{message_id}\u002Fattachments\u002FcreateUploadSession\",\n        headers=headers, timeout=60,\n        json={\"AttachmentItem\": {\"attachmentType\": \"file\", \"name\": file.name, \"size\": size}},\n    )\n    session.raise_for_status()\n    upload_url = session.json()[\"uploadUrl\"]\n\n    chunk = 4 * 1024 * 1024\n    with file.open(\"rb\") as handle:\n        start = 0\n        while start \u003C size:\n            block = handle.read(chunk)\n            end = start + len(block) - 1\n            put = requests.put(\n                upload_url, data=block, timeout=300,\n                headers={\"Content-Length\": str(len(block)),\n                         \"Content-Range\": f\"bytes {start}-{end}\u002F{size}\"},\n            )\n            put.raise_for_status()\n            start = end + 1\n\n    requests.post(f\"{base}\u002F{message_id}\u002Fsend\", headers=headers, timeout=60).raise_for_status()\n",[131,974,975,1004,1014,1053,1065,1086,1090,1125,1132,1151,1176,1181,1186,1201,1205,1216,1229,1238,1266,1282,1322,1326,1331,1346,1350,1371,1393,1403,1417,1427,1452,1463,1486,1511,1553,1559,1565,1580,1585],{"__ignoreMap":129},[134,976,977,979,982,984,986,988,990,992,994,996,998,1000,1002],{"class":74,"line":136},[134,978,264],{"class":172},[134,980,981],{"class":267}," send_large_report",[134,983,518],{"class":176},[134,985,275],{"class":274},[134,987,523],{"class":176},[134,989,275],{"class":274},[134,991,528],{"class":176},[134,993,275],{"class":274},[134,995,533],{"class":176},[134,997,275],{"class":274},[134,999,538],{"class":176},[134,1001,541],{"class":274},[134,1003,278],{"class":176},[134,1005,1006,1009,1011],{"class":74,"line":186},[134,1007,1008],{"class":176},"    token ",[134,1010,180],{"class":172},[134,1012,1013],{"class":176}," graph_token()\n",[134,1015,1016,1019,1021,1024,1026,1028,1030,1032,1034,1037,1039,1041,1043,1046,1048,1050],{"class":74,"line":198},[134,1017,1018],{"class":176},"    headers ",[134,1020,180],{"class":172},[134,1022,1023],{"class":176}," {",[134,1025,778],{"class":143},[134,1027,438],{"class":176},[134,1029,333],{"class":172},[134,1031,785],{"class":143},[134,1033,340],{"class":339},[134,1035,1036],{"class":176},"token",[134,1038,351],{"class":339},[134,1040,354],{"class":143},[134,1042,598],{"class":176},[134,1044,1045],{"class":143},"\"Content-Type\"",[134,1047,438],{"class":176},[134,1049,807],{"class":143},[134,1051,1052],{"class":176},"}\n",[134,1054,1055,1057,1059,1061,1063],{"class":74,"line":210},[134,1056,723],{"class":176},[134,1058,180],{"class":172},[134,1060,728],{"class":176},[134,1062,731],{"class":143},[134,1064,469],{"class":176},[134,1066,1067,1070,1072,1075,1077,1079,1081,1083],{"class":74,"line":281},[134,1068,1069],{"class":176},"    base ",[134,1071,180],{"class":172},[134,1073,1074],{"class":172}," f",[134,1076,753],{"class":143},[134,1078,340],{"class":339},[134,1080,758],{"class":176},[134,1082,351],{"class":339},[134,1084,1085],{"class":143},"\u002Fmessages\"\n",[134,1087,1088],{"class":74,"line":292},[134,1089,259],{"emptyLinePlaceholder":258},[134,1091,1092,1095,1097,1100,1103,1105,1108,1111,1113,1115,1117,1120,1122],{"class":74,"line":310},[134,1093,1094],{"class":176},"    draft ",[134,1096,180],{"class":172},[134,1098,1099],{"class":176}," requests.post(base, ",[134,1101,1102],{"class":295},"headers",[134,1104,180],{"class":172},[134,1106,1107],{"class":176},"headers, ",[134,1109,1110],{"class":295},"timeout",[134,1112,180],{"class":172},[134,1114,832],{"class":274},[134,1116,598],{"class":176},[134,1118,1119],{"class":295},"json",[134,1121,180],{"class":172},[134,1123,1124],{"class":176},"{\n",[134,1126,1127,1130],{"class":74,"line":325},[134,1128,1129],{"class":143},"        \"subject\"",[134,1131,579],{"class":176},[134,1133,1134,1137,1139,1141,1143,1145,1147,1149],{"class":74,"line":360},[134,1135,1136],{"class":143},"        \"body\"",[134,1138,587],{"class":176},[134,1140,590],{"class":143},[134,1142,438],{"class":176},[134,1144,595],{"class":143},[134,1146,598],{"class":176},[134,1148,601],{"class":143},[134,1150,604],{"class":176},[134,1152,1153,1156,1158,1160,1162,1164,1167,1169,1172,1174],{"class":74,"line":366},[134,1154,1155],{"class":143},"        \"toRecipients\"",[134,1157,612],{"class":176},[134,1159,615],{"class":143},[134,1161,587],{"class":176},[134,1163,620],{"class":143},[134,1165,1166],{"class":176},": a}} ",[134,1168,626],{"class":172},[134,1170,1171],{"class":176}," a ",[134,1173,632],{"class":172},[134,1175,635],{"class":176},[134,1177,1178],{"class":74,"line":391},[134,1179,1180],{"class":176},"    })\n",[134,1182,1183],{"class":74,"line":409},[134,1184,1185],{"class":176},"    draft.raise_for_status()\n",[134,1187,1188,1191,1193,1196,1199],{"class":74,"line":457},[134,1189,1190],{"class":176},"    message_id ",[134,1192,180],{"class":172},[134,1194,1195],{"class":176}," draft.json()[",[134,1197,1198],{"class":143},"\"id\"",[134,1200,469],{"class":176},[134,1202,1203],{"class":74,"line":658},[134,1204,259],{"emptyLinePlaceholder":258},[134,1206,1207,1210,1213],{"class":74,"line":667},[134,1208,1209],{"class":295},"    file",[134,1211,1212],{"class":172}," =",[134,1214,1215],{"class":176}," pathlib.Path(path)\n",[134,1217,1218,1221,1223,1226],{"class":74,"line":680},[134,1219,1220],{"class":176},"    size ",[134,1222,180],{"class":172},[134,1224,1225],{"class":295}," file",[134,1227,1228],{"class":176},".stat().st_size\n",[134,1230,1231,1234,1236],{"class":74,"line":689},[134,1232,1233],{"class":176},"    session ",[134,1235,180],{"class":172},[134,1237,744],{"class":176},[134,1239,1240,1242,1244,1246,1249,1251,1254,1256,1259,1261,1264],{"class":74,"line":695},[134,1241,750],{"class":172},[134,1243,354],{"class":143},[134,1245,340],{"class":339},[134,1247,1248],{"class":176},"base",[134,1250,351],{"class":339},[134,1252,1253],{"class":143},"\u002F",[134,1255,340],{"class":339},[134,1257,1258],{"class":176},"message_id",[134,1260,351],{"class":339},[134,1262,1263],{"class":143},"\u002Fattachments\u002FcreateUploadSession\"",[134,1265,357],{"class":176},[134,1267,1268,1270,1272,1274,1276,1278,1280],{"class":74,"line":701},[134,1269,771],{"class":295},[134,1271,180],{"class":172},[134,1273,1107],{"class":176},[134,1275,1110],{"class":295},[134,1277,180],{"class":172},[134,1279,832],{"class":274},[134,1281,357],{"class":176},[134,1283,1284,1286,1288,1290,1293,1295,1298,1300,1303,1305,1308,1310,1313,1316,1319],{"class":74,"line":714},[134,1285,816],{"class":295},[134,1287,180],{"class":172},[134,1289,340],{"class":176},[134,1291,1292],{"class":143},"\"AttachmentItem\"",[134,1294,587],{"class":176},[134,1296,1297],{"class":143},"\"attachmentType\"",[134,1299,438],{"class":176},[134,1301,1302],{"class":143},"\"file\"",[134,1304,598],{"class":176},[134,1306,1307],{"class":143},"\"name\"",[134,1309,438],{"class":176},[134,1311,1312],{"class":295},"file",[134,1314,1315],{"class":176},".name, ",[134,1317,1318],{"class":143},"\"size\"",[134,1320,1321],{"class":176},": size}},\n",[134,1323,1324],{"class":74,"line":720},[134,1325,363],{"class":176},[134,1327,1328],{"class":74,"line":736},[134,1329,1330],{"class":176},"    session.raise_for_status()\n",[134,1332,1333,1336,1338,1341,1344],{"class":74,"line":747},[134,1334,1335],{"class":176},"    upload_url ",[134,1337,180],{"class":172},[134,1339,1340],{"class":176}," session.json()[",[134,1342,1343],{"class":143},"\"uploadUrl\"",[134,1345,469],{"class":176},[134,1347,1348],{"class":74,"line":768},[134,1349,259],{"emptyLinePlaceholder":258},[134,1351,1352,1355,1357,1360,1363,1366,1368],{"class":74,"line":799},[134,1353,1354],{"class":176},"    chunk ",[134,1356,180],{"class":172},[134,1358,1359],{"class":274}," 4",[134,1361,1362],{"class":172}," *",[134,1364,1365],{"class":274}," 1024",[134,1367,1362],{"class":172},[134,1369,1370],{"class":274}," 1024\n",[134,1372,1373,1376,1378,1381,1384,1387,1390],{"class":74,"line":813},[134,1374,1375],{"class":172},"    with",[134,1377,1225],{"class":295},[134,1379,1380],{"class":176},".open(",[134,1382,1383],{"class":143},"\"rb\"",[134,1385,1386],{"class":176},") ",[134,1388,1389],{"class":172},"as",[134,1391,1392],{"class":176}," handle:\n",[134,1394,1395,1398,1400],{"class":74,"line":824},[134,1396,1397],{"class":176},"        start ",[134,1399,180],{"class":172},[134,1401,1402],{"class":274}," 0\n",[134,1404,1405,1408,1411,1414],{"class":74,"line":837},[134,1406,1407],{"class":172},"        while",[134,1409,1410],{"class":176}," start ",[134,1412,1413],{"class":172},"\u003C",[134,1415,1416],{"class":176}," size:\n",[134,1418,1419,1422,1424],{"class":74,"line":842},[134,1420,1421],{"class":176},"            block ",[134,1423,180],{"class":172},[134,1425,1426],{"class":176}," handle.read(chunk)\n",[134,1428,1430,1433,1435,1437,1440,1443,1446,1449],{"class":74,"line":1429},30,[134,1431,1432],{"class":176},"            end ",[134,1434,180],{"class":172},[134,1436,1410],{"class":176},[134,1438,1439],{"class":172},"+",[134,1441,1442],{"class":274}," len",[134,1444,1445],{"class":176},"(block) ",[134,1447,1448],{"class":172},"-",[134,1450,1451],{"class":274}," 1\n",[134,1453,1455,1458,1460],{"class":74,"line":1454},31,[134,1456,1457],{"class":176},"            put ",[134,1459,180],{"class":172},[134,1461,1462],{"class":176}," requests.put(\n",[134,1464,1466,1469,1472,1474,1477,1479,1481,1484],{"class":74,"line":1465},32,[134,1467,1468],{"class":176},"                upload_url, ",[134,1470,1471],{"class":295},"data",[134,1473,180],{"class":172},[134,1475,1476],{"class":176},"block, ",[134,1478,1110],{"class":295},[134,1480,180],{"class":172},[134,1482,1483],{"class":274},"300",[134,1485,357],{"class":176},[134,1487,1489,1492,1494,1496,1499,1501,1503,1505,1508],{"class":74,"line":1488},33,[134,1490,1491],{"class":295},"                headers",[134,1493,180],{"class":172},[134,1495,340],{"class":176},[134,1497,1498],{"class":143},"\"Content-Length\"",[134,1500,438],{"class":176},[134,1502,275],{"class":274},[134,1504,418],{"class":176},[134,1506,1507],{"class":274},"len",[134,1509,1510],{"class":176},"(block)),\n",[134,1512,1514,1517,1519,1521,1524,1526,1529,1531,1533,1535,1538,1540,1542,1544,1547,1549,1551],{"class":74,"line":1513},34,[134,1515,1516],{"class":143},"                         \"Content-Range\"",[134,1518,438],{"class":176},[134,1520,333],{"class":172},[134,1522,1523],{"class":143},"\"bytes ",[134,1525,340],{"class":339},[134,1527,1528],{"class":176},"start",[134,1530,351],{"class":339},[134,1532,1448],{"class":143},[134,1534,340],{"class":339},[134,1536,1537],{"class":176},"end",[134,1539,351],{"class":339},[134,1541,1253],{"class":143},[134,1543,340],{"class":339},[134,1545,1546],{"class":176},"size",[134,1548,351],{"class":339},[134,1550,354],{"class":143},[134,1552,810],{"class":176},[134,1554,1556],{"class":74,"line":1555},35,[134,1557,1558],{"class":176},"            )\n",[134,1560,1562],{"class":74,"line":1561},36,[134,1563,1564],{"class":176},"            put.raise_for_status()\n",[134,1566,1568,1571,1573,1576,1578],{"class":74,"line":1567},37,[134,1569,1570],{"class":176},"            start ",[134,1572,180],{"class":172},[134,1574,1575],{"class":176}," end ",[134,1577,1439],{"class":172},[134,1579,1451],{"class":274},[134,1581,1583],{"class":74,"line":1582},38,[134,1584,259],{"emptyLinePlaceholder":258},[134,1586,1588,1591,1593,1595,1597,1599,1601,1603,1605,1607,1609,1612,1614,1616,1618,1620,1622,1624,1626],{"class":74,"line":1587},39,[134,1589,1590],{"class":176},"    requests.post(",[134,1592,333],{"class":172},[134,1594,354],{"class":143},[134,1596,340],{"class":339},[134,1598,1248],{"class":176},[134,1600,351],{"class":339},[134,1602,1253],{"class":143},[134,1604,340],{"class":339},[134,1606,1258],{"class":176},[134,1608,351],{"class":339},[134,1610,1611],{"class":143},"\u002Fsend\"",[134,1613,598],{"class":176},[134,1615,1102],{"class":295},[134,1617,180],{"class":172},[134,1619,1107],{"class":176},[134,1621,1110],{"class":295},[134,1623,180],{"class":172},[134,1625,832],{"class":274},[134,1627,1628],{"class":176},").raise_for_status()\n",[10,1630,1631,1632,1635],{},"The ",[131,1633,1634],{},"Content-Range"," header is the part that must be exactly right — byte offsets are inclusive at\nboth ends, and an off-by-one produces an error whose message does not point at the arithmetic. Chunk\nsizes must be a multiple of 320 KiB; 4 MB satisfies that and is a reasonable balance.",[119,1637,1639],{"id":1638},"compose-the-body-from-the-data","Compose the body from the data",[10,1641,1642],{},"The most useful report email says enough that a recipient does not have to open the attachment to\nknow whether they need to. Building that summary from the same frame that produced the workbook costs\nalmost nothing.",[124,1644,1646],{"className":234,"code":1645,"language":236,"meta":129,"style":129},"import pandas as pd\n\ndef summary_html(frame: pd.DataFrame) -> str:\n    by_region = frame.groupby(\"Region\", as_index=False)[\"Revenue\"].sum()\n    table = by_region.to_html(index=False, float_format=lambda v: f\"{v:,.0f}\", border=0)\n    return (\n        f\"\u003Cp>Total revenue \u003Cstrong>{frame['Revenue'].sum():,.0f}\u003C\u002Fstrong> \"\n        f\"across {len(frame):,} orders.\u003C\u002Fp>{table}\"\n    )\n",[131,1647,1648,1660,1664,1678,1710,1765,1772,1797,1829],{"__ignoreMap":129},[134,1649,1650,1652,1655,1657],{"class":74,"line":136},[134,1651,243],{"class":172},[134,1653,1654],{"class":176}," pandas ",[134,1656,1389],{"class":172},[134,1658,1659],{"class":176}," pd\n",[134,1661,1662],{"class":74,"line":186},[134,1663,259],{"emptyLinePlaceholder":258},[134,1665,1666,1668,1671,1674,1676],{"class":74,"line":198},[134,1667,264],{"class":172},[134,1669,1670],{"class":267}," summary_html",[134,1672,1673],{"class":176},"(frame: pd.DataFrame) -> ",[134,1675,275],{"class":274},[134,1677,278],{"class":176},[134,1679,1680,1683,1685,1688,1691,1693,1696,1698,1701,1704,1707],{"class":74,"line":210},[134,1681,1682],{"class":176},"    by_region ",[134,1684,180],{"class":172},[134,1686,1687],{"class":176}," frame.groupby(",[134,1689,1690],{"class":143},"\"Region\"",[134,1692,598],{"class":176},[134,1694,1695],{"class":295},"as_index",[134,1697,180],{"class":172},[134,1699,1700],{"class":274},"False",[134,1702,1703],{"class":176},")[",[134,1705,1706],{"class":143},"\"Revenue\"",[134,1708,1709],{"class":176},"].sum()\n",[134,1711,1712,1715,1717,1720,1723,1725,1727,1729,1732,1735,1738,1740,1742,1744,1747,1750,1752,1754,1756,1759,1761,1763],{"class":74,"line":281},[134,1713,1714],{"class":176},"    table ",[134,1716,180],{"class":172},[134,1718,1719],{"class":176}," by_region.to_html(",[134,1721,1722],{"class":295},"index",[134,1724,180],{"class":172},[134,1726,1700],{"class":274},[134,1728,598],{"class":176},[134,1730,1731],{"class":295},"float_format",[134,1733,1734],{"class":172},"=lambda",[134,1736,1737],{"class":176}," v: ",[134,1739,333],{"class":172},[134,1741,354],{"class":143},[134,1743,340],{"class":339},[134,1745,1746],{"class":176},"v",[134,1748,1749],{"class":172},":,.0f",[134,1751,351],{"class":339},[134,1753,354],{"class":143},[134,1755,598],{"class":176},[134,1757,1758],{"class":295},"border",[134,1760,180],{"class":172},[134,1762,41],{"class":274},[134,1764,454],{"class":176},[134,1766,1767,1769],{"class":74,"line":292},[134,1768,460],{"class":172},[134,1770,1771],{"class":176}," (\n",[134,1773,1774,1776,1779,1781,1784,1787,1790,1792,1794],{"class":74,"line":310},[134,1775,750],{"class":172},[134,1777,1778],{"class":143},"\"\u003Cp>Total revenue \u003Cstrong>",[134,1780,340],{"class":339},[134,1782,1783],{"class":176},"frame[",[134,1785,1786],{"class":143},"'Revenue'",[134,1788,1789],{"class":176},"].sum()",[134,1791,1749],{"class":172},[134,1793,351],{"class":339},[134,1795,1796],{"class":143},"\u003C\u002Fstrong> \"\n",[134,1798,1799,1801,1804,1806,1808,1811,1814,1816,1819,1821,1824,1826],{"class":74,"line":325},[134,1800,750],{"class":172},[134,1802,1803],{"class":143},"\"across ",[134,1805,340],{"class":339},[134,1807,1507],{"class":274},[134,1809,1810],{"class":176},"(frame)",[134,1812,1813],{"class":172},":,",[134,1815,351],{"class":339},[134,1817,1818],{"class":143}," orders.\u003C\u002Fp>",[134,1820,340],{"class":339},[134,1822,1823],{"class":176},"table",[134,1825,351],{"class":339},[134,1827,1828],{"class":143},"\"\n",[134,1830,1831],{"class":74,"line":360},[134,1832,363],{"class":176},[10,1834,1835,1838,1839,1842,1843,1847],{},[131,1836,1837],{},"to_html"," produces a table most mail clients render acceptably; adding inline styles rather than a\nstylesheet is what makes it survive Outlook, which strips ",[131,1840,1841],{},"\u003Cstyle>"," blocks. The wider treatment is in\n",[14,1844,1846],{"href":1845},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-an-excel-report-with-an-html-summary-body\u002F","Email an Excel Report with an HTML Summary Body",".",[119,1849,1851],{"id":1850},"verifying-delivery-rather-than-assuming-it","Verifying delivery rather than assuming it",[10,1853,1854],{},"A 202 response tells you Microsoft accepted the message, which is a weaker guarantee than most\nscripts treat it as. Two cheap checks turn that into something you can act on. The first is to read\nback the sending mailbox's Sent Items and confirm a message with the expected subject arrived there.",[124,1856,1858],{"className":234,"code":1857,"language":236,"meta":129,"style":129},"def confirm_sent(subject: str, within_minutes: int = 5) -> bool:\n    from datetime import datetime, timedelta, timezone\n    since = (datetime.now(timezone.utc) - timedelta(minutes=within_minutes)).isoformat()\n    sender = os.environ[\"GRAPH_SENDER\"]\n    response = requests.get(\n        f\"https:\u002F\u002Fgraph.microsoft.com\u002Fv1.0\u002Fusers\u002F{sender}\u002FmailFolders\u002Fsentitems\u002Fmessages\",\n        headers={\"Authorization\": f\"Bearer {graph_token()}\"},\n        params={\"$filter\": f\"sentDateTime ge {since}\", \"$select\": \"subject,sentDateTime\", \"$top\": 25},\n        timeout=60,\n    )\n    response.raise_for_status()\n    return any(item[\"subject\"] == subject for item in response.json().get(\"value\", []))\n",[131,1859,1860,1890,1903,1926,1938,1947,1964,1990,2040,2050,2054,2059],{"__ignoreMap":129},[134,1861,1862,1864,1867,1870,1872,1875,1878,1880,1883,1885,1888],{"class":74,"line":136},[134,1863,264],{"class":172},[134,1865,1866],{"class":267}," confirm_sent",[134,1868,1869],{"class":176},"(subject: ",[134,1871,275],{"class":274},[134,1873,1874],{"class":176},", within_minutes: ",[134,1876,1877],{"class":274},"int",[134,1879,1212],{"class":172},[134,1881,1882],{"class":274}," 5",[134,1884,538],{"class":176},[134,1886,1887],{"class":274},"bool",[134,1889,278],{"class":176},[134,1891,1892,1895,1898,1900],{"class":74,"line":186},[134,1893,1894],{"class":172},"    from",[134,1896,1897],{"class":176}," datetime ",[134,1899,243],{"class":172},[134,1901,1902],{"class":176}," datetime, timedelta, timezone\n",[134,1904,1905,1908,1910,1913,1915,1918,1921,1923],{"class":74,"line":198},[134,1906,1907],{"class":176},"    since ",[134,1909,180],{"class":172},[134,1911,1912],{"class":176}," (datetime.now(timezone.utc) ",[134,1914,1448],{"class":172},[134,1916,1917],{"class":176}," timedelta(",[134,1919,1920],{"class":295},"minutes",[134,1922,180],{"class":172},[134,1924,1925],{"class":176},"within_minutes)).isoformat()\n",[134,1927,1928,1930,1932,1934,1936],{"class":74,"line":210},[134,1929,723],{"class":176},[134,1931,180],{"class":172},[134,1933,728],{"class":176},[134,1935,731],{"class":143},[134,1937,469],{"class":176},[134,1939,1940,1942,1944],{"class":74,"line":281},[134,1941,739],{"class":176},[134,1943,180],{"class":172},[134,1945,1946],{"class":176}," requests.get(\n",[134,1948,1949,1951,1953,1955,1957,1959,1962],{"class":74,"line":292},[134,1950,750],{"class":172},[134,1952,753],{"class":143},[134,1954,340],{"class":339},[134,1956,758],{"class":176},[134,1958,351],{"class":339},[134,1960,1961],{"class":143},"\u002FmailFolders\u002Fsentitems\u002Fmessages\"",[134,1963,357],{"class":176},[134,1965,1966,1968,1970,1972,1974,1976,1978,1980,1982,1984,1986,1988],{"class":74,"line":310},[134,1967,771],{"class":295},[134,1969,180],{"class":172},[134,1971,340],{"class":176},[134,1973,778],{"class":143},[134,1975,438],{"class":176},[134,1977,333],{"class":172},[134,1979,785],{"class":143},[134,1981,340],{"class":339},[134,1983,790],{"class":176},[134,1985,351],{"class":339},[134,1987,354],{"class":143},[134,1989,810],{"class":176},[134,1991,1992,1995,1997,1999,2002,2004,2006,2009,2011,2014,2016,2018,2020,2023,2025,2028,2030,2033,2035,2038],{"class":74,"line":325},[134,1993,1994],{"class":295},"        params",[134,1996,180],{"class":172},[134,1998,340],{"class":176},[134,2000,2001],{"class":143},"\"$filter\"",[134,2003,438],{"class":176},[134,2005,333],{"class":172},[134,2007,2008],{"class":143},"\"sentDateTime ge ",[134,2010,340],{"class":339},[134,2012,2013],{"class":176},"since",[134,2015,351],{"class":339},[134,2017,354],{"class":143},[134,2019,598],{"class":176},[134,2021,2022],{"class":143},"\"$select\"",[134,2024,438],{"class":176},[134,2026,2027],{"class":143},"\"subject,sentDateTime\"",[134,2029,598],{"class":176},[134,2031,2032],{"class":143},"\"$top\"",[134,2034,438],{"class":176},[134,2036,2037],{"class":274},"25",[134,2039,810],{"class":176},[134,2041,2042,2044,2046,2048],{"class":74,"line":360},[134,2043,827],{"class":295},[134,2045,180],{"class":172},[134,2047,832],{"class":274},[134,2049,357],{"class":176},[134,2051,2052],{"class":74,"line":366},[134,2053,363],{"class":176},[134,2055,2056],{"class":74,"line":391},[134,2057,2058],{"class":176},"    response.raise_for_status()\n",[134,2060,2061,2063,2066,2069,2072,2075,2078,2081,2083,2086,2088,2091,2094],{"class":74,"line":409},[134,2062,460],{"class":172},[134,2064,2065],{"class":274}," any",[134,2067,2068],{"class":176},"(item[",[134,2070,2071],{"class":143},"\"subject\"",[134,2073,2074],{"class":176},"] ",[134,2076,2077],{"class":172},"==",[134,2079,2080],{"class":176}," subject ",[134,2082,626],{"class":172},[134,2084,2085],{"class":176}," item ",[134,2087,632],{"class":172},[134,2089,2090],{"class":176}," response.json().get(",[134,2092,2093],{"class":143},"\"value\"",[134,2095,2096],{"class":176},", []))\n",[10,2098,2099],{},"The second is simply to log the message id that the draft-and-send path returns, which gives support\nsomething to search on when a recipient says the report never arrived. Neither check proves delivery\nto the recipient's inbox — nothing available to the sender does — but both distinguish \"we did not\nsend it\" from \"they did not receive it\", which is the question that actually gets asked.",[119,2101,2103],{"id":2102},"keeping-the-secret-out-of-the-repository","Keeping the secret out of the repository",[10,2105,2106],{},"A client secret is a credential with the same weight as the mailbox password it replaces, and it\nexpires — typically after six or twenty-four months — which makes it operationally different from a\npassword nobody rotates. Reading it from the environment is the minimum; reading it from a managed\nsecret store is better, and on Azure a managed identity removes the secret entirely.",[124,2108,2110],{"className":234,"code":2109,"language":236,"meta":129,"style":129},"from azure.identity import DefaultAzureCredential\n\ncredential = DefaultAzureCredential()          # managed identity where available\ntoken = credential.get_token(\"https:\u002F\u002Fgraph.microsoft.com\u002F.default\").token\n",[131,2111,2112,2125,2129,2142],{"__ignoreMap":129},[134,2113,2114,2117,2120,2122],{"class":74,"line":136},[134,2115,2116],{"class":172},"from",[134,2118,2119],{"class":176}," azure.identity ",[134,2121,243],{"class":172},[134,2123,2124],{"class":176}," DefaultAzureCredential\n",[134,2126,2127],{"class":74,"line":186},[134,2128,259],{"emptyLinePlaceholder":258},[134,2130,2131,2134,2136,2139],{"class":74,"line":198},[134,2132,2133],{"class":176},"credential ",[134,2135,180],{"class":172},[134,2137,2138],{"class":176}," DefaultAzureCredential()          ",[134,2140,2141],{"class":848},"# managed identity where available\n",[134,2143,2144,2147,2149,2152,2154],{"class":74,"line":210},[134,2145,2146],{"class":176},"token ",[134,2148,180],{"class":172},[134,2150,2151],{"class":176}," credential.get_token(",[134,2153,385],{"class":143},[134,2155,2156],{"class":176},").token\n",[10,2158,2159,2162,2163,1847],{},[131,2160,2161],{},"DefaultAzureCredential"," tries a managed identity first and falls back through environment variables\nand developer sign-in, so the same code runs on a laptop and in a container without branching. That\nremoves the expiry problem along with the storage one, which is the more valuable half — a secret\nthat expires at 02:00 on a Sunday is a class of incident worth designing out. The general argument is\nin ",[14,2164,2166],{"href":2165},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file\u002F","Keep Excel Report Settings in a Config File",[119,2168,2170],{"id":2169},"common-pitfalls","Common pitfalls",[1823,2172,2173,2189],{},[2174,2175,2176],"thead",{},[2177,2178,2179,2183,2186],"tr",{},[2180,2181,2182],"th",{},"Symptom",[2180,2184,2185],{},"Cause",[2180,2187,2188],{},"Fix",[2190,2191,2192,2210,2224,2235,2246,2259],"tbody",{},[2177,2193,2194,2201,2204],{},[2195,2196,2197,2200],"td",{},[131,2198,2199],{},"403 Forbidden"," on sendMail",[2195,2202,2203],{},"Application permission not consented",[2195,2205,2206,2207,2209],{},"Grant admin consent for ",[131,2208,156],{}," in the app registration",[2177,2211,2212,2218,2221],{},[2195,2213,2214,2217],{},[131,2215,2216],{},"401 Unauthorized"," after months of working",[2195,2219,2220],{},"Client secret expired",[2195,2222,2223],{},"Rotate the secret; set a calendar reminder before expiry",[2177,2225,2226,2229,2232],{},[2195,2227,2228],{},"202 returned, no mail arrives",[2195,2230,2231],{},"Sender address is not a real mailbox",[2195,2233,2234],{},"Use the mailbox's own address, not the app name",[2177,2236,2237,2240,2243],{},[2195,2238,2239],{},"Attachment rejected with a size error",[2195,2241,2242],{},"Over the inline limit",[2195,2244,2245],{},"Use the upload-session path above",[2177,2247,2248,2253,2256],{},[2195,2249,2250],{},[131,2251,2252],{},"ErrorInvalidUser",[2195,2254,2255],{},"Sending as a mailbox the app cannot access",[2195,2257,2258],{},"Scope the app with an access policy, or use a shared mailbox it can send as",[2177,2260,2261,2264,2273],{},[2195,2262,2263],{},"Body renders as plain text",[2195,2265,2266,2269,2270],{},[131,2267,2268],{},"contentType"," left as ",[131,2271,2272],{},"Text",[2195,2274,2275,2276],{},"Set ",[131,2277,2278],{},"\"contentType\": \"HTML\"",[119,2280,2282],{"id":2281},"restricting-which-mailboxes-the-app-can-use","Restricting which mailboxes the app can use",[10,2284,2285,2286,2288],{},"An application with ",[131,2287,156],{}," can, by default, send as any mailbox in the tenant — which is more\nauthority than a reporting job needs and more than most security teams will accept. Exchange's\napplication access policy narrows it to a named group.",[124,2290,2294],{"className":2291,"code":2293,"language":46,"meta":129},[2292],"language-text","New-DistributionGroup -Name \"Automation Senders\" -Type Security\nAdd-DistributionGroupMember -Identity \"Automation Senders\" -Member reports@example.com\nNew-ApplicationAccessPolicy -AppId \u003Cclient-id> `\n    -PolicyScopeGroupId \"Automation Senders\" -AccessRight RestrictAccess `\n    -Description \"Reporting jobs may send only as the reports mailbox\"\n",[131,2295,2293],{"__ignoreMap":129},[10,2297,2298],{},"Running that once turns the permission from tenant-wide into one mailbox, and it makes the approval\nconversation considerably shorter. It is worth doing before the first production run rather than\nafter the first security review.",[119,2300,2302],{"id":2301},"performance-and-scale","Performance and scale",[20,2304,29,2309,29,2312,29,2315,29,2318,29,2324,29,2329,29,2334,29,2339,29,2344,29,2347,29,2350,29,2354,29,2358,29,2362,29,2365,29,2368,29,2372,29,2376,29,2380,29,2383,29,2386,29,2390,29,2394,29,2398],{"viewBox":2305,"role":23,"ariaLabelledBy":2306,"xmlns":27,"style":28},"0 0 760 308",[2307,2308],"gr-fail-t","gr-fail-d",[31,2310,2311],{"id":2307},"The four failures worth handling explicitly",[35,2313,2314],{"id":2308},"A missing admin consent returns 403, an expired secret returns 401, throttling returns 429 with a Retry-After header, and a 202 with no delivery usually means the sender address is not a real mailbox.",[39,2316],{"x":41,"y":41,"width":42,"height":2317,"fill":44},"308",[39,2319],{"x":2320,"y":883,"width":2321,"height":2322,"rx":2323,"fill":103,"stroke":104},"22","716","52","10",[2325,2326],"circle",{"cx":2322,"cy":2327,"r":2328,"fill":83},"54.0","15",[46,2330,2333],{"x":2322,"y":2331,"style":2332},"59.0","font-size:13px;font-weight:700;fill:#ffffff;text-anchor:middle","1",[46,2335,2199],{"x":2336,"y":2337,"style":2338},"82","50","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start",[46,2340,2343],{"x":2336,"y":2341,"style":2342},"68","font-size:11.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:start","the application permission was never consented",[39,2345],{"x":2320,"y":2346,"width":2321,"height":2322,"rx":2323,"fill":103,"stroke":104},"90",[2325,2348],{"cx":2322,"cy":2349,"r":2328,"fill":83},"116.0",[46,2351,2353],{"x":2322,"y":2352,"style":2332},"121.0","2",[46,2355,2357],{"x":2336,"y":2356,"style":2338},"112","401 after months of success",[46,2359,2361],{"x":2336,"y":2360,"style":2342},"130","the client secret expired",[39,2363],{"x":2320,"y":2364,"width":2321,"height":2322,"rx":2323,"fill":103,"stroke":104},"152",[2325,2366],{"cx":2322,"cy":2367,"r":2328,"fill":83},"178.0",[46,2369,2371],{"x":2322,"y":2370,"style":2332},"183.0","3",[46,2373,2375],{"x":2336,"y":2374,"style":2338},"174","429 with Retry-After",[46,2377,2379],{"x":2336,"y":2378,"style":2342},"192","throttled — wait the stated interval",[39,2381],{"x":2320,"y":2382,"width":2321,"height":2322,"rx":2323,"fill":103,"stroke":104},"214",[2325,2384],{"cx":2322,"cy":2385,"r":2328,"fill":83},"240.0",[46,2387,2389],{"x":2322,"y":2388,"style":2332},"245.0","4",[46,2391,2393],{"x":2336,"y":2392,"style":2338},"236","202 but nothing arrives",[46,2395,2397],{"x":2336,"y":2396,"style":2342},"254","the sender address is not a mailbox",[46,2399,2401],{"x":48,"y":2400,"style":116},"296","each has a different fix and identical-looking silence",[10,2403,2404,2405,2407],{},"The token acquisition is the slow part of a single send — a network round trip against the identity\nservice — and ",[131,2406,230],{}," caches it, so a job sending twenty reports should reuse one client rather than\nconstructing a new one each time. Sending itself is a single HTTPS request per message.",[124,2409,2411],{"className":234,"code":2410,"language":236,"meta":129,"style":129},"app = msal.ConfidentialClientApplication(...)     # build once\n\nfor recipient, path in deliveries:\n    token = app.acquire_token_for_client(scopes=[SCOPE])[\"access_token\"]   # cached\n    send_with(token, recipient, path)\n",[131,2412,2413,2432,2436,2448,2476],{"__ignoreMap":129},[134,2414,2415,2418,2420,2423,2426,2429],{"class":74,"line":136},[134,2416,2417],{"class":176},"app ",[134,2419,180],{"class":172},[134,2421,2422],{"class":176}," msal.ConfidentialClientApplication(",[134,2424,2425],{"class":274},"...",[134,2427,2428],{"class":176},")     ",[134,2430,2431],{"class":848},"# build once\n",[134,2433,2434],{"class":74,"line":186},[134,2435,259],{"emptyLinePlaceholder":258},[134,2437,2438,2440,2443,2445],{"class":74,"line":198},[134,2439,626],{"class":172},[134,2441,2442],{"class":176}," recipient, path ",[134,2444,632],{"class":172},[134,2446,2447],{"class":176}," deliveries:\n",[134,2449,2450,2452,2454,2456,2458,2460,2462,2465,2468,2470,2473],{"class":74,"line":210},[134,2451,1008],{"class":176},[134,2453,180],{"class":172},[134,2455,374],{"class":176},[134,2457,377],{"class":295},[134,2459,180],{"class":172},[134,2461,382],{"class":176},[134,2463,2464],{"class":274},"SCOPE",[134,2466,2467],{"class":176},"])[",[134,2469,466],{"class":143},[134,2471,2472],{"class":176},"]   ",[134,2474,2475],{"class":848},"# cached\n",[134,2477,2478],{"class":74,"line":281},[134,2479,2480],{"class":176},"    send_with(token, recipient, path)\n",[10,2482,2483,2484,2487,2488,2492],{},"Graph applies throttling per application and per mailbox, and it signals it with a 429 and a\n",[131,2485,2486],{},"Retry-After"," header. Honouring that header rather than retrying immediately is the difference\nbetween a job that recovers and one that gets throttled harder — the backoff patterns in\n",[14,2489,2491],{"href":2490},"\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","\napply directly.",[119,2494,2496],{"id":2495},"conclusion","Conclusion",[10,2498,2499,2500,2502,2503,2506,2507,2509],{},"Register an application with ",[131,2501,156],{},", acquire a token with the client-credentials flow, and post\nthe message to ",[131,2504,2505],{},"\u002Fusers\u002F{sender}\u002FsendMail"," with the workbook base64-encoded as a file attachment. Use\nan upload session once the file passes a few megabytes, narrow the app's reach with an application\naccess policy, and honour ",[131,2508,2486],{}," when Graph throttles. The setup is longer than SMTP; what you\nget back is a sender with no password to rotate.",[119,2511,2513],{"id":2512},"frequently-asked-questions","Frequently asked questions",[10,2515,2516,2519],{},[159,2517,2518],{},"Do I need a user account to send through Graph?","\nNo. An application registration with the Mail.Send application permission sends as any mailbox in the tenant, which is the right model for a scheduled report. A delegated flow that signs a user in is only needed when the mail must come from that person's own session.",[10,2521,2522,2525],{},[159,2523,2524],{},"What is the attachment size limit?","\nA simple attachment in the sendMail payload is limited to about 3 MB after base64 encoding. Larger files need an upload session against a draft message, which uploads the file in chunks before the message is sent.",[10,2527,2528,2531],{},[159,2529,2530],{},"Why does sendMail return 202 and nothing arrives?","\n202 Accepted means Microsoft queued it, not that it was delivered. Check the sending mailbox's Sent Items, and confirm the application permission was granted admin consent — a missing consent produces a 403 rather than silence, but a wrong mailbox address produces exactly this.",[10,2533,2534,2537],{},[159,2535,2536],{},"Is Graph better than SMTP for this?","\nIt is more work to set up and considerably more robust afterwards: no password to rotate, no basic-auth deprecation to worry about, and errors that name the problem. For a tenant that has disabled basic authentication it is often the only option left.",[119,2539,2541],{"id":2540},"related","Related",[2543,2544,2545,2552,2559,2564,2571],"ul",{},[2546,2547,2548,2549,2551],"li",{},"Up one level: ",[14,2550,17],{"href":16}," — the SMTP route and the message-construction basics.",[2546,2553,2554,2558],{},[14,2555,2557],{"href":2556},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-through-outlook-from-python\u002F","Send an Excel Report Through Outlook from Python"," — the desktop alternative when a user's own mailbox must send it.",[2546,2560,2561,2563],{},[14,2562,1846],{"href":1845}," — building the body this send carries.",[2546,2565,2566,2570],{},[14,2567,2569],{"href":2568},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python\u002F","Send an Excel Report to Multiple Recipients in Python"," — recipient lists, copies and per-recipient files.",[2546,2572,2573,2575],{},[14,2574,2491],{"href":2490}," — backoff for the throttling responses.",[2577,2578,2579],"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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":129,"searchDepth":186,"depth":186,"links":2581},[2582,2583,2584,2585,2586,2587,2588,2589,2590,2591,2592,2593,2594],{"id":121,"depth":186,"text":122},{"id":223,"depth":186,"text":224},{"id":475,"depth":186,"text":476},{"id":863,"depth":186,"text":864},{"id":1638,"depth":186,"text":1639},{"id":1850,"depth":186,"text":1851},{"id":2102,"depth":186,"text":2103},{"id":2169,"depth":186,"text":2170},{"id":2281,"depth":186,"text":2282},{"id":2301,"depth":186,"text":2302},{"id":2495,"depth":186,"text":2496},{"id":2512,"depth":186,"text":2513},{"id":2540,"depth":186,"text":2541},"2026-09-04","Basic SMTP auth is off in most tenants. Send the workbook through Graph instead: a client-credentials token, an inline attachment, and an upload session for larger files.","md",[2599,2601,2603,2605],{"q":2518,"a":2600},"No. An application registration with the Mail.Send application permission sends as any mailbox in the tenant, which is the right model for a scheduled report. A delegated flow that signs a user in is only needed when the mail must come from that person's own session.",{"q":2524,"a":2602},"A simple attachment in the sendMail payload is limited to about 3 MB after base64 encoding. Larger files need an upload session against a draft message, which uploads the file in chunks before the message is sent.",{"q":2530,"a":2604},"202 Accepted means Microsoft queued it, not that it was delivered. Check the sending mailbox's Sent Items, and confirm the application permission was granted admin consent — a missing consent produces a 403 rather than silence, but a wrong mailbox address produces exactly this.",{"q":2536,"a":2606},"It is more work to set up and considerably more robust afterwards: no password to rotate, no basic-auth deprecation to worry about, and errors that name the problem. For a tenant that has disabled basic authentication it is often the only option left.",{"breadcrumb":2608},[2609,2611,2614],{"name":2610,"item":1253},"Home",{"name":2612,"item":2613},"Automating Reporting Workflows","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api",{"title":5,"description":2617},"Replace SMTP with Microsoft Graph for scheduled Excel reports — app registration, msal client credentials, base64 attachments, chunked upload sessions and access policies.","send-an-excel-report-with-microsoft-graph-api","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api\u002Findex","how-to","13rtJbchaS8jfjKuaBF8DFREYV-VbR3wafBNJtneByU",[2623,2626],{"title":2557,"path":2624,"stem":2625,"children":-1},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-through-outlook-from-python","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-through-outlook-from-python\u002Findex",{"title":2569,"path":2627,"stem":2628,"children":-1},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python\u002Findex",1788710160065]