Guide
Send an Excel Report to Multiple Recipients in Python
Email one .xlsx report to many recipients in Python: To vs Cc vs Bcc, hidden distribution lists, personalized per-recipient sends over one SMTP connection.
Sending one Excel report to a single inbox is the easy case. Distributing it to a whole team — or to a list pulled from a spreadsheet — needs care: put the wrong addresses in the wrong header and you leak everyone's email to everyone else, hit a provider rate limit, or let one typo'd address kill the entire batch. This page builds on Emailing Excel Reports with smtplib and covers two distinct shapes of multi-recipient delivery: one identical message to many people, and a personalized message per person. Everything uses the standard library only.
What you need
- Python 3.6+ for the
EmailMessageAPI. - SMTP credentials — host, port, username, and an app password (most providers block your normal account password for scripts).
- An
.xlsxto attach. The examples generate a tiny one so they stand alone. - A recipient list — from an env var, a config value, or a column of the workbook.
pip install pandas openpyxl # only to generate the sample workbook
Build a sample report and a recipient list
The report comes from your upstream job; here it doubles as the source of the recipient column so you can see reading addresses from a sheet.
import pandas as pd
report = pd.DataFrame({
"region": ["North", "South", "West"],
"owner_email": ["alice@example.com", "bob@example.com", "carol@example.com"],
"revenue": [159.92, 247.50, 137.44],
})
report.to_excel("regional_report.xlsx", sheet_name="Summary", index=False)
print("Wrote regional_report.xlsx")
To vs Cc vs Bcc — what each reveals
The three address headers route the same way at the envelope level but differ entirely in what recipients see:
- To — primary recipients. Everyone in
Tosees every otherToandCcaddress. - Cc ("carbon copy") — also-informed recipients. Visible to all, same as
To. - Bcc ("blind carbon copy") — hidden recipients. Each sees only themselves; the
Bccheader is stripped before transmission.
The rule that matters: never put a distribution list in To or Cc, or you expose every subscriber's address to the whole list. Use Bcc for that.
One message to a visible team: join with ", "
For a small team that legitimately should see each other (e.g. three managers cc'd on the same report), set multiple addresses on one header by joining them with ", ". send_message() parses that into the envelope recipient list automatically.
from email.message import EmailMessage
from pathlib import Path
def build_report_email(sender, to, subject, body, attachment_path, cc=None, bcc=None):
"""Compose an EmailMessage with an .xlsx attachment. No network I/O."""
path = Path(attachment_path)
if not path.is_file() or path.stat().st_size == 0:
raise FileNotFoundError(f"Attachment missing or empty: {path}")
msg = EmailMessage()
msg["From"] = sender
msg["To"] = ", ".join(to)
if cc:
msg["Cc"] = ", ".join(cc)
if bcc:
msg["Bcc"] = ", ".join(bcc)
msg["Subject"] = subject
msg.set_content(body)
msg.add_attachment(
path.read_bytes(),
maintype="application",
subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=path.name,
)
return msg
msg = build_report_email(
sender="reports@example.com",
to=["lead@example.com"],
cc=["manager@example.com"],
subject="Regional Revenue Summary",
body="Hi team,\n\nThe latest regional report is attached.\n\nThanks.",
attachment_path="regional_report.xlsx",
)
print(msg["To"], "| Cc:", msg["Cc"])
One message to a hidden distribution list (Bcc)
For a newsletter-style blast where recipients must not see each other, leave To minimal (often just the sender's own address) and put the list in Bcc. send_message() reads To, Cc, and Bcc to build the envelope, sends to all of them, then removes the Bcc header so it never appears in the delivered message.
recipients = ["alice@example.com", "bob@example.com", "carol@example.com"]
msg = build_report_email(
sender="reports@example.com",
to=["reports@example.com"], # the list itself stays hidden
bcc=recipients,
subject="Regional Revenue Summary",
body="Report attached.",
attachment_path="regional_report.xlsx",
)
print("Bcc count:", len(msg["Bcc"].split(",")))
# Send once; the server fans out to every Bcc address:
# server.send_message(msg) # Bcc header is stripped on the wire
One message object, one send, every recipient blind to the others.
Personalized separate emails over one connection
When each person should get a tailored message (their name, their region's numbers), you must send a separate message per recipient. The key efficiency point: authenticate once and reuse the connection for the whole batch instead of reconnecting per message — reconnecting and re-logging-in for every email is slow and trips provider connection limits.
Per-recipient try/except means a single bad address logs and is skipped rather than aborting the run.
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path
def safe_header(value):
"""Strip CR/LF so an untrusted address can't inject extra headers."""
return value.replace("\r", " ").replace("\n", " ").strip()
def build_personalized(sender, recipient, region, attachment_path):
path = Path(attachment_path)
msg = EmailMessage()
msg["From"] = sender
msg["To"] = safe_header(recipient)
msg["Subject"] = f"Revenue Summary — {region}"
msg.set_content(f"Hi,\n\nYour {region} report is attached.\n\nThanks.")
msg.add_attachment(
path.read_bytes(),
maintype="application",
subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=path.name,
)
return msg
def send_batch(host, port, username, password, sender, rows, attachment_path):
"""One login, many sends. Returns (sent, failures)."""
sent, failures = [], []
with smtplib.SMTP(host, port, timeout=30) as server:
server.ehlo()
server.starttls()
server.ehlo()
server.login(username, password)
for recipient, region in rows:
try:
msg = build_personalized(sender, recipient, region, attachment_path)
server.send_message(msg)
sent.append(recipient)
except (smtplib.SMTPRecipientsRefused,
smtplib.SMTPSenderRefused, ValueError) as exc:
failures.append((recipient, str(exc)))
print(f"Skipped {recipient!r}: {exc}")
return sent, failures
# Example call (needs real, reachable credentials):
# rows = [("alice@example.com", "North"), ("bob@example.com", "South")]
# sent, failures = send_batch(
# "smtp.gmail.com", 587,
# os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"],
# "reports@example.com", rows, "regional_report.xlsx")
Catching SMTPRecipientsRefused and friends inside the loop is what keeps one rejected address from terminating the batch. Connection-level failures (a dropped socket, auth failure) still raise out of the with block, as they should — there's no point continuing a batch with no connection.
Reading the recipient list from env or the spreadsheet
Hard-coding addresses doesn't scale. Pull them from a comma-separated env var for a fixed list, or from a column of the workbook for a data-driven one.
import os
import pandas as pd
# From an env var: SMTP_RECIPIENTS="alice@example.com,bob@example.com"
env_list = [a.strip() for a in os.getenv("SMTP_RECIPIENTS", "").split(",") if a.strip()]
# From a spreadsheet column produced upstream:
df = pd.read_excel("regional_report.xlsx", sheet_name="Summary")
rows = list(zip(df["owner_email"], df["region"])) # for personalized sends
print("env recipients:", env_list)
print("sheet rows:", rows)
Common pitfalls and fixes
| Error / symptom | Cause | Fix |
|---|---|---|
| Recipients see everyone's address | Whole list placed in To or Cc | Put the distribution list in Bcc; keep To to the sender or a single alias. |
SMTPRecipientsRefused aborts the run | One bad address raised and was uncaught in a loop | Wrap each send in try/except so a single failure is logged and skipped. |
421 / Too many messages / throttling | Provider per-message or per-connection rate limit | Send in batches with a short time.sleep() between sends; check your provider's documented daily and hourly caps. |
| Slow batch, intermittent disconnects | Reconnecting and logging in per message | Open one smtplib.SMTP connection, login() once, and reuse it for every send_message(). |
Unexpected extra headers / ValueError | CR/LF (header injection) in an untrusted address | Strip \r and \n from any address or subject built from external data before setting the header. |
A short note on personalization vs Bcc: they are mutually exclusive strategies. Bcc sends one message to many; the loop sends many messages, one each. Don't mix them — a personalized message in To with the whole list also in Bcc exposes nothing but defeats the personalization and inflates your recipient count.
Frequently asked questions
Should I use Bcc or a loop for a 500-person list? Bcc, in chunks. A loop is for genuinely different content per recipient. For identical content, Bcc is far fewer messages — but split a large Bcc into batches of, say, 50–100 to stay under per-message recipient caps, sending each batch as its own message.
Does send_message() really strip the Bcc header? Yes. It reads To, Cc, and Bcc to compute the envelope recipients, then removes Bcc so the delivered message never reveals the hidden list. You don't pass recipients separately — the headers are the source of truth.
Why reuse one SMTP connection instead of reconnecting? Each connect/starttls/login cycle is a full TLS handshake plus authentication round-trips — slow, and many providers limit how often you may connect. One connection for the whole batch is dramatically faster and friendlier to rate limits.
How do I avoid header injection from spreadsheet addresses? Treat any externally sourced address as untrusted and strip \r/\n before assigning it to a header (the safe_header helper above). A newline smuggled into an address could otherwise add arbitrary headers to your message.
What if I hit a daily send limit mid-batch? Catch the connection-level error, record which recipients were already sent, and resume from there on the next run. Persisting a "sent" set lets you make batches idempotent across retries.
Conclusion
Multi-recipient delivery comes down to picking the right tool for the shape of the job. For identical content where readers shouldn't see each other, build one message and put the list in Bcc — send_message() handles the envelope and strips the header. For tailored content, loop over the recipients, build a distinct message each time, and crucially reuse a single authenticated connection while catching per-recipient errors so one bad address can't sink the batch. Read your list from an env var or a spreadsheet column, sanitize untrusted addresses, and respect your provider's recipient and rate limits.
Where to go next
Return to Emailing Excel Reports with smtplib for the message-construction and secure-connection fundamentals these examples build on. To run the whole generate-then-send pipeline unattended, see Scheduling Python Excel Scripts with Cron. And for producing the workbooks you distribute, see Building Multi-Sheet Excel Dashboards and Writing DataFrames to Excel with pandas.