mailsmtp

📬 Boring SMTP server for Ruby.

Answers one question: how do I receive emails in Ruby?

mailsmtp implements the SMTP protocol so you can plug in your own logic. Receive email in your own app — no Postfix, no ESP required. As the conversation progresses, you decide what AUTH, MAIL FROM, RCPT TO, and DATA mean.

mailsmtp handles:

  • the protocol - AUTH, MAIL FROM, RCPT TO, DATA, STARTTLS, PIPELINING
  • streaming - receive gets a live io into the DATA stream, no buffering the whole message
  • size limits - SIZE is advertised and enforced as DATA arrives
  • smuggling protection - bare LF lines are rejected by default
  • backpressure - concurrent DATA transfers are capped, with a queue and timeout
  • hot reloading - swap the STARTTLS certificate without restarting

Plus:

  • subclass-and-override hooks for every verb
  • raise a typed error to choose the SMTP reply
  • multi-listen across any number of host×port pairs
  • injectable logger and live connection/processing counters

Contents

Installation

Add this line to your application's Gemfile:

gem "mailsmtp"

Requires Ruby >= 3.3.4

Getting Started

Subclass MailSmtp::Server and override the hooks you care about.

require "mailsmtp"

class MyServer < MailSmtp::Server
  def connected(session)
    session.local_response = "mail.example.com ESMTP"
    session.helo_response = "mail.example.com"
  end

  def authenticate(session, username:, password:, authorization_id: "")
    raise MailSmtp::AuthenticationFailed unless username == "user" && password == "secret"
    username # becomes session.authorization_id when non-empty
  end

  def mail_from(session, address)
    address[/<([^>]*)>/, 1] || address # returned value is stored on the envelope
  end

  def rcpt_to(session, address)
    address[/<([^>]*)>/, 1] || address
  end

  def receive(session, io)
    # Prefer streaming — io.read materializes the whole message in memory.
    IO.copy_stream(io, storage_destination)
  end
end

And start it:

server = MyServer.new(
  host: "127.0.0.1",
  port: 2525,
  tls: nil,
  auth: :disabled,
  starttls: :optional,
  max_connections: 100,
  max_processings: 4,
  max_message_size: 10_485_760,
  max_recipients: 100,
  read_timeout: 300,
  write_timeout: 30,
  handshake_timeout: 30
)
server.start

Hot-swap the STARTTLS certificate without restarting:

server.tls = new_tls_context

Multi-listen: hosts: / ports: expand to every host×port pair.

Design

Role — server only

mailsmtp is an SMTP server. Outbound submission (Net::SMTP, your MTA, Action Mailer) stays outside the gem.

Hooks — subclass verbs

Subclass MailSmtp::Server and override methods: authenticate, auth_mechanisms, mail_from, rcpt_to, receive, plus build_session / process_line when you need a custom session or command gate.

One server object owns policy; Session is connection state (envelope, auth), not a second interface you implement. Raise a MailSmtp::Error subclass to choose the SMTP reply.

DATA — live reader into receive

After 354, the engine builds a DataReader over the socket (dot-unstuffing, SIZE, line limits) and calls receive(session, io). Prefer IO.copy_stream (or chunked reads); the engine drains through . then sends one reply — except when SIZE/MessageTooLarge (or the drain byte ceiling) forces a close after the error reply (no unbounded drain).

def receive(session, io)
  IO.copy_stream(io, storage_destination)
  # or: while (chunk = io.read(8192)); ...; end  — read(len) returns nil at EOF
end

SIZE / line-length failures raise a MailSmtp::Error from read / readpartial (not a short successful body). Mid-DATA disconnect raises ServiceUnavailable. If receive ignores the io, the reply still comes from the reader's failure after drain.

Reference

Hooks

Raising a MailSmtp::Error subclass sends that SMTP reply to the client. Other exceptions during receive become a 451 (LocalError). Unexpected errors outside DATA still need cleanup (prefer raising a defined error).

Hook Signature Return / raise
connected (session) —
disconnected (session) —
helo (session, name) after STARTTLS, session.tls_version / session.tls_cipher are set
proxy (session, proxy_data) hash replaces PROXY data
authenticate (session, username:, password:, authorization_id:) value becomes authorization_id (else username); raise AuthenticationFailed
auth_mechanisms (session)
mail_from (session, address) returned string is stored as envelope from
rcpt_to (session, address) returned string is appended to envelope to
receiving_started (session) once, before receive
headers_received (session) when the header/body blank line is seen while reading
receive (session, io) read the DataReader (or discard); raise to reject
received_header (session) string prefixed onto the reader when add_received: true
build_session () a Session (or subclass)
process_line (session, line) override for policy; call super for dispatch
unknown_command (session, line) default raises CommandNotRecognized
log (session, severity, msg, err:) —

Options

Option Default Notes
auth required arg :disabled or :required
eight_bit_mime false RFC 6152
smtputf8 false RFC 6531; when off, non-ASCII envelope addresses are refused
pipelining false RFC 2920; STARTTLS always drops plaintext buffers
proxy_hosts [] PROXY v1 from these IPs/CIDRs
add_received false Prefix received_header onto the DATA reader
forbid_bare_newline true Reject DATA lines that end with bare LF (SMTP smuggling)
max_message_size 10485760 (10 MiB) Advertises SIZE and enforces as DATA arrives; nil disables
max_recipients 100 Minimum 100 (RFC 5321); enforced at RCPT TO
max_processings required arg Concurrent DATA transfers; idle sessions do not consume a slot
max_processings_wait 30 Seconds to wait for a DATA processing slot before 421
max_auth_failures 3 Failed AUTH attempts before 421; nil disables
max_exceptions 20 Protocol errors before 421; nil disables
read_timeout 300 Idle read timeout (seconds); RFC 5321 §4.5.3.2.7; nil disables
write_timeout 30 Reply write timeout (seconds)
handshake_timeout 30 STARTTLS handshake timeout (seconds); independent of read_timeout; nil falls back to 30
max_session_duration 1800 Absolute session ceiling (seconds); nil to disable
logger stdout logger Injectable Logger

starttls: :required requires a non-nil tls object. Per-IP connection caps are deferred for peers in proxy_hosts until PROXY rewrites the client address. Forced #stop aborts lingering sockets with RST (SO_LINGER 0). Inspect live load with connections_count / processings_count (and connections?).

Testing

bundle install
rake

History

View the changelog.

Contributing

Everyone is encouraged to help improve this project:

  • Report bugs
  • Fix bugs and submit pull requests
  • Write, clarify, or fix documentation
  • Suggest or add new features

License

MIT. See LICENSE.