Email learning series

An SMTP conversation, step by step

By Pedro Paiva

In the previous post, we mapped the journey of our welcome email to reader@example.net. Our application handed it to SES through the HTTPS API. SES then connected to the recipient’s mail server using SMTP.

Let’s zoom into that SMTP connection. What do the sending and receiving mail servers actually say to each other?

We’ll follow the same welcome email. On this connection, SES takes the sending role and the recipient’s mail system takes the receiving role. Our application’s API call is already behind us.

The exchanges below are illustrative, not a capture of SES traffic. We’ll call the sending server outgoing.example.org and the receiving server incoming.example.net. Both names are fictional. S: labels what the sending server says; R: labels the receiving server’s replies. These labels are annotations, not part of SMTP. Some message headers and protocol extensions are omitted to keep the exchange easy to follow.

Who speaks first?

The sending server opens the connection, but the receiving server speaks first:

R: 220 incoming.example.net ready

In plain English: “You’ve reached the receiving mail server. We can begin.”

The number is the reply code. The text after it is a human-readable explanation; different servers can phrase it differently.

Who is connecting?

The sending server introduces itself:

S: EHLO outgoing.example.org
R: 250 incoming.example.net hello

EHLO says: “Hello, I’m outgoing.example.org. What SMTP extensions do you support?” The 250 reply says this greeting succeeded. A real reply can also list supported extensions; we’ll leave that list in the optional notes below.

The greeting identifies the sending server. It hasn’t supplied an email address or sent the welcome message yet.

Who is this email for?

First, the sending server starts a mail transaction and supplies the address for delivery-failure notifications:

S: MAIL FROM:<bounces@example.org>
R: 250 Sender accepted

MAIL FROM says: “For this message, send delivery failures to bounces@example.org.” The receiver accepts that return address for the transaction.

Then comes the recipient:

S: RCPT TO:<reader@example.net>
R: 250 Recipient accepted

RCPT TO says: “This message is for reader@example.net.” The receiver agrees to accept that recipient for this transaction.

Here’s the interesting part: the receiver has accepted the recipient without seeing the subject or body. So far, it has only the delivery instructions. The welcome email itself is still on the sending side.

When does the actual message arrive?

The sender asks to send the message:

S: DATA
R: 354 Send message, end with a line containing only a dot

DATA says: “I’m ready to send the message itself.” The 354 reply means “Go ahead.” It invites the sender to continue; it does not accept the completed message.

Now the sender supplies the headers and body:

S: From: Welcome <hello@example.org>
S: To: reader@example.net
S: Subject: Welcome
S:
S: Welcome to the application.
S: .

The blank line separates the headers from the body. The line containing only a dot tells the receiver: “That’s the end of the message.” The dot is a protocol marker, not part of the reader’s email.

Notice that MAIL FROM used bounces@example.org, while the message’s From: header uses hello@example.org. One belongs to the delivery instructions; the other is part of the message the reader sees. The next post explores why email has these two layers of addressing.

What does the final acceptance mean?

Only after that terminating dot does the receiver answer for the complete message:

R: 250 Message accepted

In plain English: “I’ve accepted the message and taken responsibility for delivering it or passing it onward.”

Compare the two important replies: the earlier 250 after RCPT TO accepted a recipient; this 250 after the message data accepts the message itself. The receiver could still have rejected the message at this final step, even after accepting its recipient.

As we saw in the previous post, acceptance doesn’t prove inbox placement or reading. It tells us this SMTP handoff succeeded.

The sender can then close the conversation:

S: QUIT
R: 221 Closing connection

QUIT says: “We’re finished with this connection.”

What if the receiver rejects the recipient?

Suppose the same transaction gets a different answer to RCPT TO:

S: RCPT TO:<reader@example.net>
R: 550 No such mailbox

In this illustrative failure, the receiver refuses the recipient. The sender cannot proceed to send the message for that rejected recipient. If this was the only recipient, there is no accepted recipient to continue with DATA.

The reply’s position tells us where the conversation stopped: the subject and body haven’t been sent. That’s why an SMTP reply is most useful when we know which step it answers.

