Email learning series

An email lab: experiments for understanding delivery

By Pedro Paiva

Understanding email is easier when we inspect its evidence. These five exercises separate local observations from internet-delivery claims. Use local tools or domains and accounts you control. Fictional addresses don’t provide live mailboxes.

1. Inspect raw MIME

Question: Which parts are alternatives and which are attachments?

Setup: Python 3.11+ and the complete fictional message from the MIME article, saved as welcome.eml. No server or account is required.

Steps: Parse locally:

python3 - <<'PY'
from email import policy
from email.parser import BytesParser
with open('welcome.eml', 'rb') as source:
    message = BytesParser(policy=policy.default).parse(source)
for part in message.walk():
    print(part.get_content_type(), part.get_filename(), part.defects)
    if not part.is_multipart():
        print(repr(part.get_payload(decode=True)))
PY

Observe: The mixed container holds an alternative container and notes.txt. Plain text and HTML represent the same content; the attachment decodes to Hello and a newline. No defects is useful structure evidence, not a guarantee of identical rendering in all clients.

Cleanup and limits: Keep the file for exercise 4, then delete it. Nothing was sent; this doesn’t test delivery, recipient validity or DKIM.

2. Capture SMTP on loopback

Question: When does the capture server accept the whole message?

Setup: Docker, Ruby 3.3 and Mailpit v1.20.3. Start a loopback-only capture without relay:

docker run --rm --name smtp-lab \
  -p 127.0.0.1:1025:1025 -p 127.0.0.1:8025:8025 \
  axllent/mailpit:v1.20.3

Steps: Run this Ruby probe in another terminal:

require 'socket'
s = TCPSocket.new('127.0.0.1', 1025)
def read_reply(s)
  loop do
    line = s.gets or raise 'Connection closed'
    puts line
    break unless line[3] == '-'
  end
end
read_reply(s)
['EHLO app.example.org', 'MAIL FROM:<bounces@example.org>',
 'RCPT TO:<reader@example.net>', 'DATA'].each do |line|
  s.write(line + "\r\n")
  read_reply(s)
end
s.write("From: hello@example.org\r\nTo: reader@example.net\r\n" \
        "Subject: Lab\r\n\r\nLocal capture only.\r\n.\r\n")
read_reply(s)
s.write("QUIT\r\n")
read_reply(s)
s.close

Observe: 354 invites DATA; final 250 follows its dot terminator. Inspect http://127.0.0.1:8025 and the raw-message view. Wording can differ. The SMTP article explains stages and dot-stuffing.

Cleanup and limits: docker stop smtp-lab removes the container. Capture doesn’t test DNS, TLS or real mailboxes. Mailpit isn’t a production sending queue and doesn’t demonstrate its retry policy.

3. Query routing for your own domain

Question: Which hosts does DNS advertise as mail destinations?

Setup: BIND dig 9.18+ or equivalent and a reader-controlled domain with known DNS. Replace the reserved example names; they aren’t public fixtures.

Steps: Query MX, then each returned exchanger’s address records:

dig example.net MX
dig mx1.example.net A
dig mx1.example.net AAAA

Observe: Record actual status, TTL, preference and addresses; compare with your configured DNS. If you configured null MX, expect preference zero and target .. With no MX, investigate the applicable SMTP address fallback. These are conditional expectations, not observed example.net answers.

Cleanup and limits: Queries need no cleanup. Undo any test DNS changes, accounting for cache TTLs. DNS identifies routes, not mailbox existence or inbox placement. The DNS article separates temporary and negative answers.

4. Inspect authentication assertions

Question: Who asserted an authentication result, and which identity did they check?

Setup: Insert this fictional header into the header block of welcome.eml. It is deliberately an assertion you author yourself:

Authentication-Results: mx.example.net;
 spf=pass smtp.mailfrom=example.org;
 dkim=pass header.d=example.org;
 dmarc=pass header.from=example.org

Steps: Print all such headers:

python3 - <<'PY'
from email import policy
from email.parser import BytesParser
with open('welcome.eml', 'rb') as source:
    message = BytesParser(policy=policy.default).parse(source)
for value in message.get_all('Authentication-Results', []):
    print(value)
PY

Observe: You can identify the authentication service name and method-specific domains. Because you inserted the header, its presence proves no authentication. A familiar service hostname can also be forged.

For a live comparison, export a message from a mailbox you control and identify that provider’s documented trusted authentication service and header-sanitization boundary. Evaluate only assertions inside that trust boundary. This optional step needs an account. If using SES, verified identities, sandbox restrictions and possible charges apply; never send to unrelated recipients.

Cleanup and limits: Remove saved messages after inspection. Authentication-Results isn’t self-verifying evidence, and even a trusted pass doesn’t imply inbox placement elsewhere. RFC 8601 describes the trust model.

5. Simulate temporary and permanent rejection

Question: Can our probe distinguish a transient refusal from a permanent one?

Setup: Ruby 3.3. Save the following as failure-server.rb. It is an intentionally limited loopback fixture, not a general SMTP server. It has no relay or message storage:

require 'socket'
server = TCPServer.new('127.0.0.1', 1026)
loop do
  client = server.accept
  client.write("220 local-failure-lab\r\n")
  while (line = client.gets)
    response = case line
    when /\AEHLO /i then '250 local-failure-lab'
    when /\AMAIL FROM:/i then '250 2.1.0 sender accepted'
    when /\ARCPT TO:<temporary@example\.net>/i
      '451 4.3.0 simulated temporary failure'
    when /\ARCPT TO:<permanent@example\.net>/i
      '550 5.1.1 simulated missing mailbox'
    when /\AQUIT/i then '221 closing'
    else '503 5.5.1 command outside lab sequence'
    end
    client.write(response + "\r\n")
    break if line.match?(/\AQUIT/i)
  end
  client.close
end

Steps: Start ruby failure-server.rb, then run this probe in another terminal:

require 'socket'
%w[temporary permanent].each do |name|
  s = TCPSocket.new('127.0.0.1', 1026)
  puts s.gets
  ["EHLO app.example.org", "MAIL FROM:<bounces@example.org>",
   "RCPT TO:<#{name}@example.net>", 'QUIT'].each do |line|
    s.write(line + "\r\n")
    puts s.gets
  end
  s.close
end

Observe: Temporary returns 451 4.3.0; permanent returns 550 5.1.1. The probe doesn’t retry. Run it again for a second attempt: the fixture deliberately returns the same failure. A production queue applies bounded retries to temporary failure rather than blindly repeating permanent failure.

Cleanup and limits: Stop with Ctrl+C and remove the fixture. Both failures occur at RCPT before DATA. No message was accepted and no later DSN was generated. To test application recovery, include the real worker and durable queue; a capture fixture alone can’t establish it.

Questions for another round

How does forwarding change the IP SPF checks? Which mailing-list changes invalidate a full-body DKIM signature? Which DNS records determine your DMARC Organizational Domain under RFC 9989? How would controlled accounts distinguish server acceptance from spam placement?

What does an open event actually measure? Image blocking can conceal human reading; privacy proxies, prefetching and automated scanners can fetch pixels without a human opening the message. Open tracking is weak evidence, not a reliable read receipt.

References