Guide Developer tools

Python email through Gmail: app passwords, OAuth or the API

Gmail no longer takes a plain password from scripts. Three routes still work; which fits depends on your account, and set up wrong, OAuth expires in 7 days.

The short read

A Gmail script on an External OAuth app in Testing loses its sign-in 7 days after consent.

  • Google ended plain password sign-in for SMTP; app passwords, OAuth over SMTP and the Gmail API still work, each with its own catch.
  • Our script sent through all three routes in 14 runs against a local mock of Google's servers, not against Gmail itself.

What you can do with this

If you send from personal Gmail, use an app password with 2-Step Verification on. On Workspace, use the Gmail API with gmail.send on an Internal app, so the sign-in does not expire weekly.

A bearded man with glasses typing on a laptop whose screen shows lines of white text in a terminal window.
Photo: Kowalski7cc, CC0
In this article
  1. What Google turned off, and when its pages say it happened
  2. Route 1: an app password over SMTP
  3. Route 2: OAuth over SMTP, with a full-mailbox scope
  4. Route 3: the Gmail API with the send-only scope
  5. The 7-day trap, and the six ways a token dies
  6. The three routes side by side
  7. One script for all three routes
  8. What our tests against a local mock showed
  9. Two things the tests changed
  10. Pick your route in three questions
  11. How we made this

A Python script can no longer sign in to Gmail with your normal password. Google’s Workspace admin page says SMTP stopped taking “legacy passwords” on March 14, 2025. Three routes still send mail: an app password over SMTP, OAuth over SMTP, and the Gmail API.

Each fails in its own way. One needs a scope that can delete your whole mailbox. Both OAuth routes stop signing in after 7 days if you set them up the obvious way on a personal account.

This guide compares the three on Google’s documentation, read on November 1, 2026. It also gives one Python script that sends by any of them. We tested the script in 14 runs against local servers we built to act like Gmail’s SMTP server, the Gmail API and Google’s token endpoint. We did not send through a real Gmail account.

What Google turned off, and when its pages say it happened

The old way was smtplib.login("you@gmail.com", "your password"). Google calls apps that sign in like that “less secure apps.” Its pages give three different dates for the end, so here they are side by side.

Google page What it says Date given
Transition from less secure apps to OAuth (Workspace admins, updated October 26, 2026) New connections from less secure apps blocked; admin setting removed Summer 2024
Same page “CalDAV, CardDAV, IMAP, SMTP, and POP will no longer work with legacy passwords (basic authentication)” March 14, 2025
Send email from a printer, scanner, or app (updated October 26, 2026) “Google Workspace accounts no longer support less secure apps” May 1, 2025
Less secure apps and your Google Account Less secure apps can no longer sign in with only a username and password January 2025

The dates disagree, but the outcome is the same: by mid-2025 the plain password was gone. The transition page says access to less secure apps is turned off “for all Google Accounts,” and none of the pages we read gives a separate date for personal accounts.

The same transition page keeps one exception: users stop using a password for this kind of access, “with the exception of app passwords.” That is the first route.

Route 1: an app password over SMTP

Google defines an app password as “a 16-digit passcode” that lets a less secure app or device access your Google Account. Your script uses it in place of your real password, with the same smtplib.login call as before. Nothing else changes: the server is smtp.gmail.com, port 465 for SSL or 587 for TLS.

It has four conditions, all from Google’s app password page:

  • 2-Step Verification must be on. App passwords “can only be used with accounts that have 2-Step Verification turned on.”
  • Some accounts don’t get the option: accounts with 2-Step Verification “set up only for security keys,” accounts with Advanced Protection, and some work or school accounts.
  • Changing your Google password kills them. “We revoke your app passwords when you change your Google Account password.”
  • Google discourages them. “App passwords aren’t recommended and are unnecessary in most cases.”

The downside is reach. The passcode grants access to the Google Account, not to a send-only slice of it. Whoever reads it from your script, a .env file or a backup can use it until you revoke it or change your password.

Use it when the script is yours, runs on a machine you control, and sends from a personal Gmail account. It needs no Google Cloud project. Don’t use it for a script other people deploy, or on a work account where the option isn’t offered.

Route 2: OAuth over SMTP, with a full-mailbox scope

