Send an Excel Report with Amazon SES
When a reporting job already runs on AWS — in Lambda, ECS or on an EC2 instance — Amazon SES is the natural way to send its output. There is no mail server to run, the credentials come from the job's IAM role rather than a password in a config file, and deliverability features such as DKIM signing are handled by the service. SES's simple API sends text and HTML bodies only; attachments go through the raw message format, where you build the complete MIME email yourself and SES delivers it as is. Python's standard email package builds that message, and boto3 sends it. This page, part of Emailing Excel Reports with smtplib, covers both, plus the errors SES actually returns and a way to test everything locally.
Prerequisites
pip install boto3 pandas openpyxl
pip install "moto[ses]" # only for the local tests
In AWS you need a verified sender identity in SES — a domain, or a single email address — and permission to send through SES for the credentials the script runs with. New SES accounts start in the sandbox, where mail can only go to verified addresses and the daily quota is small; production access is requested per region. The examples run against moto, a library that imitates AWS services in memory, so they work without an account.
Step 1 — Build the message with the workbook attached
EmailMessage from the standard library produces a correct multipart message: a plain-text body, an HTML alternative, and the attachment with the right content type:
from email.message import EmailMessage
from pathlib import Path
import pandas as pd
XLSX_TYPE = ("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet")
def build_message(sender, recipients, subject, report_path):
df = pd.read_excel(report_path)
below = df[df["Sales"] < df["Target"]]["Region"].tolist()
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = ", ".join(recipients)
msg.set_content(f"Weekly sales attached. Below target: {', '.join(below) or 'none'}.")
msg.add_alternative(
f"<p>Weekly sales attached.</p><p><b>Below target:</b> {', '.join(below) or 'none'}</p>"
+ df.to_html(index=False, border=0), subtype="html")
msg.add_attachment(Path(report_path).read_bytes(), maintype=XLSX_TYPE[0], subtype=XLSX_TYPE[1],
filename=Path(report_path).name)
return msg
msg = build_message("reports@example.com", ["sales-team@example.org"], "Weekly sales — week 39", "weekly_sales.xlsx")
print(msg.get_content_type(), [part.get_content_type() for part in msg.iter_parts()])
print(f"{len(msg.as_bytes()):,} bytes")
The message is multipart/mixed: an alternative part holding the text and HTML versions, followed by the workbook. Mail programs show the HTML version with its table and offer the .xlsx as an attachment; the plain-text part is what shows in clients and notification previews that do not render HTML. Email an Excel Report with an HTML Summary Body covers the HTML in depth.
Step 2 — Send it with the SES v2 client
The SES v2 send_email call takes the raw bytes of the message in Content={"Raw": {"Data": ...}}. Here it runs inside mock_aws(), which is the only line that differs from production:
import os
import boto3
from moto import mock_aws
os.environ.update(AWS_ACCESS_KEY_ID="testing", AWS_SECRET_ACCESS_KEY="testing", AWS_DEFAULT_REGION="eu-west-1")
def send_report(ses, msg):
response = ses.send_email(
FromEmailAddress=msg["From"],
Destination={"ToAddresses": [a.strip() for a in msg["To"].split(",")]},
Content={"Raw": {"Data": msg.as_bytes()}},
)
return response["MessageId"]
with mock_aws():
boto3.client("ses").verify_email_identity(EmailAddress="reports@example.com")
ses = boto3.client("sesv2")
print("sent:", send_report(ses, msg))
In production there is no mock_aws and no fake credentials: boto3 finds real credentials from the environment, a profile or — on AWS — the job's IAM role, and the region should be the one where your sender identity is verified. Destination is what SES uses for delivery; the To header inside the message is only what recipients see. Keeping both from the same list, as send_report does, avoids surprises, and addresses meant to be hidden go in BccAddresses in Destination only.
Step 3 — Handle the errors SES actually returns
The two errors a reporting job meets are MessageRejected — the sender is not verified, or in the sandbox the recipient is not — and throttling when the sending rate is exceeded. Rejections are configuration problems that retrying cannot fix; throttling is transient and clears on its own within seconds:
from botocore.config import Config
from botocore.exceptions import ClientError
def send_or_explain(ses, msg):
try:
return send_report(ses, msg)
except ClientError as err:
code = err.response["Error"]["Code"]
if code == "MessageRejected":
raise RuntimeError(f"SES rejected the message: {err.response['Error']['Message']}") from err
raise
with mock_aws():
boto3.client("ses").verify_email_identity(EmailAddress="reports@example.com")
ses = boto3.client("sesv2", config=Config(retries={"mode": "standard", "max_attempts": 5}))
bad = build_message("nobody@unverified.example", ["sales-team@example.org"], "Test", "weekly_sales.xlsx")
try:
send_or_explain(ses, bad)
except RuntimeError as err:
print(err)
print(boto3.client("ses").get_send_quota()["MaxSendRate"], "messages per second allowed")
The standard retry mode makes boto3 retry throttling errors with exponential backoff on its own, so the script does not need its own retry loop for them. A rejection is turned into a clear message and allowed to fail the job — the right outcome, since the report never left. Send an Alert When an Excel Report Job Fails covers what should happen next.
Step 4 — Stay under the size limit
SES limits the size of a message, including attachments after base64 encoding, which inflates binary data by about a third. Check the encoded size before sending, and fall back to a link when the workbook is too big:
MAX_BYTES = 10 * 1024 * 1024 # stay well under the service limit and recipients' mailbox limits
size = len(msg.as_bytes())
print(f"message is {size / 1024:.1f} KB of {MAX_BYTES / 1024 / 1024:.0f} MB budget")
if size > MAX_BYTES:
print("too large: upload the workbook and send a link instead")
A budget of 10 MB is deliberately below SES's own limit, because many recipients' mail servers reject large messages long before SES would. For bigger reports, compress them first — Zip Large Excel Attachments Before Emailing — or upload them to S3 and send a presigned link, as in Upload an Excel Report to Amazon S3 with boto3.
Step 5 — Test the message end to end with moto
moto records every message SES "sends", so a test can open the raw bytes, parse them back into an email, and check the recipients and the attachment — the parts that break silently:
import io
from email import message_from_bytes, policy
from moto.core import DEFAULT_ACCOUNT_ID
from moto.ses.models import ses_backends
with mock_aws():
boto3.client("ses").verify_email_identity(EmailAddress="reports@example.com")
send_report(boto3.client("sesv2"), msg)
sent = ses_backends[DEFAULT_ACCOUNT_ID]["eu-west-1"].sent_messages
raw = sent[0].raw_data
parsed = message_from_bytes(raw.encode() if isinstance(raw, str) else raw, policy=policy.default)
attachment = next(parsed.iter_attachments())
body = pd.read_excel(io.BytesIO(attachment.get_content()))
print(len(sent), "sent to", sorted(set(sent[0].destinations)))
print(attachment.get_filename(), attachment.get_content_type())
print(body.shape, "regions:", body["Region"].tolist())
moto records the recipient once from Destination and once from the message's To header, which is why the test compares a set of addresses rather than the raw list. The attachment itself comes back as the same workbook — pandas reads the four regions from it — with the right file name and content type. A test like this in the project's test suite catches the classic mistakes: attaching the previous run's file, a wrong MIME type that makes Outlook show the report as ATT00001.bin, or a recipient list that lost an address. Test Report Emails with a Local SMTP Server does the same for SMTP.
Common pitfalls
- Using the simple API for attachments. Attachments need the raw message format; build it with
EmailMessage. - Sending from an unverified identity. SES rejects it; verify the domain (preferably) or the address in the same region.
- Forgetting the sandbox. New accounts can only send to verified recipients until production access is granted.
- A generic content type.
application/octet-streamworks but some clients show a meaningless name; use the.xlsxMIME type. - Retrying rejections. Only throttling is worth retrying; rejections need a configuration fix.
Conclusion
Build the email with EmailMessage — text, an HTML alternative and the workbook attached with its proper MIME type — and send its bytes with the SES v2 send_email call using raw content. Verify the sending domain, leave the sandbox before emailing real recipients, let boto3's standard retry mode absorb throttling, check the encoded size against a conservative budget, and test the whole flow with moto by parsing the sent message and reading the attachment back.
Frequently asked questions
How do I send an email with an attachment using Amazon SES in Python? Build a MIME message with email.message.EmailMessage, add the file with add_attachment, and pass msg.as_bytes() to the SES v2 client's send_email in Content={"Raw": {"Data": ...}}. SES delivers the message as built.
Why does SES reject my message? The most common causes are a sender address or domain that is not verified in the region you are sending from, and — for accounts still in the sandbox — a recipient that is not verified. The error message says which.
How large can an SES email attachment be? SES limits the total message size, including attachments after base64 encoding, which adds about a third to binary files. Many recipients' servers accept less, so keep report emails small and send a link for large workbooks.
How can I test SES emails without sending real mail? Use moto's mock_aws: it imitates SES in memory and records sent messages, whose raw data you can parse back into an email to check recipients and attachments.
Related
- Up one level: Emailing Excel Reports with smtplib — every way to deliver reports by email.
- Send an Excel Report with the Microsoft Graph API — the Microsoft 365 equivalent.
- Run an Excel Report in AWS Lambda — where SES sending usually runs.
- Store Credentials for Excel Report Scripts Safely — why IAM roles beat stored keys.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toDownload Excel Attachments from an Email Inbox with Python
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toEmail an Excel Report with an HTML Summary Body
- How-toEmbed a Chart Image in the Body of a Report Email