A script that generates a report or a document often ends with the same manual gesture: open the mail client, write two lines, attach the file, send. Python's standard library includes everything you need to take this last step too: the smtplib module to talk to the mail server and the email module to build well-formed messages, with attachments and accented characters.
How sending works
When you send a message, an email program connects to your provider's SMTP server, authenticates and hands over the message, which the server then forwards to the recipients. A script does exactly the same. You therefore need three pieces of information, found in your account settings:
- the SMTP server address, for example
smtp.provider.com; - the port and encryption type: usually 465 with direct SSL/TLS or 587 with STARTTLS;
- username and password.
Building the message
The email.message module offers the EmailMessage class, which handles encodings and headers automatically:
from email.message import EmailMessage
from pathlib import Path
msg = EmailMessage()
msg["From"] = "Accounts office <accounts@example.com>"
msg["To"] = "manager@example.com"
msg["Subject"] = "Weekly sales report"
msg.set_content("Good morning,\nplease find this week's report attached.\n\nKind regards")
attachment = Path("sales_2025-03-10.xlsx")
msg.add_attachment(
attachment.read_bytes(),
maintype="application",
subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=attachment.name,
)
The attachment type can be worked out automatically with the mimetypes module, which returns the correct pair from the extension, for example application/pdf for PDFs. For a formatted version of the text you can also add an HTML alternative with msg.add_alternative(html, subtype="html"), keeping the plain text as a fallback for clients that don't display it.
Sending over an encrypted connection
import smtplib, ssl
context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.provider.com", 465, context=context) as server:
server.login(user, password)
server.send_message(msg)
With port 587 you use smtplib.SMTP instead, followed by server.starttls(context=context) before logging in. In both cases password and content travel encrypted. An unencrypted connection should never be used, not even for testing.
The password doesn't belong in the code
The most common mistake is writing the password straight into the script. Sooner or later the script gets copied, shared with a colleague or saved in a synced folder, and the password goes with it. There are several alternatives:
| Method | How it works |
|---|---|
| Environment variable | os.environ["SMTP_PASSWORD"] read at start-up |
| Separate file | A configuration file outside the code folder, with restricted permissions |
| System keychain | The keyring library uses Windows Credential Manager or the macOS Keychain |
In my work I prefer the system keychain: the password is saved once with keyring.set_password("smtp", user, password) and read by the script with keyring.get_password, never appearing in plain text on disk.
Accounts with two-step verification
Many providers, including the main free email services, do not accept the account's normal password for script access when two-step verification is on. You need to generate an app password from the security settings: a specific, revocable password to be used only for that script. If one day the script is retired or compromised, you revoke that single password without touching the account. Some business services require more modern token-based authentication methods instead: in that case follow the provider's documentation.
Sending to several recipients
When the same script has to send personalised messages to many people, for example a reminder to each client with their own statement, a few rules apply:
- one message per recipient, never a list of visible addresses in the "To" field;
- a pause between sends, with
time.sleep(2), so you don't exceed the provider's limits and get the account blocked as a source of spam; - a send log, in a CSV with address, date and outcome, so you know who has already received the message if the script stops halfway;
- a test mode that sends everything to an internal address before going out to the real recipients.
for client in clients:
msg = build_message(client)
try:
server.send_message(msg)
outcome = "sent"
except smtplib.SMTPException as e:
outcome = f"error: {e}"
log(client["email"], outcome)
time.sleep(2)
For truly mass mailings, such as newsletters, a script using the company mailbox is not the right tool: you need dedicated services that handle unsubscriptions and sender reputation.
In short
email.messagebuilds well-formed messages with attachments;smtplibsends them.- Always use an encrypted connection, on port 465 or 587.
- Keep the password out of the code, preferably in the system keychain.
- With two-step verification use a revocable app password.
- For several recipients send individual messages, with pauses, a log and an internal test.