Send an Excel Report Through Outlook from Python
Sometimes the requirement is not "send an email" but "send it from my account, with my signature, looking like I sent it" — a weekly report to clients, an approval request, anything where the recipient should reply to a person. On Windows with Outlook installed, that is a handful of lines through COM. This guide, part of Emailing Excel Reports with smtplib, covers composing, attaching and sending — plus the review-before-send pattern that makes it safe.
Prerequisites
pip install pywin32
Windows, Outlook installed with a configured mail profile, and an interactive session. The same COM mechanics used here are covered generally in Automating Excel with COM and pywin32.
Send a report in nine lines
import win32com.client as win32
outlook = win32.Dispatch("Outlook.Application")
mail = outlook.CreateItem(0) # 0 = olMailItem
mail.To = "finance@example.com"
mail.CC = "ops@example.com"
mail.Subject = "Regional revenue — August 2026"
mail.HTMLBody = "<p>August figures attached. Totals unchanged from the draft.</p>"
mail.Attachments.Add(r"C:\reports\regional-2026-08.xlsx")
mail.Send()
CreateItem(0) creates a mail item; the other values create appointments, contacts and tasks.
Attachments.Add takes an absolute path — a relative one resolves against Outlook's working
directory rather than the script's, which is a surprising and reproducible way to attach nothing.
Display instead of sending
The variant worth adopting as a default during development is Display(), which opens the composed
message in Outlook and leaves the human to press send. It is also the right permanent behaviour for
anything going to clients.
mail.Display(True) # modal: blocks until the window is closed
mail.Display(False) # opens the window and returns immediately
Building the whole message and then handing it over is a considerably safer pattern than sending
directly, and it costs one word. A configuration flag that switches between Display and Send
means the same script serves the review workflow and the automated one.
Keeping the signature
Setting HTMLBody replaces everything, signature included. The trick is to display the message
first — which populates the signature — and then insert content ahead of it.
mail = outlook.CreateItem(0)
mail.To = "finance@example.com"
mail.Subject = "Regional revenue — August 2026"
mail.Display(False) # Outlook inserts the signature into HTMLBody
signature = mail.HTMLBody
mail.HTMLBody = "<p>August figures attached.</p>" + signature
Concatenating rather than replacing preserves the signature's own HTML structure, which matters because Outlook signatures frequently contain images referenced as embedded content that a naive rebuild would break.
Sending as a shared mailbox
Reports often come from a departmental address rather than a person's. Two properties handle it, and they mean different things.
mail.SentOnBehalfOfName = "reports@example.com" # needs Send On Behalf rights
# or, to send as an account configured in this profile:
for account in outlook.Session.Accounts:
if account.SmtpAddress.lower() == "reports@example.com":
mail.SendUsingAccount = account
break
SentOnBehalfOfName produces "Ana on behalf of Reports" in most clients unless full Send As rights
are granted. SendUsingAccount requires the mailbox to be a configured account in the profile and
sends as it directly. Which one is available is an Exchange configuration question, not a Python one.
Attaching several files and embedding an image
import pathlib
for path in sorted(pathlib.Path(r"C:\reports\august").glob("*.xlsx")):
mail.Attachments.Add(str(path.resolve()))
chart = mail.Attachments.Add(r"C:\reports\revenue.png")
chart.PropertyAccessor.SetProperty(
"http://schemas.microsoft.com/mapi/proptag/0x3712001F", "revenuechart"
)
mail.HTMLBody = '<p>August figures attached.</p><img src="cid:revenuechart">' + signature
The property tag sets the attachment's content id, which is what lets an <img src="cid:..."> in the
body resolve to it. That is the same mechanism SMTP uses for inline images, expressed through MAPI —
and it is the only reliable way to put a chart in the body rather than beside it, since remote images
are blocked by default in most clients.
Driving Outlook and Excel together
A common shape for a desktop reporting script is to refresh a workbook, export a range as an image,
and email the result — three applications, one COM session each. They coexist without difficulty
provided each is quit in its own finally.
import win32com.client as win32
def refresh_and_send(workbook_path: str, to: str) -> None:
excel = win32.DispatchEx("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
try:
book = excel.Workbooks.Open(workbook_path)
try:
book.RefreshAll()
excel.CalculateUntilAsyncQueriesDone()
book.Save()
book.Sheets("Summary").ChartObjects(1).Chart.Export(r"C:\reports\chart.png")
finally:
book.Close(SaveChanges=False)
finally:
excel.Quit()
outlook = win32.Dispatch("Outlook.Application")
mail = outlook.CreateItem(0)
mail.To = to
mail.Subject = "Refreshed report"
mail.Attachments.Add(workbook_path)
mail.Display(False)
Note the asymmetry: Excel is started with DispatchEx so quitting it cannot disturb a workbook the
user has open, while Outlook uses Dispatch deliberately — the point is to use the person's own
running Outlook and their profile. Quitting Outlook is not appropriate at all, and the script simply
leaves it as it found it.
The lifecycle rules for the Excel half are the ones in Close Excel Cleanly and Avoid Orphan COM Processes; Outlook does not accumulate orphans the same way because it is a single-instance application.
Building the recipient list from the data
Where the report is per-region or per-client, the recipients belong beside the data rather than in the script. Reading them from a sheet in the same workbook keeps the distribution list editable by the people who own it.
import pandas as pd
recipients = pd.read_excel("report.xlsx", sheet_name="Distribution")
for row in recipients.itertuples(index=False):
mail = outlook.CreateItem(0)
mail.To = row.Email
mail.Subject = f"{row.Region} revenue — August 2026"
mail.HTMLBody = f"<p>Hello {row.Name}, your regional figures are attached.</p>"
mail.Attachments.Add(rf"C:\reports\august\{row.Region}.xlsx")
mail.Display(False)
Displaying rather than sending is doubly worth it here: a mistake in the distribution sheet becomes twelve draft windows a person can close, rather than twelve messages already gone. The per-region file generation that feeds it is covered in Generate One Excel Report per Region in a Loop.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Nothing is attached | A relative path resolved against Outlook's directory | Pass an absolute path |
| The signature disappears | HTMLBody was assigned rather than appended | Display(False) first, then concatenate |
| A security prompt appears on send | Outlook's programmatic-access guard | Run under a trusted setup; there is no code-side override |
SentOnBehalfOfName is ignored | The account lacks Send On Behalf rights | Grant the right in Exchange, or use SendUsingAccount |
| The script hangs | Display(True) is modal and waits for the window | Use Display(False) in unattended contexts |
| Works interactively, fails scheduled | No desktop session or profile | Use Graph or SMTP for scheduled sends |
When not to use this
Outlook automation is the right tool for a script a person runs from their own machine and the wrong one for anything on a schedule. It needs an interactive session, a configured profile and a licensed installation; it triggers security prompts under conditions that are hard to predict; and it fails in ways that are invisible to a scheduler because the failure is a dialog.
For a nightly report, use SMTP or the Graph API — the latter covered in Send an Excel Report with the Microsoft Graph API. Keep Outlook for the case it is genuinely best at: a person pressing a button and reviewing what goes out under their own name.
Performance and scale
Outlook sends through the profile's outbox, which means a script that creates fifty messages hands them all to Outlook and returns before any of them have left. That is fast from the script's point of view and slow from the recipient's, and it makes error handling awkward — a message rejected by the server fails after your script has finished.
For batches, the workable pattern is to create the messages, then poll the outbox until it drains:
import time
outbox = outlook.Session.GetDefaultFolder(4) # 4 = olFolderOutbox
deadline = time.monotonic() + 300
while outbox.Items.Count and time.monotonic() < deadline:
time.sleep(2)
if outbox.Items.Count:
raise TimeoutError(f"{outbox.Items.Count} message(s) still queued")
Beyond a few dozen messages, the throttling and delivery-tracking limitations make a real mail API the better choice regardless of platform.
Conclusion
CreateItem(0), set To, Subject and HTMLBody, add attachments by absolute path, and either
Send() or Display() — the latter being the safer default for anything a person should see before
it goes. Preserve the signature by displaying first and concatenating, use SendUsingAccount for a
shared mailbox, and keep the whole approach for interactive machines: scheduled sending belongs to
SMTP or Graph.
Frequently asked questions
Does this need Outlook running? Not running, but installed and configured with a profile. Dispatching Outlook.Application starts it if it is closed, and the message is sent from whichever account the profile has as its default unless you set SendUsingAccount.
Can I send from a shared mailbox? Yes — set SentOnBehalfOfName to the shared mailbox address, provided the signed-in account has Send As or Send On Behalf rights. Without those rights the send fails after the message is composed, which is a confusing place to find out.
Will this work on a server? No, in the sense that matters: Outlook automation needs an interactive desktop session with a configured profile, and Microsoft does not support it from a service. For scheduled sending use the Graph API or SMTP.
How do I include the user's signature? Call Display() on the message first, which populates the signature into HTMLBody, then insert your content ahead of the existing HTML rather than replacing it. Setting HTMLBody outright discards the signature.
Related
- Up one level: Emailing Excel Reports with smtplib — the cross-platform route and message construction.
- Send an Excel Report with the Microsoft Graph API — the scheduled-job alternative with no desktop dependency.
- Attach Multiple Excel Files to One Email in Python — batching attachments and keeping the message under limits.
- Automating Excel with COM and pywin32 — the COM mechanics this uses.
- Handle COM Errors and Excel Dialog Prompts in Python — the prompts that stall an unattended send.