Guide
Send Email from Python Without SMTP
Send email from Python without SMTP credentials, app passwords, Postfix, or provider-specific SDK setup. Use a verified CLI command for OAuth-backed multi-provider delivery.
Written by Qasim Muhammad Staff SRE
Reviewed by Qasim Muhammad
Why avoid SMTP in Python scripts?
SMTP adds operational work that most Python scripts do not need in 2026: secrets in environment variables, app passwords, TLS settings, provider throttles, and local relay failures. A small report script should not need 5 provider-specific branches just to send a daily email.
Python's smtplib is useful for raw SMTP clients, but it leaves provider policy and credentials to your application. The CLI path keeps delivery behind an OAuth-backed account and returns structured output. That matters for CI jobs, data pipelines, and AI agents that run inside sandboxes where ports 25, 465, and 587 may be blocked.
How do you authenticate once for Python?
Authenticate outside the Python process so the script does not own long-lived mail secrets. The setup command stores the API key in the CLI config, and every later Python run can send through the selected account without opening a browser or embedding an SMTP password.
Run this once on the host, CI runner, or agent sandbox before the Python job starts. It uses the headless API-key flow, which is better for 24-hour automation than a browser OAuth flow that cannot complete in many containers.
Keep the setup links specific: nylas auth config documents the API-key path and nylas auth status documents the health check. A Python script should fail before sending if the host is not authenticated.
nylas auth config --api-key "$NYLAS_API_KEY"
nylas auth status --jsonHow do you send from Python?
Python can call the CLI with subprocess.run, pass recipient fields as arguments, and fail loudly when delivery fails. The function below sends one message, captures JSON output, and gives the caller a normal Python exception if the command returns a non-zero exit code.
This example keeps the command to 8 arguments and uses --yes so non-interactive jobs do not hang on a confirmation prompt. Add --cc, --bcc, --schedule, or hosted template flags when your workflow needs them.
The command syntax lives at nylas email send. Use argument-array form instead of shell=True, because each recipient, subject, and body is passed as a separate value. That keeps a report title with quotes or semicolons from turning into shell syntax.
import json
import subprocess
def send_email(to: str, subject: str, body: str) -> dict:
result = subprocess.run(
[
"nylas",
"email",
"send",
"--to",
to,
"--subject",
subject,
"--body",
body,
"--yes",
"--json",
],
check=True,
capture_output=True,
text=True,
timeout=30,
)
return json.loads(result.stdout)
send_email(
"ops@example.com",
"Daily import finished",
"The 2026-05-14 import completed successfully.",
)How do you handle failed sends?
Treat a failed CLI send like any other process failure: capture stderr, preserve the exit code, and include enough context to retry manually. A 30-second timeout is a reasonable starting point for small notification jobs, because it distinguishes a provider or network stall from normal command execution.
The wrapper below returns the parsed JSON on success and raises a typed exception on failure. It records the recipient and subject but does not log the full body, which keeps personal data out of application logs. That split is useful for CI, cron, and agent jobs where logs are retained for 7-90 days.
class EmailSendError(RuntimeError):
pass
def send_email_checked(to: str, subject: str, body: str) -> dict:
try:
return send_email(to, subject, body)
except subprocess.CalledProcessError as exc:
raise EmailSendError(
f"nylas email send failed for {to!r} subject={subject!r}: {exc.stderr}"
) from exc
except subprocess.TimeoutExpired as exc:
raise EmailSendError(
f"nylas email send timed out after {exc.timeout} seconds for {to!r}"
) from excHow do you handle bulk sends safely?
Bulk sending should stay bounded, observable, and easy to stop. Start with a CSV loop that sends 1 row at a time, logs every recipient, and exits on the first failure. That gives you a clear audit trail before adding retry queues or scheduling.
The script below uses Python's csv module to send a maximum of 100 rows from a file with columns named email and name. It keeps personalization simple, uses one CLI send per recipient, and avoids sharing a raw SMTP credential across every row.
import csv
with open("recipients.csv", newline="") as handle:
for index, row in enumerate(csv.DictReader(handle), start=1):
if index > 100:
break
send_email(
row["email"],
"Your weekly status report",
f"Hi {row['name']}, your weekly report is ready.",
)
print(f"sent {index}: {row['email']}")When should Python use an Agent Account?
Use an Agent Account when the Python job should send from an app-owned address such as reports@yourapp.nylas.email instead of a human's Gmail or Outlook mailbox. The account is a provider=nylas grant with its own inbox, calendar, webhooks, and policy controls, so report jobs and AI agents get a separate identity.
The setup is still CLI-first. Create the identity with nylas agent account create, verify it with nylas agent status, then send through the same nylas email send command by selecting the Agent Account grant. This is cleaner than putting a shared SMTP app password in every Python environment.
How does this compare to SMTP code?
SMTP code is still useful when you operate the mail server or need protocol-level behavior. For normal product notifications, the cost is usually not the 15 lines of Python; it is the credential rotation, port access, TLS settings, provider-specific sender rules, and failure modes. Those concerns appear every time the script moves from a laptop to CI, Docker, or a managed agent sandbox.
A CLI subprocess keeps those concerns in one configured binary. Your Python code sends a small argument array, receives JSON, and gets a normal process exit status. If you later move the sender from Gmail to Outlook or an Agent Account, the Python function stays the same and the grant changes underneath it.
How do you test before putting it in cron?
Test the send path in 3 passes before scheduling it. First, run the CLI command by hand with a real recipient you control. Second, run the Python function once with a fixed subject that includes the date. Third, run the same function from the target environment, such as cron, GitHub Actions, Docker, or an agent sandbox.
Each pass should prove a different failure mode. The manual pass proves the grant can send. The Python pass proves argument handling, timeout behavior, and JSON parsing. The target-environment pass proves PATH, API-key config, network egress, and log capture. Most SMTP migrations fail in the third category, not in Python syntax.
Keep the test commands linked in runbooks. nylas auth status proves the host is configured, nylas email send proves delivery, and nylas email list can confirm the sent or reply path when the sender account is also readable.
What should Python avoid owning?
Python should not own token refresh, SMTP credential storage, retry policy, or provider-specific MIME edge cases unless email delivery is the product itself. Application code should own business decisions: who gets the message, what the message says, whether the send is approved, and where failures are logged.
That split keeps the wrapper small enough to review. If the email function grows past 50 lines before templates, attachments, or retries are added, it is usually absorbing infrastructure work that belongs in the mail layer. The CLI command page becomes the stable contract, and Python remains a caller with clear inputs and outputs.
Next steps
- Send email from the terminal -- the base CLI send workflow for Linux, macOS, and Windows
- Send email from a Linux sandbox -- HTTPS sending when SMTP ports are blocked
- Build an LLM agent with email tools -- subprocess patterns for custom AI agents
- Create an AI agent email identity -- use an Agent Account as the sender
- Best email infrastructure for AI agents -- choose between provider APIs, MCP, and CLI calls
- Email send command -- exact flags for recipients, subjects, templates, scheduling, and JSON output
- Full command reference -- every flag and subcommand documented