· Iqbal Abdullah Iqbal Abdullah

Email in the Python Ecosystem: My Talk at the Tokyo Python Meetup

I gave a talk on email protocols and Python's email toolkit at the Tokyo Python Meetup in April 2026. This is the expanded, written version with all the code examples, the context I had to skip, and the parts where I tripped over my own slides.


I had 31 slides and 20 minutes. That is not a ratio that works in anyone's favour. So on April 15, 2026, at the Tokyo Python Meetup (or as some of us call it, "Python Anonymous"), I did what any reasonable person would do: I rushed through the most interesting parts and ran out of time before the good stuff.

This post is the version I wish I had given. Same material, same code, same opinions, but with the luxury of not having a timer counting down while I fumble with display settings.

Download the slides (PDF)

P.S If you're interested about the meetup itself, I have written a short writeup ono ur Kafkai AI blog here.

Talking About KaiMail at Tokyo Python Meetup

Why Am I Talking About Email?

Two reasons. Both of them selfish.

First, I built KaiMail, an email forwarding and sending service for custom domains. The nearly the entire thing is built in Python. Standard library for parsing and message creation, PyPI packages for DKIM, SPF, and ARC. Every protocol, every library, and every gotcha in this post is something I have hit in production.

Second, and this is the real origin story: when I was organising PyCon JP and PyCon MY, our "email system" was one person's Gmail. Sponsor enquiries sat unread for days when that person was on holiday. Security reports went to a personal inbox. When a new co-chair came on board, we forwarded the password over LINE. It was embarrassing.

What we actually needed was simple: contact@, sponsors@, security@ on our own domain, each forwarding to the right person's inbox. No mail server, no per-seat fees, no shared passwords. Just DNS and a forwarding service. That frustration is why KaiMail exists, and it is also why we now offer a free program for open source communities to set up proper project email.

My career started with SMTP and mail servers. I have a soft spot for email. It is old technology, but Python gives you everything you need to work with it.

A Brief History of Email

At the start of the talk, I threw a bunch of numbers at the audience and asked them to raise their hand if any meant something to them: 1971, 821, 1995, 993, 587, 25. Port 25 got a few hands. The rest got blank stares. That is fair. Most developers interact with email through an API or a send_mail() function and never think about what happens underneath.

Here is the short version:

  • 1971: Ray Tomlinson sends the first networked email on ARPANET. He chose the @ symbol because it was not commonly used in people's names. The first message was allegedly "QWERTYUIOP," which is about as profound as you would expect from a test message.
  • 1976: Queen Elizabeth II sends the first head-of-state email from the Royal Signals and Radar Establishment. (I asked the audience who was alive in 1976. Two people raised their hands. Now we all know their age.)
  • 1982: RFC 821 (SMTP) and RFC 822 (message format) are standardised.
  • 2001: RFC 2822 modernises the message format.
  • 2008: RFC 5322, the current standard for email messages.

The key point: email predates the World Wide Web by roughly 20 years. HTTP, HTML, browsers: none of that existed when email was already working. There are over 4 billion email users worldwide, and the protocol they rely on was designed for a small, trusted academic network.

I have written a longer piece on the full history of email if you want the complete timeline from ARPANET to 400 billion daily messages.

Email Protocols: SMTP, IMAP, POP3 and MIME

When you talk about email on the internet, you are talking about protocols defined in RFCs. Here are the main ones:

Protocol Port Purpose
SMTP 25 / 587 Sending and relaying messages
IMAP 993 Reading mail (server-side storage)
POP3 995 Reading mail (download and delete)

Think of SMTP as the postal service. It moves the letter from sender to recipient. IMAP and POP3 are your mailbox. They let you read what arrived.

Port 25 is server-to-server relay. This is how mail transfer agents (MTAs) talk to each other. Port 587 is for authenticated client submission. This is what your mail client (your MUA, or mail user agent) uses to hand a message to the server. These two are not the same thing, even though they both speak SMTP.

Then there is MIME (Multipurpose Internet Mail Extensions). This is what makes modern email possible. Email is a very old protocol, much older than HTTP. It is character-based, not binary. So how do you send images, PDFs, or HTML in something designed for plain ASCII text? MIME.

