How-to
Automating Reporting WorkflowsDeep dive

Send an Excel Report with Gmail from Python

Email an Excel workbook from a Gmail or Google Workspace account with Python's smtplib — app passwords and why your normal password is refused, port 465 versus 587, building the message with the correct xlsx MIME type, handling partly refused recipients, retrying only what is safe to retry, and XOAUTH2 when app passwords are switched off.

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.

What one send through Gmail looks like The script opens an encrypted connection to smtp.gmail.com on port 465 and logs in with the app password. It names the sender and each recipient; Gmail accepts or refuses every recipient individually. The script then sends the message with the base64-encoded workbook, and Gmail replies 250 once it has queued it and stored a copy in the Sent folder. Report script smtp.gmail.com Recipients 1. TLS on 465, AUTH with app password 2. 235 authenticated 3. MAIL FROM, RCPT TO per recipient 4. DATA with encoded xlsx 5. 250 queued 6. delivered

Prerequisites

Everything below uses the standard library, plus pandas to produce a sample report:

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

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

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

The two ways to connect to smtp.gmail.com Port 465 uses smtplib.SMTP_SSL and is encrypted from the start. Port 587 uses smtplib.SMTP and must call starttls before logging in. Both use the same app password or XOAUTH2 login and have the same limits. smtplib class Encryption Before login port 465 SMTP_SSL from the first byte nothing extra port 587 SMTP upgraded with STARTTLS call starttls(context=…)

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:

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

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

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

Choosing how the script signs in to Gmail For a personal Gmail account, use an app password over SMTP. For a Workspace account where app passwords are allowed, the same works. When app passwords are disabled, use XOAUTH2 with an OAuth token over SMTP. For high volume or company mail, send through the organisation's own relay or a transactional email service. Which account sends the report? personal Gmail app password SMTP_SSL, port 465 Workspace with app passwords app password same code app passwords disabled XOAUTH2 token mail.google.com scope high volume or company mail organisation relay not a mailbox

Common pitfalls

  • Changing the sender address. Gmail rewrites the From header 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 .xlsx unless 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.

More on email delivery

More on email delivery →