Google says Gmail’s SMTP server, the same smtp.gmail.com, supports OAuth 2.0 too. The sign-in method is called XOAUTH2. The script sends a short string with your address and the token, base64-encoded:

user=you@gmail.com^Aauth=Bearer ya29.the-token^A^A

Here ^A is the control character \001. Python’s smtplib has no built-in XOAUTH2, but its SMTP.auth() method exists for “authentication methods not (or not yet) supported directly,” so a dozen lines add it.

The catch is the scope. For SMTP, Google’s XOAUTH2 page gives one scope: https://mail.google.com/. On Gmail’s scope list that is full access, “including permanent deletion” of messages, and it is classed as restricted. Restricted scopes “require restricted scope OAuth App Verification,” and if you store or pass that data through servers, “you must go through a security assessment.”

So a token that only needs to send mail can also delete the mailbox. Use it when you have working SMTP code, a Workspace account and an Internal app (explained below), and want to keep the SMTP code. Don’t use it for anything other people sign in to: the verification work falls on you.

Route 3: the Gmail API with the send-only scope

The Gmail API takes the whole email as one base64url-encoded string and sends it with users.messages.send. The method accepts four scopes. The narrowest is gmail.send, which Google classes as sensitive rather than restricted. Its description covers sending mail on the user’s behalf, with no reading or deleting.

Setup follows Google’s Python quickstart: Python 3.10.7 or later, a Google Cloud project, and an OAuth client of type Desktop app saved as credentials.json. Then install the libraries:

python3 -m pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib

The first run opens a browser to sign in and saves a token file. Later runs reuse it and refresh it on their own.

The cost is that setup, about the same as route 2. It is the right default for anything new, with one trap in the next section.

The 7-day trap, and the six ways a token dies

Both OAuth routes need a consent screen, and its Audience settings decide how long your script stays signed in. On Google’s audience page, an External app in Testing status is limited to 100 test users. And “authorizations by a test user will expire seven days from the time of consent.” Google’s OAuth overview says the same about refresh tokens for an External app in Testing: they expire after 7 days.

Internal is offered to projects that belong to an organization, so a personal gmail.com account is left with External. So the obvious personal setup, an External app left in Testing, signs you out a week after you click Allow. A cron job built on it works for 7 days, then fails until someone opens a browser.

There are three ways out:

  1. On Workspace, choose Internal. The quickstart’s own instruction is “Under Audience, select Internal.” The 7-day rule is written for External apps in Testing.
  2. On personal Gmail, use route 1 instead. An app password has no weekly expiry.
  3. Publish the app to In production. Without verification, users see an “unverified app” screen before consent, says Google’s unverified apps page. An app that stays unverified is capped at “100 new users in total.” We did not test this path.

Even past the 7 days, a refresh token can stop working. Google’s OAuth overview lists six reasons:

  • You revoked the app’s access.
  • The token went unused for six months.
  • You changed your password, and the token carries Gmail scopes.
  • The account passed its limit of 100 refresh tokens per OAuth client; the oldest is then dropped “without warning.” Signing in from many machines with one client can end the oldest sign-in this way.
  • Time-based access you granted ran out.
  • A Workspace admin restricted the service.

Note the password-change rule. Combined with the app password page, it means all three routes break when you change your Google password. A password change is also a script outage.

The three routes side by side

All facts below are from the Google pages linked above, read November 1, 2026.

App password + SMTP OAuth + SMTP Gmail API
Server or endpoint smtp.gmail.com, 465 or 587 smtp.gmail.com, 465 or 587 gmail.googleapis.com
What the script holds A 16-digit passcode A token file with refresh token A token file with refresh token
What a leak exposes Access to the Google Account Full mailbox, including deletion Sending as you
Google Cloud project No Yes Yes
Breaks on password change Yes Yes Yes
External app in Testing Not applicable Sign-in expires after 7 days Sign-in expires after 7 days
Verification before others sign in Not applicable Restricted scope verification Sensitive scope verification
Best fit Your own script, personal Gmail Existing SMTP code on Workspace New scripts, Workspace above all