MIME introduces content types (text/plain, text/html, application/pdf), character encodings, and the multipart structure that lets a single message contain multiple representations of the same content. Here is what a simple email looks like in raw form:

From: [email protected]
To: [email protected]
Subject: Hello!
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="abc123"

--abc123
Content-Type: text/plain
Hello, Bob!

--abc123
Content-Type: text/html
<html><body><b>Hello, Bob!</b></body></html>

--abc123--

The boundary string separates each part. The multipart/alternative structure tells the mail client: "these are different representations of the same content, pick the best one you can display." Clients typically pick HTML when it is available.

I urged everyone in the audience to go home and look at the raw data of an email. If you use Gmail, there is a "Download original" option. Do it. Open it in a text editor. It is all just text, boundaries, and headers. Start with a plain text email, then try one with images or HTML. You will learn more about email in ten minutes of poking around than in hours of reading RFCs.

Email's Original Sin: No Sender Verification

This is the fundamental problem with email, and it is worth understanding before we get to the solutions.

When SMTP was designed in 1982, the internet was a small, trusted academic network. There was no reason to verify senders. Nobody was going to pretend to be someone else on a network where everyone knew each other.

Today, this means anyone with a TCP connection can claim to be anyone:

MAIL FROM: <[email protected]>    <- Anyone can write anything here
RCPT TO: <[email protected]>
DATA
From: CEO <[email protected]>     <- And here too
Subject: Wire $50,000 immediately

The SMTP envelope MAIL FROM and the message From: header are completely independent. They do not even have to match. You can literally claim to be anyone. As I put it during the talk: you can say you are [email protected] and send someone an email. That is how phishing works. It is that easy.

The original SMTP protocol is essentially an honour system. And that is why we need authentication.

The Authentication Stack

Email authentication is a set of protocols layered on top of SMTP to answer three questions: Is the sending server authorised? Was the message tampered with? And what should happen when checks fail?

SPF: Sender Policy Framework

The question it answers: "Which servers are allowed to send for this domain?"

SPF works through DNS. A domain owner publishes a TXT record listing the IP addresses and servers authorised to send email on their behalf:

example.com.  IN TXT  "v=spf1 ip4:192.0.2.0/24 include:mailprovider.example.com -all"

When a receiving server gets an email, it checks: does the sending server's IP match what the DNS record says? The result is one of pass, fail, softfail, neutral, or none.

Think of SPF as a bouncer checking IDs at the door. It verifies the sending server is on the guest list. SPF has a limit of 10 DNS lookups to prevent abuse, which is something you will run into if your SPF record includes multiple third-party services.

DKIM: DomainKeys Identified Mail

The question it answers: "Was this message actually sent by this domain, and has it been tampered with?"

DKIM uses public-key cryptography. The sender signs the message headers and body with a private key. The receiver verifies the signature using a public key published in DNS:

DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=selector;
    h=from:to:subject:date; bh=abc123...; b=XYZ789...
selector._domainkey.example.com.  IN TXT  "v=DKIM1; k=rsa; p=MIIBIjAN..."

Think of DKIM as a wax seal on a letter. It proves the message has not been tampered with and actually came from the domain it claims.

A bit of personal history: DKIM was originally started by Yahoo. I was actually at Yahoo at the time when they were building this protocol, so it has a special place in my memory. Both SPF and DKIM work through DNS, which is clever because DNS is already the distributed database of the internet.

DMARC: Domain-Based Message Authentication

The question it answers: "What should receivers do when SPF or DKIM fail?"

DMARC is not a signing or verification mechanism itself. It is a policy. You publish it in DNS to tell the world what your email sending policy is:

_dmarc.example.com.  IN TXT  "v=DMARC1; p=reject; rua=mailto:[email protected]"

The policy can be none (just monitor), quarantine (put it in the spam folder), or reject (bounce it). The key concept is alignment: the domain in SPF or DKIM must match the From: header domain. Without DMARC, even if SPF fails, there is no instruction on what to do about it.

Of course, the receiving server is free to do whatever it likes. You just tell it your preference through DMARC.

