Mail services
A row here means a real send through that mail service passed the tests/test_live_*.py checks. docs/research/live-send-findings.md records each reply.
| Mail service | Backend | Credential | Security | Tested on |
|---|---|---|---|---|
| iCloud Mail, including an iCloud+ custom domain | SMTPBackend on smtp.mail.me.com |
smtp.Password with the full iCloud address and an app-specific password | starttls on port 587, tls on port 465 |
2026-09-29 |
| Gmail | SMTPBackend on smtp.gmail.com |
smtp.Password with the Gmail address and an app password | starttls on port 587, tls on port 465 |
2026-09-29 |
| Gmail | GmailBackend | gmail.AuthorizedUser with a consent saved from your own Google Cloud OAuth client | HTTPS | 2026-09-29 |
| Gmail | SMTPBackend on smtp.gmail.com |
smtp.OAuth over the same gmail.AuthorizedUser | starttls on port 587 |
2026-09-29 |
No mail service has passed a real send through GraphBackend, or through SMTP OAuth over a Graph credential, yet. Issue #23 tracks them.
Each call below reads its secrets from the environment, under the names in .env.example. uv run --env-file .env sets them from a .env file, as it does for the live tests.
iCloud Mail
- Sign in at account.apple.com. Under Sign-In and Security, choose App-Specific Passwords, then Generate an app-specific password. Apple requires two-factor authentication for it.
- Set
ICLOUD_ADDRESSto the account’s full iCloud address, andICLOUD_APP_PASSWORDto the app-specific password.
import os
from epistole import SMTPBackend, smtp
backend = SMTPBackend(
host="smtp.mail.me.com",
port=587,
security="starttls",
from_address=os.environ["ICLOUD_ADDRESS"],
credential=smtp.Password(
username=os.environ["ICLOUD_ADDRESS"],
password=os.environ["ICLOUD_APP_PASSWORD"],
),
)from_address can also be an address on the account’s iCloud+ custom domain. The username stays the iCloud address, and iCloud signs the message with DKIM for the custom domain. A from address the account does not own raises SenderRefusedError. Changing or resetting the Apple Account password revokes every app-specific password.
Gmail
A Gmail sender proves who it is in one of two ways: an app password, or an OAuth consent. The app password takes fewer steps, and it has no 7-day expiry. Google may offer no app password to a work or school account, to an account with Advanced Protection, or to one whose 2-Step Verification uses only security keys. The Google Cloud pieces exist only for the consent, because a consent needs an OAuth client to consent to.
| Piece | Where it lives | What it produces | What uses it |
|---|---|---|---|
| 2-Step Verification | Google Account, Security & sign-in | Nothing on its own | App passwords, which require it |
| App password | Google Account, App passwords | A 16-character password | smtp.Password on smtp.gmail.com, and the live tests’ IMAP read-back |
| Google Cloud project | Cloud console | The project that holds the OAuth client | Every row below |
| Gmail API, enabled | Cloud console, APIs & Services | The project’s right to call the Gmail API | GmailBackend |
| Google Auth Platform | Cloud console | The consent screen: app name, audience, test users, publishing status | The consent. In Testing, only a test user can give it, and Google expires its refresh token after 7 days |
| OAuth client, Desktop app | Cloud console, Google Auth Platform, Clients | A client JSON file with an id and a secret | The consent, again every 7 days in Testing |
| Consent | A browser tab that google-auth-oauthlib opens |
A saved consent file with a refresh token | gmail.AuthorizedUser, for both GmailBackend and smtp.OAuth |
Scope gmail.send |
Granted at consent | The right to send through the Gmail API | GmailBackend |
Scope https://mail.google.com/ |
Granted at consent | Full mailbox access, and the scope Google requires for SMTP and IMAP over OAuth | smtp.OAuth, and the live tests’ Gmail API read-back |
Over either backend, Gmail sends from the account’s own address when from_address is neither the account nor a verified alias. Nothing raises. Changing the Google Account password revokes every app password. A refresh token with a Gmail scope stops working then too.
With an app password
- Turn on 2-Step Verification.
- Create an app password at App passwords.
- Set
GMAIL_ADDRESSto the account’s address, andGMAIL_APP_PASSWORDto the app password.
import os
from epistole import SMTPBackend, smtp
backend = SMTPBackend(
host="smtp.gmail.com",
port=587,
security="starttls",
from_address=os.environ["GMAIL_ADDRESS"],
credential=smtp.Password(
username=os.environ["GMAIL_ADDRESS"],
password=os.environ["GMAIL_APP_PASSWORD"].replace(" ", ""),
),
)The call removes the spaces Google shows between the password’s four groups of letters. A wrong app password raises AuthenticationError.
With your own OAuth client
- Create a project in the Google Cloud console.
- Enable the Gmail API in it.
- Open Google Auth Platform, and choose Get Started. Give an app name and your address, choose External as the audience, and agree to the policy.
- Under Audience, leave the publishing status at Testing, and add your Gmail address as a test user.
- Under Clients, create a client of type Desktop app. Google shows its secret only at creation, so download the JSON then. Save it as
gmail-oauth-client.json. - Save the script below as
save_consent.pybesidegmail-oauth-client.json, and run it.
save_consent.py
import os
from pathlib import Path
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = ["https://www.googleapis.com/auth/gmail.send", "https://mail.google.com/"]
flow = InstalledAppFlow.from_client_secrets_file("gmail-oauth-client.json", SCOPES)
credentials = flow.run_local_server(
port=0,
prompt="consent", # Without it, a repeat run may save no refresh token.
)
path = Path(os.environ["GMAIL_AUTHORIZED_USER"]).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(mode=0o600)
path.write_text(credentials.to_json(), encoding="utf-8")uv run --env-file .env --with google-auth-oauthlib save_consent.pyThe script opens a browser. Sign in as the test user. Google warns that it has not verified the app. Choose Advanced, then the link that ends in “(unsafe)”. Allow both scopes, because the script raises if Google grants only one. Run the script on a machine with a browser, then copy the file to the machine that sends.
GmailBackend sends through the Gmail API with the saved consent:
import os
from pathlib import Path
from epistole import GmailBackend, gmail
consent = gmail.AuthorizedUser(
path=Path(os.environ["GMAIL_AUTHORIZED_USER"]).expanduser()
)
backend = GmailBackend(from_address=os.environ["GMAIL_ADDRESS"], credential=consent)smtp.OAuth sends through Gmail’s SMTP server with the same consent:
from epistole import SMTPBackend, smtp
backend = SMTPBackend(
host="smtp.gmail.com",
port=587,
security="starttls",
from_address=os.environ["GMAIL_ADDRESS"],
credential=smtp.OAuth(username=os.environ["GMAIL_ADDRESS"], credential=consent),
)In Testing, Google expires the refresh token after 7 days. The next connect() or backend.send() then raises AuthenticationError, and running save_consent.py again saves a new consent. The Gmail API replaces the Message-ID Epistole sets, so a recipient sees a different one than SendResult.message_id. smtp.OAuth keeps it.