Package smtpd implements an SMTP server in Go.
| Version | Status | Branch | Tag | Docs |
|---|---|---|---|---|
| v1 | stable | v1 |
v1.0.1 |
godoc |
| v2 | stable | main |
v2.5.0 |
godoc |
v1 is the original battle-tested API.
import "github.com/chrj/smtpd"v2 is a ground-up rewrite of the v1 API. It keeps the same wire behavior but
restructures the programming model around context.Context, a streaming
Envelope, structured logging, and composable middleware.
import "github.com/chrj/smtpd/v2"Note
This README covers the v2 API only. Click here for the v1 README
- STARTTLS and implicit TLS
- PLAIN/LOGIN authentication (after STARTTLS), with a limit on the failed attempts of a connection (RFC 4954)
- Enhanced status codes (RFC 3463)
CHUNKINGwithBDAT, andBINARYMIME(RFC 3030)- DSN parameters (RFC 3461), off by default
SMTPUTF8for addresses of Unicode (RFC 6531), off by default- XCLIENT and the PROXY protocol, version 1 and version 2, from trusted proxies alone
VRFY(RFC 5321), through a middleware hook- LMTP (RFC 2033), with one reply for every recipient of a message
- Per-phase middleware: connection, HELO, MAIL FROM, RCPT TO, AUTH, VRFY, DATA, RESET, DISCONNECT
- Streaming
Envelope.Dataasio.ReadCloser- no forced buffering context.Contextthreaded through every hook and handler- Structured logging via
*slog.Logger - Context-aware
Shutdown(ctx)that drains in-flight sessions - Ready-made middleware in
github.com/chrj/smtpd/v2/middleware: SPF, RBL, greylisting, per-IP rate limiting, per-IP limiting of failed authentication,RequireAuth,RequireTLS - Test servers in
github.com/chrj/smtpd/v2/smtptest, for end-to-end tests of an SMTP client
A no-op server that accepts and discards:
srv := &smtpd.Server{Logger: slog.Default()}
_ = srv.ListenAndServe(":25")A relay with per-IP rate limiting, SPF, and RequireTLS:
srv := &smtpd.Server{
Hostname: "mx.example.com",
TLSConfig: tlsCfg,
Logger: slog.Default(),
Handler: forwardUpstream,
}
srv.Use(middleware.CheckConnection(middleware.IPAddressRateLimit(1, 10)))
srv.Use(middleware.CheckHelo(middleware.SPF().HeloCheck))
srv.Use(middleware.RequireTLS())
_ = srv.ListenAndServe(":25")| Type | Role |
|---|---|
Server |
Listener + configuration. Set fields, register middleware with Use, call ListenAndServe / Serve. |
Handler |
func(ctx, peer, *Envelope) (ctx, error) - the terminal delivery stage. |
Middleware |
Struct with optional per-phase hook fields. Any combination of fields may be set. |
Peer |
Connection-scoped state, populated progressively (Addr at connect, HeloName after HELO, TLS after handshake, Username after AUTH). Passed by value to every hook. |
Envelope |
Transaction-scoped state: Sender, Recipients, Data io.ReadCloser, BodyType, DSN. Passed by pointer so Handlers can mutate Data. |
Error |
{Code, Enhanced, Message} - returned from any hook to produce a specific SMTP reply. Non-Error errors are reported as 502. |
EnhancedCode |
[3]int - the RFC 3463 status code that goes after the reply code, such as {5, 7, 1}. |
Envelope.Sender and Envelope.Recipients hold an address in the form that
goes on the wire. An address with a quoted local part keeps its quoting, so a
relay can write the value into a command of its own.
| The client sends | The value |
|---|---|
MAIL FROM:<user@example.org> |
user@example.org |
MAIL FROM:<"a b"@example.org> |
"a b"@example.org |
MAIL FROM:<> |
"" (the null sender) |
MAIL FROM:<jörg@example.org> SMTPUTF8 |
jörg@example.org |
The server answers 501 for an address that carries a line break. An address
of Unicode needs Server.EnableSMTPUTF8. See SMTPUTF8.
Message delivery is expressed with the Handler function type:
type Handler func(ctx context.Context, peer Peer, env *Envelope) (context.Context, error)Server.Handler is the terminal delivery step for an accepted message.
Middleware can also contribute a Handler; those run first, in Use order, as
pre-delivery stages that can inspect or replace env.Data before
Server.Handler runs.
Middleware is a struct of optional function fields - one per SMTP phase. A
middleware only "participates" in phases whose field it sets:
type Middleware struct {
CheckConnection func(ctx, peer) (ctx, error)
CheckHelo func(ctx, peer, name) (ctx, error)
CheckSender func(ctx, peer, addr) (ctx, error)
CheckRecipient func(ctx, peer, addr) (ctx, error)
Authenticate func(ctx, peer, user, pass) (ctx, error)
Verify func(ctx, peer, name) (ctx, Verification, error)
Handler Handler // pre-deliver stage
Reset func(ctx, peer) ctx
Disconnect func(ctx, peer, err error)
}Server.Use appends every non-nil field to the matching per-phase list. At
runtime, the server walks each list in Use order; the first non-nil error
short-circuits the phase and is returned to the client. Server.Handler (the
terminal delivery function) runs after all middleware Handler stages succeed.
Each accepted connection gets its own context.Context, derived from
Server.BaseContext / Server.ConnContext. It:
- is cancelled when the connection closes or
Shutdownis called - carries a per-connection
*slog.Loggerretrievable withLoggerFromContext - carries the current MAIL FROM via
SenderFromContext(useful insideCheckRecipient, e.g. for greylisting) - is returned from every checker, so middleware can install its own values
for later stages using
context.WithValue
At the DEBUG level the server writes every line it receives and every reply
it sends. The credentials of an AUTH command do not go to the log: the
server keeps the verb and the mechanism, and replaces the rest.
level=DEBUG msg=received peer=10.0.0.7:52344 line="AUTH PLAIN [redacted]"
The other form of AUTH sends the credentials on their own lines, after a
334 reply. The server reads those lines directly, so they never reach the
log.
Peer.Username holds the user name after a successful AUTH. The password is
given to the Authenticate hooks and is not kept.
One AUTH command can succeed in a session. RFC 4954 section 4 gives 503 to
every AUTH command after that one, so a client cannot take a second identity
on a session whose earlier commands ran under the first. A refused AUTH
closes no door: the client can try again, up to the limit below.
A successful STARTTLS opens the session to a new AUTH, because the
handshake drops everything the client sent in plain text.
AUTH PLAIN and AUTH LOGIN both carry a password, and a client that sends
enough guesses finds one. Two limits stand against that.
Server.MaxAuthAttempts is the number of AUTH commands that can fail on one
connection. The default is 5. The attempt that reaches the limit gets a 421
reply in the place of the refusal, and the server closes the connection.
C: AUTH PLAIN AHVzZXIAd3Jvbmc=
S: 535 5.7.8 Authentication credentials invalid
C: AUTH PLAIN AHVzZXIAd3Jvbmcy
S: 421 4.7.0 Too many failed authentication attempts
The 535 above is the reply of the Authenticate hook, which writes its own
code. Only a refusal from those hooks counts. A command that does not read
leaves the count where it is, and a successful AUTH sets it back to zero.
Set the field to -1 for a server that closes no connection for this reason.
The limit counts the attempts of one connection, so a client that opens
another one starts over. middleware.AuthRateLimit holds the failures of an
address across connections:
srv.Use(middleware.Authenticator(
middleware.AuthRateLimit(myAuthFn, 1.0/60, 10),
))Each address gets a bucket of 10 attempts that refills at one per minute. A failed attempt takes a token, and a successful one takes none, so a client that always sends the right password never spends the bucket of its address.
An address with no tokens left gets a 454 reply, and the credential check
does not run for it. A successful attempt gives back none of the tokens that
earlier failures took, because a client that holds one good credential would
then clear the count and go on guessing the rest.
The bucket belongs to the address, so clients behind one address share it,
and the 454 reaches a client with the right password as well. Idle buckets
are dropped once they would have refilled.
flowchart TD
accept["accept"] --> checkConnection["CheckConnection"]
checkConnection --> vrfy["VRFY (any time)"]
vrfy --> verifyHook["Verify"]
checkConnection --> helo["HELO/EHLO"]
helo --> checkHelo["CheckHelo"]
checkHelo --> starttls["STARTTLS?"]
checkHelo --> auth["AUTH?"]
starttls --> auth
checkHelo --> mailFrom["MAIL FROM"]
auth --> authenticate["Authenticate"]
authenticate --> mailFrom
mailFrom --> checkSender["CheckSender"]
checkSender --> rcptTo["RCPT TO (0..n)"]
rcptTo --> checkRecipient["CheckRecipient"]
checkRecipient --> data["DATA"]
checkRecipient --> bdat["BDAT (1..n)"]
data --> middlewareHandler["middleware Handler"]
bdat --> middlewareHandler
middlewareHandler --> serverHandler["Server.Handler"]
serverHandler --> rset["RSET"]
rset --> resetHook["Reset"]
resetHook --> mailFrom
classDef phase fill:#1d4ed8,stroke:#1e3a8a,color:#ffffff;
classDef hook fill:#f59e0b,stroke:#92400e,color:#111827;
class accept,helo,starttls,auth,vrfy,mailFrom,rcptTo,data,bdat,rset phase;
class checkConnection,checkHelo,authenticate,verifyHook,checkSender,checkRecipient,middlewareHandler,serverHandler,resetHook hook;
Blue boxes are SMTP phases; amber boxes are middleware hooks. The Envelope
is created at MAIL FROM, grows across RCPT TO, gets Data at DATA or at
the first BDAT, and is cleared after delivery or RSET.
The handlers start as soon as the message does, and Data streams the rest to
them, both for DATA and for BDAT. The two differ in where they run: the
handlers of a DATA message hold the session while they read, and the
handlers of a BDAT message run on a goroutine of their own while the session
reads the commands that carry the chunks.
VRFY stands outside the transaction. RFC 5321 section 4.1.4 lets a client
send it at any time, and it changes nothing that the transaction holds.
Disconnect always runs exactly once per session. err is nil on clean
shutdown (QUIT or server Shutdown); non-nil if a TLS/read/DATA error
terminated the session, or a PanicError if a hook panicked.
Everything the client sends before the handshake goes over the wire in plain
text, where anybody on the path can change it. RFC 3207 says the server must
drop what it learned there, so a successful STARTTLS clears Peer.HeloName,
Peer.Protocol and Peer.Username, and the envelope. It opens the session to
a new AUTH command as well.
The count of the failed AUTH attempts stays. It bounds what the client may
try on this connection, and it says nothing about the client, so a client that
could set it back would take its guesses over again.
The client must send EHLO or HELO again. Without it, MAIL FROM is
answered with 503. Client libraries do this for themselves: StartTLS in
net/smtp sends EHLO again as part of the call.
Commands that arrive in the same write as STARTTLS never run. The session
builds a new reader on the TLS connection, so the bytes that were read into
the buffer with STARTTLS are dropped.
The XCLIENT command and the PROXY protocol both let the sender restate who
the client is. Peer.Addr is what the greylist, the RBL check, the rate limit
and the SPF check all read, so a client that reaches either one takes the
identity of any client it names.
Server.TrustedProxies holds the addresses that may do that. It is one list
for both features.
An empty list trusts the addresses that the public internet does not reach: the loopback addresses, the private ranges of RFC 1918 and RFC 4193, and the link-local addresses. That covers a proxy that stands beside the server or on the network of the server.
Name the addresses where the proxy reaches the server from another network, such as a load balancer with a public address:
srv.TrustedProxies = []netip.Prefix{
netip.MustParsePrefix("203.0.113.7/32"),
}The list replaces the default rule rather than adding to it. A server that names its proxies and still takes connections over the loopback interface names that range too:
srv.TrustedProxies = []netip.Prefix{
netip.MustParsePrefix("203.0.113.7/32"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("::1/128"),
}The server reads the address of the connection, and never Peer.Addr. A
PROXY header writes Peer.Addr before any XCLIENT command arrives, so a
client that could authorize itself with the address it just sent would get
past the list in two steps.
A unix socket and a pipe are always trusted: they carry no address to match, and a local process is at the other end of one. An address of any other kind that carries no IP is refused, because no prefix can name it either.
A session that took a PROXY header for a client behind the proxy takes no
XCLIENT command, and answers 550 to one. The proxy writes its header and
then passes on what the client sends, so every octet after the header comes
from that client. The address of the connection is still that of the proxy,
so it says nothing about who wrote the command, and the client would take an
identity of its own with it.
A header of the LOCAL command leaves XCLIENT open. The proxy opened such a
connection for itself, so the proxy is the one that speaks SMTP on it.
The list bounds who may speak for another client. It does not turn the
features on: without Server.EnableProxyProtocol or Server.EnableXCLIENT
the server takes neither, from any address.
Set Server.EnableXCLIENT to let a proxy in front of the server give the
identity of the client it took the connection from. ADDR and PORT replace
Peer.Addr, HELO replaces Peer.HeloName, LOGIN replaces
Peer.Username, and PROTO replaces Peer.Protocol.
The command gives the client any identity it asks for, and middleware such as
the greylist, the RBL check and the rate limit all read Peer.Addr, so only a
trusted proxy may send one. See Trusted proxies. A client
that is not trusted gets a 550, and the session goes on with the address of
the connection.
Attribute values arrive in the xtext encoding of RFC 1891, where + starts a
byte written as two hexadecimal digits. The server decodes them, so
LOGIN=user+40example.com gives user@example.com. A + that two such
digits do not follow stands for itself, so a value that the proxy sent without
encoding still arrives whole.
The values [UNAVAILABLE] and [TEMPUNAVAIL] say that the proxy has no
information for that attribute, and leave it as it was.
Set Server.EnableProxyProtocol to take the header that a proxy such as
HAProxy writes ahead of the session. The address of the client goes on
Peer.Addr, so the middleware that reads it sees the client and not the
proxy.
Only a trusted proxy may send a header. See Trusted
proxies. A PROXY command from an address that is not
trusted gets a 550 and the session ends, and a header of version 2 from one
ends the session without a reply.
The server takes both versions of the protocol. Version 1 is a line of text, and version 2 is binary. The first octet of the stream tells them apart, so a proxy of either version needs no setting of its own.
C: PROXY TCP4 42.42.42.42 5.6.7.8 4242 25
S: 220 localhost.localdomain ESMTP ready.
A header of version 1 arrives as the PROXY command, so a line that does not
read gets a 501 reply.
A header of version 2 carries the address family. Peer.Addr holds a
*net.TCPAddr for the IPv4 and the IPv6 families, and a *net.UnixAddr for a
unix socket. The values that follow the addresses come off the stream with the
rest of the header, and the server looks at none of them. They carry what the
proxy knows about the connection, such as the name that the client asked for
in a TLS handshake.
Two headers of version 2 carry no address of a client, and the addresses of
the connection stay on the peer: the LOCAL command, which a proxy writes for
a connection of its own such as a health check, and the unspecified address
family.
A header of version 2 that the server cannot read ends the session without a
reply, which the specification asks for. A version that is not 2, a command
that is neither LOCAL nor PROXY, a transport protocol of datagrams, and an
address block that is too short all end it. So does a header that stops in the
middle, and ProxyError.Err carries the error of the read there.
The Disconnect hooks read the cause as a smtpd.ProxyError, from the first
octet of the header on:
smtpd.Middleware{
Disconnect: func(ctx context.Context, peer smtpd.Peer, err error) {
var proxyErr smtpd.ProxyError
if errors.As(err, &proxyErr) {
log.Printf("%s sent a bad PROXY header: %s", peer.Addr, proxyErr.Reason)
}
},
}The header is the first line of the session, in both versions. A PROXY
command after it gets a 503 reply, because the proxy writes its header
before it passes on anything of the client.
Note
The server holds the greeting back until the header arrives, so give it a listener that the proxy alone reaches. A client that reaches it without a proxy writes the address that every hook then reads.
The server takes a message in chunks with the BDAT command of
RFC 3030. It offers CHUNKING and
BINARYMIME in the reply to EHLO, and there is nothing to turn on.
C: BDAT 24
S: 250 2.0.0 24 octets received
C: BDAT 12 LAST
S: 250 2.0.0 Message OK, 36 octets received
The octets of a chunk follow the command line on the same stream. They carry
no dot stuffing and no line structure, so the body reaches the handler as the
client sent it. A line with one dot on it is part of the message, where the
same line ends a DATA message.
Envelope.Data streams the chunks as they arrive. The handler starts with the
first chunk and reads the message through the rest of the transfer, in the same
way as for a DATA message, so a chunked message is never held in memory as a
whole. The server runs it on a goroutine of its own and waits for it at
BDAT LAST.
A transfer that ends before the last chunk gives the handler an error in the
place of the rest of the message. RSET does that, and so does a connection
that closes in the middle. Half a message therefore never looks whole to a
handler.
| The client sends | The server answers |
|---|---|
BDAT before RCPT TO |
503, after it read the chunk off the wire |
a chunk that takes the message past MaxMessageSize |
552, and every chunk after it gets the same answer |
DATA after BDAT in one transaction |
503 |
DATA for a BODY=BINARYMIME message |
503 |
RCPT TO after the first chunk |
503 |
BDAT with a chunk size that is not a number |
501, and the connection closes |
A refused chunk still comes off the wire, which is what RFC 3030 asks for: a
chunk that stays there is read as commands. The one command that closes the
connection is a BDAT whose size cannot be read, because nothing then says
where the chunk ends.
Envelope.BodyType holds the BODY parameter of MAIL FROM: 7BIT,
8BITMIME or BINARYMIME. It is empty when the client sent no such
parameter. A BINARYMIME message needs BDAT, so DATA answers 503 for
one.
Set Server.EnableDSN to offer the DSN extension of
RFC 3461. The server reads four
parameters and puts them on Envelope.DSN.
| Command | Parameter | Field |
|---|---|---|
MAIL FROM |
RET |
DSN.Return |
MAIL FROM |
ENVID |
DSN.EnvID |
RCPT TO |
NOTIFY |
DSN.Recipients[i].Notify |
RCPT TO |
ORCPT |
DSN.Recipients[i].OriginalRecipient and .OriginalType |
DSN.Recipients holds one entry for each address in Envelope.Recipients, at
the same index. A recipient that came with no parameter of its own has the
zero value there. Envelope.DSN is nil when the client sent none of the four
parameters.
srv := &smtpd.Server{
EnableDSN: true,
Handler: func(ctx context.Context, peer smtpd.Peer, env *smtpd.Envelope) (context.Context, error) {
defer func() { _ = env.Data.Close() }()
if env.DSN != nil {
for i, addr := range env.Recipients {
rcpt := env.DSN.Recipients[i]
if rcpt.Notify&smtpd.DSNNotifyNever != 0 {
log.Printf("%s asks for no notification", addr)
}
}
}
return ctx, deliver(env)
},
}The server writes no notification of its own. It carries the request to the handler, which knows what became of the message. A relay passes the parameters on to the next server, and a mailbox store writes the notification.
DSNNotify is a set of events, and String writes it back in the form that
the parameter takes, such as SUCCESS,DELAY.
The extension is off by default. A server that offers it tells the client that
a notification follows the request. Without EnableDSN, the server answers
555 to each of the four parameters.
The values of ENVID and ORCPT arrive in the xtext encoding of RFC 3461,
where + starts a byte written as two hexadecimal digits. The server decodes
them, so ENVID=QQ+40314159 gives QQ@314159. A value that breaks the
encoding gets a 501 reply, and so does a byte outside printable US-ASCII.
That check keeps a line break out of the command that a relay writes next.
An ORCPT of the utf-8 address type carries an address of Unicode, and it
takes the encoding of
RFC 6533 instead, where \x{HEX}
holds one code point. A server with Server.EnableSMTPUTF8 decodes that one
too, so ORCPT=utf-8;j\x{00F6}rg@example.net gives jörg@example.net. A
transaction with the SMTPUTF8 parameter takes the bytes of the address as
they came, and every other one needs the escape.
Without EnableSMTPUTF8, the server reads the value as ordinary xtext, in the
way that it did before, and the escape reaches the handler as it came. RFC
6531 section 3.2 asks for the address type from a server that offers DSN
next to SMTPUTF8, and this one offers neither.
Set Server.EnableSMTPUTF8 to offer the SMTPUTF8 extension of
RFC 6531. A client that sends the
SMTPUTF8 parameter with MAIL FROM can then write an address of Unicode in
UTF-8, in the local part and in the domain. The server offers 8BITMIME next
to the keyword, which RFC 6531 section 3.1 asks for.
C: MAIL FROM:<jörg@example.org> SMTPUTF8
S: 250 2.1.0 Go ahead
C: RCPT TO:<用户@例子.广告>
S: 250 2.1.5 Go ahead
The parameter takes no value, and Envelope.SMTPUTF8 tells the handler that
the transaction carried it. The message itself can hold headers in UTF-8, and
the server streams it as it does every other message.
srv := &smtpd.Server{
EnableSMTPUTF8: true,
Handler: func(ctx context.Context, peer smtpd.Peer, env *smtpd.Envelope) (context.Context, error) {
defer func() { _ = env.Data.Close() }()
if env.SMTPUTF8 {
log.Printf("an internationalized message from %s", env.Sender)
}
return ctx, deliver(env)
},
}The extension is off by default. A server that offers it says that it carries such a message onward, and the next server on the way has to offer the extension as well. Turn it on where the handler keeps the message, or where it relays to a server that takes one.
A server that offers the extension holds every other transaction to US-ASCII. RFC 6531 section 3.5 gives the two replies:
| The client sends | The reply |
|---|---|
MAIL FROM:<jörg@example.org> |
550 5.6.7 |
RCPT TO:<用户@example.net> |
553 5.6.7 |
An address in such a transaction has to be UTF-8. A byte outside that encoding
gets a 501 reply.
Without EnableSMTPUTF8, the server answers 555 to the parameter and reads
an address as it did before.
The server looks up no domain of its own, so it keeps an address as the client wrote it. A domain of Unicode reaches the handler and the middleware in the U-label form. RFC 6531 section 3.2 asks the party that looks a domain up in the DNS to convert it to the A-label form first, which the SPF middleware and a handler that relays the message have to do. Read RFC 5890 for the two forms.
The VRFY command asks the server to confirm that a name stands for a user.
RFC 5321 section 4.5.1 counts it among the commands that every server
implements. A Verify hook looks the name up:
srv.Use(smtpd.Middleware{
Verify: func(ctx context.Context, peer smtpd.Peer, name string) (context.Context, smtpd.Verification, error) {
user, err := directory.Lookup(ctx, name)
if errors.Is(err, directory.ErrNoSuchUser) {
return ctx, smtpd.Verification{}, smtpd.Error{
Code: 550,
Enhanced: smtpd.EnhancedCode{5, 1, 1},
Message: "No such user here",
}
}
if err != nil {
return ctx, smtpd.Verification{}, err
}
return ctx, smtpd.Verification{
Mailbox: user.Mailbox,
FullName: user.FullName,
}, nil
},
})C: VRFY Smith
S: 250 2.1.5 Jörg Smith <jörg@example.org>
Verification.Mailbox is an address in the form that Envelope.Sender takes,
without the angle brackets. The server writes it inside them, which RFC 5321
section 3.5.2 asks for, because the client writes that address into a RCPT TO command of its own. FullName is optional and stands before the mailbox.
| What the hook gives back | The reply |
|---|---|
a Verification with a mailbox |
250 with the mailbox |
the zero Verification and no error |
252 |
an Error |
the code of that error, such as 550 or 553 |
| an error of another kind | 451, and the error goes to the log |
A hook that fails gets a 451, because a 500 or a 502 says that the
server does not carry the command, which RFC 5321 section 3.5.3 calls a fault.
The text of the error stays in the log: it comes from the site, and a client
of any kind reads a reply.
A server without a Verify hook answers 252 Cannot VRFY user, but will accept message and attempt delivery. RFC 5321 section 7.3 asks for that code
and no other one: a server that answers 250 says that it verified the name,
and one that answers 550 says that the name is wrong. A 502 there would
say that the command is not implemented, which the same section calls a fault.
The hooks run in Use order. The first one that finds a mailbox, or that
returns an error, ends the phase. A hook that finds nothing returns the zero
Verification and lets the next one look.
The name reaches the hook as the client wrote it, and RFC 5321 section 3.5.1 leaves its form to the site: a user name, an address, or a string of another kind. A name with a space in it arrives whole.
A mailbox that is not an address gets the 252 reply, and the server writes
the fault to the log at the ERROR level. The reply carries an address that
the client uses, so a broken one never goes on the wire.
A reply of UTF-8 needs a client that reads one. RFC 6531 section 3.7.4.2 adds
an SMTPUTF8 parameter to the command for that, and a server with
Server.EnableSMTPUTF8 takes it:
C: VRFY Smith SMTPUTF8
S: 250 2.1.5 Jörg Smith <jörg@example.org>
The parameter stands after the name and takes no value. A value on it gets a
501 reply. It holds for that one command, so the next VRFY needs it again.
Without it, the reply keeps to US-ASCII. A FullName of Unicode goes, and the
mailbox stays, because RFC 5321 section 3.5.1 gives the name as the optional
part. A Mailbox of Unicode leaves nothing to write, so it gets 252 2.6.8,
which is the status code that RFC 6531 gives for a reply that needs UTF-8 and
cannot use it.
| The client sends | The mailbox | The reply |
|---|---|---|
VRFY Smith SMTPUTF8 |
jörg@example.org |
250 with the mailbox |
VRFY Smith |
jörg@example.org |
252 2.6.8 |
VRFY Smith |
joe@example.org |
250 with the mailbox |
A server without EnableSMTPUTF8 knows no such parameter, so the whole
argument is the name there: VRFY Smith SMTPUTF8 looks up Smith SMTPUTF8.
The name of a user takes any form that the site knows, and VRFY SMTPUTF8
asks for the user named SMTPUTF8 on every server.
A mailbox and a name of the hook reach the client as UTF-8, and as nothing
else. A value that carries a byte outside that encoding gets the 252 reply,
and the server writes the fault to the log.
The message of an Error goes on the wire as the hook wrote it, and the
server writes it to every client. The hook does not see the parameter, so keep
that message to US-ASCII. The mailbox needs no such care: the server holds it
back with a 252 where the client cannot read it.
The server offers no VRFY keyword in the reply to EHLO. RFC 5321 section
3.5.2 makes that keyword optional, because every server carries the command.
Note
VRFY tells anybody who asks which addresses exist, and RFC 5321 section
7.3 gives that as the reason to hold it back. The hook reads Peer, so it
can answer for an authenticated client alone, and give the zero
Verification to every other one.
Set Server.LMTP to serve the Local Mail Transfer Protocol of
RFC 2033 in the place of SMTP. Such
a server takes LHLO as the greeting, and it answers 500 to HELO and to
EHLO. Peer.Protocol holds LMTP from the greeting on.
LHLO carries the semantics of EHLO, so the reply lists the same
extensions, and the replies of the session carry a status code.
The server writes one reply for every recipient of a message, in the order
that RCPT TO added them. Two RCPT TO commands for one address therefore
take two replies:
S: 220 localhost.localdomain LMTP ready.
C: LHLO client.local
S: 250-localhost.localdomain
S: 250-SIZE 10240000
S: 250-8BITMIME
S: 250-BINARYMIME
S: 250-CHUNKING
S: 250-PIPELINING
S: 250 ENHANCEDSTATUSCODES
C: MAIL FROM:<sender@example.org>
S: 250 2.1.0 Go ahead
C: RCPT TO:<one@example.net>
S: 250 2.1.5 Go ahead
C: RCPT TO:<two@example.net>
S: 250 2.1.5 Go ahead
C: DATA
S: 354 Go ahead. End your data with <CR><LF>.<CR><LF>
C: [the message, and a line with one dot on it]
S: 250 2.0.0 Thank you.
S: 550 5.1.1 No such user
A handler that takes a message for some of the recipients calls
Envelope.RejectRecipient for each of the rest. The index is the one of the
recipient in Envelope.Recipients:
srv := &smtpd.Server{
LMTP: true,
Handler: func(ctx context.Context, peer smtpd.Peer, env *smtpd.Envelope) (context.Context, error) {
defer env.Data.Close()
body, err := io.ReadAll(env.Data)
if err != nil {
return ctx, err
}
for i, recipient := range env.Recipients {
if err := mailbox.Deliver(recipient, body); err != nil {
// The index comes from the range, so it is a recipient of
// the envelope and the call takes it.
_ = env.RejectRecipient(i, smtpd.Error{
Code: 550,
Enhanced: smtpd.EnhancedCode{5, 1, 1},
Message: "No such user",
})
}
}
return ctx, nil
},
}A recipient without an error of its own gets the reply of the message: the
250, or the error that the handler returned. An error of the handler refuses
the message for every recipient, because the message failed for all of them.
| The client sends | The server answers |
|---|---|
HELO or EHLO |
500, with the name of the greeting to send |
LHLO, to a server of SMTP |
500 |
the last chunk of a BDAT transfer |
one reply for every recipient |
a message that is larger than MaxMessageSize |
552 for every recipient |
A message that arrives in chunks takes the same replies. The chunks before the last one take one reply each, because they end no message.
RejectRecipient returns an error for an index that no recipient carries, and
it records nothing. A server of SMTP writes one reply for the whole message
and reads none of these errors, so a handler for both protocols reads
Peer.Protocol.
Note
LMTP keeps no queue. The client holds the message until the server answers
250 for a recipient, and the server delivers no message on its own. RFC
2033 section 5 keeps the protocol off port 25 and away from wide area
networks. Serve takes any net.Listener, so a unix socket or an address
of the loopback interface carries it.
A panic in Server.Handler or in a middleware hook stops that session only.
The server writes the panic and the stack trace to the log at the ERROR
level, replies 421, and closes the connection. Other sessions continue.
The Disconnect hooks receive the panic as a smtpd.PanicError. Value
holds the value given to panic and Stack holds the stack trace:
smtpd.Middleware{
Disconnect: func(ctx context.Context, peer smtpd.Peer, err error) {
var panicErr smtpd.PanicError
if errors.As(err, &panicErr) {
report(peer.Addr, panicErr.Value, panicErr.Stack)
}
},
}A panic in a Disconnect hook is contained the same way, but it cannot
reach a hook, so the server only writes it to the log.
BaseContext and ConnContext run in the accept loop, not in a session, so
they behave differently:
| Hook | On a panic |
|---|---|
BaseContext |
Serve returns a PanicError. The hook runs once, before the accept loop, so the server never starts. |
ConnContext |
The server writes the panic to the log, drops that connection, and keeps accepting. |
Server.Handler is the delivery step:
func deliver(ctx context.Context, peer smtpd.Peer, env *smtpd.Envelope) (context.Context, error) {
defer env.Data.Close()
body, err := io.ReadAll(env.Data)
if err != nil {
return ctx, err
}
if err := store.Save(ctx, env.Sender, env.Recipients, body); err != nil {
return ctx, smtpd.Error{Code: 451, Message: "temporary failure, try again"}
}
return ctx, nil
}
srv := &smtpd.Server{Handler: deliver}Notes:
env.Datais a stream over the connection. It is valid only for the duration of the call. Fully consume it, or stream it throughio.Copy. The server drains and closes it after you return, to keep the SMTP protocol in sync.- Returning an
smtpd.Errorlets you pick the reply code; any other error becomes502. - Set
Enhancedon the error to give the client a precise reason. An error that leaves it out takes the generic code for its class, such as5.0.0. See Enhanced status codes. - The returned context replaces the session context for any subsequent commands on the connection.
The server offers ENHANCEDSTATUSCODES and writes the status code of
RFC 3463 after the reply code:
250 2.1.5 Go ahead
550 5.7.1 Relay access denied
452 4.5.3 Too many recipients
Set the code on the error that your hook returns:
return ctx, smtpd.Error{
Code: 550,
Enhanced: smtpd.EnhancedCode{5, 7, 1},
Message: "Relay access denied",
}Enhanced is optional. An error that leaves it out takes the generic code
for the class of Code, which RFC 3463 section 3.1 writes as x.0.0. The
example above sends 550 5.0.0 Relay access denied without the Enhanced
field.
The server leaves the status code out in three places, because RFC 2034 does:
- The greeting, which the client reads before it sends a command.
- The reply to
HELOand toEHLO. - Every reply to a client that sent
HELO. Such a client never saw the server offer the extension, so it gets the replies of RFC 5321.
RFC 3463 defines no status class for a 1yz or a 3yz reply, so the 354
of DATA and the 334 of AUTH also stay as they were.
The middleware in middleware sets a code for each of its refusals:
| Middleware | Reply |
|---|---|
RequireAuth |
530 5.7.0 Authentication required |
RequireTLS |
530 5.7.0 Must issue STARTTLS first |
Greylist |
450 4.7.1 greylisted, try again later |
IPAddressRateLimit |
450 4.7.1 rate-limited, try again later |
AuthRateLimit |
454 4.7.0 Too many failed authentication attempts, try again later |
RBL |
554 5.7.1 {list message} |
SPF (fail) |
550 5.7.23 SPF check failed |
SPF (temporary error) |
451 4.7.24 SPF check temporary error |
SPF (permanent error) |
550 5.7.24 SPF check permanent error |
The SPF codes come from RFC 7372.
A middleware is just a smtpd.Middleware value. Set the fields for the phases
you participate in, leave the rest nil:
func rejectNullSender() smtpd.Middleware {
return smtpd.Middleware{
CheckSender: func(ctx context.Context, peer smtpd.Peer, addr string) (context.Context, error) {
if addr == "" {
return ctx, smtpd.Error{Code: 550, Message: "Null sender not accepted"}
}
return ctx, nil
},
}
}
srv.Use(rejectNullSender())For single-phase checks, the middleware sub-package defines three function
signatures and matching adapters that turn them into a smtpd.Middleware:
type PeerCheck func(ctx, peer) error // Connect, Helo
type AddrCheck func(ctx, peer, addr) error // MailFrom, RcptTo
type DataCheck func(ctx, peer, env) error // post-DATAsrv.Use(middleware.CheckConnection(myPeerCheck))
srv.Use(middleware.CheckSender(mySenderCheck))
srv.Use(middleware.CheckData(myDataCheck))This is the pattern used by built-ins like SPF, RBL, and Greylist, which
expose check methods you can wire to any compatible phase.
A middleware-level Handler runs as a pre-deliver stage: after DATA is
received, before Server.Handler. Use it to rewrite or enrich the message.
This is also the v2 replacement for v1's Envelope.AddReceivedLine:
func addReceivedHeader() smtpd.Middleware {
return smtpd.Middleware{
Handler: func(ctx context.Context, peer smtpd.Peer, env *smtpd.Envelope) (context.Context, error) {
body, err := io.ReadAll(env.Data)
if err != nil {
return ctx, err
}
header := fmt.Sprintf(
"Received: from %s by %s; %s\r\n",
peer.Addr.String(),
peer.ServerName,
time.Now().UTC().Format(time.RFC1123Z),
)
env.Data = io.NopCloser(bytes.NewReader(append([]byte(header), body...)))
return ctx, nil
},
}
}
srv.Use(addReceivedHeader())Every checker returns a context.Context. To pass data to later stages,
return a derived context:
CheckHelo: func(ctx context.Context, peer smtpd.Peer, name string) (context.Context, error) {
return context.WithValue(ctx, traceIDKey{}, uuid.NewString()), nil
}The sub-package github.com/chrj/smtpd/v2/smtptest runs a real server on the
loopback interface, so a test can drive an SMTP client end to end. It follows
net/http/httptest. A setup fault that a test cannot correct causes a panic,
and the caller must call Close.
import "github.com/chrj/smtpd/v2/smtptest"| Function | Transport |
|---|---|
NewServer |
Plain SMTP. |
NewSTARTTLSServer |
Plain SMTP, with STARTTLS in the reply to EHLO. |
NewTLSServer |
TLS before the greeting, as SMTPS on port 465. |
NewUnstartedServer |
None yet. Change Config or TLS, then call a Start method. |
A Recorder is an smtpd.Handler that keeps the messages that it receives:
rec := &smtptest.Recorder{}
srv := smtptest.NewSTARTTLSServer(rec.Handler)
defer srv.Close()
sendWithTheClientUnderTest(srv.Addr, srv.ClientTLSConfig())
if got := rec.Messages()[0].Sender; got != "sender@example.org" {
t.Errorf("Sender: got %q, want %q", got, "sender@example.org")
}The server gives its address in three forms. Addr is the "host:port" pair
for net.Dial. Host and Port are the two parts, for a client that takes
them apart, and Host is also the name that smtp.PlainAuth expects.
Dial opens a connection and returns a *smtp.Client that completed the
handshake of the transport. It sends STARTTLS to a STARTTLS server, and it
does the TLS handshake before the greeting for an implicit TLS server:
c := srv.Dial()
defer func() { _ = c.Quit() }()
if err := c.Auth(smtp.PlainAuth("", "joe", "secret", srv.Host)); err != nil {
t.Fatalf("AUTH: %v", err)
}Each call opens one connection. Use Dial for the parts of a test that only
need a working client. A client library under test connects to Addr
itself.
If the server stopped, Dial panics with the reason from Serve. A test
with a bad configuration reads that reason instead of a dial error.
Dial speaks SMTP. A test of an LMTP server sets Config.LMTP on an
unstarted server and drives the connection to Addr itself, because
net/smtp sends EHLO.
Close waits five seconds for the sessions that are still open, then closes
their connections and writes one line to stderr. A test that ends with a
connection open is the usual cause, so Close does not fail for it.
Send runs one transaction on a client: MAIL FROM, RCPT TO, DATA and QUIT.
It returns the first error, so a test can read the reply code of a
rejection:
err := smtptest.Send(srv.Dial(), "sender@example.org",
[]string{"recipient@example.net"}, "Subject: hello\r\n\r\nbody\r\n")
var reply *textproto.Error
if !errors.As(err, &reply) || reply.Code != 550 {
t.Errorf("RCPT TO error: got %v, want 550", err)
}Cmd sends one raw command and compares the reply code. Use it for a
command that net/smtp does not send, such as XCLIENT or PROXY, or for bad
syntax:
if err := smtptest.Cmd(c.Text, 550, "XCLIENT NAME=ignored"); err != nil {
t.Errorf("XCLIENT: %v", err)
}Both take a *smtp.Client, and not a *smtptest.Server. A test of your own
SMTP server can use them against a server that this package did not start.
Both TLS servers present a self-signed certificate for localhost,
127.0.0.1 and ::1. Three methods pass it to the client under test:
ClientTLSConfig()returns a*tls.Configthat trusts the server.Certificate()returns the parsed*x509.Certificate.CertPEM()returns the certificate in PEM form, for a client that reads a trust anchor from a file.KeyPEM()returns the private key in PEM form. WithCertPEMit makes the pair that a server under test loads from two files.
To present your own certificate, set TLS before a Start method. The
Start methods keep that certificate and add the default one only when the
configuration holds none.
srv := smtptest.NewUnstartedServer(rec.Handler)
srv.TLS = &tls.Config{Certificates: []tls.Certificate{expiredCert}}
srv.StartSTARTTLS()
defer srv.Close()Recorder.Handler reads the body into memory and then puts an equal stream
back on env.Data. A stage that runs after it reads the same bytes, so the
Recorder also works in front of the delivery handler under test:
srv := smtptest.NewUnstartedServer(deliveryHandlerUnderTest)
srv.Config.Use(smtpd.Middleware{Handler: rec.Handler})
srv.Start()
defer srv.Close()The server accepts on a TCP listener of the loopback interface, because most
SMTP clients take an address and not a connection. A test that needs another
transport, such as an in-memory pipe, replaces Listener before a Start
method:
srv := smtptest.NewUnstartedServer(rec.Handler)
srv.Listener = myListener
srv.Start()
defer srv.Close()Such a listener gives no TCP address. Host and Port stay empty and zero,
Peer.Addr no longer identifies the client, and Dial cannot open the
connection for you.
The wire behavior is unchanged. The Go API changed significantly. Minimum required Go version is 1.25.
// v1
import "github.com/chrj/smtpd"
// v2
import "github.com/chrj/smtpd/v2"Handler is now context-aware, takes an *Envelope (so it can replace
Data), and returns the context back.
// v1
Handler: func(peer smtpd.Peer, env smtpd.Envelope) error { ... }
// v2
Handler: func(ctx context.Context, peer smtpd.Peer, env *smtpd.Envelope) (context.Context, error) { ... }// v1
body := env.Data // []byte, already buffered
// v2
body, err := io.ReadAll(env.Data)
// or: io.Copy(dst, env.Data) to stream without bufferingFor DKIM / content inspection, read it once; for relay, stream it directly into the upstream writer.
v1's helper was removed along with the old buffered Envelope.Data. In v2,
inject the Received: line in a middleware Handler, then replace env.Data
with a new reader:
srv.Use(addReceivedHeader())The addReceivedHeader example above is the direct compatibility pattern.
The four checker fields (ConnectionChecker, HeloChecker, SenderChecker,
RecipientChecker) and Authenticator have been removed from Server. Use
srv.Use(smtpd.Middleware{...}):
// v1
srv := &smtpd.Server{
HeloChecker: checkHelo,
SenderChecker: checkSender,
Authenticator: authFn,
}
// v2
srv := &smtpd.Server{}
srv.Use(smtpd.Middleware{
CheckHelo: func(ctx context.Context, peer smtpd.Peer, name string) (context.Context, error) {
return ctx, checkHelo(peer, name)
},
CheckSender: func(ctx context.Context, peer smtpd.Peer, addr string) (context.Context, error) {
return ctx, checkSender(peer, addr)
},
Authenticate: func(ctx context.Context, peer smtpd.Peer, u, p string) (context.Context, error) {
return ctx, authFn(peer, u, p)
},
})Or, for single-phase checks, use the lifting adapters from the middleware
package: middleware.CheckHelo, middleware.CheckSender, etc.
Registering an authenticator no longer implicitly enforces AUTH. Opt in:
// v1
srv.Authenticator = authFn
srv.AuthOptional = false // enforced at MAIL FROM
// v2
srv.Use(middleware.Authenticator(authFn))
srv.Use(middleware.RequireAuth()) // MAIL FROM (default)
srv.Use(middleware.RequireAuthAt(middleware.AuthAtData)) // or pick a stage// v1
srv.TLSConfig = tlsCfg
srv.ForceTLS = true
// v2
srv.TLSConfig = tlsCfg
srv.Use(middleware.RequireTLS())// v1
srv.ProtocolLogger = log.New(os.Stderr, "", log.LstdFlags)
// v2
srv.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))Per-connection loggers are exposed to middleware via
smtpd.LoggerFromContext(ctx).
// v1
_ = srv.Shutdown(true)
// v2
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)The password is still delivered to the Authenticate hook, but is no longer
stored on Peer. If you need it beyond the AUTH step, stash whatever you
need in the returned context.
- A failed STARTTLS handshake now closes the connection (v1 continued the
session in cleartext). The failure is reported through the
Disconnecthook'serrargument. smtpd.Errorrenders to"{Code} {Message}", or to"{Code} {Enhanced} {Message}"when you setEnhanced-errors.Is/errors.Aswork as expected on it.- A client that sends
EHLOnow gets the status codes of RFC 3463 on every reply. A test that asserts on the text of a reply must expect them. A client that sendsHELOsees no change. ResetandDisconnectmiddleware hooks are new in v2.
Reach the author at christian@technobabble.dk.