Project

mailsmtp

0.0
The project is in a healthy, maintained state
Answers one question: how do I receive emails in Ruby? Implements the SMTP protocol so you can plug in your own logic for auth, accepting mail, and storage.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0
>= 0
 Project Readme

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
  • Getting Started
  • Design
    • Role — server only
    • Hooks — subclass verbs
    • DATA — live reader into receive
  • Reference
    • Hooks
    • Options
  • Testing
  • History
  • Contributing
  • License

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.