[{"data":1,"prerenderedAt":1937},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server":3,"surround:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server":1904,"ancestors:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server":1913,"children:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server":1918,"related:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server":1919},{"id":4,"title":5,"body":6,"dateModified":1870,"datePublished":1870,"description":1871,"extension":1872,"faq":1873,"keyword":1882,"kind":1883,"meta":1884,"navigation":283,"path":1893,"seo":1894,"slug":1897,"stem":1898,"topics":1899,"type":1902,"__hash__":1903},"docs\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server\u002Findex.md","Test Report Emails with a Local SMTP Server",{"type":7,"value":8,"toc":1857},"minimark",[9,24,188,193,226,240,244,255,461,468,472,475,697,704,711,833,837,840,1032,1044,1170,1174,1177,1670,1679,1683,1686,1709,1712,1726,1730,1765,1769,1775,1779,1792,1796,1802,1808,1814,1820,1824,1853],[10,11,12,13,18,19,23],"p",{},"A report job's last step is the one most often tested by hand: run it, wait for the email, open the attachment. That is slow, it spams whoever is on the recipient list, and it does not happen for every change. Python can run a small SMTP server of its own that accepts mail and keeps it instead of delivering it. Point the job at that server and every message it sends can be inspected — recipients, subject, body, and the workbook itself — inside an automated test. This page, part of ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","Emailing Excel Reports with smtplib",", sets that up with ",[20,21,22],"code",{},"aiosmtpd"," and pytest.",[25,26,34,35,34,39,34,43,34,51,34,61,34,68,34,73,34,78,34,81,34,85,34,91,34,93,34,95,34,100,34,105,34,113,34,119,34,122,34,125,34,130,34,134,34,139,34,142,34,147,34,151,34,156,34,159,34,166,34,172,34,176,34,179,34,184],"svg",{"viewBox":27,"role":28,"ariaLabelledBy":29,"xmlns":32,"style":33},"4 0 769 340","img",[30,31],"smtp-seq-t","smtp-seq-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:769px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[36,37,38],"title",{"id":30},"What happens when the job sends to the test server",[40,41,42],"desc",{"id":31},"A sequence between the report job, a local aiosmtpd server and the test. The job connects and sends MAIL FROM, RCPT TO for each recipient including Bcc, and DATA with the message. The server stores the envelope and message. The test then reads the stored message and checks recipients, subject and attachment.",[44,45],"rect",{"x":46,"y":47,"width":48,"height":49,"fill":50},"4","0","769","340","#ffffff",[44,52],{"x":53,"y":54,"width":55,"height":56,"rx":57,"fill":58,"stroke":59,"style":60},"20.0","16.0","180.0","48.0","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[62,63,67],"text",{"x":64,"y":65,"style":66},"110.0","44.5","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","report job",[44,69],{"x":70,"y":54,"width":55,"height":56,"rx":57,"fill":71,"stroke":72,"style":60},"290.0","#e7ebef","var(--line,#cdd5e6)",[62,74,77],{"x":75,"y":65,"style":76},"380.0","font-size:13px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","local SMTP",[44,79],{"x":80,"y":54,"width":55,"height":56,"rx":57,"fill":71,"stroke":72,"style":60},"560.0",[62,82,84],{"x":83,"y":65,"style":76},"650.0","test",[86,87],"line",{"x1":64,"y1":88,"x2":64,"y2":89,"stroke":72,"style":90},"64.0","324.0","stroke-width:1.5px;stroke-dasharray:5 4",[86,92],{"x1":75,"y1":88,"x2":75,"y2":89,"stroke":72,"style":90},[86,94],{"x1":83,"y1":88,"x2":83,"y2":89,"stroke":72,"style":90},[86,96],{"x1":97,"y1":98,"x2":99,"y2":98,"stroke":59,"style":60},"115.0","104.0","371.0",[101,102],"polygon",{"points":103,"fill":104},"375.0,104.0 366.0,109.0 366.0,99.0","#5b5cf0",[44,106],{"x":107,"y":108,"width":109,"height":110,"rx":111,"fill":50,"stroke":72,"style":112},"156.1","75.0","177.8","21.0","10","stroke-width:1.2px",[62,114,118],{"x":115,"y":116,"style":117},"245.0","89.5","font-size:11.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","1. MAIL FROM, RCPT TO ×3",[86,120],{"x1":97,"y1":121,"x2":99,"y2":121,"stroke":59,"style":60},"152.0",[101,123],{"points":124,"fill":104},"375.0,152.0 366.0,157.0 366.0,147.0",[44,126],{"x":127,"y":128,"width":129,"height":110,"rx":111,"fill":50,"stroke":72,"style":112},"153.8","123.0","182.4",[62,131,133],{"x":115,"y":132,"style":117},"137.5","2. DATA — the full message",[86,135],{"x1":136,"y1":137,"x2":138,"y2":137,"stroke":59,"style":60},"375.0","200.0","381.0",[101,140],{"points":141,"fill":104},"385.0,200.0 376.0,205.0 376.0,195.0",[44,143],{"x":144,"y":145,"width":146,"height":110,"rx":111,"fill":50,"stroke":72,"style":112},"289.0","171.0","182.0",[62,148,150],{"x":75,"y":149,"style":117},"185.5","3. store envelope and bytes",[86,152],{"x1":153,"y1":154,"x2":155,"y2":154,"stroke":59,"style":60},"645.0","248.0","389.0",[101,157],{"points":158,"fill":104},"385.0,248.0 394.0,243.0 394.0,253.0",[44,160],{"x":161,"y":162,"width":163,"height":110,"rx":111,"fill":164,"stroke":165,"style":112},"425.4","219.0","179.2","#d9f4f1","var(--teal,#0f9488)",[62,167,171],{"x":168,"y":169,"style":170},"515.0","233.5","font-size:11.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","4. read captured messages",[86,173],{"x1":153,"y1":174,"x2":175,"y2":174,"stroke":59,"style":60},"296.0","651.0",[101,177],{"points":178,"fill":104},"655.0,296.0 646.0,301.0 646.0,291.0",[44,180],{"x":181,"y":182,"width":183,"height":110,"rx":111,"fill":164,"stroke":165,"style":112},"543.3","267.0","213.3",[62,185,187],{"x":83,"y":186,"style":170},"281.5","5. assert recipients, subject, xlsx",[189,190,192],"h2",{"id":191},"prerequisites","Prerequisites",[194,195,200],"pre",{"className":196,"code":197,"language":198,"meta":199,"style":199},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install aiosmtpd pytest pandas openpyxl\n","bash","",[20,201,202],{"__ignoreMap":199},[203,204,206,210,214,217,220,223],"span",{"class":86,"line":205},1,[203,207,209],{"class":208},"sMTad","pip",[203,211,213],{"class":212},"srMev"," install",[203,215,216],{"class":212}," aiosmtpd",[203,218,219],{"class":212}," pytest",[203,221,222],{"class":212}," pandas",[203,224,225],{"class":212}," openpyxl\n",[10,227,228,231,232,235,236,239],{},[20,229,230],{},"report_mailer.py"," holds the code under test: ",[20,233,234],{},"send_report"," builds a message with a plain-text and HTML body and an Excel attachment made from a DataFrame, and sends it using an ",[20,237,238],{},"SmtpConfig",". Its TLS and login steps are driven by the config — the one change that makes it testable.",[189,241,243],{"id":242},"step-1-run-a-server-that-captures-mail","Step 1 — Run a server that captures mail",[10,245,246,247,250,251,254],{},"An aiosmtpd handler is a class with an async ",[20,248,249],{},"handle_DATA"," method, called once per message. Store the envelope — the sender and recipients from the SMTP conversation — and the raw message bytes. ",[20,252,253],{},"Controller"," runs the server in a background thread:",[194,256,260],{"className":257,"code":258,"language":259,"meta":199,"style":199},"language-python shiki shiki-themes github-light github-dark-high-contrast","from aiosmtpd.controller import Controller\n\nclass Capture:\n    def __init__(self):\n        self.messages = []\n\n    async def handle_DATA(self, server, session, envelope):\n        self.messages.append({\"mail_from\": envelope.mail_from, \"rcpt_tos\": list(envelope.rcpt_tos),\n                              \"data\": envelope.content})\n        return \"250 Message accepted\"\n\nhandler = Capture()\ncontroller = Controller(handler, hostname=\"127.0.0.1\", port=8025)\ncontroller.start()\nprint(\"listening on\", controller.hostname, controller.port)\n","python",[20,261,262,278,285,297,310,325,330,346,372,381,390,395,406,440,446],{"__ignoreMap":199},[203,263,264,268,272,275],{"class":86,"line":205},[203,265,267],{"class":266},"s-kum","from",[203,269,271],{"class":270},"skGVy"," aiosmtpd.controller ",[203,273,274],{"class":266},"import",[203,276,277],{"class":270}," Controller\n",[203,279,281],{"class":86,"line":280},2,[203,282,284],{"emptyLinePlaceholder":283},true,"\n",[203,286,288,291,294],{"class":86,"line":287},3,[203,289,290],{"class":266},"class",[203,292,293],{"class":208}," Capture",[203,295,296],{"class":270},":\n",[203,298,300,303,307],{"class":86,"line":299},4,[203,301,302],{"class":266},"    def",[203,304,306],{"class":305},"sP0c6"," __init__",[203,308,309],{"class":270},"(self):\n",[203,311,313,316,319,322],{"class":86,"line":312},5,[203,314,315],{"class":305},"        self",[203,317,318],{"class":270},".messages ",[203,320,321],{"class":266},"=",[203,323,324],{"class":270}," []\n",[203,326,328],{"class":86,"line":327},6,[203,329,284],{"emptyLinePlaceholder":283},[203,331,333,336,339,343],{"class":86,"line":332},7,[203,334,335],{"class":266},"    async",[203,337,338],{"class":266}," def",[203,340,342],{"class":341},"s_Opv"," handle_DATA",[203,344,345],{"class":270},"(self, server, session, envelope):\n",[203,347,349,351,354,357,360,363,366,369],{"class":86,"line":348},8,[203,350,315],{"class":305},[203,352,353],{"class":270},".messages.append({",[203,355,356],{"class":212},"\"mail_from\"",[203,358,359],{"class":270},": envelope.mail_from, ",[203,361,362],{"class":212},"\"rcpt_tos\"",[203,364,365],{"class":270},": ",[203,367,368],{"class":305},"list",[203,370,371],{"class":270},"(envelope.rcpt_tos),\n",[203,373,375,378],{"class":86,"line":374},9,[203,376,377],{"class":212},"                              \"data\"",[203,379,380],{"class":270},": envelope.content})\n",[203,382,384,387],{"class":86,"line":383},10,[203,385,386],{"class":266},"        return",[203,388,389],{"class":212}," \"250 Message accepted\"\n",[203,391,393],{"class":86,"line":392},11,[203,394,284],{"emptyLinePlaceholder":283},[203,396,398,401,403],{"class":86,"line":397},12,[203,399,400],{"class":270},"handler ",[203,402,321],{"class":266},[203,404,405],{"class":270}," Capture()\n",[203,407,409,412,414,417,421,423,426,429,432,434,437],{"class":86,"line":408},13,[203,410,411],{"class":270},"controller ",[203,413,321],{"class":266},[203,415,416],{"class":270}," Controller(handler, ",[203,418,420],{"class":419},"sa561","hostname",[203,422,321],{"class":266},[203,424,425],{"class":212},"\"127.0.0.1\"",[203,427,428],{"class":270},", ",[203,430,431],{"class":419},"port",[203,433,321],{"class":266},[203,435,436],{"class":305},"8025",[203,438,439],{"class":270},")\n",[203,441,443],{"class":86,"line":442},14,[203,444,445],{"class":270},"controller.start()\n",[203,447,449,452,455,458],{"class":86,"line":448},15,[203,450,451],{"class":305},"print",[203,453,454],{"class":270},"(",[203,456,457],{"class":212},"\"listening on\"",[203,459,460],{"class":270},", controller.hostname, controller.port)\n",[10,462,463,464,467],{},"The server listens only on the loopback address, so nothing outside the machine can reach it. Nothing is delivered anywhere: messages exist only in ",[20,465,466],{},"handler.messages",".",[189,469,471],{"id":470},"step-2-send-the-report-to-it","Step 2 — Send the report to it",[10,473,474],{},"Build a test configuration — local host, the captured port, no TLS, no login — and call the real sending function:",[194,476,478],{"className":257,"code":477,"language":259,"meta":199,"style":199},"import pandas as pd\nfrom report_mailer import SmtpConfig, send_report\n\ntest_cfg = SmtpConfig(host=\"127.0.0.1\", port=8025, use_tls=False)\ndf = pd.DataFrame({\"Region\": [\"North\", \"South\", \"East\"], \"Revenue\": [48200, 39100, 51750]})\nsend_report(test_cfg, df, to=[\"finance@example.com\", \"ops@example.com\"], bcc=[\"archive@example.com\"])\n\ncaptured = handler.messages[0]\nprint(captured[\"mail_from\"], \"->\", captured[\"rcpt_tos\"])\nprint(len(captured[\"data\"]), \"bytes received\")\n",[20,479,480,493,505,509,546,599,635,639,654,675],{"__ignoreMap":199},[203,481,482,484,487,490],{"class":86,"line":205},[203,483,274],{"class":266},[203,485,486],{"class":270}," pandas ",[203,488,489],{"class":266},"as",[203,491,492],{"class":270}," pd\n",[203,494,495,497,500,502],{"class":86,"line":280},[203,496,267],{"class":266},[203,498,499],{"class":270}," report_mailer ",[203,501,274],{"class":266},[203,503,504],{"class":270}," SmtpConfig, send_report\n",[203,506,507],{"class":86,"line":287},[203,508,284],{"emptyLinePlaceholder":283},[203,510,511,514,516,519,522,524,526,528,530,532,534,536,539,541,544],{"class":86,"line":299},[203,512,513],{"class":270},"test_cfg ",[203,515,321],{"class":266},[203,517,518],{"class":270}," SmtpConfig(",[203,520,521],{"class":419},"host",[203,523,321],{"class":266},[203,525,425],{"class":212},[203,527,428],{"class":270},[203,529,431],{"class":419},[203,531,321],{"class":266},[203,533,436],{"class":305},[203,535,428],{"class":270},[203,537,538],{"class":419},"use_tls",[203,540,321],{"class":266},[203,542,543],{"class":305},"False",[203,545,439],{"class":270},[203,547,548,551,553,556,559,562,565,567,570,572,575,578,581,583,586,588,591,593,596],{"class":86,"line":312},[203,549,550],{"class":270},"df ",[203,552,321],{"class":266},[203,554,555],{"class":270}," pd.DataFrame({",[203,557,558],{"class":212},"\"Region\"",[203,560,561],{"class":270},": [",[203,563,564],{"class":212},"\"North\"",[203,566,428],{"class":270},[203,568,569],{"class":212},"\"South\"",[203,571,428],{"class":270},[203,573,574],{"class":212},"\"East\"",[203,576,577],{"class":270},"], ",[203,579,580],{"class":212},"\"Revenue\"",[203,582,561],{"class":270},[203,584,585],{"class":305},"48200",[203,587,428],{"class":270},[203,589,590],{"class":305},"39100",[203,592,428],{"class":270},[203,594,595],{"class":305},"51750",[203,597,598],{"class":270},"]})\n",[203,600,601,604,607,609,612,615,617,620,622,625,627,629,632],{"class":86,"line":327},[203,602,603],{"class":270},"send_report(test_cfg, df, ",[203,605,606],{"class":419},"to",[203,608,321],{"class":266},[203,610,611],{"class":270},"[",[203,613,614],{"class":212},"\"finance@example.com\"",[203,616,428],{"class":270},[203,618,619],{"class":212},"\"ops@example.com\"",[203,621,577],{"class":270},[203,623,624],{"class":419},"bcc",[203,626,321],{"class":266},[203,628,611],{"class":270},[203,630,631],{"class":212},"\"archive@example.com\"",[203,633,634],{"class":270},"])\n",[203,636,637],{"class":86,"line":332},[203,638,284],{"emptyLinePlaceholder":283},[203,640,641,644,646,649,651],{"class":86,"line":348},[203,642,643],{"class":270},"captured ",[203,645,321],{"class":266},[203,647,648],{"class":270}," handler.messages[",[203,650,47],{"class":305},[203,652,653],{"class":270},"]\n",[203,655,656,658,661,663,665,668,671,673],{"class":86,"line":374},[203,657,451],{"class":305},[203,659,660],{"class":270},"(captured[",[203,662,356],{"class":212},[203,664,577],{"class":270},[203,666,667],{"class":212},"\"->\"",[203,669,670],{"class":270},", captured[",[203,672,362],{"class":212},[203,674,634],{"class":270},[203,676,677,679,681,684,686,689,692,695],{"class":86,"line":383},[203,678,451],{"class":305},[203,680,454],{"class":270},[203,682,683],{"class":305},"len",[203,685,660],{"class":270},[203,687,688],{"class":212},"\"data\"",[203,690,691],{"class":270},"]), ",[203,693,694],{"class":212},"\"bytes received\"",[203,696,439],{"class":270},[10,698,699,700,703],{},"The envelope lists three recipients: the two in To and the archive address in Bcc. The code path is exactly the one production uses; only the configuration differs. In production, the same function receives ",[20,701,702],{},"SmtpConfig(host=\"smtp.example.com\", username=..., password=...)"," with TLS on by default.",[10,705,706,707,710],{},"A local server is worth the few extra lines over mocking ",[20,708,709],{},"smtplib",", because it tests the message rather than the method call:",[25,712,34,718,34,721,34,724,34,728,34,735,34,746,34,752,34,758,34,764,34,770,34,774,34,781,34,785,34,790,34,792,34,799,34,804,34,808,34,812,34,817,34,820,34,825,34,828],{"viewBox":713,"role":28,"ariaLabelledBy":714,"xmlns":32,"style":717},"4 0 752 207",[715,716],"smtp-mock-t","smtp-mock-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[36,719,720],{"id":715},"Mocking smtplib versus a local SMTP server",[40,722,723],{"id":716},"Two panels. A mock of smtplib proves that send_message was called, but never builds the bytes a mail server receives, so broken encodings, missing headers and corrupt attachments pass. A local aiosmtpd server receives a real SMTP conversation and the exact message bytes, so the test can check the envelope, decode headers and open the attachment.",[44,725],{"x":46,"y":47,"width":726,"height":727,"fill":50},"752","207",[44,729],{"x":53,"y":54,"width":730,"height":731,"rx":732,"fill":733,"stroke":734,"style":60},"351.0","175.0","14","#fdefd8","var(--gold,#b4740a)",[736,737,739],"g",{"transform":738},"translate(182.5,29.0) scale(1.083)",[740,741],"path",{"d":742,"fill":743,"stroke":744,"style":745},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4","none","var(--gold-ink,#7a4e06)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[62,747,751],{"x":748,"y":749,"style":750},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Mocked smtplib",[86,753],{"x1":754,"y1":755,"x2":756,"y2":755,"stroke":734,"style":757},"36.0","91.0","355.0","stroke-width:1px",[62,759,763],{"x":760,"y":761,"style":762},"60.0","111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","proves a method was called",[736,765,767],{"transform":766},"translate(38.5,99.5) scale(0.625)",[740,768],{"d":742,"fill":743,"stroke":734,"style":769},"stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[62,771,773],{"x":760,"y":772,"style":762},"137.0","no real message bytes",[736,775,777],{"transform":776},"translate(38.5,125.5) scale(0.625)",[740,778],{"d":779,"fill":743,"stroke":780,"style":769},"M6 6l12 12 M18 6L6 18","var(--accent,#f43f8f)",[62,782,784],{"x":760,"y":783,"style":762},"163.0","broken attachments pass",[736,786,788],{"transform":787},"translate(38.5,151.5) scale(0.625)",[740,789],{"d":779,"fill":743,"stroke":780,"style":769},[44,791],{"x":155,"y":54,"width":730,"height":731,"rx":732,"fill":164,"stroke":165,"style":60},[736,793,795],{"transform":794},"translate(551.5,29.0) scale(1.083)",[740,796],{"d":797,"fill":743,"stroke":798,"style":745},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[62,800,803],{"x":801,"y":749,"style":802},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Local aiosmtpd server",[86,805],{"x1":806,"y1":755,"x2":807,"y2":755,"stroke":165,"style":757},"405.0","724.0",[62,809,811],{"x":810,"y":761,"style":762},"429.0","real SMTP conversation",[736,813,815],{"transform":814},"translate(407.5,99.5) scale(0.625)",[740,816],{"d":797,"fill":743,"stroke":165,"style":769},[62,818,819],{"x":810,"y":772,"style":762},"envelope incl. Bcc",[736,821,823],{"transform":822},"translate(407.5,125.5) scale(0.625)",[740,824],{"d":797,"fill":743,"stroke":165,"style":769},[62,826,827],{"x":810,"y":783,"style":762},"attachment opened and checked",[736,829,831],{"transform":830},"translate(407.5,151.5) scale(0.625)",[740,832],{"d":797,"fill":743,"stroke":165,"style":769},[189,834,836],{"id":835},"step-3-inspect-what-was-sent","Step 3 — Inspect what was sent",[10,838,839],{},"Parse the captured bytes back into a message and check every part: headers, both bodies, and the attachment, which should open as a workbook with the right data:",[194,841,843],{"className":257,"code":842,"language":259,"meta":199,"style":199},"import io\nfrom email import message_from_bytes\nfrom email.policy import default\n\nmsg = message_from_bytes(captured[\"data\"], policy=default)\nprint(\"Subject:\", msg[\"Subject\"])\nprint(\"To:\", msg[\"To\"], \"| Bcc header present:\", \"Bcc\" in msg)\nprint(\"text:\", msg.get_body(preferencelist=(\"plain\",)).get_content().strip())\nprint(\"html:\", msg.get_body(preferencelist=(\"html\",)).get_content().strip())\n\nattachment = next(msg.iter_attachments())\nprint(\"attachment:\", attachment.get_filename(), attachment.get_content_type())\nprint(pd.read_excel(io.BytesIO(attachment.get_content())))\n",[20,844,845,852,864,876,880,902,919,949,974,996,1000,1013,1025],{"__ignoreMap":199},[203,846,847,849],{"class":86,"line":205},[203,848,274],{"class":266},[203,850,851],{"class":270}," io\n",[203,853,854,856,859,861],{"class":86,"line":280},[203,855,267],{"class":266},[203,857,858],{"class":270}," email ",[203,860,274],{"class":266},[203,862,863],{"class":270}," message_from_bytes\n",[203,865,866,868,871,873],{"class":86,"line":287},[203,867,267],{"class":266},[203,869,870],{"class":270}," email.policy ",[203,872,274],{"class":266},[203,874,875],{"class":270}," default\n",[203,877,878],{"class":86,"line":299},[203,879,284],{"emptyLinePlaceholder":283},[203,881,882,885,887,890,892,894,897,899],{"class":86,"line":312},[203,883,884],{"class":270},"msg ",[203,886,321],{"class":266},[203,888,889],{"class":270}," message_from_bytes(captured[",[203,891,688],{"class":212},[203,893,577],{"class":270},[203,895,896],{"class":419},"policy",[203,898,321],{"class":266},[203,900,901],{"class":270},"default)\n",[203,903,904,906,908,911,914,917],{"class":86,"line":327},[203,905,451],{"class":305},[203,907,454],{"class":270},[203,909,910],{"class":212},"\"Subject:\"",[203,912,913],{"class":270},", msg[",[203,915,916],{"class":212},"\"Subject\"",[203,918,634],{"class":270},[203,920,921,923,925,928,930,933,935,938,940,943,946],{"class":86,"line":332},[203,922,451],{"class":305},[203,924,454],{"class":270},[203,926,927],{"class":212},"\"To:\"",[203,929,913],{"class":270},[203,931,932],{"class":212},"\"To\"",[203,934,577],{"class":270},[203,936,937],{"class":212},"\"| Bcc header present:\"",[203,939,428],{"class":270},[203,941,942],{"class":212},"\"Bcc\"",[203,944,945],{"class":266}," in",[203,947,948],{"class":270}," msg)\n",[203,950,951,953,955,958,961,964,966,968,971],{"class":86,"line":348},[203,952,451],{"class":305},[203,954,454],{"class":270},[203,956,957],{"class":212},"\"text:\"",[203,959,960],{"class":270},", msg.get_body(",[203,962,963],{"class":419},"preferencelist",[203,965,321],{"class":266},[203,967,454],{"class":270},[203,969,970],{"class":212},"\"plain\"",[203,972,973],{"class":270},",)).get_content().strip())\n",[203,975,976,978,980,983,985,987,989,991,994],{"class":86,"line":374},[203,977,451],{"class":305},[203,979,454],{"class":270},[203,981,982],{"class":212},"\"html:\"",[203,984,960],{"class":270},[203,986,963],{"class":419},[203,988,321],{"class":266},[203,990,454],{"class":270},[203,992,993],{"class":212},"\"html\"",[203,995,973],{"class":270},[203,997,998],{"class":86,"line":383},[203,999,284],{"emptyLinePlaceholder":283},[203,1001,1002,1005,1007,1010],{"class":86,"line":392},[203,1003,1004],{"class":270},"attachment ",[203,1006,321],{"class":266},[203,1008,1009],{"class":305}," next",[203,1011,1012],{"class":270},"(msg.iter_attachments())\n",[203,1014,1015,1017,1019,1022],{"class":86,"line":397},[203,1016,451],{"class":305},[203,1018,454],{"class":270},[203,1020,1021],{"class":212},"\"attachment:\"",[203,1023,1024],{"class":270},", attachment.get_filename(), attachment.get_content_type())\n",[203,1026,1027,1029],{"class":86,"line":408},[203,1028,451],{"class":305},[203,1030,1031],{"class":270},"(pd.read_excel(io.BytesIO(attachment.get_content())))\n",[10,1033,1034,1035,1038,1039,1043],{},"The Bcc header is gone — ",[20,1036,1037],{},"smtplib.send_message"," removes it before sending, as it should, so blind-copied recipients stay hidden. That is why the test checks the ",[1040,1041,1042],"em",{},"envelope"," for Bcc recipients, not the headers. The subject's em dash survives the round trip, the total appears in both bodies, and the attachment reads back as the DataFrame that went in.",[25,1045,34,1050,34,1053,34,1056,34,1059,34,1063,34,1067,34,1072,34,1075,34,1079,34,1083,34,1088,34,1090,34,1095,34,1097,34,1100,34,1103,34,1107,34,1109,34,1112,34,1114,34,1117,34,1120,34,1124,34,1126,34,1130,34,1132,34,1135,34,1138,34,1141,34,1143,34,1147,34,1149,34,1152,34,1155,34,1159,34,1161,34,1165,34,1167],{"viewBox":1046,"role":28,"ariaLabelledBy":1047,"xmlns":32,"style":717},"4 0 752 242",[1048,1049],"smtp-where-t","smtp-where-d",[36,1051,1052],{"id":1048},"Where each thing to check lives in a captured message",[40,1054,1055],{"id":1049},"A table of checks. Who received it is in the envelope rcpt_tos, including Bcc recipients. Who it was addressed to visibly is in the To and Cc headers. The subject is a header, decoded by the default policy. The bodies are the plain and html parts found with get_body. The workbook is the attachment part, read back with pandas.",[44,1057],{"x":46,"y":47,"width":726,"height":1058,"fill":50},"242",[44,1060],{"x":53,"y":54,"width":1061,"height":1062,"rx":47,"fill":50,"stroke":72,"style":757},"171.9","40.0",[44,1064],{"x":1065,"y":54,"width":1066,"height":1062,"rx":47,"fill":58,"stroke":59,"style":112},"191.9","274.0",[62,1068,1071],{"x":1069,"y":1070,"style":66},"328.9","41.0","Found in",[44,1073],{"x":1074,"y":54,"width":1066,"height":1062,"rx":47,"fill":58,"stroke":59,"style":112},"466.0",[62,1076,1078],{"x":1077,"y":1070,"style":66},"603.0","Read with",[44,1080],{"x":53,"y":1081,"width":1061,"height":1082,"rx":47,"fill":71,"stroke":72,"style":757},"56.0","34.0",[62,1084,1087],{"x":1085,"y":749,"style":1086},"32.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","All recipients incl. Bcc",[44,1089],{"x":1065,"y":1081,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1091,1094],{"x":1069,"y":1092,"style":1093},"77.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","SMTP envelope",[44,1096],{"x":1074,"y":1081,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1098,1099],{"x":1077,"y":1092,"style":1093},"envelope.rcpt_tos",[44,1101],{"x":53,"y":1102,"width":1061,"height":1082,"rx":47,"fill":71,"stroke":72,"style":757},"90.0",[62,1104,1106],{"x":1085,"y":1105,"style":1086},"112.0","Visible recipients",[44,1108],{"x":1065,"y":1102,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1110,1111],{"x":1069,"y":761,"style":1093},"To and Cc headers",[44,1113],{"x":1074,"y":1102,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1115,1116],{"x":1077,"y":761,"style":1093},"msg[\"To\"]",[44,1118],{"x":53,"y":1119,"width":1061,"height":1082,"rx":47,"fill":71,"stroke":72,"style":757},"124.0",[62,1121,1123],{"x":1085,"y":1122,"style":1086},"146.0","Subject",[44,1125],{"x":1065,"y":1119,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1127,1129],{"x":1069,"y":1128,"style":1093},"145.0","header",[44,1131],{"x":1074,"y":1119,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1133,1134],{"x":1077,"y":1128,"style":1093},"msg[\"Subject\"]",[44,1136],{"x":53,"y":1137,"width":1061,"height":1082,"rx":47,"fill":71,"stroke":72,"style":757},"158.0",[62,1139,1140],{"x":1085,"y":55,"style":1086},"Text and HTML bodies",[44,1142],{"x":1065,"y":1137,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1144,1146],{"x":1069,"y":1145,"style":1093},"179.0","alternative parts",[44,1148],{"x":1074,"y":1137,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1150,1151],{"x":1077,"y":1145,"style":1093},"get_body",[44,1153],{"x":53,"y":1154,"width":1061,"height":1082,"rx":47,"fill":71,"stroke":72,"style":757},"192.0",[62,1156,1158],{"x":1085,"y":1157,"style":1086},"214.0","The workbook",[44,1160],{"x":1065,"y":1154,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1162,1164],{"x":1069,"y":1163,"style":1093},"213.0","attachment part",[44,1166],{"x":1074,"y":1154,"width":1066,"height":1082,"rx":47,"fill":50,"stroke":72,"style":757},[62,1168,1169],{"x":1077,"y":1163,"style":1093},"read_excel(BytesIO)",[189,1171,1173],{"id":1172},"step-4-turn-it-into-pytest-tests","Step 4 — Turn it into pytest tests",[10,1175,1176],{},"Wrap the server in a fixture so each test gets a fresh one, and write tests for what matters: the right people get the report, the subject names the period, and the workbook contains the data:",[194,1178,1180],{"className":257,"code":1179,"language":259,"meta":199,"style":199},"import subprocess, sys, textwrap\n\ncontroller.stop()\nopen(\"conftest.py\", \"w\").write(textwrap.dedent('''\n    import socket\n    import pytest\n    from aiosmtpd.controller import Controller\n\n    def free_port():\n        with socket.socket() as s:\n            s.bind((\"127.0.0.1\", 0))\n            return s.getsockname()[1]\n\n    class Capture:\n        def __init__(self):\n            self.messages = []\n        async def handle_DATA(self, server, session, envelope):\n            self.messages.append((list(envelope.rcpt_tos), envelope.content))\n            return \"250 OK\"\n\n    @pytest.fixture\n    def smtp_server():\n        handler = Capture()\n        port = free_port()\n        controller = Controller(handler, hostname=\"127.0.0.1\", port=port)\n        controller.start()\n        yield port, handler\n        controller.stop()\n'''))\nopen(\"test_report_mailer.py\", \"w\").write(textwrap.dedent('''\n    import io\n    from email import message_from_bytes\n    from email.policy import default\n    import pandas as pd\n    from report_mailer import SmtpConfig, send_report\n\n    DF = pd.DataFrame({\"Region\": [\"North\", \"South\"], \"Revenue\": [48200, 39100]})\n\n    def send(port, **kw):\n        send_report(SmtpConfig(\"127.0.0.1\", port, use_tls=False), DF, **kw)\n\n    def test_everyone_receives_it_including_bcc(smtp_server):\n        port, handler = smtp_server\n        send(port, to=[\"finance@example.com\"], bcc=[\"archive@example.com\"])\n        rcpts, _ = handler.messages[0]\n        assert sorted(rcpts) == [\"archive@example.com\", \"finance@example.com\"]\n\n    def test_bcc_is_not_visible(smtp_server):\n        port, handler = smtp_server\n        send(port, to=[\"finance@example.com\"], bcc=[\"archive@example.com\"])\n        msg = message_from_bytes(handler.messages[0][1], policy=default)\n        assert \"Bcc\" not in msg and \"archive\" not in msg[\"To\"]\n\n    def test_workbook_matches_the_data(smtp_server):\n        port, handler = smtp_server\n        send(port, to=[\"finance@example.com\"])\n        msg = message_from_bytes(handler.messages[0][1], policy=default)\n        part = next(msg.iter_attachments())\n        assert part.get_filename() == \"sales_September_2026.xlsx\"\n        pd.testing.assert_frame_equal(pd.read_excel(io.BytesIO(part.get_content())), DF)\n\n    def test_subject_and_total(smtp_server):\n        port, handler = smtp_server\n        send(port, to=[\"finance@example.com\"])\n        msg = message_from_bytes(handler.messages[0][1], policy=default)\n        assert msg[\"Subject\"] == \"Sales report — September 2026\"\n        assert \"87,300\" in msg.get_body(preferencelist=(\"plain\",)).get_content()\n'''))\nresult = subprocess.run([sys.executable, \"-m\", \"pytest\", \"-q\", \"-p\", \"no:cacheprovider\", \"test_report_mailer.py\"],\n                        capture_output=True, text=True)\nprint(result.stdout.strip().splitlines()[-1])\n",[20,1181,1182,1189,1193,1198,1219,1224,1229,1234,1238,1243,1248,1253,1258,1262,1267,1272,1278,1284,1290,1296,1301,1307,1313,1319,1325,1331,1337,1343,1349,1358,1376,1382,1388,1394,1400,1406,1411,1417,1422,1428,1434,1439,1445,1451,1457,1463,1469,1474,1480,1485,1490,1496,1502,1507,1513,1518,1524,1529,1535,1541,1547,1552,1558,1563,1568,1573,1579,1585,1592,1633,1654],{"__ignoreMap":199},[203,1183,1184,1186],{"class":86,"line":205},[203,1185,274],{"class":266},[203,1187,1188],{"class":270}," subprocess, sys, textwrap\n",[203,1190,1191],{"class":86,"line":280},[203,1192,284],{"emptyLinePlaceholder":283},[203,1194,1195],{"class":86,"line":287},[203,1196,1197],{"class":270},"controller.stop()\n",[203,1199,1200,1203,1205,1208,1210,1213,1216],{"class":86,"line":299},[203,1201,1202],{"class":305},"open",[203,1204,454],{"class":270},[203,1206,1207],{"class":212},"\"conftest.py\"",[203,1209,428],{"class":270},[203,1211,1212],{"class":212},"\"w\"",[203,1214,1215],{"class":270},").write(textwrap.dedent(",[203,1217,1218],{"class":212},"'''\n",[203,1220,1221],{"class":86,"line":312},[203,1222,1223],{"class":212},"    import socket\n",[203,1225,1226],{"class":86,"line":327},[203,1227,1228],{"class":212},"    import pytest\n",[203,1230,1231],{"class":86,"line":332},[203,1232,1233],{"class":212},"    from aiosmtpd.controller import Controller\n",[203,1235,1236],{"class":86,"line":348},[203,1237,284],{"emptyLinePlaceholder":283},[203,1239,1240],{"class":86,"line":374},[203,1241,1242],{"class":212},"    def free_port():\n",[203,1244,1245],{"class":86,"line":383},[203,1246,1247],{"class":212},"        with socket.socket() as s:\n",[203,1249,1250],{"class":86,"line":392},[203,1251,1252],{"class":212},"            s.bind((\"127.0.0.1\", 0))\n",[203,1254,1255],{"class":86,"line":397},[203,1256,1257],{"class":212},"            return s.getsockname()[1]\n",[203,1259,1260],{"class":86,"line":408},[203,1261,284],{"emptyLinePlaceholder":283},[203,1263,1264],{"class":86,"line":442},[203,1265,1266],{"class":212},"    class Capture:\n",[203,1268,1269],{"class":86,"line":448},[203,1270,1271],{"class":212},"        def __init__(self):\n",[203,1273,1275],{"class":86,"line":1274},16,[203,1276,1277],{"class":212},"            self.messages = []\n",[203,1279,1281],{"class":86,"line":1280},17,[203,1282,1283],{"class":212},"        async def handle_DATA(self, server, session, envelope):\n",[203,1285,1287],{"class":86,"line":1286},18,[203,1288,1289],{"class":212},"            self.messages.append((list(envelope.rcpt_tos), envelope.content))\n",[203,1291,1293],{"class":86,"line":1292},19,[203,1294,1295],{"class":212},"            return \"250 OK\"\n",[203,1297,1299],{"class":86,"line":1298},20,[203,1300,284],{"emptyLinePlaceholder":283},[203,1302,1304],{"class":86,"line":1303},21,[203,1305,1306],{"class":212},"    @pytest.fixture\n",[203,1308,1310],{"class":86,"line":1309},22,[203,1311,1312],{"class":212},"    def smtp_server():\n",[203,1314,1316],{"class":86,"line":1315},23,[203,1317,1318],{"class":212},"        handler = Capture()\n",[203,1320,1322],{"class":86,"line":1321},24,[203,1323,1324],{"class":212},"        port = free_port()\n",[203,1326,1328],{"class":86,"line":1327},25,[203,1329,1330],{"class":212},"        controller = Controller(handler, hostname=\"127.0.0.1\", port=port)\n",[203,1332,1334],{"class":86,"line":1333},26,[203,1335,1336],{"class":212},"        controller.start()\n",[203,1338,1340],{"class":86,"line":1339},27,[203,1341,1342],{"class":212},"        yield port, handler\n",[203,1344,1346],{"class":86,"line":1345},28,[203,1347,1348],{"class":212},"        controller.stop()\n",[203,1350,1352,1355],{"class":86,"line":1351},29,[203,1353,1354],{"class":212},"'''",[203,1356,1357],{"class":270},"))\n",[203,1359,1361,1363,1365,1368,1370,1372,1374],{"class":86,"line":1360},30,[203,1362,1202],{"class":305},[203,1364,454],{"class":270},[203,1366,1367],{"class":212},"\"test_report_mailer.py\"",[203,1369,428],{"class":270},[203,1371,1212],{"class":212},[203,1373,1215],{"class":270},[203,1375,1218],{"class":212},[203,1377,1379],{"class":86,"line":1378},31,[203,1380,1381],{"class":212},"    import io\n",[203,1383,1385],{"class":86,"line":1384},32,[203,1386,1387],{"class":212},"    from email import message_from_bytes\n",[203,1389,1391],{"class":86,"line":1390},33,[203,1392,1393],{"class":212},"    from email.policy import default\n",[203,1395,1397],{"class":86,"line":1396},34,[203,1398,1399],{"class":212},"    import pandas as pd\n",[203,1401,1403],{"class":86,"line":1402},35,[203,1404,1405],{"class":212},"    from report_mailer import SmtpConfig, send_report\n",[203,1407,1409],{"class":86,"line":1408},36,[203,1410,284],{"emptyLinePlaceholder":283},[203,1412,1414],{"class":86,"line":1413},37,[203,1415,1416],{"class":212},"    DF = pd.DataFrame({\"Region\": [\"North\", \"South\"], \"Revenue\": [48200, 39100]})\n",[203,1418,1420],{"class":86,"line":1419},38,[203,1421,284],{"emptyLinePlaceholder":283},[203,1423,1425],{"class":86,"line":1424},39,[203,1426,1427],{"class":212},"    def send(port, **kw):\n",[203,1429,1431],{"class":86,"line":1430},40,[203,1432,1433],{"class":212},"        send_report(SmtpConfig(\"127.0.0.1\", port, use_tls=False), DF, **kw)\n",[203,1435,1437],{"class":86,"line":1436},41,[203,1438,284],{"emptyLinePlaceholder":283},[203,1440,1442],{"class":86,"line":1441},42,[203,1443,1444],{"class":212},"    def test_everyone_receives_it_including_bcc(smtp_server):\n",[203,1446,1448],{"class":86,"line":1447},43,[203,1449,1450],{"class":212},"        port, handler = smtp_server\n",[203,1452,1454],{"class":86,"line":1453},44,[203,1455,1456],{"class":212},"        send(port, to=[\"finance@example.com\"], bcc=[\"archive@example.com\"])\n",[203,1458,1460],{"class":86,"line":1459},45,[203,1461,1462],{"class":212},"        rcpts, _ = handler.messages[0]\n",[203,1464,1466],{"class":86,"line":1465},46,[203,1467,1468],{"class":212},"        assert sorted(rcpts) == [\"archive@example.com\", \"finance@example.com\"]\n",[203,1470,1472],{"class":86,"line":1471},47,[203,1473,284],{"emptyLinePlaceholder":283},[203,1475,1477],{"class":86,"line":1476},48,[203,1478,1479],{"class":212},"    def test_bcc_is_not_visible(smtp_server):\n",[203,1481,1483],{"class":86,"line":1482},49,[203,1484,1450],{"class":212},[203,1486,1488],{"class":86,"line":1487},50,[203,1489,1456],{"class":212},[203,1491,1493],{"class":86,"line":1492},51,[203,1494,1495],{"class":212},"        msg = message_from_bytes(handler.messages[0][1], policy=default)\n",[203,1497,1499],{"class":86,"line":1498},52,[203,1500,1501],{"class":212},"        assert \"Bcc\" not in msg and \"archive\" not in msg[\"To\"]\n",[203,1503,1505],{"class":86,"line":1504},53,[203,1506,284],{"emptyLinePlaceholder":283},[203,1508,1510],{"class":86,"line":1509},54,[203,1511,1512],{"class":212},"    def test_workbook_matches_the_data(smtp_server):\n",[203,1514,1516],{"class":86,"line":1515},55,[203,1517,1450],{"class":212},[203,1519,1521],{"class":86,"line":1520},56,[203,1522,1523],{"class":212},"        send(port, to=[\"finance@example.com\"])\n",[203,1525,1527],{"class":86,"line":1526},57,[203,1528,1495],{"class":212},[203,1530,1532],{"class":86,"line":1531},58,[203,1533,1534],{"class":212},"        part = next(msg.iter_attachments())\n",[203,1536,1538],{"class":86,"line":1537},59,[203,1539,1540],{"class":212},"        assert part.get_filename() == \"sales_September_2026.xlsx\"\n",[203,1542,1544],{"class":86,"line":1543},60,[203,1545,1546],{"class":212},"        pd.testing.assert_frame_equal(pd.read_excel(io.BytesIO(part.get_content())), DF)\n",[203,1548,1550],{"class":86,"line":1549},61,[203,1551,284],{"emptyLinePlaceholder":283},[203,1553,1555],{"class":86,"line":1554},62,[203,1556,1557],{"class":212},"    def test_subject_and_total(smtp_server):\n",[203,1559,1561],{"class":86,"line":1560},63,[203,1562,1450],{"class":212},[203,1564,1566],{"class":86,"line":1565},64,[203,1567,1523],{"class":212},[203,1569,1571],{"class":86,"line":1570},65,[203,1572,1495],{"class":212},[203,1574,1576],{"class":86,"line":1575},66,[203,1577,1578],{"class":212},"        assert msg[\"Subject\"] == \"Sales report — September 2026\"\n",[203,1580,1582],{"class":86,"line":1581},67,[203,1583,1584],{"class":212},"        assert \"87,300\" in msg.get_body(preferencelist=(\"plain\",)).get_content()\n",[203,1586,1588,1590],{"class":86,"line":1587},68,[203,1589,1354],{"class":212},[203,1591,1357],{"class":270},[203,1593,1595,1598,1600,1603,1606,1608,1611,1613,1616,1618,1621,1623,1626,1628,1630],{"class":86,"line":1594},69,[203,1596,1597],{"class":270},"result ",[203,1599,321],{"class":266},[203,1601,1602],{"class":270}," subprocess.run([sys.executable, ",[203,1604,1605],{"class":212},"\"-m\"",[203,1607,428],{"class":270},[203,1609,1610],{"class":212},"\"pytest\"",[203,1612,428],{"class":270},[203,1614,1615],{"class":212},"\"-q\"",[203,1617,428],{"class":270},[203,1619,1620],{"class":212},"\"-p\"",[203,1622,428],{"class":270},[203,1624,1625],{"class":212},"\"no:cacheprovider\"",[203,1627,428],{"class":270},[203,1629,1367],{"class":212},[203,1631,1632],{"class":270},"],\n",[203,1634,1636,1639,1641,1644,1646,1648,1650,1652],{"class":86,"line":1635},70,[203,1637,1638],{"class":419},"                        capture_output",[203,1640,321],{"class":266},[203,1642,1643],{"class":305},"True",[203,1645,428],{"class":270},[203,1647,62],{"class":419},[203,1649,321],{"class":266},[203,1651,1643],{"class":305},[203,1653,439],{"class":270},[203,1655,1657,1659,1662,1665,1668],{"class":86,"line":1656},71,[203,1658,451],{"class":305},[203,1660,1661],{"class":270},"(result.stdout.strip().splitlines()[",[203,1663,1664],{"class":266},"-",[203,1666,1667],{"class":305},"1",[203,1669,634],{"class":270},[10,1671,1672,1675,1676,1678],{},[20,1673,1674],{},"free_port()"," asks the operating system for a port nobody is using — binding to port 0 does that — so tests never clash with each other or with something already listening on 8025. aiosmtpd's ",[20,1677,253],{}," cannot take port 0 itself, because after starting it connects to the port it was given to check the server is up; hence the helper. Each test starts with an empty mailbox, and the server stops afterwards even if the test fails, so one broken test cannot leave a server running for the next.",[189,1680,1682],{"id":1681},"step-5-look-at-the-emails-by-hand-when-you-need-to","Step 5 — Look at the emails by hand when you need to",[10,1684,1685],{},"Automated checks cover structure; how the HTML actually looks needs eyes. Run aiosmtpd from the command line and every message is printed to the terminal as it arrives:",[194,1687,1689],{"className":196,"code":1688,"language":198,"meta":199,"style":199},"python -m aiosmtpd -n -l 127.0.0.1:8025\n",[20,1690,1691],{"__ignoreMap":199},[203,1692,1693,1695,1698,1700,1703,1706],{"class":86,"line":205},[203,1694,259],{"class":208},[203,1696,1697],{"class":305}," -m",[203,1699,216],{"class":212},[203,1701,1702],{"class":305}," -n",[203,1704,1705],{"class":305}," -l",[203,1707,1708],{"class":212}," 127.0.0.1:8025\n",[10,1710,1711],{},"For a mailbox with a web interface that renders HTML and opens attachments, run a local mail catcher such as Mailpit, which listens for SMTP on one port and shows the messages in a browser on another. Point a staging run of the job at it and review the result before the configuration is switched to the real server.",[10,1713,1714,1715,1718,1719,1722,1723,1725],{},"A third option is a dry-run mode that writes each message to a ",[20,1716,1717],{},".eml"," file with ",[20,1720,1721],{},"path.write_bytes(msg.as_bytes())"," instead of sending it; most desktop mail clients open ",[20,1724,1717],{}," files directly. It is useful in production too, for rerunning a report to check it without resending it.",[189,1727,1729],{"id":1728},"common-pitfalls","Common pitfalls",[1731,1732,1733,1741,1747,1753,1759],"ul",{},[1734,1735,1736,1740],"li",{},[1737,1738,1739],"strong",{},"Hard-coded TLS and login."," Put them in configuration so tests can use a plain local server.",[1734,1742,1743,1746],{},[1737,1744,1745],{},"Checking Bcc in the headers."," It is removed before sending; check the envelope.",[1734,1748,1749,1752],{},[1737,1750,1751],{},"A fixed test port."," Ask the operating system for a free port so parallel test runs do not collide.",[1734,1754,1755,1758],{},[1737,1756,1757],{},"Mocking smtplib instead."," A mock cannot catch broken encodings or attachments.",[1734,1760,1761,1764],{},[1737,1762,1763],{},"Production credentials in tests."," Tests should never be able to reach the real server; default the config to the test server in the test suite.",[189,1766,1768],{"id":1767},"performance-notes","Performance notes",[10,1770,1771,1772,1774],{},"Starting and stopping the server per test takes a few milliseconds. For a large suite, use a session-scoped fixture and clear ",[20,1773,466],{}," at the start of each test instead.",[189,1776,1778],{"id":1777},"conclusion","Conclusion",[10,1780,1781,1782,1784,1785,1788,1789,1791],{},"Run an aiosmtpd ",[20,1783,253],{}," with a handler that stores the envelope and message bytes, make the job's TLS and login steps configurable, and point a test configuration at the local server. Parse captured messages with ",[20,1786,1787],{},"policy=default"," to check the subject, both bodies and the attachment, and check Bcc recipients in the envelope. Wrap the server in a pytest fixture on a free port, and use the command-line server, a mail catcher or ",[20,1790,1717],{}," files when a person needs to see the result.",[189,1793,1795],{"id":1794},"frequently-asked-questions","Frequently asked questions",[10,1797,1798,1801],{},[1737,1799,1800],{},"How do I test code that sends email without sending real email?","\nRun a local SMTP server that accepts messages and keeps them instead of delivering them — aiosmtpd does this in a few lines — and point your code at it through configuration. Then inspect the captured messages in your tests.",[10,1803,1804,1807],{},[1737,1805,1806],{},"Why not just mock smtplib?","\nA mock only proves your code called a method. A real local server receives a real SMTP conversation and the exact bytes a mail server would get, so the test catches encoding problems, missing headers and broken attachments that a mock would pass.",[10,1809,1810,1813],{},[1737,1811,1812],{},"How do I check Bcc recipients in a test?","\nsmtplib removes the Bcc header before sending, so it is not in the captured message. Bcc recipients appear in the SMTP envelope instead — the RCPT TO addresses — which aiosmtpd hands to the handler as envelope.rcpt_tos.",[10,1815,1816,1819],{},[1737,1817,1818],{},"My code calls starttls() and login(). How can it talk to a local server?","\nMake TLS and login configuration settings rather than hard-coded calls. Tests use a config with use_tls False and no username; production uses the real server settings. The sending code is otherwise identical.",[189,1821,1823],{"id":1822},"related","Related",[1731,1825,1826,1832,1839,1846],{},[1734,1827,1828,1829,1831],{},"Up one level: ",[14,1830,17],{"href":16}," — the sending side in full.",[1734,1833,1834,1838],{},[14,1835,1837],{"href":1836},"\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"," — the kind of message tested here.",[1734,1840,1841,1845],{},[14,1842,1844],{"href":1843},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-output-with-pytest\u002F","Test Excel Output with pytest"," — testing the workbook itself.",[1734,1847,1848,1852],{},[14,1849,1851],{"href":1850},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-each-recipient-their-own-excel-report\u002F","Send Each Recipient Their Own Excel Report"," — many messages to check at once.",[1854,1855,1856],"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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}",{"title":199,"searchDepth":280,"depth":280,"links":1858},[1859,1860,1861,1862,1863,1864,1865,1866,1867,1868,1869],{"id":191,"depth":280,"text":192},{"id":242,"depth":280,"text":243},{"id":470,"depth":280,"text":471},{"id":835,"depth":280,"text":836},{"id":1172,"depth":280,"text":1173},{"id":1681,"depth":280,"text":1682},{"id":1728,"depth":280,"text":1729},{"id":1767,"depth":280,"text":1768},{"id":1777,"depth":280,"text":1778},{"id":1794,"depth":280,"text":1795},{"id":1822,"depth":280,"text":1823},"2026-09-25","Check what your report job actually sends — recipients, subject, body and the attached workbook — without emailing anyone. Run a throwaway SMTP server with aiosmtpd, point the job at it through configuration, capture every message, and assert on it in pytest, including the Bcc recipients that never appear in the headers.","md",[1874,1876,1878,1880],{"q":1800,"a":1875},"Run a local SMTP server that accepts messages and keeps them instead of delivering them — aiosmtpd does this in a few lines — and point your code at it through configuration. Then inspect the captured messages in your tests.",{"q":1806,"a":1877},"A mock only proves your code called a method. A real local server receives a real SMTP conversation and the exact bytes a mail server would get, so the test catches encoding problems, missing headers and broken attachments that a mock would pass.",{"q":1812,"a":1879},"smtplib removes the Bcc header before sending, so it is not in the captured message. Bcc recipients appear in the SMTP envelope instead — the RCPT TO addresses — which aiosmtpd hands to the handler as envelope.rcpt_tos.",{"q":1818,"a":1881},"Make TLS and login configuration settings rather than hard-coded calls. Tests use a config with use_tls False and no username; production uses the real server settings. The sending code is otherwise identical.","test report emails with a local smtp server",null,{"breadcrumb":1885},[1886,1889,1892],{"name":1887,"item":1888},"Home","\u002F",{"name":1890,"item":1891},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server",{"title":1895,"description":1896},"Test Python Report Emails Locally with aiosmtpd and pytest","Test emailed Excel reports without sending real mail: a local aiosmtpd server, capturing envelopes and messages, a pytest fixture, asserting recipients, Bcc, subject, HTML body and the xlsx attachment, config switches for TLS and login, and dry-run .eml files.","test-report-emails-with-a-local-smtp-server","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server\u002Findex",[1900,1901],"email-delivery","reliable-jobs","how-to","7oECh7E6-6zVs2K2EGJF_9BMZ0LyRlQDXOfXJVlo13k",[1905,1909],{"title":1906,"path":1907,"stem":1908,"children":-1},"Send Report Emails at Each Recipient's Local Time","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time\u002Findex",{"title":1910,"path":1911,"stem":1912,"children":-1},"Zip Large Excel Attachments Before Emailing Them","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fzip-large-excel-attachments-before-emailing","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fzip-large-excel-attachments-before-emailing\u002Findex",[1914,1916],{"path":1915,"title":17},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib",{"path":1917,"title":1890},"\u002Fautomating-reporting-workflows",[],[1920,1923,1926,1929,1932,1934],{"path":1921,"title":1922,"kind":1902},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-credit-control-dunning-letter-run","Build a Credit Control Dunning Letter Run",{"path":1924,"title":1925,"kind":1902},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fattach-multiple-excel-files-to-one-email-in-python","Attach Multiple Excel Files to One Email in Python",{"path":1927,"title":1928,"kind":1902},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fdownload-excel-attachments-from-an-email-inbox","Download Excel Attachments from an Email Inbox with Python",{"path":1930,"title":1931,"kind":1902},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-a-download-link-instead-of-an-excel-attachment","Email a Download Link Instead of an Excel Attachment",{"path":1933,"title":1837,"kind":1902},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-an-excel-report-with-an-html-summary-body",{"path":1935,"title":1936,"kind":1902},"\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",1790972129974]