guide · smtp python
How should a product team implement SMTP with Python safely?
Implement SMTP in Python from an authorized background worker, not directly from a web request. Build the message with EmailMessage, use SMTP_SSL for implicit TLS or explicitly upgrade with STARTTLS when the provider's current contract requires it, authenticate with a server-side secret, and call send_message with bounded timeouts. Persist the job before connecting, record recipient-level refusal evidence, reconcile ambiguous disconnects, and distinguish SMTP acceptance from later delivery and inbox placement.
Authorize and persist the send before SMTP
Begin with a legitimate application event such as a receipt, security alert, requested verification, or account notice. Authenticate the caller and authorize the tenant, message class, visible From identity, recipient, and template revision. Write a durable outbound job with a stable business-event key before opening any SMTP connection. That key should prevent two workers from independently creating the same logical message. Browser input must not choose the SMTP host, port, username, envelope sender, arbitrary recipients, headers, or TLS policy. Keep those values in reviewed server configuration. A queue worker should claim one job, recheck suppressions and authorization at send time, record each attempt, and release or finalize the job through explicit states. Python's SMTP library transports the prepared message; it does not supply tenant authorization, consent, idempotency, or suppression policy.
Construct the message with EmailMessage
Use email.message.EmailMessage rather than concatenating raw headers and bodies. Set From, To, Subject, Date, and a generated Message-ID according to the application's approved model, then use set_content for text and add_alternative for HTML when needed. Validate address objects, bound recipient and attachment counts, reject newline injection in values, and escape template data for the output context. Generate both text and HTML from one immutable template revision. Keep secrets and unnecessary personal data out of subjects, custom headers, filenames, diagnostic fields, and logs. Separate the visible From header from the SMTP envelope sender deliberately because authentication and bounce processing can depend on different identities. Store a content revision or privacy-safe hash for audit instead of retaining full message bodies without a defined need.
Choose implicit TLS or STARTTLS explicitly
Python documents SMTP_SSL for connections encrypted from the start and SMTP.starttls for upgrading an established connection. Follow the provider's current hostname, port, certificate, and submission contract instead of guessing from a generic port list. Create a verified default SSL context and do not disable certificate or hostname checks. For STARTTLS, connect, issue EHLO as needed, call starttls with the context, and issue EHLO again because advertised extensions can change after the upgrade. Never send credentials or customer message content over a cleartext connection. RFC 8314 recommends TLS-protected submission and deprecates cleartext access. Treat certificate failure, hostname mismatch, missing required STARTTLS, or unexpected capability changes as hard failures requiring investigation rather than falling back silently.
Keep SMTP credentials in a narrow secret boundary
Load the username and password or token from a managed server-side secret facility at runtime. Do not put credentials in source code, client bundles, environment dumps, URLs, exception traces, analytics, notebooks, screenshots, prompts, or committed fixtures. Scope each credential to the smallest environment and workload the provider supports, and separate development from production. Authenticate only after the required TLS state is established. Exercise rotation with controlled recipients: provision the replacement through approved administration, update the worker, confirm authentication and a full event lifecycle, then revoke the old value. Repeated authentication failure should pause the affected route rather than trigger a rapid retry loop. Python's login method negotiates among server-advertised mechanisms, but the provider's actual mechanism, account policy, token permissions, and rotation behavior require current evidence.
Use a bounded Python sending function
Keep the provider adapter small and return structured evidence to the job state machine. A typical flow creates an SSL context, opens SMTP_SSL(host, port, timeout=10) as smtp for implicit TLS, calls smtp.login(username, secret), then calls smtp.send_message(message, from_addr=envelope_from, to_addrs=recipients). For a provider requiring explicit upgrade, use SMTP with a timeout, ehlo, starttls(context=context), ehlo, and then login. Do not present example hostnames or ports as universal defaults. Pass a normalized recipient list rather than relying on untrusted header parsing. Capture the exception class, SMTP reply code and bounded diagnostic text when available, but redact addresses, credentials, and message content. Measure connection, TLS, authentication, envelope, data, and quit phases separately so operational failures remain diagnosable.
Interpret send_message recipient results precisely
Python documents that sendmail and send_message return normally when mail is accepted for at least one recipient and return a dictionary for refused recipients; an empty dictionary means no recipient was refused at that stage. Preserve that recipient-level result rather than marking the entire job delivered. If all recipients are refused, the library raises an SMTPRecipientsRefused exception. Other exceptions distinguish sender refusal, DATA refusal, authentication, connection, protocol, and related errors. Map exact evidence into application states: accepted by the submission server, permanently refused, transiently refused, or unknown. A normal return proves only the scoped SMTP submission result. It does not establish destination-server acceptance, final mailbox placement, reading, or engagement. Later delivery status notifications or provider events must be correlated separately.
Retry only when duplicate risk is controlled
Classify failures before scheduling another attempt. Permanent address, sender, authentication, policy, or content failures generally need correction or suppression rather than automated repetition. Transient 4xx replies may be retried with exponential backoff, jitter, an attempt ceiling, expiry, and a per-destination budget. A connection reset or timeout after message data may be ambiguous: the server could have accepted the message while the client missed its final reply. Keep that attempt unknown, inspect provider activity or later events through privacy-safe correlation, and avoid immediate blind resend. SMTP has no universal application idempotency key. The durable business-event key prevents concurrent application attempts but cannot force a remote SMTP server to deduplicate two accepted submissions. Escalate repeated ambiguous outcomes and preserve the exact evidence used for the decision.
Handle partial recipients and suppressions
When a message has multiple recipients, SMTP can accept some and refuse others. Store each recipient's reply and advance only the accepted subset into the next state. Do not resend the entire original list merely because one address received a transient refusal. Apply permanent-bounce, complaint, unsubscribe, legal, tenant, and administrator suppressions before each attempt, including retries. Separate message classes only through an explicit documented policy; labeling a message transactional does not erase recipient safety or provider restrictions. Prefer one-recipient jobs for sensitive workflows when privacy and individualized state justify the cost. Avoid exposing recipient lists through To or Cc, and never use Bcc behavior as a substitute for authorization. Limit and redact diagnostic text because SMTP replies can contain recipient addresses or receiver-specific details.
Test failure paths with controlled systems
Test message construction, Unicode, text and HTML alternatives, attachments, header rejection, recipient normalization, TLS verification, missing STARTTLS, invalid credentials, sender refusal, one and all recipient refusals, DATA refusal, timeouts before and after possible acceptance, disconnects, rate responses, retry expiry, duplicate workers, suppression changes, and secret rotation. Use a controlled test SMTP service or local fake for deterministic unit and integration tests; never route accidental lower-environment traffic to customer addresses. In production canaries, use authorized recipients and inspect raw headers for the visible From, envelope path, Message-ID, DKIM, SPF, DMARC alignment, and provider evidence. Confirm logs and metrics contain no credential or message-body leakage. Fail launch if the worker can bypass tenant authorization, downgrade TLS, retry without bounds, ignore partial refusal, or cannot pause the sending route.
How SendHQ fits
SendHQ is a workspace-scoped email API for expected product communication. Its documentation covers sending, verified domains, delivery events, and suppressions. Use its documented HTTP API when integrating SendHQ with Python.
Frequently asked questions
Should Python use SMTP_SSL or STARTTLS?
Use the mode required by the provider's current submission contract. SMTP_SSL encrypts from connection start; STARTTLS upgrades explicitly and requires verified TLS plus a fresh EHLO.
Does send_message returning normally prove delivery?
No. It means at least one recipient was accepted at that SMTP submission stage. Destination acceptance, mailbox placement, and engagement need later scoped evidence.
What does the send_message return dictionary mean?
It maps recipients refused by the SMTP server to reply evidence. An empty dictionary means none were refused at that stage, not that every message reached an inbox.
Can a timeout be retried immediately?
Not safely when it occurred after possible submission. Preserve the attempt as ambiguous, reconcile provider or later event evidence, and resend only under a bounded duplicate-risk policy.
Where should the SMTP password be stored?
Use a managed server-side secret facility with narrow workload and environment access, audited retrieval, tested rotation, and no exposure to clients, logs, prompts, or fixtures.
Should certificate verification ever be disabled for production?
No. A certificate or hostname failure is evidence of unsafe or incorrect configuration. Stop the route and diagnose it instead of silently weakening TLS verification.
How should partial recipient refusal be handled?
Persist each recipient result, advance the accepted subset, and retry only eligible transient refusals. Do not resend already accepted recipients with the entire original list.
Does this page prove SendHQ supports SMTP?
No. This guide covers Python SMTP generally; use SendHQ's current documentation for its email API.
Sources
- smtplib — SMTP protocol client — Python Software Foundation
- email.message: Representing an email message — Python Software Foundation
- Python email examples — Python Software Foundation
- RFC 5321: Simple Mail Transfer Protocol — RFC Editor
- RFC 4954: SMTP Service Extension for Authentication — RFC Editor
- RFC 8314: Cleartext Considered Obsolete: Use of Transport Layer Security (TLS) for Email Submission and Access — RFC Editor