Skip to content

chrj/smtpd

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

77 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Go smtpd v2 GoDoc Go Report Card

Package smtpd implements an SMTP server in Go.

Versions

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

Features

  • 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.Data as io.ReadCloser - no forced buffering
  • context.Context threaded 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

Quick start

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")

Architecture

Core types

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.

Delivery handlers

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.

The Middleware value

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.

Context flow

Each accepted connection gets its own context.Context, derived from Server.BaseContext / Server.ConnContext. It:

  • is cancelled when the connection closes or Shutdown is called
  • carries a per-connection *slog.Logger retrievable with LoggerFromContext
  • carries the current MAIL FROM via SenderFromContext (useful inside CheckRecipient, e.g. for greylisting)
  • is returned from every checker, so middleware can install its own values for later stages using context.WithValue

Session lifecycle

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;
Loading

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.

Writing a handler

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.Data is a stream over the connection. It is valid only for the duration of the call. Fully consume it, or stream it through io.Copy. The server drains and closes it after you return, to keep the SMTP protocol in sync.
  • Returning an smtpd.Error lets you pick the reply code; any other error becomes 502.
  • The returned context replaces the session context for any subsequent commands on the connection.

Writing middleware

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())

Lifting plain check functions

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-DATA
srv.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.

Mutating the envelope

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())

Propagating values through context

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
}

Migration guide - v1 → v2

The wire behavior is unchanged. The Go API changed significantly. Minimum required Go version is 1.21 (for log/slog).

1. Import path

// v1
import "github.com/chrj/smtpd"

// v2
import "github.com/chrj/smtpd/v2"

2. Handler signature

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) { ... }

3. Envelope.Data is a stream

// v1
body := env.Data   // []byte, already buffered

// v2
body, err := io.ReadAll(env.Data)
// or: io.Copy(dst, env.Data) to stream without buffering

For DKIM / content inspection, read it once; for relay, stream it directly into the upstream writer.

4. Envelope.AddReceivedLine → middleware Handler

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.

5. Checkers are now middleware

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.

6. AuthOptionalRequireAuth

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

7. ForceTLSRequireTLS

// v1
srv.TLSConfig = tlsCfg
srv.ForceTLS  = true

// v2
srv.TLSConfig = tlsCfg
srv.Use(middleware.RequireTLS())

8. ProtocolLoggerLogger

// 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).

9. Shutdown(wait) + Wait()Shutdown(ctx)

// v1
_ = srv.Shutdown(true)

// v2
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)

10. Peer.Password removed

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.

11. Behavior differences worth testing

  • A failed STARTTLS handshake now closes the connection (v1 continued the session in cleartext). The failure is reported through the Disconnect hook's err argument.
  • smtpd.Error renders to "{Code} {Message}" - errors.Is/errors.As work as expected on it.
  • Reset and Disconnect middleware hooks are new in v2.

Feedback

Reach the author at christian@technobabble.dk.

About

Go SMTP server library

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Languages