Guide
Automating Reporting WorkflowsDeep dive

Send an Excel Report Through Outlook from Python

Compose and send from a real Outlook profile with win32com: attachments by absolute path, the signature preserved, shared-mailbox senders, and Display for review before sending.

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.

Which sending route the requirement calls for A message that must come from a person's own account with their signature suits Outlook automation, a scheduled job suits the Graph API, and a server with credentials suits SMTP. Who is the message from? Outlook COM their account, their signature a person Graph API no password, scheduled a service SMTP where basic auth remains a server the sender decides the route, not the platform

Prerequisites

Bash
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

Python
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.

Python
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

Keeping the signature Outlook inserts Displaying the message first makes Outlook populate the body with the account's signature, which can then be concatenated behind your own content rather than overwritten. body composition Display(False) signature is inserted read HTMLBody capture the signature prepend content your text, then it assigning HTMLBody outright discards the signature and its images

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.

Python
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.

Python
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

Python
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.

Python
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.

Python
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

SymptomCauseFix
Nothing is attachedA relative path resolved against Outlook's directoryPass an absolute path
The signature disappearsHTMLBody was assigned rather than appendedDisplay(False) first, then concatenate
A security prompt appears on sendOutlook's programmatic-access guardRun under a trusted setup; there is no code-side override
SentOnBehalfOfName is ignoredThe account lacks Send On Behalf rightsGrant the right in Exchange, or use SendUsingAccount
The script hangsDisplay(True) is modal and waits for the windowUse Display(False) in unattended contexts
Works interactively, fails scheduledNo desktop session or profileUse 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

Handing messages to the outbox is not delivery Outlook accepts the messages and returns immediately, so a script finishes before anything is sent and a server rejection surfaces after the run has ended. what the script sees 50 items created returns in seconds no errors what actually happened queued in the outbox sending continues after rejections arrive later poll the outbox wait for the queue to drain before reporting success

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:

Python
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.