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.
In this article
- What Google turned off, and when its pages say it happened
- Route 1: an app password over SMTP
- Route 2: OAuth over SMTP, with a full-mailbox scope
- Route 3: the Gmail API with the send-only scope
- The 7-day trap, and the six ways a token dies
- The three routes side by side
- One script for all three routes
- What our tests against a local mock showed
- Two things the tests changed
- Pick your route in three questions
- 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:
- 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.
- On personal Gmail, use route 1 instead. An app password has no weekly expiry.
- 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-browsermakes 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
- 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. - 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. - Existing SMTP code you can’t rewrite yet? On Workspace with an Internal app,
--route smtp-oauthkeeps 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
- Google Account Help, Sign in with app passwords (read November 1, 2026)
- Google Workspace Admin Help, Transition from less secure apps to OAuth (updated October 26, 2026)
- Google Workspace Admin Help, Send email from a printer, scanner, or app (updated October 26, 2026)
- Google Account Help, Less secure apps and your Google Account (read November 1, 2026)
- Google for Developers, IMAP, POP, and SMTP (updated September 15, 2026)
- Google for Developers, OAuth 2.0 mechanism (XOAUTH2) for IMAP and SMTP (updated September 15, 2026)
- Google for Developers, Choose Gmail API scopes (updated September 10, 2026)
- Google for Developers, Sending email with the Gmail API (updated September 10, 2026)
- Google for Developers, users.messages.send reference (updated April 15, 2026)
- Google for Developers, Gmail API Python quickstart (updated October 12, 2026)
- Google for Developers, Using OAuth 2.0 to access Google APIs (updated October 29, 2026)
- Google for Developers, Using OAuth 2.0 for server to server applications (updated March 23, 2026)
- Google Cloud Help, Manage app audience (read November 1, 2026)
- Google Cloud Help, Unverified apps (read November 1, 2026)
- Python documentation, smtplib (read November 1, 2026)
- IETF, RFC 6749, The OAuth 2.0 Authorization Framework, section 5.2