Two Workspace-only routes skip personal sign-in entirely. A service account with domain-wide delegation can act for users in the domain, for example with gmail.send. A super administrator grants it under Security > Access and data control > API Controls. In our view its key file is then a password to every mailbox the delegation covers.

The SMTP relay at smtp-relay.gmail.com checks the sender’s IP address instead of a password, so a dynamic IP may need replacing with a static one. It allows 10,000 recipients a day per user.

Sending limits differ by route too. Google lists 2,000 messages a day for a Workspace user on smtp.gmail.com; our guide to sending limits has every route’s numbers and a sender that paces itself under them. Microsoft 365 is retiring password sign-in for SMTP too; our SMTP AUTH piece covers its replacements.

One script for all three routes

The script below sends one message by the route you name. It reads the app password from the GMAIL_APP_PASSWORD environment variable, never from the command line, where other users on the machine could see it in the process list. For the OAuth routes it keeps one token file per route, written with owner-only permissions (mode 600).

Four choices come from the failure cases above:

  • --no-browser makes a scheduled run that needs a new sign-in stop with exit code 2. Without it, the run waits for a browser nobody opens. Pair the exit code with an alert, as in our checks for scheduled scripts.
  • On the SMTP OAuth route, a refused token gets one refresh and one retry. The refusal comes before any mail is handed over, so the retry can’t send twice. The Google API library already does this for route 3.
  • The API call runs with num_retries=0. A retry after a timeout can send the same email twice, so the script stops there.
  • Exit codes separate the cases: 0 sent, 1 bad input, 2 sign-in needed or refused, 3 send failed.
#!/usr/bin/env python3
"""Send one email through Gmail from Python, by one of three routes.

  python3 send_gmail.py --route app-password you@gmail.com to@example.com "Subject" body.txt
  python3 send_gmail.py --route smtp-oauth   you@gmail.com to@example.com "Subject" body.txt
  python3 send_gmail.py --route api          you@gmail.com to@example.com "Subject" body.txt

app-password: reads the app password from GMAIL_APP_PASSWORD, never from the command line.
smtp-oauth and api: need credentials.json (a Desktop app OAuth client). The first run opens
a browser and saves a token file; later runs reuse and refresh it. Add --no-browser on a
server or in a scheduler, so a run that needs a new sign-in stops instead of waiting.

Exit codes: 0 sent, 1 usage or input error, 2 sign-in needed or refused, 3 send failed.
"""
import argparse
import base64
import os
import smtplib
import ssl
import sys
from email.message import EmailMessage

SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_CAFILE = os.environ.get("SMTP_CAFILE")  # only for testing against a local server
API_ENDPOINT = os.environ.get("GMAIL_API_ENDPOINT")  # only for testing
TOKEN_URI = os.environ.get("TOKEN_URI")  # only for testing; google-auth ignores the file's

SCOPES = {
    "smtp-oauth": ["https://mail.google.com/"],  # the only scope Gmail SMTP accepts
    "api": ["https://www.googleapis.com/auth/gmail.send"],  # send only
}


def build_message(sender, to, subject, body):
    msg = EmailMessage()
    msg["From"], msg["To"], msg["Subject"] = sender, to, subject
    msg.set_content(body)
    return msg


def xoauth2(user, token):
    """SASL XOAUTH2 for smtplib.auth: the token first, then an empty reply to an error."""
    first = True

    def authobject(challenge=None):
        nonlocal first
        if first:
            first = False
            return f"user={user}\x01auth=Bearer {token}\x01\x01"
        return ""  # Google answers a bad token with a 334 error; reply empty to get the 535

    return authobject


def tls_context():
    return ssl.create_default_context(cafile=SMTP_CAFILE)


def save_token(creds, path):
    tmp = path + ".tmp"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        f.write(creds.to_json())
    os.replace(tmp, path)  # a crash mid-write never leaves half a token file


