How-to
Automating Reporting WorkflowsDeep dive

Send an Excel Report with Amazon SES

Email a generated workbook through Amazon Simple Email Service from Python: build the MIME message with an HTML summary and the .xlsx attached, send it with boto3's SES v2 client, handle unverified senders, the sandbox and throttling, stay under the size limit, and test the whole flow locally with moto — no AWS account or real email needed.

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

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

What happens when the job sends a report A sequence diagram. The report job builds a MIME message with the workbook attached and calls SES SendEmail with raw content; SES checks the sender identity and sending quota, signs and delivers the message, and returns a message ID. report job Amazon SES recipient 1. SendEmail(Raw = MIME bytes) 2. MessageId (or MessageRejected) 3. signed email + .xlsx attachment

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:

Python
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 structure of the message SES receives Nested layers of the MIME message. The outer multipart/mixed part contains a multipart/alternative part with the plain-text and HTML bodies, followed by the xlsx attachment with its spreadsheet content type and file name. multipart/mixed the whole message multipart/alternative text/plain summary + text/html summary with table application/vnd.openxmlformats-…sheet weekly_sales.xlsx, base64 encoded

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:

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

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

What to do with each SES outcome A decision diagram. A message ID means the report was accepted. MessageRejected means an unverified sender or a sandbox recipient and must be fixed in configuration. Throttling is retried automatically with backoff. A message over the size limit needs a smaller attachment or a link. What did SendEmail return? MessageId Accepted log the ID MessageRejected Fix configuration verify sender, leave sandbox Throttling Retry with backoff botocore standard mode too large Shrink or link zip, or S3 link

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:

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

Python
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-stream works but some clients show a meaningless name; use the .xlsx MIME 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.

More on email delivery

More on email delivery →