Send an Excel Report with Gmail from Python
Gmail is the quickest way to get a scheduled Excel report into someone's inbox: no mail server to run, no API project to register for the simplest setup. The catch is authentication — Google refuses the account's normal password from scripts — and a handful of Gmail-specific replies that a plain smtplib example never mentions. This guide, part of Emailing Excel Reports with smtplib, sends a workbook through smtp.gmail.com with an app password, builds the message correctly, deals with refused recipients and transient failures, and switches to OAuth2 for Workspace accounts where app passwords are turned off.
Prerequisites
Everything below uses the standard library, plus pandas to produce a sample report:
pip install pandas openpyxl
On the Google side, the account needs 2-Step Verification turned on. Then create an app password under Google Account → Security → App passwords. Google shows it as four groups of four letters; it grants full access to the mailbox, so treat it like the account password and store it outside the code. On Google Workspace, an administrator can disable app passwords — Step 5 covers that case.
Step 1 — Build the message with the workbook attached
Create the workbook in memory and attach it with the spreadsheet MIME type, so mail clients show an Excel icon and open it in Excel rather than offering a generic download:
import io
import mimetypes
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import pandas as pd
report = pd.DataFrame({
"region": ["North", "South", "East", "West"],
"revenue": [182_400, 141_950, 97_300, 120_880],
})
buffer = io.BytesIO()
report.to_excel(buffer, index=False, sheet_name="Summary")
workbook = buffer.getvalue()
def build_report_email(sender, recipients, subject, body, attachment, filename):
msg = EmailMessage()
msg["From"] = sender
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=sender.split("@")[1])
msg.set_content(body)
ctype, _ = mimetypes.guess_type(filename)
maintype, subtype = (ctype or "application/octet-stream").split("/")
msg.add_attachment(attachment, maintype=maintype, subtype=subtype, filename=filename)
return msg
msg = build_report_email(
"reports.team@gmail.com", ["ana@example.com", "lee@example.com"],
"Weekly revenue — week 38", "The weekly revenue report is attached.",
workbook, "revenue_week38.xlsx",
)
print(msg.get_payload()[1].get_content_type())
# application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
mimetypes.guess_type knows the .xlsx type, so the same helper handles .xlsm or .csv attachments. The Message-ID header makes each report traceable in Gmail's Show original view and in the recipients' logs.
Step 2 — Send through smtp.gmail.com
Connect with SMTP_SSL on port 465, which is encrypted from the first byte, and verify Google's certificate with the default SSL context:
import os
import smtplib
import ssl
def send_via_gmail(msg, user, app_password, host="smtp.gmail.com", port=465):
context = ssl.create_default_context()
with smtplib.SMTP_SSL(host, port, context=context, timeout=60) as server:
server.login(user, app_password.replace(" ", ""))
return server.send_message(msg) # {} when every recipient was accepted
refused = send_via_gmail(msg, "reports.team@gmail.com", os.environ["GMAIL_APP_PASSWORD"])
print(refused)
If a firewall allows only port 587, the same send works with smtplib.SMTP(host, 587) followed by server.starttls(context=context) before login. Forget starttls there and the login fails with SMTPNotSupportedError: SMTP AUTH extension not supported by server — Gmail only offers login once the connection is encrypted, so the message is confusing but the password was never sent.
The spaces in the displayed app password are only for readability; stripping them avoids a mistake when the value is pasted from the screen. Keep the password out of the script: an environment variable set by the scheduler works, and on a desktop machine the operating system's keyring is better — keyring.set_password("gmail-reports", user, value) once, then keyring.get_password("gmail-reports", user) in the job. A password in the code ends up in version control, and anyone with the repository can read the whole mailbox.
Step 3 — Read what send_message returns
send_message does not simply succeed or fail. When Gmail accepts some recipients and refuses others, the message goes to the accepted ones and the call returns a dict of the refused addresses — an easy result to ignore. Only when every recipient is refused does it raise SMTPRecipientsRefused. Treat a non-empty result as a partial failure:
def check_refused(refused, recipients):
if refused:
delivered = [r for r in recipients if r not in refused]
details = "; ".join(f"{addr}: {code} {text.decode()}" for addr, (code, text) in refused.items())
raise RuntimeError(f"sent to {delivered}, refused {details}")
check_refused({}, ["ana@example.com"]) # all accepted
try:
check_refused({"lee@exampel.com": (553, b"5.1.3 invalid address")},
["ana@example.com", "lee@exampel.com"])
except RuntimeError as error:
print(error)
Against a test server, one good and one bad address produced exactly this shape: the message was delivered to the good one and {'bad@…': (550, b'5.1.1 …')} came back. Raising here makes the job's failure alert fire instead of reporting success to half the list.
Step 4 — Retry only what is worth retrying
Gmail's replies fall into three groups. Replies starting with 4 are temporary — try again after a pause. Replies starting with 5 are permanent — fix something first. Network failures before the message is accepted can be retried:
import time
def send_with_retry(msg, user, app_password, attempts=3, pause=30):
for attempt in range(1, attempts + 1):
try:
return send_via_gmail(msg, user, app_password)
except smtplib.SMTPAuthenticationError:
raise # 535: wrong or revoked app password
except smtplib.SMTPResponseException as error:
if not 400 <= error.smtp_code < 500 or attempt == attempts:
raise # 5xx: permanent, e.g. 552 too large
except (smtplib.SMTPServerDisconnected, ConnectionError, TimeoutError):
if attempt == attempts:
raise
time.sleep(pause * attempt)
The common permanent replies each have one fix. 535 5.7.8 Username and Password not accepted means the app password is wrong or was revoked, or 2-Step Verification was switched off, which deletes app passwords. 552 5.3.4 means the message exceeds Gmail's size limit; see Zip Large Excel Attachments Before Emailing Them. 550 5.4.5 Daily sending quota exceeded means the account hit its daily limit, which for a personal account is a few hundred recipients; retrying makes it worse.
A timeout after the data has been sent is the one case where a retry can deliver the report twice, because Gmail may have accepted it just before the connection dropped. For a daily report a rare duplicate is usually acceptable; if it is not, log the Message-ID and check the Sent folder before resending.
Step 5 — Use XOAUTH2 when app passwords are disabled
Many Workspace domains turn app passwords off. Gmail's SMTP server also accepts an OAuth2 access token through the XOAUTH2 mechanism, which smtplib supports through its generic auth method. Get a token with the https://mail.google.com/ scope — from a stored refresh token for a user, or a service account with domain-wide delegation — and pass it like this:
def send_via_gmail_oauth(msg, user, access_token, host="smtp.gmail.com", port=465):
auth_string = f"user={user}\x01auth=Bearer {access_token}\x01\x01"
context = ssl.create_default_context()
with smtplib.SMTP_SSL(host, port, context=context, timeout=60) as server:
server.ehlo()
server.auth("XOAUTH2", lambda challenge=None: auth_string)
return server.send_message(msg)
# from google.oauth2.credentials import Credentials
# from google.auth.transport.requests import Request
# creds = Credentials.from_authorized_user_file("token.json", ["https://mail.google.com/"])
# creds.refresh(Request())
# send_via_gmail_oauth(msg, "reports@yourcompany.com", creds.token)
smtplib base64-encodes the string itself, so pass it unencoded. The callable must accept an optional challenge argument, because smtplib calls it again if the server replies with a challenge. Tested against a local server that implements XOAUTH2, this produced 235 2.7.0 Authentication successful. Access tokens last about an hour, so fetch a new one on every run rather than storing it.
Common pitfalls
- Changing the sender address. Gmail rewrites the
Fromheader to the authenticated account unless the other address is set up under Send mail as. Configure the alias first, or the report arrives from the wrong address. - Sending from a personal mailbox. Reports tied to one person's account stop when that person leaves or changes the password. Use a dedicated account for automation.
- Quoting the whole list in To. Every recipient sees every address. Send external distribution lists through
Bcc, covered in Send an Excel Report to Multiple Recipients with Python. - Macro-enabled attachments. Gmail scans attachments and may block files it judges risky, and recipients' filters often quarantine
.xlsm. Send.xlsxunless the macros are the point.
Conclusion
Sending an Excel report through Gmail takes an app password, SMTP_SSL on port 465 and a message built with the spreadsheet MIME type. Check the dict send_message returns for refused recipients, retry only 4xx replies and network drops, and treat 535, 552 and quota replies as problems to fix. Where app passwords are disabled, the same code signs in with an XOAUTH2 token instead.
Frequently asked questions
Why does Gmail reject my password in a Python script? Google no longer lets scripts sign in to SMTP with the account's normal password. Turn on 2-Step Verification, create an app password for the script and log in with that 16-character password instead, or use OAuth2 (XOAUTH2).
Should I use port 465 or 587 for Gmail? Either works. Port 465 is encrypted from the first byte and pairs with smtplib.SMTP_SSL; port 587 starts in plain text and upgrades with STARTTLS. Use 465 unless a firewall only allows 587.
What is the largest Excel file I can send through Gmail? Gmail limits a message to 25 MB, and attachments are base64-encoded, which adds about a third. A workbook of around 18 MB is the practical ceiling; above that, share a link instead.
Does a message sent through Gmail SMTP appear in the Sent folder? Yes. Gmail keeps a copy of messages sent through its SMTP server in the account's Sent folder, so there is no need to add yourself as a BCC recipient to keep a record.
Related
- Up one level: Emailing Excel Reports with smtplib — sending reports by email.
- Email an Excel Report with an HTML Summary Body — key figures in the message itself.
- Send an Excel Report with Microsoft Graph API — the Microsoft 365 equivalent.
- Send an Alert When an Excel Report Job Fails — what to do with the errors raised here.
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