Python's standard library covers email end to end: smtplib speaks SMTP and email.message builds the message. No dependencies required. This guide connects them to Postwing and covers HTML mail, attachments, inline images, bulk sending and the asyncio equivalent.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
import os
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "Acme <noreply@your-domain.com>"
msg["To"] = "customer@example.com"
msg["Subject"] = "Your order #4417 is confirmed"
msg.set_content("Thanks! Your order ships tomorrow.")
with smtplib.SMTP("smtp.postwing.app", 587, timeout=10) as smtp:
smtp.starttls() # upgrade to TLS before logging in
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
smtp.send_message(msg)On port 465 the socket is encrypted from the start instead:
import ssl
# Implicit TLS: encrypted from the first byte, no starttls() call.
context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.postwing.app", 465, context=context, timeout=10) as smtp:
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
smtp.send_message(msg)Set the plain-text body first, then attach the HTML as an alternative. The order matters — clients render the last part they understand:
msg = EmailMessage()
msg["From"] = "Acme <noreply@your-domain.com>"
msg["To"] = "customer@example.com"
msg["Subject"] = "Your order #4417 is confirmed"
# The plain-text part first...
msg.set_content("Order confirmed. Your order ships tomorrow.")
# ...then the HTML alternative. This makes it multipart/alternative.
msg.add_alternative(
"""\
<html>
<body>
<h1>Order confirmed</h1>
<p>Your order ships tomorrow.</p>
</body>
</html>
""",
subtype="html",
)from pathlib import Path
import mimetypes
path = Path("/srv/invoices/2026-03.pdf")
mime, _ = mimetypes.guess_type(path.name)
maintype, subtype = (mime or "application/octet-stream").split("/", 1)
msg.add_attachment(
path.read_bytes(),
maintype=maintype,
subtype=subtype,
filename="invoice.pdf",
)For an image the HTML references inline:
# Inline image, referenced from the HTML as <img src="cid:logo">
msg.add_alternative(
'<p>Thanks!</p><img src="cid:logo" width="120">', subtype="html"
)
logo = Path("assets/logo.png").read_bytes()
msg.get_payload()[1].add_related(logo, "image", "png", cid="<logo>")Reuse a single authenticated connection rather than reconnecting per message, and send one message per recipient:
# Open one connection and reuse it, instead of reconnecting per message.
with smtplib.SMTP("smtp.postwing.app", 587, timeout=10) as smtp:
smtp.starttls()
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
for user in users:
msg = EmailMessage()
msg["From"] = "Acme <noreply@your-domain.com>"
msg["To"] = user.email # one recipient per message
msg["Subject"] = "Your weekly report"
msg.set_content(render_report(user))
try:
smtp.send_message(msg)
except smtplib.SMTPRecipientsRefused:
log.warning("Refused: %s", user.email)smtplib blocks, which stalls an asyncio event loop for the whole SMTP round trip. Use aiosmtplib instead — it takes the same EmailMessage objects:
# aiosmtplib is the asyncio equivalent — same message objects, awaitable I/O.
import aiosmtplib
await aiosmtplib.send(
msg,
hostname="smtp.postwing.app",
port=587,
start_tls=True,
username=os.environ["SMTP_USER"],
password=os.environ["SMTP_PASS"],
)smtp.set_debuglevel(1) # print the full SMTP conversation to stderr| Error | Cause and fix |
|---|---|
SMTPAuthenticationError | Wrong credentials, or login() called before starttls(). |
SMTPNotSupportedError: STARTTLS extension not supported | starttls() on port 465. Use SMTP_SSL there instead. |
ssl.SSLError: wrong version number | SMTP_SSL on port 587. Use plain SMTP plus starttls(). |
| Hangs forever | No timeout passed and the port is blocked. Add one and switch to 8587. |
SMTPSenderRefused (550) | The From domain is not verified, or the token does not cover it. |
Subject shows as =?utf-8?B?… | Expected — that is RFC 2047 encoding. Mail clients decode it; do not "fix" it by stripping non-ASCII. |
SMTP with starttls() on port 587: the connection opens in plain text and is upgraded before login. SMTP_SSL on port 465: the socket is encrypted from the first byte and you must not call starttls(). Calling starttls() on an SMTP_SSL connection raises an error, and omitting it on port 587 sends your password in the clear.
EmailMessage is the modern email.message API and has been the recommended one since Python 3.6. It handles multipart assembly, headers, encoding and attachments through simple methods, where the older MIMEMultipart and MIMEText classes require assembling the tree yourself and get Unicode headers wrong easily.
The username or password is wrong, or you called login() before starttls() — credentials sent on an unencrypted connection are rejected. Ensure starttls() comes first on port 587.
Pass timeout to the constructor. Without it, smtplib inherits the global socket default, which is usually no timeout at all, so a blocked port leaves the process waiting indefinitely. If it does time out, your host is likely blocking outbound 587 — use 8587.
Call set_content() with the plain-text version, then add_alternative(html, subtype='html'). That order matters: mail clients display the last part they can render, so the HTML must come second. Sending HTML with no text part is a spam signal.
smtplib is synchronous and blocks the event loop, so in an async application use aiosmtplib instead. It accepts the same EmailMessage objects and exposes an awaitable send(). Alternatively hand the message to a background worker such as Celery.