ARC: Authenticated Received Chain

The question it answers: "How do we preserve authentication through forwarding?"

This is the newest of the four, and it is critical for anyone building an email forwarding service. When you forward an email, SPF breaks because the forwarding server's IP is not in the original sender's SPF record. DKIM can also break if you modify headers or add footers.

ARC solves this by creating a chain of trust. Each forwarder signs what it saw when the message arrived:

ARC-Seal: i=1; a=rsa-sha256; cv=none; d=forwarder.example.com; ...
ARC-Message-Signature: i=1; a=rsa-sha256; d=forwarder.example.com; ...
ARC-Authentication-Results: i=1; mx.forwarder.example.com;
    dkim=pass; spf=pass; dmarc=pass

The cv field tells you the chain status: cv=none means "I am the first hop, nothing before me," and cv=pass means "I checked the previous chain and it was valid."

In a production email forwarder like KaiMail, ARC signing is essential for deliverability. Without it, forwarded emails would fail DMARC checks at the destination. I have written a detailed post on the ARC protocol with Python code examples if you want to go deeper. We also implement multiple layers of inbound email security checks at the SMTP level before messages even reach the inbox.

Python's Email Toolkit

Standard Library: Batteries Included

Python is one of the few languages with comprehensive standard library support for email:

Module Purpose
email Parse and create email messages
smtplib Send emails via SMTP
imaplib Read emails via IMAP
poplib Read emails via POP3

I personally use email and smtplib heavily, poplib occasionally, and imaplib very seldom. The email module handles parsing and creating messages. It understands MIME, encodings, multipart structures, all of it. smtplib gives you low-level SMTP control. Between these two, you can do most of what you need.

Third-Party Libraries from PyPI

For authentication (DKIM, SPF, ARC), you need third-party packages, but they are mature and well-maintained:

Package Purpose
dkimpy DKIM and ARC signing/verification
pyspf SPF record checking
authres Authentication-Results headers (RFC 8601)
cryptography RSA key generation for DKIM
dnspython DNS lookups (MX, TXT records)

In KaiMail, we use all of these libraries together. They interoperate through raw bytes, which is the common currency of email processing.

Working with Email in Python

This is the part of the talk where I asked people to bear with me because the code was too small on the projector. Let me do this properly now.

Parsing Emails

from email import message_from_bytes
from email.utils import parseaddr

# Parse raw email bytes (always use bytes, not strings!)
raw_email = open("message.eml", "rb").read()
msg = message_from_bytes(raw_email)

# Access headers
sender_name, sender_addr = parseaddr(msg["From"])
subject = msg["Subject"]

# Walk multipart structure
if msg.is_multipart():
    for part in msg.walk():
        content_type = part.get_content_type()
        filename = part.get_filename()

        if filename:
            # It's an attachment
            attachment_data = part.get_payload(decode=True)
        elif content_type == "text/plain":
            charset = part.get_content_charset() or "utf-8"
            text = part.get_payload(decode=True).decode(charset)
        elif content_type == "text/html":
            charset = part.get_content_charset() or "utf-8"
            html = part.get_payload(decode=True).decode(charset)

The walk() method flattens the multipart tree so you can iterate over all parts. get_payload(decode=True) handles Content-Transfer-Encoding (base64, quoted-printable) for you.

Key insight: always use message_from_bytes(), not message_from_string(). There are two reasons for this, and both matter in production.

First, emails can contain multiple character encodings in different parts. message_from_bytes() handles this correctly. In real-world email processing, charset handling is where things get messy. You will encounter emails with incorrect charset declarations, missing charsets, and exotic encodings.

Second, and this is the one that will bite you harder: DKIM and ARC signatures are computed over the raw bytes of a message. If you read an email as a string and later re-encode it to bytes for signature verification or forwarding, you risk introducing subtle differences (a changed line ending, a re-encoded header) that invalidate the cryptographic signature. Working with bytes from the start means the bytes you verify are the bytes you forward or store, and the signature stays intact.

In short: bytes preserve both the encoding fidelity and the cryptographic integrity of the message. Strings give you neither guarantee.

Creating Emails

