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 -
receivegets a liveiointo 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.