Package smtpd implements an SMTP server in Go.
| Version | Status | Branch | Tag | Docs |
|---|---|---|---|---|
| v1 | stable | v1 |
v1.0.0 |
godoc |
| v2 | alpha | master |
v2.0.0-alpha.1 |
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)
- XCLIENT and the PROXY protocol
- Per-phase middleware: connection, HELO, MAIL FROM, RCPT TO, AUTH, 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,RequireAuth,RequireTLS
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. Passed by pointer so Handlers can mutate Data. |
Error |
{Code, Message} - returned from any hook to produce a specific SMTP reply. Non-Error errors are reported as 502. |
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)
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
flowchart TD
accept["accept"] --> checkConnection["CheckConnection"]
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"]
data --> middlewareHandler["middleware Handler"]
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,mailFrom,rcptTo,data,rset phase;
class checkConnection,checkHelo,authenticate,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, and
is cleared after delivery or RSET.
Disconnect always runs exactly once per session. err is nil on clean
shutdown (QUIT or server Shutdown); non-nil if a TLS/scanner/DATA error
terminated the session.
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. - The returned context replaces the session context for any subsequent commands on the connection.
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 wire behavior is unchanged. The Go API changed significantly. Minimum
required Go version is 1.21 (for log/slog).
// 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}"-errors.Is/errors.Aswork as expected on it.ResetandDisconnectmiddleware hooks are new in v2.
Reach the author at christian@technobabble.dk.
