Quick answer: build an EmailMessage, set sender, recipient, and subject, add plain-text and HTML alternatives, open smtplib.SMTP, call starttls() with a default SSL context, authenticate, and use send_message(). Read the host, port, username, and password from the environment.

This flow works for internal alerts, transactional confirmations, and reports sent to authorized recipients. It is not permission to send unsolicited mail. Providers apply quotas and may require app passwords, tokens, or another authentication mechanism. Check the official smtplib documentation, Python's email package documentation, and the SSL/TLS documentation.

Configure credentials without exposing them

Set variables in the deployment platform, a local file excluded from Git, or a secret manager. Never publish real values, bake them into a container image, or print the complete environment during debugging.

export SMTP_HOST=smtp.example.com
export SMTP_PORT=587
export [email protected]
export SMTP_PASSWORD=an-app-password
export [email protected]

PowerShell uses $env:SMTP_HOST = "smtp.example.com" for the current session. Production systems should use their native secret facility. Keep dependencies isolated in a Python virtual environment and configuration separate from source code.

Send plain text and HTML together

EmailMessage handles headers and MIME structure. Add plain text first and HTML as an alternative; the recipient's client selects what it supports. Escape dynamic values before inserting them into markup. The standard html.escape function handles the displayed name below.

import html
import os
import smtplib
import ssl
from email.message import EmailMessage

def send_welcome(recipient: str, name: str) -> None:
    host = os.environ["SMTP_HOST"]
    port = int(os.environ.get("SMTP_PORT", "587"))
    username = os.environ["SMTP_USER"]
    password = os.environ["SMTP_PASSWORD"]
    sender = os.environ.get("SMTP_FROM", username)

    safe_name = html.escape(name)
    message = EmailMessage()
    message["From"] = sender
    message["To"] = recipient
    message["Subject"] = "Your account is ready"
    message.set_content(
        f"Hello, {name}!\n\nYour account is ready. "
        "Sign in through the application's official address.\n"
    )
    message.add_alternative(
        f"""\
        <html><body>
          <p>Hello, <strong>{safe_name}</strong>!</p>
          <p>Your account is ready. Sign in through the official address.</p>
        </body></html>
        """,
        subtype="html",
    )

    context = ssl.create_default_context()
    with smtplib.SMTP(host, port, timeout=30) as server:
        server.ehlo()
        server.starttls(context=context)
        server.ehlo()
        server.login(username, password)
        server.send_message(message)

create_default_context() enables certificate verification and Python's safe defaults. Do not disable verification to bypass an error; correct the hostname, certificate chain, or trust store. Repeating ehlo() refreshes advertised capabilities after TLS starts.

Add attachments with explicit limits

Validate that an attachment exists, is permitted, and is not too large. Transfer encoding increases message size, and servers often reject oversized mail. This helper caps each attachment at 10 MB and detects a likely media type with mimetypes.

import mimetypes
from pathlib import Path
from email.message import EmailMessage

ATTACHMENT_LIMIT = 10 * 1024 * 1024

def attach_file(message: EmailMessage, path: Path) -> None:
    path = path.resolve()
    if not path.is_file():
        raise FileNotFoundError(path)
    if path.stat().st_size > ATTACHMENT_LIMIT:
        raise ValueError(f"Attachment exceeds 10 MB: {path.name}")

    content_type, _ = mimetypes.guess_type(path.name)
    maintype, subtype = (content_type or "application/octet-stream").split("/", 1)
    message.add_attachment(
        path.read_bytes(),
        maintype=maintype,
        subtype=subtype,
        filename=path.name,
    )

## After creating the message:
## attach_file(message, Path("report.pdf"))

Do not accept an arbitrary user-supplied path. Restrict files to a known directory and confirm that resolved paths remain inside it. Our Python pathlib guide covers robust path operations. Avoid executable attachments and inspect content supplied by another system.

Practical project: daily report sender

Build a function that receives already calculated metrics, renders text and HTML, attaches an optional PDF, and sends only to an allowlisted audience. Keep data collection separate from SMTP transport. That boundary makes the message testable without network access and lets the sender join a broader Python automation workflow.

def build_report(recipient: str, total: int) -> EmailMessage:
    if "\n" in recipient or "\r" in recipient:
        raise ValueError("Invalid recipient")
    msg = EmailMessage()
    msg["From"] = os.environ["SMTP_FROM"]
    msg["To"] = recipient
    msg["Subject"] = "Daily summary"
    msg.set_content(f"Daily summary\nProcessed items: {total}\n")
    msg.add_alternative(
        f"<p>Daily summary</p><p>Processed items: <strong>{total}</strong></p>",
        subtype="html",
    )
    return msg

