[{"data":1,"prerenderedAt":2124},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates":3,"surround:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates":2093,"ancestors:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates":2102,"children:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates":2107,"related:\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates":2108},{"id":4,"title":5,"body":6,"dateModified":2061,"datePublished":2061,"description":2062,"extension":2063,"faq":2064,"keyword":2073,"kind":2074,"meta":2075,"navigation":103,"path":2083,"seo":2084,"slug":2087,"stem":2088,"topics":2089,"type":2091,"__hash__":2092},"docs\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates\u002Findex.md","Personalise Report Emails with Jinja2 Templates",{"type":7,"value":8,"toc":2048},"minimark",[9,24,29,60,71,75,533,544,694,698,882,930,934,1340,1354,1358,1566,1582,1693,1697,1789,1803,1807,1818,1889,1893,1937,1941,1950,1954,1970,1987,1998,2011,2015,2044],[10,11,12,13,17,18,23],"p",{},"The first version of a report email is usually an f-string: ",[14,15,16],"code",{},"f\"Hi {name}, attached is the {month} report.\""," It works until the message needs a table of the recipient's figures, a paragraph that only appears when something is overdue, an HTML version with styling and a plain-text version that says the same thing — and a manager who wants to change the wording without editing Python. At that point the wording belongs in templates. Jinja2 is the standard Python template engine: templates are plain files with placeholders, loops and conditions, and the Python code only prepares the data for each recipient. This page builds personalised report emails for a list of branch managers with Jinja2, covering the details that matter for email: escaping, failing loudly on a missing field, number formatting, a shared layout, and checking the rendered messages before they go out. It belongs to ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","Emailing Excel Reports with smtplib",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install jinja2 pandas openpyxl\n","bash","",[14,37,38],{"__ignoreMap":35},[39,40,43,47,51,54,57],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"sMTad","pip",[39,48,50],{"class":49},"srMev"," install",[39,52,53],{"class":49}," jinja2",[39,55,56],{"class":49}," pandas",[39,58,59],{"class":49}," openpyxl\n",[10,61,62,63,66,67,23],{},"The data is one row per branch with the manager's name and email, revenue against target and overdue invoices. The emails are built with the standard library's ",[14,64,65],{},"email.message.EmailMessage","; sending them is the same as for any other message and is covered in ",[19,68,70],{"href":69},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-each-recipient-their-own-excel-report\u002F","Send Each Recipient Their Own Excel Report with Python",[25,72,74],{"id":73},"step-1-put-the-templates-in-files","Step 1 — Put the templates in files",[30,76,80],{"className":77,"code":78,"language":79,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","from pathlib import Path\n\ntpl = Path(\"templates\")\ntpl.mkdir(exist_ok=True)\n(tpl \u002F \"subject.txt\").write_text(\"{{ branch }}: {{ month }} results — {{ revenue | money }} ({{ vs_target | pct }} vs target)\")\n(tpl \u002F \"base.html\").write_text(\"\"\"\u003Chtml>\u003Cbody style=\"font-family:Arial,sans-serif;font-size:14px;color:#222\">\n{% block content %}{% endblock %}\n\u003Cp style=\"color:#888;font-size:12px\">Sent automatically by the finance reporting job. Reply to this email with questions.\u003C\u002Fp>\n\u003C\u002Fbody>\u003C\u002Fhtml>\"\"\")\n(tpl \u002F \"results.html\").write_text(\"\"\"{% extends \"base.html\" %}\n{% block content %}\n\u003Cp>Hi {{ first_name }},\u003C\u002Fp>\n\u003Cp>Here are the {{ month }} results for \u003Cb>{{ branch }}\u003C\u002Fb>, prepared for {{ manager }}.\u003C\u002Fp>\n\u003Ctable style=\"border-collapse:collapse\">\n  \u003Ctr>\u003Ctd style=\"padding:4px 12px\">Revenue\u003C\u002Ftd>\u003Ctd style=\"padding:4px 12px;text-align:right\">{{ revenue | money }}\u003C\u002Ftd>\u003C\u002Ftr>\n  \u003Ctr>\u003Ctd style=\"padding:4px 12px\">Target\u003C\u002Ftd>\u003Ctd style=\"padding:4px 12px;text-align:right\">{{ target | money }}\u003C\u002Ftd>\u003C\u002Ftr>\n  \u003Ctr>\u003Ctd style=\"padding:4px 12px\">Against target\u003C\u002Ftd>\n      \u003Ctd style=\"padding:4px 12px;text-align:right;color:{{ '#1a7f37' if vs_target >= 0 else '#c62828' }}\">{{ vs_target | pct }}\u003C\u002Ftd>\u003C\u002Ftr>\n\u003C\u002Ftable>\n{% if overdue_count %}\n\u003Cp>\u003Cb>{{ overdue_count }} invoice{{ 's' if overdue_count != 1 }}\u003C\u002Fb> totalling {{ overdue_amount | money }}\n{{ 'are' if overdue_count != 1 else 'is' }} overdue. The list is on the \u003Ci>Overdue\u003C\u002Fi> sheet of the attached workbook.\u003C\u002Fp>\n{% else %}\n\u003Cp>No invoices are overdue — thank you.\u003C\u002Fp>\n{% endif %}\n{% endblock %}\"\"\")\n(tpl \u002F \"results.txt\").write_text(\"\"\"Hi {{ first_name }},\n\nHere are the {{ month }} results for {{ branch }}.\n\n  Revenue         {{ \"%12s\" | format(revenue | money) }}\n  Target          {{ \"%12s\" | format(target | money) }}\n  Against target  {{ \"%12s\" | format(vs_target | pct) }}\n{% if overdue_count %}\n{{ overdue_count }} invoice{{ 's' if overdue_count != 1 }} totalling {{ overdue_amount | money }} {{ 'are' if overdue_count != 1 else 'is' }} overdue.\n{% else %}\nNo invoices are overdue - thank you.\n{% endif %}\n-- Sent automatically by the finance reporting job.\n\"\"\")\nprint(sorted(p.name for p in tpl.iterdir()))\n","python",[14,81,82,98,105,123,141,204,219,225,231,239,254,260,266,272,278,284,290,296,302,308,314,320,326,332,338,344,352,377,382,406,411,431,448,465,470,476,481,487,492,498,506],{"__ignoreMap":35},[39,83,84,88,92,95],{"class":41,"line":42},[39,85,87],{"class":86},"s-kum","from",[39,89,91],{"class":90},"skGVy"," pathlib ",[39,93,94],{"class":86},"import",[39,96,97],{"class":90}," Path\n",[39,99,101],{"class":41,"line":100},2,[39,102,104],{"emptyLinePlaceholder":103},true,"\n",[39,106,108,111,114,117,120],{"class":41,"line":107},3,[39,109,110],{"class":90},"tpl ",[39,112,113],{"class":86},"=",[39,115,116],{"class":90}," Path(",[39,118,119],{"class":49},"\"templates\"",[39,121,122],{"class":90},")\n",[39,124,126,129,133,135,139],{"class":41,"line":125},4,[39,127,128],{"class":90},"tpl.mkdir(",[39,130,132],{"class":131},"sa561","exist_ok",[39,134,113],{"class":86},[39,136,138],{"class":137},"sP0c6","True",[39,140,122],{"class":90},[39,142,144,147,150,153,156,159,163,166,169,172,174,177,179,182,184,187,189,192,194,197,199,202],{"class":41,"line":143},5,[39,145,146],{"class":90},"(tpl ",[39,148,149],{"class":86},"\u002F",[39,151,152],{"class":49}," \"subject.txt\"",[39,154,155],{"class":90},").write_text(",[39,157,158],{"class":49},"\"",[39,160,162],{"class":161},"sSjpA","{{",[39,164,165],{"class":49}," branch ",[39,167,168],{"class":161},"}}",[39,170,171],{"class":49},": ",[39,173,162],{"class":161},[39,175,176],{"class":49}," month ",[39,178,168],{"class":161},[39,180,181],{"class":49}," results — ",[39,183,162],{"class":161},[39,185,186],{"class":49}," revenue | money ",[39,188,168],{"class":161},[39,190,191],{"class":49}," (",[39,193,162],{"class":161},[39,195,196],{"class":49}," vs_target | pct ",[39,198,168],{"class":161},[39,200,201],{"class":49}," vs target)\"",[39,203,122],{"class":90},[39,205,207,209,211,214,216],{"class":41,"line":206},6,[39,208,146],{"class":90},[39,210,149],{"class":86},[39,212,213],{"class":49}," \"base.html\"",[39,215,155],{"class":90},[39,217,218],{"class":49},"\"\"\"\u003Chtml>\u003Cbody style=\"font-family:Arial,sans-serif;font-size:14px;color:#222\">\n",[39,220,222],{"class":41,"line":221},7,[39,223,224],{"class":49},"{% block content %}{% endblock %}\n",[39,226,228],{"class":41,"line":227},8,[39,229,230],{"class":49},"\u003Cp style=\"color:#888;font-size:12px\">Sent automatically by the finance reporting job. Reply to this email with questions.\u003C\u002Fp>\n",[39,232,234,237],{"class":41,"line":233},9,[39,235,236],{"class":49},"\u003C\u002Fbody>\u003C\u002Fhtml>\"\"\"",[39,238,122],{"class":90},[39,240,242,244,246,249,251],{"class":41,"line":241},10,[39,243,146],{"class":90},[39,245,149],{"class":86},[39,247,248],{"class":49}," \"results.html\"",[39,250,155],{"class":90},[39,252,253],{"class":49},"\"\"\"{% extends \"base.html\" %}\n",[39,255,257],{"class":41,"line":256},11,[39,258,259],{"class":49},"{% block content %}\n",[39,261,263],{"class":41,"line":262},12,[39,264,265],{"class":49},"\u003Cp>Hi {{ first_name }},\u003C\u002Fp>\n",[39,267,269],{"class":41,"line":268},13,[39,270,271],{"class":49},"\u003Cp>Here are the {{ month }} results for \u003Cb>{{ branch }}\u003C\u002Fb>, prepared for {{ manager }}.\u003C\u002Fp>\n",[39,273,275],{"class":41,"line":274},14,[39,276,277],{"class":49},"\u003Ctable style=\"border-collapse:collapse\">\n",[39,279,281],{"class":41,"line":280},15,[39,282,283],{"class":49},"  \u003Ctr>\u003Ctd style=\"padding:4px 12px\">Revenue\u003C\u002Ftd>\u003Ctd style=\"padding:4px 12px;text-align:right\">{{ revenue | money }}\u003C\u002Ftd>\u003C\u002Ftr>\n",[39,285,287],{"class":41,"line":286},16,[39,288,289],{"class":49},"  \u003Ctr>\u003Ctd style=\"padding:4px 12px\">Target\u003C\u002Ftd>\u003Ctd style=\"padding:4px 12px;text-align:right\">{{ target | money }}\u003C\u002Ftd>\u003C\u002Ftr>\n",[39,291,293],{"class":41,"line":292},17,[39,294,295],{"class":49},"  \u003Ctr>\u003Ctd style=\"padding:4px 12px\">Against target\u003C\u002Ftd>\n",[39,297,299],{"class":41,"line":298},18,[39,300,301],{"class":49},"      \u003Ctd style=\"padding:4px 12px;text-align:right;color:{{ '#1a7f37' if vs_target >= 0 else '#c62828' }}\">{{ vs_target | pct }}\u003C\u002Ftd>\u003C\u002Ftr>\n",[39,303,305],{"class":41,"line":304},19,[39,306,307],{"class":49},"\u003C\u002Ftable>\n",[39,309,311],{"class":41,"line":310},20,[39,312,313],{"class":49},"{% if overdue_count %}\n",[39,315,317],{"class":41,"line":316},21,[39,318,319],{"class":49},"\u003Cp>\u003Cb>{{ overdue_count }} invoice{{ 's' if overdue_count != 1 }}\u003C\u002Fb> totalling {{ overdue_amount | money }}\n",[39,321,323],{"class":41,"line":322},22,[39,324,325],{"class":49},"{{ 'are' if overdue_count != 1 else 'is' }} overdue. The list is on the \u003Ci>Overdue\u003C\u002Fi> sheet of the attached workbook.\u003C\u002Fp>\n",[39,327,329],{"class":41,"line":328},23,[39,330,331],{"class":49},"{% else %}\n",[39,333,335],{"class":41,"line":334},24,[39,336,337],{"class":49},"\u003Cp>No invoices are overdue — thank you.\u003C\u002Fp>\n",[39,339,341],{"class":41,"line":340},25,[39,342,343],{"class":49},"{% endif %}\n",[39,345,347,350],{"class":41,"line":346},26,[39,348,349],{"class":49},"{% endblock %}\"\"\"",[39,351,122],{"class":90},[39,353,355,357,359,362,364,367,369,372,374],{"class":41,"line":354},27,[39,356,146],{"class":90},[39,358,149],{"class":86},[39,360,361],{"class":49}," \"results.txt\"",[39,363,155],{"class":90},[39,365,366],{"class":49},"\"\"\"Hi ",[39,368,162],{"class":161},[39,370,371],{"class":49}," first_name ",[39,373,168],{"class":161},[39,375,376],{"class":49},",\n",[39,378,380],{"class":41,"line":379},28,[39,381,104],{"emptyLinePlaceholder":103},[39,383,385,388,390,392,394,397,399,401,403],{"class":41,"line":384},29,[39,386,387],{"class":49},"Here are the ",[39,389,162],{"class":161},[39,391,176],{"class":49},[39,393,168],{"class":161},[39,395,396],{"class":49}," results for ",[39,398,162],{"class":161},[39,400,165],{"class":49},[39,402,168],{"class":161},[39,404,405],{"class":49},".\n",[39,407,409],{"class":41,"line":408},30,[39,410,104],{"emptyLinePlaceholder":103},[39,412,414,417,419,422,425,428],{"class":41,"line":413},31,[39,415,416],{"class":49},"  Revenue         ",[39,418,162],{"class":161},[39,420,421],{"class":49}," \"",[39,423,424],{"class":161},"%12s",[39,426,427],{"class":49},"\" | format(revenue | money) ",[39,429,430],{"class":161},"}}\n",[39,432,434,437,439,441,443,446],{"class":41,"line":433},32,[39,435,436],{"class":49},"  Target          ",[39,438,162],{"class":161},[39,440,421],{"class":49},[39,442,424],{"class":161},[39,444,445],{"class":49},"\" | format(target | money) ",[39,447,430],{"class":161},[39,449,451,454,456,458,460,463],{"class":41,"line":450},33,[39,452,453],{"class":49},"  Against target  ",[39,455,162],{"class":161},[39,457,421],{"class":49},[39,459,424],{"class":161},[39,461,462],{"class":49},"\" | format(vs_target | pct) ",[39,464,430],{"class":161},[39,466,468],{"class":41,"line":467},34,[39,469,313],{"class":49},[39,471,473],{"class":41,"line":472},35,[39,474,475],{"class":49},"{{ overdue_count }} invoice{{ 's' if overdue_count != 1 }} totalling {{ overdue_amount | money }} {{ 'are' if overdue_count != 1 else 'is' }} overdue.\n",[39,477,479],{"class":41,"line":478},36,[39,480,331],{"class":49},[39,482,484],{"class":41,"line":483},37,[39,485,486],{"class":49},"No invoices are overdue - thank you.\n",[39,488,490],{"class":41,"line":489},38,[39,491,343],{"class":49},[39,493,495],{"class":41,"line":494},39,[39,496,497],{"class":49},"-- Sent automatically by the finance reporting job.\n",[39,499,501,504],{"class":41,"line":500},40,[39,502,503],{"class":49},"\"\"\"",[39,505,122],{"class":90},[39,507,509,512,515,518,521,524,527,530],{"class":41,"line":508},41,[39,510,511],{"class":137},"print",[39,513,514],{"class":90},"(",[39,516,517],{"class":137},"sorted",[39,519,520],{"class":90},"(p.name ",[39,522,523],{"class":86},"for",[39,525,526],{"class":90}," p ",[39,528,529],{"class":86},"in",[39,531,532],{"class":90}," tpl.iterdir()))\n",[10,534,535,536,539,540,543],{},"There are four templates: the subject line, a shared HTML layout, the HTML body that extends it, and a plain-text body. Each template holds only wording and layout — no calculations — so whoever owns the message can edit it without touching the script. The HTML template uses ",[14,537,538],{},"{% extends %}"," and ",[14,541,542],{},"{% block %}"," so that the greeting, footer and styling live in one place when there are several kinds of report email. The plain-text template exists because every HTML email should also carry a text part: some mail clients and many notification previews show only the text, and spam filters treat HTML-only messages with suspicion. Writing it as its own template, rather than stripping tags from the HTML, gives a version that reads properly.",[545,546,554,555,554,559,554,563,554,571,554,581,554,588,554,593,554,599,554,604,554,608,554,614,554,618,554,622,554,626,554,629,554,634,554,640,554,643,554,646,554,650,554,654,554,657,554,662,554,667,554,670,554,673,554,677,554,680,554,683,554,687,554,691],"svg",{"viewBox":547,"role":548,"ariaLabelledBy":549,"xmlns":552,"style":553},"4 0 752 126","img",[550,551],"j2-flow-t","j2-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[556,557,558],"title",{"id":550},"From a spreadsheet row to an email",[560,561,562],"desc",{"id":551},"Each row of the results sheet becomes a context dictionary. The same context renders the subject, the text body and the HTML body, which are assembled into one message with the workbook attached.",[564,565],"rect",{"x":566,"y":567,"width":568,"height":569,"fill":570},"4","0","752","126","#ffffff",[564,572],{"x":573,"y":574,"width":575,"height":576,"rx":577,"fill":578,"stroke":579,"style":580},"20.0","16.0","115.2","94.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[582,583,587],"text",{"x":584,"y":585,"style":586},"77.6","59.0","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Results row",[582,589,592],{"x":584,"y":590,"style":591},"78.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","one per branch",[41,594],{"x1":595,"y1":596,"x2":597,"y2":596,"stroke":598,"style":580},"140.2","63.0","162.2","var(--brand,#5b5cf0)",[600,601],"polygon",{"points":602,"fill":603},"166.2,63.0 157.2,68.0 157.2,58.0","#5b5cf0",[564,605],{"x":606,"y":574,"width":575,"height":576,"rx":577,"fill":607,"stroke":598,"style":580},"171.2","#f0f4ff",[582,609,613],{"x":610,"y":611,"style":612},"228.8","50.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Context dict",[582,615,617],{"x":610,"y":616,"style":591},"69.8","names,",[582,619,621],{"x":610,"y":620,"style":591},"87.2","numbers, flags",[41,623],{"x1":624,"y1":596,"x2":625,"y2":596,"stroke":598,"style":580},"291.4","313.4",[600,627],{"points":628,"fill":603},"317.4,63.0 308.4,68.0 308.4,58.0",[564,630],{"x":631,"y":574,"width":575,"height":576,"rx":577,"fill":632,"stroke":633,"style":580},"322.4","#d9f4f1","var(--teal,#0f9488)",[582,635,639],{"x":636,"y":637,"style":638},"380.0","41.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Render",[582,641,642],{"x":636,"y":585,"style":638},"templates",[582,644,645],{"x":636,"y":590,"style":591},"subject, text,",[582,647,649],{"x":636,"y":648,"style":591},"96.0","HTML",[41,651],{"x1":652,"y1":596,"x2":653,"y2":596,"stroke":598,"style":580},"442.6","464.6",[600,655],{"points":656,"fill":603},"468.6,63.0 459.6,68.0 459.6,58.0",[564,658],{"x":659,"y":574,"width":575,"height":576,"rx":577,"fill":660,"stroke":661,"style":580},"473.6","#fdefd8","var(--gold,#b4740a)",[582,663,666],{"x":664,"y":611,"style":665},"531.2","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","EmailMessage",[582,668,669],{"x":664,"y":616,"style":591},"text + HTML +",[582,671,672],{"x":664,"y":620,"style":591},"xlsx",[41,674],{"x1":675,"y1":596,"x2":676,"y2":596,"stroke":598,"style":580},"593.8","615.8",[600,678],{"points":679,"fill":603},"619.8,63.0 610.8,68.0 610.8,58.0",[564,681],{"x":682,"y":574,"width":575,"height":576,"rx":577,"fill":578,"stroke":579,"style":580},"624.8",[582,684,686],{"x":685,"y":611,"style":586},"682.4","Preview or",[582,688,690],{"x":685,"y":689,"style":586},"67.8","send",[582,692,693],{"x":685,"y":620,"style":591},".eml first",[25,695,697],{"id":696},"step-2-an-environment-that-escapes-fails-loudly-and-formats-numbers","Step 2 — An environment that escapes, fails loudly and formats numbers",[30,699,701],{"className":77,"code":700,"language":79,"meta":35,"style":35},"from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape\n\nenv = Environment(\n    loader=FileSystemLoader(\"templates\"),\n    autoescape=select_autoescape(enabled_extensions=(\"html\",), default_for_string=False),\n    undefined=StrictUndefined,\n    trim_blocks=True, lstrip_blocks=True,\n)\nenv.filters[\"money\"] = lambda x: f\"£{x:,.2f}\"\nenv.filters[\"pct\"] = lambda x: f\"{x:+.1%}\"\n",[14,702,703,715,719,729,744,777,787,808,812,852],{"__ignoreMap":35},[39,704,705,707,710,712],{"class":41,"line":42},[39,706,87],{"class":86},[39,708,709],{"class":90}," jinja2 ",[39,711,94],{"class":86},[39,713,714],{"class":90}," Environment, FileSystemLoader, StrictUndefined, select_autoescape\n",[39,716,717],{"class":41,"line":100},[39,718,104],{"emptyLinePlaceholder":103},[39,720,721,724,726],{"class":41,"line":107},[39,722,723],{"class":90},"env ",[39,725,113],{"class":86},[39,727,728],{"class":90}," Environment(\n",[39,730,731,734,736,739,741],{"class":41,"line":125},[39,732,733],{"class":131},"    loader",[39,735,113],{"class":86},[39,737,738],{"class":90},"FileSystemLoader(",[39,740,119],{"class":49},[39,742,743],{"class":90},"),\n",[39,745,746,749,751,754,757,759,761,764,767,770,772,775],{"class":41,"line":143},[39,747,748],{"class":131},"    autoescape",[39,750,113],{"class":86},[39,752,753],{"class":90},"select_autoescape(",[39,755,756],{"class":131},"enabled_extensions",[39,758,113],{"class":86},[39,760,514],{"class":90},[39,762,763],{"class":49},"\"html\"",[39,765,766],{"class":90},",), ",[39,768,769],{"class":131},"default_for_string",[39,771,113],{"class":86},[39,773,774],{"class":137},"False",[39,776,743],{"class":90},[39,778,779,782,784],{"class":41,"line":206},[39,780,781],{"class":131},"    undefined",[39,783,113],{"class":86},[39,785,786],{"class":90},"StrictUndefined,\n",[39,788,789,792,794,796,799,802,804,806],{"class":41,"line":221},[39,790,791],{"class":131},"    trim_blocks",[39,793,113],{"class":86},[39,795,138],{"class":137},[39,797,798],{"class":90},", ",[39,800,801],{"class":131},"lstrip_blocks",[39,803,113],{"class":86},[39,805,138],{"class":137},[39,807,376],{"class":90},[39,809,810],{"class":41,"line":227},[39,811,122],{"class":90},[39,813,814,817,820,823,825,828,831,834,837,840,843,846,849],{"class":41,"line":233},[39,815,816],{"class":90},"env.filters[",[39,818,819],{"class":49},"\"money\"",[39,821,822],{"class":90},"] ",[39,824,113],{"class":86},[39,826,827],{"class":86}," lambda",[39,829,830],{"class":90}," x: ",[39,832,833],{"class":86},"f",[39,835,836],{"class":49},"\"£",[39,838,839],{"class":161},"{",[39,841,842],{"class":90},"x",[39,844,845],{"class":86},":,.2f",[39,847,848],{"class":161},"}",[39,850,851],{"class":49},"\"\n",[39,853,854,856,859,861,863,865,867,869,871,873,875,878,880],{"class":41,"line":241},[39,855,816],{"class":90},[39,857,858],{"class":49},"\"pct\"",[39,860,822],{"class":90},[39,862,113],{"class":86},[39,864,827],{"class":86},[39,866,830],{"class":90},[39,868,833],{"class":86},[39,870,158],{"class":49},[39,872,839],{"class":161},[39,874,842],{"class":90},[39,876,877],{"class":86},":+.1%",[39,879,848],{"class":161},[39,881,851],{"class":49},[10,883,884,885,889,890,893,894,798,897,539,900,903,904,911,912,915,916,919,920,539,923,925,926,929],{},"Three settings make Jinja2 safe for this job. ",[886,887,888],"strong",{},"Autoescaping"," for ",[14,891,892],{},".html"," templates turns ",[14,895,896],{},"\u003C",[14,898,899],{},">",[14,901,902],{},"&"," in the data into HTML entities, so a manager name like ",[905,906,907,908],"em",{},"Smith & Sons ",[909,910],"agency",{}," is displayed as written instead of being read as a broken tag — and so text copied from a spreadsheet can never inject markup into the email. It is off for ",[14,913,914],{},".txt"," templates, where entities would show up literally. ",[886,917,918],{},"StrictUndefined"," makes any placeholder without a value raise an error; the default is to render it as an empty string, which produces emails that say \"Hi ,\" and nobody notices until a recipient replies. ",[14,921,922],{},"trim_blocks",[14,924,801],{}," remove the blank lines that ",[14,927,928],{},"{% if %}"," tags would otherwise leave in the text version. The two filters keep number formatting in one place, so every email shows money and percentages the same way.",[25,931,933],{"id":932},"step-3-build-one-context-per-recipient-and-render","Step 3 — Build one context per recipient and render",[30,935,937],{"className":77,"code":936,"language":79,"meta":35,"style":35},"from email.message import EmailMessage\nfrom email.utils import formataddr\nimport pandas as pd\n\nresults = pd.read_excel(\"branch_results.xlsx\")\nmonth = \"September 2026\"\n\ndef context(row):\n    return {\"branch\": row[\"Branch\"], \"manager\": row[\"Manager\"], \"first_name\": row[\"Manager\"].split()[0], \"month\": month,\n            \"revenue\": row[\"Revenue\"], \"target\": row[\"Target\"], \"vs_target\": row[\"Revenue\"] \u002F row[\"Target\"] - 1,\n            \"overdue_count\": int(row[\"Overdue invoices\"]), \"overdue_amount\": row[\"Overdue amount\"]}\n\ndef build(row):\n    ctx = context(row)\n    msg = EmailMessage()\n    msg[\"Subject\"] = env.get_template(\"subject.txt\").render(ctx).strip()\n    msg[\"From\"] = formataddr((\"Finance Reporting\", \"reports@example.com\"))\n    msg[\"To\"] = formataddr((row[\"Manager\"], row[\"Email\"]))\n    msg.set_content(env.get_template(\"results.txt\").render(ctx))\n    msg.add_alternative(env.get_template(\"results.html\").render(ctx), subtype=\"html\")\n    return msg\n\nmessages = [build(row) for _, row in results.iterrows()]\nfor m in messages:\n    print(m[\"Subject\"])\n",[14,938,939,951,963,976,980,995,1005,1009,1021,1071,1119,1149,1153,1162,1172,1182,1203,1228,1253,1264,1284,1291,1295,1315,1327],{"__ignoreMap":35},[39,940,941,943,946,948],{"class":41,"line":42},[39,942,87],{"class":86},[39,944,945],{"class":90}," email.message ",[39,947,94],{"class":86},[39,949,950],{"class":90}," EmailMessage\n",[39,952,953,955,958,960],{"class":41,"line":100},[39,954,87],{"class":86},[39,956,957],{"class":90}," email.utils ",[39,959,94],{"class":86},[39,961,962],{"class":90}," formataddr\n",[39,964,965,967,970,973],{"class":41,"line":107},[39,966,94],{"class":86},[39,968,969],{"class":90}," pandas ",[39,971,972],{"class":86},"as",[39,974,975],{"class":90}," pd\n",[39,977,978],{"class":41,"line":125},[39,979,104],{"emptyLinePlaceholder":103},[39,981,982,985,987,990,993],{"class":41,"line":143},[39,983,984],{"class":90},"results ",[39,986,113],{"class":86},[39,988,989],{"class":90}," pd.read_excel(",[39,991,992],{"class":49},"\"branch_results.xlsx\"",[39,994,122],{"class":90},[39,996,997,1000,1002],{"class":41,"line":206},[39,998,999],{"class":90},"month ",[39,1001,113],{"class":86},[39,1003,1004],{"class":49}," \"September 2026\"\n",[39,1006,1007],{"class":41,"line":221},[39,1008,104],{"emptyLinePlaceholder":103},[39,1010,1011,1014,1018],{"class":41,"line":227},[39,1012,1013],{"class":86},"def",[39,1015,1017],{"class":1016},"s_Opv"," context",[39,1019,1020],{"class":90},"(row):\n",[39,1022,1023,1026,1029,1032,1035,1038,1041,1044,1046,1049,1051,1054,1056,1058,1061,1063,1065,1068],{"class":41,"line":233},[39,1024,1025],{"class":86},"    return",[39,1027,1028],{"class":90}," {",[39,1030,1031],{"class":49},"\"branch\"",[39,1033,1034],{"class":90},": row[",[39,1036,1037],{"class":49},"\"Branch\"",[39,1039,1040],{"class":90},"], ",[39,1042,1043],{"class":49},"\"manager\"",[39,1045,1034],{"class":90},[39,1047,1048],{"class":49},"\"Manager\"",[39,1050,1040],{"class":90},[39,1052,1053],{"class":49},"\"first_name\"",[39,1055,1034],{"class":90},[39,1057,1048],{"class":49},[39,1059,1060],{"class":90},"].split()[",[39,1062,567],{"class":137},[39,1064,1040],{"class":90},[39,1066,1067],{"class":49},"\"month\"",[39,1069,1070],{"class":90},": month,\n",[39,1072,1073,1076,1078,1081,1083,1086,1088,1091,1093,1096,1098,1100,1102,1104,1107,1109,1111,1114,1117],{"class":41,"line":241},[39,1074,1075],{"class":49},"            \"revenue\"",[39,1077,1034],{"class":90},[39,1079,1080],{"class":49},"\"Revenue\"",[39,1082,1040],{"class":90},[39,1084,1085],{"class":49},"\"target\"",[39,1087,1034],{"class":90},[39,1089,1090],{"class":49},"\"Target\"",[39,1092,1040],{"class":90},[39,1094,1095],{"class":49},"\"vs_target\"",[39,1097,1034],{"class":90},[39,1099,1080],{"class":49},[39,1101,822],{"class":90},[39,1103,149],{"class":86},[39,1105,1106],{"class":90}," row[",[39,1108,1090],{"class":49},[39,1110,822],{"class":90},[39,1112,1113],{"class":86},"-",[39,1115,1116],{"class":137}," 1",[39,1118,376],{"class":90},[39,1120,1121,1124,1126,1129,1132,1135,1138,1141,1143,1146],{"class":41,"line":256},[39,1122,1123],{"class":49},"            \"overdue_count\"",[39,1125,171],{"class":90},[39,1127,1128],{"class":137},"int",[39,1130,1131],{"class":90},"(row[",[39,1133,1134],{"class":49},"\"Overdue invoices\"",[39,1136,1137],{"class":90},"]), ",[39,1139,1140],{"class":49},"\"overdue_amount\"",[39,1142,1034],{"class":90},[39,1144,1145],{"class":49},"\"Overdue amount\"",[39,1147,1148],{"class":90},"]}\n",[39,1150,1151],{"class":41,"line":262},[39,1152,104],{"emptyLinePlaceholder":103},[39,1154,1155,1157,1160],{"class":41,"line":268},[39,1156,1013],{"class":86},[39,1158,1159],{"class":1016}," build",[39,1161,1020],{"class":90},[39,1163,1164,1167,1169],{"class":41,"line":274},[39,1165,1166],{"class":90},"    ctx ",[39,1168,113],{"class":86},[39,1170,1171],{"class":90}," context(row)\n",[39,1173,1174,1177,1179],{"class":41,"line":280},[39,1175,1176],{"class":90},"    msg ",[39,1178,113],{"class":86},[39,1180,1181],{"class":90}," EmailMessage()\n",[39,1183,1184,1187,1190,1192,1194,1197,1200],{"class":41,"line":286},[39,1185,1186],{"class":90},"    msg[",[39,1188,1189],{"class":49},"\"Subject\"",[39,1191,822],{"class":90},[39,1193,113],{"class":86},[39,1195,1196],{"class":90}," env.get_template(",[39,1198,1199],{"class":49},"\"subject.txt\"",[39,1201,1202],{"class":90},").render(ctx).strip()\n",[39,1204,1205,1207,1210,1212,1214,1217,1220,1222,1225],{"class":41,"line":292},[39,1206,1186],{"class":90},[39,1208,1209],{"class":49},"\"From\"",[39,1211,822],{"class":90},[39,1213,113],{"class":86},[39,1215,1216],{"class":90}," formataddr((",[39,1218,1219],{"class":49},"\"Finance Reporting\"",[39,1221,798],{"class":90},[39,1223,1224],{"class":49},"\"reports@example.com\"",[39,1226,1227],{"class":90},"))\n",[39,1229,1230,1232,1235,1237,1239,1242,1244,1247,1250],{"class":41,"line":298},[39,1231,1186],{"class":90},[39,1233,1234],{"class":49},"\"To\"",[39,1236,822],{"class":90},[39,1238,113],{"class":86},[39,1240,1241],{"class":90}," formataddr((row[",[39,1243,1048],{"class":49},[39,1245,1246],{"class":90},"], row[",[39,1248,1249],{"class":49},"\"Email\"",[39,1251,1252],{"class":90},"]))\n",[39,1254,1255,1258,1261],{"class":41,"line":304},[39,1256,1257],{"class":90},"    msg.set_content(env.get_template(",[39,1259,1260],{"class":49},"\"results.txt\"",[39,1262,1263],{"class":90},").render(ctx))\n",[39,1265,1266,1269,1272,1275,1278,1280,1282],{"class":41,"line":310},[39,1267,1268],{"class":90},"    msg.add_alternative(env.get_template(",[39,1270,1271],{"class":49},"\"results.html\"",[39,1273,1274],{"class":90},").render(ctx), ",[39,1276,1277],{"class":131},"subtype",[39,1279,113],{"class":86},[39,1281,763],{"class":49},[39,1283,122],{"class":90},[39,1285,1286,1288],{"class":41,"line":316},[39,1287,1025],{"class":86},[39,1289,1290],{"class":90}," msg\n",[39,1292,1293],{"class":41,"line":322},[39,1294,104],{"emptyLinePlaceholder":103},[39,1296,1297,1300,1302,1305,1307,1310,1312],{"class":41,"line":328},[39,1298,1299],{"class":90},"messages ",[39,1301,113],{"class":86},[39,1303,1304],{"class":90}," [build(row) ",[39,1306,523],{"class":86},[39,1308,1309],{"class":90}," _, row ",[39,1311,529],{"class":86},[39,1313,1314],{"class":90}," results.iterrows()]\n",[39,1316,1317,1319,1322,1324],{"class":41,"line":334},[39,1318,523],{"class":86},[39,1320,1321],{"class":90}," m ",[39,1323,529],{"class":86},[39,1325,1326],{"class":90}," messages:\n",[39,1328,1329,1332,1335,1337],{"class":41,"line":340},[39,1330,1331],{"class":137},"    print",[39,1333,1334],{"class":90},"(m[",[39,1336,1189],{"class":49},[39,1338,1339],{"class":90},"])\n",[10,1341,1342,1343,1346,1347,1350,1351,23],{},"All the logic — which numbers, calculated how — lives in ",[14,1344,1345],{},"context()",". It turns a spreadsheet row into a plain dictionary of exactly what the templates need, with names that make sense in the template rather than the column headings of the source. Keeping calculations out of the templates matters: a template that computes ",[14,1348,1349],{},"revenue \u002F target - 1"," itself is hard to test and easy to break when someone edits the wording. The subject is rendered from a template too and stripped, because a stray newline in a subject header makes the message invalid. The workbook attachment is added exactly as in any other report email, with ",[14,1352,1353],{},"msg.add_attachment(...)",[25,1355,1357],{"id":1356},"step-4-check-the-rendered-messages-before-sending","Step 4 — Check the rendered messages before sending",[30,1359,1361],{"className":77,"code":1360,"language":79,"meta":35,"style":35},"from pathlib import Path\n\nout = Path(\"preview\")\nout.mkdir(exist_ok=True)\nfor row, m in zip(results.itertuples(), messages):\n    (out \u002F f\"{row.Branch}.eml\").write_bytes(bytes(m))\n    html = m.get_body(preferencelist=(\"html\",)).get_content()\n    line = next(l for l in html.splitlines() if \"prepared for\" in l)\n    print(f\"{row.Branch:8}\", line.split(\"prepared for \")[1])\nprint(messages[1].get_body(preferencelist=(\"plain\",)).get_content())\n",[14,1362,1363,1373,1377,1391,1404,1419,1450,1472,1507,1542],{"__ignoreMap":35},[39,1364,1365,1367,1369,1371],{"class":41,"line":42},[39,1366,87],{"class":86},[39,1368,91],{"class":90},[39,1370,94],{"class":86},[39,1372,97],{"class":90},[39,1374,1375],{"class":41,"line":100},[39,1376,104],{"emptyLinePlaceholder":103},[39,1378,1379,1382,1384,1386,1389],{"class":41,"line":107},[39,1380,1381],{"class":90},"out ",[39,1383,113],{"class":86},[39,1385,116],{"class":90},[39,1387,1388],{"class":49},"\"preview\"",[39,1390,122],{"class":90},[39,1392,1393,1396,1398,1400,1402],{"class":41,"line":125},[39,1394,1395],{"class":90},"out.mkdir(",[39,1397,132],{"class":131},[39,1399,113],{"class":86},[39,1401,138],{"class":137},[39,1403,122],{"class":90},[39,1405,1406,1408,1411,1413,1416],{"class":41,"line":143},[39,1407,523],{"class":86},[39,1409,1410],{"class":90}," row, m ",[39,1412,529],{"class":86},[39,1414,1415],{"class":137}," zip",[39,1417,1418],{"class":90},"(results.itertuples(), messages):\n",[39,1420,1421,1424,1426,1429,1431,1433,1436,1438,1441,1444,1447],{"class":41,"line":206},[39,1422,1423],{"class":90},"    (out ",[39,1425,149],{"class":86},[39,1427,1428],{"class":86}," f",[39,1430,158],{"class":49},[39,1432,839],{"class":161},[39,1434,1435],{"class":90},"row.Branch",[39,1437,848],{"class":161},[39,1439,1440],{"class":49},".eml\"",[39,1442,1443],{"class":90},").write_bytes(",[39,1445,1446],{"class":137},"bytes",[39,1448,1449],{"class":90},"(m))\n",[39,1451,1452,1455,1457,1460,1463,1465,1467,1469],{"class":41,"line":221},[39,1453,1454],{"class":90},"    html ",[39,1456,113],{"class":86},[39,1458,1459],{"class":90}," m.get_body(",[39,1461,1462],{"class":131},"preferencelist",[39,1464,113],{"class":86},[39,1466,514],{"class":90},[39,1468,763],{"class":49},[39,1470,1471],{"class":90},",)).get_content()\n",[39,1473,1474,1477,1479,1482,1485,1487,1490,1492,1495,1498,1501,1504],{"class":41,"line":227},[39,1475,1476],{"class":90},"    line ",[39,1478,113],{"class":86},[39,1480,1481],{"class":137}," next",[39,1483,1484],{"class":90},"(l ",[39,1486,523],{"class":86},[39,1488,1489],{"class":90}," l ",[39,1491,529],{"class":86},[39,1493,1494],{"class":90}," html.splitlines() ",[39,1496,1497],{"class":86},"if",[39,1499,1500],{"class":49}," \"prepared for\"",[39,1502,1503],{"class":86}," in",[39,1505,1506],{"class":90}," l)\n",[39,1508,1509,1511,1513,1515,1517,1519,1521,1524,1526,1528,1531,1534,1537,1540],{"class":41,"line":233},[39,1510,1331],{"class":137},[39,1512,514],{"class":90},[39,1514,833],{"class":86},[39,1516,158],{"class":49},[39,1518,839],{"class":161},[39,1520,1435],{"class":90},[39,1522,1523],{"class":86},":8",[39,1525,848],{"class":161},[39,1527,158],{"class":49},[39,1529,1530],{"class":90},", line.split(",[39,1532,1533],{"class":49},"\"prepared for \"",[39,1535,1536],{"class":90},")[",[39,1538,1539],{"class":137},"1",[39,1541,1339],{"class":90},[39,1543,1544,1546,1549,1551,1554,1556,1558,1560,1563],{"class":41,"line":241},[39,1545,511],{"class":137},[39,1547,1548],{"class":90},"(messages[",[39,1550,1539],{"class":137},[39,1552,1553],{"class":90},"].get_body(",[39,1555,1462],{"class":131},[39,1557,113],{"class":86},[39,1559,514],{"class":90},[39,1561,1562],{"class":49},"\"plain\"",[39,1564,1565],{"class":90},",)).get_content())\n",[10,1567,1568,1569,1572,1573,1577,1578,1581],{},"Writing each message to an ",[14,1570,1571],{},".eml"," file gives a complete preview: double-clicking one opens it in the mail client exactly as the recipient will see it, with both parts and any attachments. Reviewing the previews on the first run of a new template, or after a wording change, catches the problems that tests miss — a paragraph that reads awkwardly, a figure in the wrong place, a table that looks wrong in Outlook. The printed lines show that the Cardiff contact name, ",[905,1574,907,1575],{},[909,1576],{},", reached the HTML part as ",[14,1579,1580],{},"Smith &amp; Sons &lt;Agency&gt;"," — displayed correctly by the mail client rather than swallowed as an unknown tag — while Tom O'Neill's apostrophe is encoded harmlessly; and the text body of the Bristol message shows the \"nothing overdue\" branch of the condition.",[545,1583,554,1589,554,1592,554,1595,554,1600,554,1606,554,1610,554,1616,554,1620,554,1624,554,1628,554,1631,554,1634,554,1638,554,1640,554,1643,554,1646,554,1650,554,1652,554,1656,554,1658,554,1661,554,1663,554,1667,554,1669,554,1672,554,1674,554,1677,554,1680,554,1683,554,1685,554,1688,554,1690],{"viewBox":1584,"role":548,"ariaLabelledBy":1585,"xmlns":552,"style":1588},"100 0 560 168",[1586,1587],"j2-preview-t","j2-preview-d","width:100%;max-width:560px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[556,1590,1591],{"id":1586},"The three rendered subjects",[560,1593,1594],{"id":1587},"Leeds is 4.2 percent over target with three overdue invoices, Bristol 5.9 percent under target with none overdue, and Cardiff 1.3 percent under target with one overdue invoice. Each line is rendered from the same subject template.",[564,1596],{"x":1597,"y":567,"width":1598,"height":1599,"fill":570},"100","560","168",[564,1601],{"x":1602,"y":574,"width":1603,"height":1604,"rx":567,"fill":578,"stroke":579,"style":1605},"116.2","32.0","24.0","stroke-width:1px",[564,1607],{"x":1608,"y":574,"width":1609,"height":1604,"rx":567,"fill":578,"stroke":579,"style":1605},"148.2","67.9",[582,1611,1615],{"x":1612,"y":1613,"style":1614},"182.1","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[564,1617],{"x":1618,"y":574,"width":1619,"height":1604,"rx":567,"fill":578,"stroke":579,"style":1605},"216.1","427.7",[582,1621,1623],{"x":1622,"y":1613,"style":1614},"429.9","B",[564,1625],{"x":1602,"y":1626,"width":1603,"height":1627,"rx":567,"fill":578,"stroke":579,"style":1605},"40.0","28.0",[582,1629,1539],{"x":1630,"y":585,"style":1614},"132.2",[564,1632],{"x":1608,"y":1626,"width":1609,"height":1627,"rx":567,"fill":607,"stroke":598,"style":1633},"stroke-width:1.5px",[582,1635,1637],{"x":1612,"y":585,"style":1636},"font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Branch",[564,1639],{"x":1618,"y":1626,"width":1619,"height":1627,"rx":567,"fill":607,"stroke":598,"style":1633},[582,1641,1642],{"x":1622,"y":585,"style":1636},"Subject line",[564,1644],{"x":1602,"y":1645,"width":1603,"height":1627,"rx":567,"fill":578,"stroke":579,"style":1605},"68.0",[582,1647,1649],{"x":1630,"y":1648,"style":1614},"87.0","2",[564,1651],{"x":1608,"y":1645,"width":1609,"height":1627,"rx":567,"fill":570,"stroke":579,"style":1605},[582,1653,1655],{"x":1612,"y":1648,"style":1654},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Leeds",[564,1657],{"x":1618,"y":1645,"width":1619,"height":1627,"rx":567,"fill":570,"stroke":579,"style":1605},[582,1659,1660],{"x":1622,"y":1648,"style":1654},"Leeds: September 2026 results — £182,340.50 (+4.2% vs target)",[564,1662],{"x":1602,"y":648,"width":1603,"height":1627,"rx":567,"fill":578,"stroke":579,"style":1605},[582,1664,1666],{"x":1630,"y":1665,"style":1614},"115.0","3",[564,1668],{"x":1608,"y":648,"width":1609,"height":1627,"rx":567,"fill":570,"stroke":579,"style":1605},[582,1670,1671],{"x":1612,"y":1665,"style":1654},"Bristol",[564,1673],{"x":1618,"y":648,"width":1619,"height":1627,"rx":567,"fill":570,"stroke":579,"style":1605},[582,1675,1676],{"x":1622,"y":1665,"style":1654},"Bristol: September 2026 results — £141,200.00 (-5.9% vs target)",[564,1678],{"x":1602,"y":1679,"width":1603,"height":1627,"rx":567,"fill":578,"stroke":579,"style":1605},"124.0",[582,1681,566],{"x":1630,"y":1682,"style":1614},"143.0",[564,1684],{"x":1608,"y":1679,"width":1609,"height":1627,"rx":567,"fill":570,"stroke":579,"style":1605},[582,1686,1687],{"x":1612,"y":1682,"style":1654},"Cardiff",[564,1689],{"x":1618,"y":1679,"width":1619,"height":1627,"rx":567,"fill":570,"stroke":579,"style":1605},[582,1691,1692],{"x":1622,"y":1682,"style":1654},"Cardiff: September 2026 results — £98,650.25 (-1.3% vs target)",[25,1694,1696],{"id":1695},"step-5-catch-a-missing-field-before-the-run-sends-anything","Step 5 — Catch a missing field before the run sends anything",[30,1698,1700],{"className":77,"code":1699,"language":79,"meta":35,"style":35},"from jinja2 import UndefinedError\n\ntry:\n    env.get_template(\"results.html\").render({k: v for k, v in context(results.iloc[0]).items() if k != \"first_name\"})\nexcept UndefinedError as e:\n    print(\"render failed:\", e)\n",[14,1701,1702,1713,1717,1725,1764,1777],{"__ignoreMap":35},[39,1703,1704,1706,1708,1710],{"class":41,"line":42},[39,1705,87],{"class":86},[39,1707,709],{"class":90},[39,1709,94],{"class":86},[39,1711,1712],{"class":90}," UndefinedError\n",[39,1714,1715],{"class":41,"line":100},[39,1716,104],{"emptyLinePlaceholder":103},[39,1718,1719,1722],{"class":41,"line":107},[39,1720,1721],{"class":86},"try",[39,1723,1724],{"class":90},":\n",[39,1726,1727,1730,1732,1735,1737,1740,1742,1745,1747,1750,1752,1755,1758,1761],{"class":41,"line":125},[39,1728,1729],{"class":90},"    env.get_template(",[39,1731,1271],{"class":49},[39,1733,1734],{"class":90},").render({k: v ",[39,1736,523],{"class":86},[39,1738,1739],{"class":90}," k, v ",[39,1741,529],{"class":86},[39,1743,1744],{"class":90}," context(results.iloc[",[39,1746,567],{"class":137},[39,1748,1749],{"class":90},"]).items() ",[39,1751,1497],{"class":86},[39,1753,1754],{"class":90}," k ",[39,1756,1757],{"class":86},"!=",[39,1759,1760],{"class":49}," \"first_name\"",[39,1762,1763],{"class":90},"})\n",[39,1765,1766,1769,1772,1774],{"class":41,"line":143},[39,1767,1768],{"class":86},"except",[39,1770,1771],{"class":90}," UndefinedError ",[39,1773,972],{"class":86},[39,1775,1776],{"class":90}," e:\n",[39,1778,1779,1781,1783,1786],{"class":41,"line":206},[39,1780,1331],{"class":137},[39,1782,514],{"class":90},[39,1784,1785],{"class":49},"\"render failed:\"",[39,1787,1788],{"class":90},", e)\n",[10,1790,1791,1792,1794,1795,1798,1799,1802],{},"With ",[14,1793,918],{},", a template that refers to a value the context does not provide fails with a clear message naming it. Because every message is rendered before any is sent — the ",[14,1796,1797],{},"messages"," list in step 3 is complete before the first ",[14,1800,1801],{},"send_message"," — a mistake in one recipient's data or a renamed placeholder in a template stops the whole run, instead of sending half the emails correctly and the rest with gaps. Pair this with a log of what was sent so a rerun after a fix does not send duplicates.",[25,1804,1806],{"id":1805},"who-edits-what","Who edits what",[10,1808,1809,1810,539,1812,1814,1815,1817],{},"The point of templates is to divide the work cleanly. The person who owns the message — often in finance or operations rather than development — edits the ",[14,1811,914],{},[14,1813,892],{}," files: the greeting, the wording, which figures appear and in what order. The developer owns ",[14,1816,1345],{}," and the filters, which decide what values exist and how they are calculated and formatted. A useful convention is a short comment at the top of each template listing the variables available to it, so editors know what they can use without reading the Python. Keep the templates under version control alongside the script, so a change of wording is reviewed and can be undone like any other change.",[545,1819,554,1824,554,1827,554,1830,554,1833,554,1838,554,1844,554,1849,554,1853,554,1857,554,1861,554,1865,554,1868,554,1873,554,1877,554,1880,554,1883,554,1886],{"viewBox":1820,"role":548,"ariaLabelledBy":1821,"xmlns":552,"style":553},"4 0 752 199",[1822,1823],"j2-roles-t","j2-roles-d",[556,1825,1826],{"id":1822},"Template or code",[560,1828,1829],{"id":1823},"Wording, layout, which figures appear and conditional paragraphs belong in the templates. Calculations, data access, number formatting rules and who receives which message belong in the Python code.",[564,1831],{"x":566,"y":567,"width":568,"height":1832,"fill":570},"199",[564,1834],{"x":573,"y":574,"width":1835,"height":1836,"rx":1837,"fill":632,"stroke":633,"style":580},"351.0","167.0","14",[582,1839,1843],{"x":1840,"y":1841,"style":1842},"195.5","44.0","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","In the templates",[41,1845],{"x1":1846,"y1":1847,"x2":1848,"y2":1847,"stroke":633,"style":1605},"36.0","57.0","355.0",[582,1850,1852],{"x":1840,"y":1851,"style":1654},"77.0","greeting and wording",[582,1854,1856],{"x":1840,"y":1855,"style":1654},"103.0","order of the figures",[582,1858,1860],{"x":1840,"y":1859,"style":1654},"129.0","conditional paragraphs",[582,1862,1864],{"x":1840,"y":1863,"style":1654},"155.0","styling of the HTML",[564,1866],{"x":1867,"y":574,"width":1835,"height":1836,"rx":1837,"fill":607,"stroke":598,"style":580},"389.0",[582,1869,1872],{"x":1870,"y":1841,"style":1871},"564.5","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","In the Python code",[41,1874],{"x1":1875,"y1":1847,"x2":1876,"y2":1847,"stroke":598,"style":1605},"405.0","724.0",[582,1878,1879],{"x":1870,"y":1851,"style":1654},"reading the results",[582,1881,1882],{"x":1870,"y":1855,"style":1654},"calculations such as vs target",[582,1884,1885],{"x":1870,"y":1859,"style":1654},"money and percent filters",[582,1887,1888],{"x":1870,"y":1863,"style":1654},"recipients and sending",[25,1890,1892],{"id":1891},"common-pitfalls","Common pitfalls",[1894,1895,1896,1911,1919,1925,1931],"ul",{},[1897,1898,1899,1902,1903,1905,1906,1908,1909,23],"li",{},[886,1900,1901],{},"HTML templates without autoescape."," Data with ",[14,1904,902],{}," or ",[14,1907,896],{}," breaks the layout; enable it for ",[14,1910,892],{},[1897,1912,1913,1916,1917,23],{},[886,1914,1915],{},"Silent blanks."," The default undefined renders as nothing; use ",[14,1918,918],{},[1897,1920,1921,1924],{},[886,1922,1923],{},"Calculations in templates."," Prepare values in Python and pass them in the context.",[1897,1926,1927,1930],{},[886,1928,1929],{},"HTML-only emails."," Add a plain-text part rendered from its own template.",[1897,1932,1933,1936],{},[886,1934,1935],{},"Newlines in the subject."," Strip the rendered subject before setting the header.",[25,1938,1940],{"id":1939},"conclusion","Conclusion",[10,1942,1943,1944,1946,1947,1949],{},"Jinja2 separates what a report email says from how its data is prepared. Put the subject, text and HTML bodies in template files with a shared layout, create the environment with autoescape for HTML, ",[14,1945,918],{}," and filters for money and percentages, build one context dictionary per recipient, render every message before sending any, and preview them as ",[14,1948,1571],{}," files the first time a template changes.",[25,1951,1953],{"id":1952},"frequently-asked-questions","Frequently asked questions",[10,1955,1956,1959,1960,1963,1964,1967,1968,23],{},[886,1957,1958],{},"How do I use Jinja2 for email templates in Python?","\nCreate an ",[14,1961,1962],{},"Environment"," with a ",[14,1965,1966],{},"FileSystemLoader",", render the subject, text and HTML templates with a context dictionary, and put the results into an ",[14,1969,666],{},[10,1971,1972,1975,1976,1979,1980,1982,1983,1986],{},[886,1973,1974],{},"How do I make Jinja2 fail when a variable is missing?","\nPass ",[14,1977,1978],{},"undefined=StrictUndefined"," to the ",[14,1981,1962],{},"; rendering then raises ",[14,1984,1985],{},"UndefinedError"," instead of inserting an empty string.",[10,1988,1989,1992,1993,539,1995,1997],{},[886,1990,1991],{},"Should autoescape be on for email templates?","\nYes for HTML templates, so that characters like ",[14,1994,902],{},[14,1996,896],{}," in the data are displayed safely; leave it off for plain-text templates.",[10,1999,2000,2003,2004,2007,2008,23],{},[886,2001,2002],{},"How do I format currency in a Jinja2 template?","\nRegister a filter in Python, such as ",[14,2005,2006],{},"env.filters[\"money\"]",", and use it in the template as ",[14,2009,2010],{},"{{ revenue | money }}",[25,2012,2014],{"id":2013},"related","Related",[1894,2016,2017,2023,2030,2037],{},[1897,2018,2019,2020,2022],{},"Up one level: ",[19,2021,22],{"href":21}," — sending reports by email.",[1897,2024,2025,2029],{},[19,2026,2028],{"href":2027},"\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"," — HTML email styling.",[1897,2031,2032,2036],{},[19,2033,2035],{"href":2034},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2\u002F","Generate an HTML Report from Excel Data with Jinja2"," — Jinja2 for web reports.",[1897,2038,2039,2043],{},[19,2040,2042],{"href":2041},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Ftest-report-emails-with-a-local-smtp-server\u002F","Test Report Emails with a Local SMTP Server"," — test before sending.",[2045,2046,2047],"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 .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}",{"title":35,"searchDepth":100,"depth":100,"links":2049},[2050,2051,2052,2053,2054,2055,2056,2057,2058,2059,2060],{"id":27,"depth":100,"text":28},{"id":73,"depth":100,"text":74},{"id":696,"depth":100,"text":697},{"id":932,"depth":100,"text":933},{"id":1356,"depth":100,"text":1357},{"id":1695,"depth":100,"text":1696},{"id":1805,"depth":100,"text":1806},{"id":1891,"depth":100,"text":1892},{"id":1939,"depth":100,"text":1940},{"id":1952,"depth":100,"text":1953},{"id":2013,"depth":100,"text":2014},"2026-10-02","Write the subject, plain-text and HTML body of report emails as Jinja2 templates instead of f-strings: one context per recipient, autoescaping for names and comments that contain HTML characters, StrictUndefined so a missing field stops the run, filters for money and percentages, conditional paragraphs, a shared layout, and previews written to disk before anything is sent.","md",[2065,2067,2069,2071],{"q":1958,"a":2066},"Create an Environment with a FileSystemLoader, render the subject, text and HTML templates with a context dictionary, and put the results into an EmailMessage.",{"q":1974,"a":2068},"Pass undefined=StrictUndefined to the Environment; rendering then raises UndefinedError instead of inserting an empty string.",{"q":1991,"a":2070},"Yes for HTML templates, so that characters like & and \u003C in the data are displayed safely; leave it off for plain-text templates.",{"q":2002,"a":2072},"Register a filter in Python, such as env.filters[\"money\"], and use it in the template as {{ revenue | money }}.","jinja2 email template python report",null,{"breadcrumb":2076},[2077,2079,2082],{"name":2078,"item":149},"Home",{"name":2080,"item":2081},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates",{"title":2085,"description":2086},"Personalise Report Emails with Jinja2 Templates in Python","Personalised report emails with Jinja2: subject, text and HTML templates per recipient, autoescape, StrictUndefined, money and percent filters, conditional sections, a base layout and .eml previews before sending.","personalise-report-emails-with-jinja2-templates","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fpersonalise-report-emails-with-jinja2-templates\u002Findex",[2090],"email-delivery","how-to","da7ehrO0WalkW98Is66UNGLDTyy6RTvYAjQVpEmS3n4",[2094,2098],{"title":2095,"path":2096,"stem":2097,"children":-1},"Embed a Chart Image in the Body of a Report Email","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fembed-a-chart-image-in-the-body-of-a-report-email","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fembed-a-chart-image-in-the-body-of-a-report-email\u002Findex",{"title":2099,"path":2100,"stem":2101,"children":-1},"Queue and Retry Failed Report Emails","\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails","automating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails\u002Findex",[2103,2105],{"path":2104,"title":22},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib",{"path":2106,"title":2080},"\u002Fautomating-reporting-workflows",[],[2109,2112,2115,2118,2121,2123],{"path":2110,"title":2111,"kind":2091},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-credit-control-dunning-letter-run","Build a Credit Control Dunning Letter Run",{"path":2113,"title":2114,"kind":2091},"\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":2116,"title":2117,"kind":2091},"\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":2119,"title":2120,"kind":2091},"\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":2122,"title":2028,"kind":2091},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-an-excel-report-with-an-html-summary-body",{"path":2096,"title":2095,"kind":2091},1790972129701]