Skip to content
shellhacks.dev
Python

How to Use Python to Send Email From Scripts the Safe Way

Send email from a Python script with smtplib and EmailMessage: STARTTLS, passwords from the environment, attachments, HTML bodies and error handling.

6 min read
Summarize in AI
Illustration for "How to Use Python to Send Email From Scripts the Safe Way": a shell script in an editor, a directory tree, environment variables.

A script that finishes a long task, a backup or a report, should tell someone. Email is still the simplest channel, and the standard library is enough. The risk is hardcoding credentials and swallowing errors. The messaging patterns in emailing reports from shell scripts have a Python counterpart here. The examples assume Python 3.13.

A minimal message

Python
import os
import smtplib
import sys
from email.message import EmailMessage

HOST = os.environ["SMTP_HOST"]
USER = os.environ["SMTP_USER"]
PASSWORD = os.environ["SMTP_PASSWORD"]

msg = EmailMessage()
msg["Subject"] = "Backup finished"
msg["From"] = "Backups <[email protected]>"
msg["To"] = "[email protected]"
msg.set_content("The nightly backup finished at 02:41 with 48 MB written.")

try:
    with smtplib.SMTP(HOST, 587, timeout=20) as smtp:
        smtp.starttls()
        smtp.login(USER, PASSWORD)
        smtp.send_message(msg)
except (smtplib.SMTPException, OSError) as err:
    print(f"mail failed: {err}", file=sys.stderr)
    sys.exit(1)

Credentials come from the environment, so they are not in the script or the repository. A missing variable raises KeyError at start, which is the right failure. starttls() upgrades the connection to TLS before the password is sent; calling login before it would expose the credentials. The timeout stops a dead server from hanging a cron job forever.

HTML body and attachment

Python
from pathlib import Path

msg.add_alternative("<p>Backup <strong>finished</strong>.</p>", subtype="html")

report = Path("report.pdf")
msg.add_attachment(
    report.read_bytes(),
    maintype="application",
    subtype="pdf",
    filename=report.name,
)

Set the text body first, then add the HTML as an alternative so clients that prefer plain text still get a readable message. Attachments are base64-encoded, which inflates size by a third, and many servers refuse messages above 10 to 25 MB, so link large files instead. Use mimetypes.guess_type to choose the type from the file name.

Credentials and providers

Most providers require an app-specific password or an API key, not your account password, and many have disabled basic authentication entirely. Store the secret in a root-only file, a systemd EnvironmentFile or a secret manager; the options are in secrets in deploy scripts and safe password handling. Test with a local mail catcher such as Mailpit on port 1025 without TLS: replace the TLS calls with a plain SMTP("localhost", 1025) in a test configuration.

Failure and deliverability

A successful send_message means the server accepted the mail, not that it arrived. Mail from an address on a domain without SPF, DKIM and DMARC often lands in spam. Send from a domain you control through a provider that signs messages, and set a valid From. Rate limits apply: a loop that mails on every error can flood an inbox, so batch alerts or send at most one per interval. Deduplicate with a state file.

Plain text, HTML and character sets

The modern API builds messages with email.message.EmailMessage, which handles encoding of headers and bodies. Set the subject, sender and recipients on the message object, and call set_content for the plain text body. To offer HTML as well, call add_alternative with the HTML and subtype="html", producing a multipart message in which clients choose the part they prefer. Always include the plain text part: some clients show it, spam filters score messages that lack it, and accessibility tools benefit. Non-ASCII text in subjects and names is encoded for you, so do not build header strings by hand. Avoid putting user-supplied text in headers without checking for newlines, which would allow header injection.

Set a proper Date header with email.utils.formatdate and a Message-ID with make_msgid. Their absence raises spam scores and makes threading and debugging harder.

Connecting securely

Use smtplib.SMTP_SSL on port 465 for implicit TLS, or SMTP on port 587 followed by starttls(). Create the context with ssl.create_default_context() so the certificate and host name are verified; passing no context in older code, or disabling verification to get past an error, removes the protection and should be treated as a bug. Port 25 is for server-to-server delivery and is often blocked from home and cloud networks. Log in only after the TLS channel is up, with credentials from the environment or a secrets file, not source code. Set a timeout on the connection, since the default is to wait indefinitely, which would hang a scheduled job when the server is down.

Many providers disable password logins for SMTP and require an app-specific password or an API. Check the provider's documentation before debugging code that is correct.

Attachments and size limits

Attach files with add_attachment, passing bytes, a main type, a subtype and a file name. Guess the type with the mimetypes module and fall back to application/octet-stream. Attachments are base64 encoded, which inflates size by about a third, and mail servers typically refuse messages beyond 10 to 25 megabytes, a limit that applies to the encoded size. For large reports, upload the file to storage and mail a link, or compress it first. Read files in binary mode, and take care with a log or report file that changes while you read it; copy it first if it is still being written.

Inline images use the Content-ID mechanism and a related part. They are fiddly, and many clients block remote images by default, so keep the message meaningful without them.

Deliverability and failure handling

A message that the server accepts is not necessarily delivered. Sending from a domain requires SPF and DKIM records, and a DMARC policy, which receiving servers check; without them, messages land in spam or are rejected. Use a transactional mail service or your organisation's relay for anything beyond a personal script, and send from an address on a domain you control. Catch smtplib.SMTPException and its subclasses, and log the recipient refusals returned by sendmail, which reports them in a dictionary even when the call succeeds for other recipients. Retry transient failures with backoff, and give up with an alert, not silence, after a few attempts. Include an unsubscribe route for anything bulk. For testing, run a local capture server such as Mailpit or Python's debugging server, and look at the actual raw message to check headers and parts before any real recipient sees it.

Keep credentials out of the repository and out of logs. Read the password from the environment or a protected file, and never print the message object with its headers at debug level on a shared log, since authentication lines can leak there. When debugging, set the SMTP debug level on the connection only for a single run, and remove it afterwards. Rotate the app password if it ever appears in a log. For production, prefer a mail service's HTTP API with a scoped key, which can be revoked without touching the account password.

Where this does not apply

If you only need to notify yourself, a push service such as ntfy or a chat webhook needs less setup and no mail server, as in chat alerts. For bulk or marketing email, use a provider's API with unsubscribe handling, not SMTP from a script. Never build the headers by concatenating user input: EmailMessage rejects header injection by raising on newlines, and that protection is lost if you assemble raw text. See the smtplib documentation and the email examples.

Also inAutomation
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.