During development, use a testing SMTP server that captures messages instead of delivering them. Inspect subject, recipients, text, HTML, and attachment metadata. Unit tests can replace the connection with a fake object; our Python unit testing guide explains dependency isolation.

Handle errors without hiding the cause

Network failures may be temporary; bad authentication and rejected recipients normally need correction. Catch specific exceptions, record only non-sensitive context, and decide whether retrying makes sense. Avoid endless retries: use a small maximum, increasing delay, and an idempotency mechanism to reduce duplicates.

import logging
import smtplib
import socket

logger = logging.getLogger(__name__)

try:
    send_welcome("[email protected]", "Alex")
except smtplib.SMTPAuthenticationError:
    logger.error("SMTP authentication was rejected")
    raise
except smtplib.SMTPRecipientsRefused:
    logger.warning("The SMTP server rejected a recipient")
    raise
except (smtplib.SMTPException, TimeoutError, socket.timeout, OSError):
    logger.exception("SMTP communication failed")
    raise

Do not log passwords, message bodies, attachments, or complete addresses when they are personal data. An internal delivery identifier is often enough. Follow the principles in our Python exception-handling guide and Python logging tutorial. In a queue, mark success after the server accepts the message, while remembering that acceptance is not proof of final delivery or reading.

Use SMTP_SSL for implicit TLS

When the provider explicitly requires a connection encrypted from the beginning, commonly on port 465, use SMTP_SSL rather than starttls(). Follow the service documentation instead of selecting behavior from the port number alone.

context = ssl.create_default_context()
with smtplib.SMTP_SSL(host, 465, context=context, timeout=30) as server:
    server.login(username, password)
    server.send_message(message)

Validate addresses and prevent header injection

An address that looks plausible is not necessarily deliverable, but basic validation still prevents malformed headers and obvious mistakes. Never concatenate an untrusted value into raw header text. EmailMessage provides structured headers, yet application input should still reject carriage returns and line feeds. Parse addresses with utilities from email.utils, apply the product's business rules, and confirm ownership through a verification message when identity matters. Do not attempt to prove delivery by making speculative SMTP requests to the recipient's mail server; those checks are unreliable and can resemble abusive traffic.

Keep To, Cc, and Bcc semantics explicit. Recipients in Bcc receive the message without appearing in the transmitted visible headers, but application logs and the provider still process their addresses. For a notification sent to unrelated people, send individual messages instead of exposing a shared recipient list. Place a reasonable cap on recipients per operation and require an allowlist for sensitive internal reports.

Understand delivery after SMTP acceptance

A successful send_message() means the contacted SMTP server accepted responsibility for the message. It does not prove arrival in the inbox, presentation to the user, or reading. A later bounce can report an invalid mailbox, a policy rejection, or a temporary failure. Production integrations therefore need a provider-supported mechanism for processing bounces and complaints, plus an internal identifier that connects those events to the original notification without storing the entire body in logs.

Retries require special care because a timeout may happen after the remote server accepted the message. Add a stable application delivery key, persist state around the queue operation, and make repeated work detectable. The standard Message-ID header helps mail systems thread and identify messages, but it is not by itself a universal idempotency guarantee. Retry only failures classified as temporary, use bounded exponential delay, and move exhausted jobs to a review queue.

Authentication and domain reputation

SMTP credentials authorize the connection, while domain authentication helps receiving systems evaluate the message. Configure SPF, DKIM, and DMARC through the sending provider and DNS administrator. Align the visible sender with a domain the service is permitted to use. These controls cannot be implemented solely in a Python script, and none guarantees inbox placement, but omitting them commonly damages trust and deliverability.

For bulk or marketing communication, use a service designed for consent records, suppression lists, unsubscribe handling, rate limits, and complaint feedback. A direct smtplib loop is appropriate for controlled transactional work, not for bypassing those operational and legal responsibilities.

Common errors and checklist

  • 535 Authentication failed: verify account, secret, authentication policy, and whether an app password is required.
  • TLS or certificate error: check hostname, system clock, and certificate chain; never disable verification.
  • Mail reaches spam: configure SPF, DKIM, and DMARC with the provider; code alone cannot guarantee reputation.
  • Attachment rejected: reduce its size and check file and total-message limits.
  • Broken HTML: retain the text alternative, escape data, and test representative mail clients.

Before deployment, confirm credentials are outside source, TLS is verified, timeout is set, sender is authorized, recipients are validated, consent and unsubscribe rules are met, attachments are bounded, plain text exists, logs are minimized, tests cannot deliver real mail, retries are finite, and failures are monitored. These controls make a simple integration predictable without mistaking SMTP acceptance for guaranteed delivery.