def oauth_credentials(route, token_path, client_path, no_browser):
    from google.auth.exceptions import RefreshError
    from google.auth.transport.requests import Request
    from google.oauth2.credentials import Credentials

    creds = None
    if os.path.exists(token_path):
        creds = Credentials.from_authorized_user_file(token_path, SCOPES[route])
        if TOKEN_URI:  # testing only; with_token_uri drops the expiry, so keep it
            expiry = creds.expiry
            creds = creds.with_token_uri(TOKEN_URI)
            creds.expiry = expiry
    if creds and creds.valid:
        return creds
    if creds and creds.refresh_token:
        try:
            creds.refresh(Request())
            save_token(creds, token_path)
            return creds
        except RefreshError as e:
            print(f"Refresh refused: {e}", file=sys.stderr)
            print("Sign in again. On an External app in Testing, sign-ins last 7 days.", file=sys.stderr)
    if no_browser:
        print(f"No usable token in {token_path}; run once without --no-browser.", file=sys.stderr)
        sys.exit(2)
    if not os.path.exists(client_path):
        print(f"{client_path} not found; download your Desktop app OAuth client first.", file=sys.stderr)
        sys.exit(1)
    from google_auth_oauthlib.flow import InstalledAppFlow
    flow = InstalledAppFlow.from_client_secrets_file(client_path, SCOPES[route])
    creds = flow.run_local_server(port=0)
    save_token(creds, token_path)
    return creds


def send_smtp(msg, sender, password=None, token=None):
    with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as smtp:
        smtp.starttls(context=tls_context())
        smtp.ehlo()
        if token:
            smtp.auth("XOAUTH2", xoauth2(sender, token))
        else:
            smtp.login(sender, password)
        smtp.send_message(msg)


def send_api(msg, creds):
    from googleapiclient.discovery import build
    opts = {"api_endpoint": API_ENDPOINT} if API_ENDPOINT else None
    service = build("gmail", "v1", credentials=creds, client_options=opts, cache_discovery=False)
    raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
    # num_retries=0: a retry after a timeout can send the same email twice
    return service.users().messages().send(userId="me", body={"raw": raw}).execute(num_retries=0)


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--route", choices=["app-password", "smtp-oauth", "api"], required=True)
    p.add_argument("--token", help="token file (default token-<route>.json)")
    p.add_argument("--client", default="credentials.json")
    p.add_argument("--no-browser", action="store_true")
    p.add_argument("sender")
    p.add_argument("to")
    p.add_argument("subject")
    p.add_argument("body_file")
    a = p.parse_args()

    with open(a.body_file, encoding="utf-8") as f:
        body = f.read()
    if not body.strip():
        print("Body file is empty; nothing sent.", file=sys.stderr)
        sys.exit(1)
    msg = build_message(a.sender, a.to, a.subject, body)

    try:
        if a.route == "app-password":
            password = os.environ.get("GMAIL_APP_PASSWORD")
            if not password:
                print("Set GMAIL_APP_PASSWORD first.", file=sys.stderr)
                sys.exit(1)
            send_smtp(msg, a.sender, password=password)
            print("sent over SMTP with an app password")
        else:
            token_path = a.token or f"token-{a.route}.json"
            creds = oauth_credentials(a.route, token_path, a.client, a.no_browser)
            if a.route == "smtp-oauth":
                try:
                    send_smtp(msg, a.sender, token=creds.token)
                except smtplib.SMTPAuthenticationError:
                    # A token can be refused before its expiry time. Sign-in fails
                    # before any mail is handed over, so one refresh and retry is safe.
                    from google.auth.transport.requests import Request
                    creds.refresh(Request())
                    save_token(creds, token_path)
                    send_smtp(msg, a.sender, token=creds.token)
                print("sent over SMTP with OAuth")
            else:
                result = send_api(msg, creds)
                print(f"sent with the Gmail API, message id {result['id']}")
                save_token(creds, token_path)  # keep a token refreshed during the call
    except smtplib.SMTPAuthenticationError as e:
        print(f"Sign-in refused: {e.smtp_code} {e.smtp_error.decode(errors='replace')}", file=sys.stderr)
        sys.exit(2)
    except (smtplib.SMTPException, OSError) as e:
        print(f"Send failed: {e}", file=sys.stderr)
        sys.exit(3)
    except Exception as e:  # HttpError and auth errors from the Google libraries
        if type(e).__name__ == "RefreshError":
            print(f"Sign-in refused: {e}", file=sys.stderr)
            sys.exit(2)
        print(f"Send failed: {type(e).__name__}: {e}", file=sys.stderr)
        sys.exit(3)


