0.0
The project is in a healthy, maintained state
The SMTP command/reply state machine (RFC 5321) and the message it assembles (RFC 5322), over any stream. No sockets and no concurrency: async-smtp binds this to an endpoint, the way async-http binds protocol-http.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 2.1
~> 13.0
~> 1.60
~> 1.0

Runtime

~> 0.2
 Project Readme

protocol-smtp

Abstractions for the SMTP protocol: RFC 5321's command/reply state machine and the RFC 5322 message it assembles, for both sides of the conversation.

No sockets and no concurrency, and the one dependency is a deprecation DSL. A Connection talks over anything that answers #gets(separator, limit), #write, #flush and #close — a TCPSocket, an IO::Stream, a StringIO. Binding it to an endpoint and a reactor is async-smtp's job, the way async-http binds protocol-http.

Server

require "protocol/smtp"

server = Protocol::SMTP::Server.new(stream, domain: "mail.example.com")
server.write_greeting

while message = server.read_message
  message.from                 # "me@example.com" — the envelope
  message.to                   # ["you@example.com"]
  message.subject              # "Hello" — the headers, unfolded
  message.data                 # the whole of it, verbatim

  server.write_reply(Protocol::SMTP::Reply.ok("queued"))
end

#read_message answers every command the state machine owns — MAIL, RCPT, RSET, NOOP, QUIT, the 354 before DATA — and hands back each complete message. The reply to the message is the one reply the protocol has no opinion about, so it is yours to write.

Who drives that loop, what an application is allowed to answer with, and when the stream gets closed are all deliberately absent: that is async-smtp's job, for a socket on a reactor. Nothing here opens, closes, or waits on anything.

The state machine enforces RFC 5321 4.3.2 sequencing (MAIL before RCPT before DATA, a 503 otherwise), re-issued MAIL FROM starting the transaction over (4.1.1.2), dot unstuffing (4.5.2), the line length limit (4.5.3.1) and a message size limit. Past that size it keeps reading the body and answers 552 at the terminating dot, rather than replying to the rest of the message as if it were commands (RFC 1870 6.2).

EHLO advertises SIZE, 8BITMIME, and STARTTLS when — and only when — an upgrade is possible. Pass a callable that takes the current stream and returns an encrypted one; the 220 goes out in the clear first, and the client has to EHLO again afterwards (RFC 3207 4.2):

Protocol::SMTP::Server.new(stream, starttls: ->(stream) {OpenSSL::SSL::SSLSocket.new(stream, context).tap(&:accept)})

What a framework lets its users return instead of a Reply — a String, a status code, nothing at all — is that framework's business, not the protocol's.

Client

client = Protocol::SMTP::Client.new(stream)
client.deliver(
  from: "me@example.com",
  to: "you@example.com",
  body: "Subject: Hello\r\n\r\nHi.\r\n",
)
client.quit

Each command returns its Reply rather than raising on one, because which codes are fatal depends on what you are doing. #deliver, which has to get a whole transaction through in order, raises ReplyError on anything it cannot continue from. #quit sends QUIT and reads the 221; closing the stream is for whoever opened it.

#hello falls back to HELO for a server that does not know EHLO (RFC 5321 2.2.1), and what EHLO advertised is available afterwards:

client.hello("client.example.com")
client.extensions            # {"SIZE" => "35651584", "AUTH" => "PLAIN LOGIN", ...}
client.starttls?             # true — ask with #starttls, then replace client.stream
client.mechanisms            # ["PLAIN", "LOGIN"]
client.maximum_message_size  # 35651584

client.authenticate("user", "password")  # AUTH PLAIN (RFC 4616), or LOGIN

Upgrading the stream itself is the caller's job, because a protocol gem has no socket to upgrade — that is async-smtp's Client.

Message

The envelope (#from, #to) is what the conversation said; the headers are what the data claims. They disagree more often than people expect, so both are there and neither is derived from the other.

A message pattern matches as [from, to], or by keys — from, to, data, helo, peer, headers, subject, body:

case message
in { to: [/@example\.com\z/, *], subject: /urgent/i } then ...
end

What it does not do

No server-side AUTH (an AUTH command gets a 502), no pipelining, no CHUNKING, no relaying or queueing, no DKIM or SPF. It is the conversation, not a mail system.

Tests

They live in the __END__ section of the file they test, and run with scampi:

bin/test                            # everything
bin/test lib/protocol/smtp/server.rb # one file

Releasing

Inside the devshell:

bin/test                  # 1. green suite
gem kit bump minor        # 2. bump the version
gem kit changelog --write # 3. write the entry
git commit -am "Release ..." # 4. the bump and the entry, one commit
gem kit release           # 5. gates, build, push
gem kit tag --push        # 6. tag it

Steps 2 and 5 are gates: a deprecation due at the new version, or a missing changelog section, stops them.

License

MIT.