Optional: look closer or try it locally

You now have the basic conversation. The sections below are for readers who want to inspect its wire format or send a message to a local capture server.

Technical notes: line endings, longer replies and dots

Line endings and the terminating dot

SMTP command and data lines use carriage return followed by line feed, written \r\n or CRLF. At the end of the data, the sequence is <CRLF>.<CRLF>: the final message line ends, then a line containing only a dot ends the transfer.

What if a body line itself begins with a dot? The sender adds one extra dot on the wire, and the receiver removes it. This is dot-stuffing. A body line .Hello travels as ..Hello, so message content cannot accidentally become the terminator.

Multiline replies and SIZE

An EHLO reply can advertise extensions over several lines:

R: 250-incoming.example.net
R: 250 SIZE 1048576

The hyphen after 250 means the reply continues. A space after the code marks its final line. The sender needs to read the whole reply before continuing.

Here, the fictional receiver advertises the SIZE extension with a maximum message size of 1,048,576 octets. A sender can use that extension to declare a message’s size with MAIL FROM. These values describe this example, not SES’s limits.

Optional lab: capture the welcome email with Mailpit

What you’ll see

In this exercise, a small local Ruby program plays the sending side and Mailpit plays the receiving side. You’ll watch the replies, then open the captured email and inspect its raw source. This makes the boundary between SMTP delivery instructions and message headers visible.

Mailpit captures the message locally. Accepting reader@example.net here does not demonstrate that the mailbox exists or that anything was delivered over the internet.

Start the receiving side

You’ll need Docker and Ruby 3.3. Start the pinned Mailpit image with both published ports bound to loopback:

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

Port 1025 accepts the local SMTP connection; port 8025 serves Mailpit’s web interface. This configuration does not relay the message to external servers.

Run the sending side

Save this as send_welcome.rb and run ruby send_welcome.rb in another terminal. The helper prints the receiver’s replies, reads multiline replies completely, and checks the expected codes.

require 'socket'
require 'time'

def reply(socket, expected)
  loop do
    line = socket.gets or raise 'Connection closed'
    puts line
    raise "Unexpected reply: #{line}" unless line.start_with?(expected)
    break unless line[3] == '-'
  end
end

def command(socket, text, expected)
  socket.write(text + "\r\n")
  reply(socket, expected)
end

socket = TCPSocket.new('127.0.0.1', 1025)
begin
  reply(socket, '220')
  command(socket, 'EHLO outgoing.example.org', '250')
  command(socket, 'MAIL FROM:<bounces@example.org>', '250')
  command(socket, 'RCPT TO:<reader@example.net>', '250')
  command(socket, 'DATA', '354')

  headers = [
    "Date: #{Time.now.rfc2822}",
    'From: Welcome <hello@example.org>',
    'To: reader@example.net',
    'Subject: Welcome'
  ]
  socket.write(headers.join("\r\n") +
               "\r\n\r\nWelcome to the application.\r\n.\r\n")
  reply(socket, '250')
  command(socket, 'QUIT', '221')
ensure
  socket.close
end

The program uses actual CRLF line endings. It sends a fixed body without dot-prefixed lines; a sender for arbitrary content would also need to apply dot-stuffing. Mailpit’s reply text and extension list can differ from the illustrative walkthrough.

Inspect the captured message

Open http://127.0.0.1:8025 and select the welcome email. You should see its subject and body. Then inspect its raw source: find From:, To: and Subject:, followed by the blank line and body. The SMTP commands and terminating dot are not message headers or body content.

An extra syntax experiment

If you want to see the receiver reject a malformed command, add this line immediately before the valid RCPT TO command, then run the program again:

command(socket, 'RCPT TO:broken', '501')

The missing address brackets cause a 501 syntax error in this pinned Mailpit implementation. The valid command that follows can still succeed. This exercises the command parser; it does not test whether a mailbox exists and is different from the illustrative 550 recipient rejection above.

Clean up

Stop Mailpit with docker stop smtp-capture. The --rm container is removed, and no persistent volume was configured. This lab exercises a local SMTP exchange and capture; it doesn’t test DNS routing or internet delivery.

References