if __name__ == "__main__":
    main()

The three lines marked “only for testing” point the script at our local servers when their environment variables are set. Left unset, as in normal use, they change nothing.

If you get exit code 3, look in your Sent folder before running the script again. A connection can drop after Gmail has accepted the message, and a second run would send it twice.

What our tests against a local mock showed

We had no Gmail account to send from, so we built stand-ins on a Linux machine with Python 3.11.18. A local SMTP server (aiosmtpd 1.4.6) required STARTTLS with a certificate we made. It accepted one set app password or a list of valid OAuth tokens.

When it refused a token it followed Google’s documented XOAUTH2 failure: a 334 reply carrying an error, then 535 after the client’s empty answer. A small web server played the Gmail API send endpoint and Google’s token endpoint. The libraries were google-api-python-client 2.201.0, google-auth 2.62.0 and google-auth-oauthlib 1.5.0.

Test Result
Our XOAUTH2 string against Google’s published example Same string, byte for byte
App password, correct Sent, exit 0
App password, wrong Refused with 535, exit 2; smtplib tried PLAIN, then LOGIN; nothing sent
Empty body file Exit 1, nothing sent
SMTP OAuth, valid token Sent, exit 0
SMTP OAuth, token past its expiry time One refresh, then sent; new token file mode 600
SMTP OAuth, token the server refuses before expiry 535, one refresh, one retry, sent once
SMTP OAuth, token refused, then refresh refused Exit 2, nothing sent
SMTP OAuth, refresh answered with invalid_grant Exit 2 with the 7-day hint, nothing sent
No token file, --no-browser Exit 2 in 0.19 seconds, no browser opened
No token file, no credentials.json Exit 1 with a message, no traceback
Gmail API, valid token Sent; the server decoded the right From, To and Subject
Gmail API, token the server refuses The library refreshed and retried on its own; sent once
Gmail API, server answers 503 Exit 3 after one attempt, no retry
Gmail API, refresh answered with invalid_grant Exit 2, nothing sent

The mock answered refused refreshes with invalid_grant, the error OAuth 2.0 defines for a refresh token that is “invalid, expired, revoked.” These runs show the script’s own logic. They can’t show Google’s exact replies or timing. Nor can they show whether its servers behave as the documentation says.

Two things the tests changed

The SMTP route gave up on a refused token. Our first version refreshed only when the saved expiry time had passed. When the mock refused a token that had not reached that time, the run ended with exit 2.

The Gmail API route, run the same way, recovered, because the Google library refreshes and retries on a 401. We added the same single retry to the SMTP route, and the test then sent once.

Our test hook hid an expired token. To point the script at our token server we used google-auth’s with_token_uri(). In google-auth 2.62.0 the copy it returns has no expiry time, so an expired token looked valid and the script sent it without refreshing. That only affects the testing line, which now carries the expiry across.

We found it because the “expired token” test sent an old token instead of refreshing.

Pick your route in three questions

  1. Personal gmail.com, a script only you run? Turn on 2-Step Verification. Create an app password on your Google Account’s app password page. Then run GMAIL_APP_PASSWORD=... python3 send_gmail.py --route app-password .... Put the passcode in your scheduler’s secret store, not in the script.
  2. Google Workspace? Create the Google Cloud project inside your organization and set Audience to Internal. Make a Desktop app client and use --route api. Run it once by hand to sign in, then schedule it with --no-browser.
  3. Existing SMTP code you can’t rewrite yet? On Workspace with an Internal app, --route smtp-oauth keeps your SMTP calls and swaps the password for a token. Accept that the token can delete mail, and store it like a password.

Whichever you pick, put a note in your calendar for the day you change your Google password. All three routes stop then, and the scheduled job is the first thing to check.

How we made this

On November 1, 2026 we read the Google pages listed below on app passwords, the end of less secure apps, Gmail SMTP, XOAUTH2, OAuth and the Gmail API, plus Python’s smtplib documentation and RFC 6749. We wrote the script and ran it 14 times against local stand-ins we built for Google’s SMTP server, API and token endpoint. We also checked its XOAUTH2 string against Google’s example, byte for byte. We did not sign in to a real Google account or send a real email, so Google’s actual replies are taken from its documentation.

Sources