from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "Alice <[email protected]>"
msg["To"] = "[email protected]"
msg["Subject"] = "Meeting Tomorrow"

# Plain text content
msg.set_content("Hi Bob,\n\nLet's meet at 2pm.\n\nAlice")

# Add HTML alternative
msg.add_alternative(
    "<html><body>"
    "<p>Hi Bob,</p>"
    "<p>Let's meet at <b>2pm</b>.</p>"
    "<p>Alice</p>"
    "</body></html>",
    subtype="html",
)

# Add an attachment
with open("agenda.pdf", "rb") as f:
    msg.add_attachment(
        f.read(),
        maintype="application",
        subtype="pdf",
        filename="agenda.pdf",
    )

# Serialize to bytes (for signing or sending)
raw_bytes = msg.as_bytes()

EmailMessage is the modern API, added in Python 3.6. The older MIMEMultipart/MIMEText approach still works but is more verbose. Notice how set_content() sets the primary content, and add_alternative() creates the multipart/alternative structure automatically. The order matters: plain text first, then HTML, so mail clients can pick the richest format they support.

Once you call as_bytes(), you get the fully formatted RFC 5322 message ready to send or sign.

Sending Emails with smtplib

import smtplib
from email.message import EmailMessage

def send_email(msg: EmailMessage, mailfrom: str, recipients: list[str]):
    with smtplib.SMTP("smtp.example.com", 587) as smtp:
        smtp.starttls()
        smtp.login("username", "password")
        smtp.sendmail(
            from_addr=mailfrom,        # <- Envelope MAIL FROM
            to_addrs=recipients,
            msg=msg.as_bytes(),
        )

Standard SMTP submission on port 587 with STARTTLS. If you are looking for a service that handles all of this for your custom domain (including automatic DKIM signing), that is exactly what KaiMail's SMTP sending does.

The Envelope vs Headers Distinction

This is one of the most important concepts in email programming, and it trips up nearly everyone who has not worked with email at the protocol level.

Envelope (SMTP):                    Message (Headers):
  MAIL FROM: <bounces+123@         From: [email protected]
              example.com>          To: [email protected]
  RCPT TO: <[email protected]>

The envelope MAIL FROM controls where bounces and delivery failures go. It is invisible to the recipient. The header From: is what shows up in the recipient's mail client.

For an email forwarding service, you need different values: the From: header keeps the original sender, but the envelope MAIL FROM uses a special bounce tracking address so you know which forwarded message bounced.

I asked the audience how many people use Django on a daily basis. A fair number of hands went up. Here is the problem: Django's send_mail() cannot separate the envelope from the headers. Whatever From address you use in send_mail() will be used for both the envelope sender and the From: header. This is fine for basic notification emails, but it breaks down the moment you need proper bounce handling, forwarding, or any kind of production email processing.

This is the primary reason production email systems use smtplib directly. One concept, half of email's quirks explained.

DKIM Signing in Python

Key Generation

DKIM signing starts with generating an RSA keypair. The private key stays on your server, the public key goes into a DNS TXT record:

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import base64

private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,
)

# Private key (keep secret - used for signing)
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.NoEncryption(),
)

# Public key (publish in DNS as TXT record)
public_der = private_key.public_key().public_bytes(
    encoding=serialization.Encoding.DER,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
dns_record = f"v=DKIM1; k=rsa; p={base64.b64encode(public_der).decode()}"

2048-bit keys are the standard. Anything less than 2048 is considered too weak now. Anything more (like 4096-bit) can cause issues with DNS UDP packet size limits, because the public key has to fit in a DNS TXT record. Stick with 2048.

Signing a Message

import dkim

signature = dkim.sign(
    message=msg.as_bytes(),
    selector=b"selector1",
    domain=b"example.com",
    privkey=private_pem,
)
# Prepend signature to message
signed_message = signature + msg.as_bytes()

The dkimpy library handles all the complexity of canonicalisation, header selection, and signature computation. The result is a DKIM-Signature header that you prepend to the message.

One critical detail: dkimpy outputs CRLF line endings, but some mail servers store messages with LF only. You may need to normalise line endings. Also, and I cannot stress this enough: never re-serialise the message after signing. Any change to the bytes invalidates the signature. In a production system, once you have the bytes and sign them with DKIM, you send those exact bytes.

SPF Checking and ARC Signing in Python

SPF Checking

SPF checking is straightforward. One function call:

import spf

result, code, explanation = spf.check2(
    i="192.0.2.1",            # Sending server IP
    s="[email protected]",     # Envelope sender
    h="mail.example.com",      # HELO hostname
)
# result: "pass", "fail", "softfail", "neutral", "none"

You get the sending server IP, the envelope sender, the HELO hostname, and the library tells you whether the server is authorised to send for that domain.

ARC Signing (for Email Forwarding)

ARC signing is more involved because it builds on top of SPF and DKIM verification. I have to admit: when I first worked with ARC, I stumbled a lot. It is not intuitive.

The flow is: verify SPF and DKIM first, record the results in an Authentication-Results header, then ARC-sign the whole thing:

import dkim
import authres

# 1. Build Authentication-Results header
auth_results = str(authres.AuthenticationResultsHeader(
    authserv_id="mx.yourdomain.com",
    results=[
        authres.SPFAuthenticationResult(result="pass",
            smtp_mailfrom="[email protected]"),
        authres.DKIMAuthenticationResult(result="pass",
            d="example.com", s="selector1"),
    ],
))

# 2. ARC-sign the message (includes auth results)
arc_headers = dkim.arc_sign(
    message=msg_bytes,
    selector=b"arc1",
    domain=b"yourdomain.com",
    privkey=private_pem,
    authserv_id=b"mx.yourdomain.com",
)

# 3. Prepend ARC headers in reverse order (RFC 8617)
for header in reversed(arc_headers):
    msg_bytes = header + msg_bytes

ARC headers come in sets of three (Seal, Message-Signature, Authentication-Results) and must be prepended in reverse order per RFC 8617.

That reversed() call is the part that tripped me up. Here is why it is necessary.

dkimpy's arc_sign() builds the three headers in the order they must be computed:

  1. First the Authentication-Results, because it just records what SPF, DKIM, and DMARC checks returned.
  2. Then the Message-Signature, because it signs the message headers and body including the Authentication-Results it just created.
  3. Finally the Seal, because it signs over the entire ARC chain so far, including the Message-Signature.

Each header depends on the one before it, so they have to be generated in that order. But when you prepend headers one at a time, the last one prepended ends up on top. If you prepended them in generation order, you would get Authentication-Results on top. Reversing the list before prepending gives you the correct final order: Seal on top, then Message-Signature, then Authentication-Results.

Here is what that looks like in a real email forwarded through two hops (Yahoo to Google Groups to KaiMail):

ARC-Seal: i=2; a=rsa-sha256; t=1712217127; cv=pass;        <- cv=pass: prior chain valid
        d=google.com; s=arc-20160816;
        b=iD67k7qMUG25SzPI3bpk...
ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed;
        d=google.com; s=arc-20160816;                         Hop 2
        h=subject:message-id:to:from:date:dkim-signature;     (Google Groups
        b=NJKFf8IVE8U8jXrAaBki...                              -> KaiMail)
ARC-Authentication-Results: i=2; mx.google.com;
       dkim=pass [email protected]; spf=pass; dmarc=pass

ARC-Seal: i=1; a=rsa-sha256; t=1712217126; cv=none;         <- cv=none: first in chain
        d=google.com; s=arc-20160816;
        b=f9CpKU5mEMgn1vXT3R32...
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed;
        d=google.com; s=arc-20160816;                         Hop 1
        h=subject:message-id:to:from:date:dkim-signature;     (Yahoo
        b=eWaBjG77uH6bJnXBKrg+...                              -> Google Groups)
ARC-Authentication-Results: i=1; mx.google.com;
       dkim=pass [email protected]; spf=pass; dmarc=pass

The most recent hop (i=2) appears first in the raw headers because each forwarder prepends its headers to the top of the message. Once you understand how email works (headers stack from the top), it makes sense. But when you are working with it for the first time, it will trip you up. Trust me on that.

The Complete Email Lifecycle in Python

Here is the full picture. Every step uses Python, standard library for structure, PyPI for crypto:

    Raw Bytes (from network or file)
         |
         v
    +---------------------+
    |  email.message_     |   Parse raw bytes into
    |  from_bytes()       |   structured message object
    +---------+-----------+
              |
              v
    +---------------------+
    |  spf.check2()       |   Verify SPF
    |  dkim.verify()      |   Verify DKIM signature
    |  dkim.arc_verify()  |   Verify ARC chain
    +---------+-----------+
              |
              v
    +---------------------+
    |  authres.            |   Record what we verified
    |  Authentication      |   in standard header format
    |  ResultsHeader()     |
    +---------+-----------+
              |
              v
    +---------------------+
    |  dkim.sign()        |   Sign outbound message
    |  dkim.arc_sign()    |   Add ARC chain for forwarding
    +---------+-----------+
              |
              v
    +---------------------+
    |  smtplib.SMTP()     |   Deliver via SMTP
    |  .sendmail()        |   (separate envelope & headers)
    +---------------------+

Parse once into a message object for inspection, but always keep the raw bytes for cryptographic operations. Once you sign a message, you must send those exact bytes. Any re-serialisation breaks the signature.

Key Takeaways

  1. Python's standard library has everything for basic email. The email module for parsing and creating, smtplib for sending. You do not need a third-party library just to send or read email.

  2. Authentication matters: DKIM + SPF + ARC = deliverability. Without proper authentication, your emails land in spam or get rejected. Gmail and Yahoo tightened their sender requirements in 2024, and "it mostly works" is no longer good enough.

  3. Real-world email is messy. Encodings, multipart structures, broken headers, bounces, 50 years of backwards compatibility. Who does that now? Nobody. But email does it, and Python helps you navigate it.

  4. Python makes it manageable. dkimpy, pyspf, authres: these are mature, well-maintained libraries that handle the hard parts.

  5. The envelope/header distinction is fundamental. This one concept explains half of email's quirks. If you take away one thing from this post, let it be this.

Email is old, but it is not boring. If there is one thing I want you to take away, it is that email is a surprisingly deep and interesting protocol, and Python is one of the best languages to work with it. Download an .eml file, run message_from_bytes() on it, and explore the structure. You will learn more about email in ten minutes of hands-on Python than in hours of reading RFCs.

Bonus: PythonAsia 2026 in Manila

At the end of the talk, I spent a few minutes sharing highlights from PythonAsia 2026, the annual regional Python conference, which was held in Manila from March 21 to 23 this year.

The venue was De La Salle University (DLSU) in Malate, Manila. Over 500 people attended, from 18 different countries outside the Philippines. The programme included 3 keynotes, 7 workshops, 35 talks, 2 lightning talk sessions, and 1 open space. The third day was dedicated to an Education Summit and Sprints.

Jay Miller was one of our keynote speakers, and we had our Python Asia Organisation (PAO) booth set up. The VIP dinner on the first day was courtesy of the PAO, and the sponsor booths filled an entire hall.

I will encourage all of you to consider attending a PythonAsia conference. It is the nearest and one of the biggest international Python conferences on this side of the planet. You do not have to fly to Europe or the United States. With fuel surcharges going up, having a world-class Python conference in Asia matters. If you are looking for alternatives in the region, there is also PyCon SG in June and conferences across Southeast Asia throughout the year.

Resources

Python Documentation

RFCs

  • RFC 5321: SMTP Protocol
  • RFC 5322: Internet Message Format
  • RFC 6376: DKIM Signatures
  • RFC 7208: SPF
  • RFC 8617: ARC Protocol

PyPI Packages

Further Reading on KaiMail's Blog

If You Run an Open Source Community Or Project

We want to help. We have a free program to help open source projects and volunteer communities use their own domain for email. No mail server, no per-seat fees. If you run a project or community, tell us about it and we will get you set up.

Connect

If you have questions about anything in this post, or you want to talk about email, Python, or the intersection of the two:

I also write a monthly newsletter about Japan, AI, marketing, and the community. You can subscribe to the Kafkai Insights here.