#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics

This commit is contained in:
Lucas Rayan committed 2026-08-01 22:49:02 -03:00
1 parent 7249419efc
commit d56f29d084
70 files changed
+5163 -659

No files matched your search

@@ -3,6 +3,7 @@ package httppresentation
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
@@ -11,10 +12,16 @@ import (
"lambdamail/protocols/internal/domain/valueobject"
)
// mtaStsMaxAge is the policy lifetime advertised to senders. RFC 8461
// recommends a value in the order of weeks so that an attacker cannot strip
// the policy by making the endpoint unreachable.
const mtaStsMaxAge = 604800 // 7 days
type Router struct {
mux *http.ServeMux
reportUseCase *appusecase.IngestReportsUseCase
pingFunc func() error
mtaStsMode valueobject.MtaStsMode
}
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
@@ -22,11 +29,24 @@ func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() e
mux: http.NewServeMux(),
reportUseCase: reportUseCase,
pingFunc: pingFunc,
// PLAN.md section 5 ramps MTA-STS from testing to enforce. Starting at
// enforce means any TLS misconfiguration silently blackholes inbound
// mail from every sender that honours the policy, with no way to tell
// from this side - so the safe mode is the default and the operator
// promotes it once TLS-RPT reports come back clean.
mtaStsMode: valueobject.MtaStsModeTesting,
}
r.registerRoutes()
return r
}
// SetMtaStsMode selects the mode advertised in the served policy. Callers
// should promote to enforce only after TLS-RPT confirms senders can negotiate
// TLS against the published MX.
func (r *Router) SetMtaStsMode(mode valueobject.MtaStsMode) {
r.mtaStsMode = mode
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
@@ -57,19 +77,23 @@ func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
host := extractHost(req.Host)
domainName := strings.TrimPrefix(host, "mta-sts.")
policy := valueobject.NewMtaStsPolicy(valueobject.MtaStsModeEnforce, []string{fmt.Sprintf("mail.%s", domainName)}, 604800)
policy := valueobject.NewMtaStsPolicy(r.mtaStsMode, []string{fmt.Sprintf("mail.%s", domainName)}, mtaStsMaxAge)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
// Kept in step with max_age so an HTTP cache cannot outlive the policy it
// is caching.
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", mtaStsMaxAge))
w.WriteHeader(http.StatusOK)
w.Write([]byte(policy.Format()))
}
func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
host := extractHost(req.Host)
// The contact must be a real mailbox on the mail domain, not on whichever
// service hostname the request happened to arrive at.
domainName := baseDomain(extractHost(req.Host))
expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339)
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", host, expires)
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", domainName, expires)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
@@ -144,12 +168,27 @@ func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
}
// extractHost strips the optional port from a Host header, handling the
// bracketed IPv6 literal form as well.
func extractHost(hostHeader string) string {
if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" {
return h
}
if hostHeader == "" {
return "example.test"
}
return hostHeader
if host, _, err := net.SplitHostPort(hostHeader); err == nil && host != "" {
return host
}
return strings.Trim(hostHeader, "[]")
}
// serviceHostPrefixes are the per-service names published under a mail domain
// (PLAN.md section 7.3); stripping one yields the mail domain itself.
var serviceHostPrefixes = []string{"mta-sts.", "autoconfig.", "autodiscover.", "mail."}
func baseDomain(host string) string {
for _, prefix := range serviceHostPrefixes {
if strings.HasPrefix(host, prefix) {
return strings.TrimPrefix(host, prefix)
}
}
return host
}
@@ -24,4 +24,3 @@ func (s *session) Unsubscribe(_ string) error { return errN
func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) {
return nil, errNotYetImplemented
}
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"io"
"net"
"strings"
gosmtp "github.com/emersion/go-smtp"
@@ -24,19 +25,44 @@ func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend {
return &Backend{useCase: uc}
}
func (b *Backend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
return &session{useCase: b.useCase}, nil
func (b *Backend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
return &session{useCase: b.useCase, conn: c}, nil
}
// session holds one SMTP transaction's accumulated state (RFC 5321 section
// 4.1.1: MAIL, then one or more RCPT, then DATA, then reset).
type session struct {
useCase *usecase.ProcessInboundEmailUseCase
conn *gosmtp.Conn
from string
recipients []port.MailboxRecord
recipientAddresses []string
}
// clientIP is the peer address SPF is evaluated against. It returns nil when
// the connection is not available, which downgrades SPF to "none" rather than
// producing a wrong verdict.
func (s *session) clientIP() net.IP {
if s.conn == nil || s.conn.Conn() == nil {
return nil
}
if addr, ok := s.conn.Conn().RemoteAddr().(*net.TCPAddr); ok {
return addr.IP
}
host, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
if err != nil {
return nil
}
return net.ParseIP(host)
}
func (s *session) heloDomain() string {
if s.conn == nil {
return ""
}
return s.conn.Hostname()
}
func (s *session) Reset() {
s.from = ""
s.recipients = nil
@@ -107,6 +133,8 @@ func (s *session) Data(r io.Reader) error {
Recipients: s.recipients,
RecipientAddresses: s.recipientAddresses,
Body: r,
ClientIP: s.clientIP(),
HeloDomain: s.heloDomain(),
})
if err != nil {
var smtpErr *gosmtp.SMTPError
@@ -114,6 +142,13 @@ func (s *session) Data(r io.Reader) error {
return smtpErr
}
errMsg := err.Error()
if strings.HasPrefix(errMsg, "550 ") {
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
Message: strings.TrimPrefix(errMsg, "550 5.7.1 "),
}
}
if strings.HasPrefix(errMsg, "554 ") {
return &gosmtp.SMTPError{
Code: 554,
@@ -0,0 +1,67 @@
package smtppresentation
import (
"errors"
"github.com/emersion/go-sasl"
)
// loginMechanism is the SASL mechanism name for the legacy LOGIN exchange.
// PLAN.md section 4 requires it alongside PLAIN because several desktop and
// mobile clients still offer nothing else. Neither go-sasl nor go-smtp ships a
// server side for it, so it is implemented here.
const loginMechanism = "LOGIN"
// LOGIN is not specified by an RFC; it is a de facto mechanism where the
// server prompts for the username, then for the password, each as a bare
// challenge string.
var (
loginUsernameChallenge = []byte("Username:")
loginPasswordChallenge = []byte("Password:")
)
// loginAuthenticator verifies a username and password pair.
type loginAuthenticator func(username, password string) error
// loginServer implements sasl.Server for the LOGIN mechanism.
type loginServer struct {
authenticate loginAuthenticator
username string
// state advances from awaiting the username, to awaiting the password,
// to finished.
state int
}
const (
loginStateAwaitingUsername = iota
loginStateAwaitingPassword
loginStateDone
)
func newLoginServer(authenticate loginAuthenticator) sasl.Server {
return &loginServer{authenticate: authenticate}
}
func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
switch s.state {
case loginStateAwaitingUsername:
// A client may send the username as the initial response, in which
// case there is nothing to prompt for yet.
if response == nil {
return loginUsernameChallenge, false, nil
}
s.username = string(response)
s.state = loginStateAwaitingPassword
return loginPasswordChallenge, false, nil
case loginStateAwaitingPassword:
s.state = loginStateDone
if err := s.authenticate(s.username, string(response)); err != nil {
return nil, false, err
}
return nil, true, nil
default:
return nil, false, errors.New("sasl: LOGIN exchange already finished")
}
}
@@ -0,0 +1,67 @@
package smtppresentation
import (
"errors"
"testing"
)
// The LOGIN exchange prompts for the username, then the password, and only
// then authenticates.
func TestLoginServer_PromptsThenAuthenticates(t *testing.T) {
var gotUser, gotPass string
server := newLoginServer(func(username, password string) error {
gotUser, gotPass = username, password
return nil
})
challenge, done, err := server.Next(nil)
if err != nil || done || string(challenge) != "Username:" {
t.Fatalf("first step: challenge=%q done=%v err=%v", challenge, done, err)
}
challenge, done, err = server.Next([]byte("user@example.test"))
if err != nil || done || string(challenge) != "Password:" {
t.Fatalf("second step: challenge=%q done=%v err=%v", challenge, done, err)
}
_, done, err = server.Next([]byte("secret"))
if err != nil || !done {
t.Fatalf("third step: done=%v err=%v", done, err)
}
if gotUser != "user@example.test" || gotPass != "secret" {
t.Errorf("credentials = %q/%q", gotUser, gotPass)
}
}
// A client that sends the username as its initial response skips the first
// prompt.
func TestLoginServer_AcceptsInitialResponse(t *testing.T) {
server := newLoginServer(func(string, string) error { return nil })
challenge, done, err := server.Next([]byte("user@example.test"))
if err != nil || done || string(challenge) != "Password:" {
t.Fatalf("challenge=%q done=%v err=%v", challenge, done, err)
}
}
func TestLoginServer_PropagatesAuthFailure(t *testing.T) {
authErr := errors.New("bad credentials")
server := newLoginServer(func(string, string) error { return authErr })
server.Next(nil)
server.Next([]byte("user@example.test"))
if _, done, err := server.Next([]byte("wrong")); err == nil || done {
t.Fatalf("expected the failure to surface: done=%v err=%v", done, err)
}
}
func TestLoginServer_RefusesExtraSteps(t *testing.T) {
server := newLoginServer(func(string, string) error { return nil })
server.Next(nil)
server.Next([]byte("user@example.test"))
server.Next([]byte("secret"))
if _, _, err := server.Next([]byte("extra")); err == nil {
t.Fatal("expected an error after the exchange finished")
}
}
@@ -0,0 +1,159 @@
package smtppresentation
import (
"context"
"errors"
"io"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
// SubmissionBackend serves the submission ports (587 and 465). It is a
// separate backend from the MX one on purpose: submission always requires
// authentication and always relays outward, while port 25 never authenticates
// and only accepts mail for local mailboxes (PLAN.md section 4).
type SubmissionBackend struct {
useCase *usecase.ProcessOutboundEmailUseCase
}
func NewSubmissionBackend(uc *usecase.ProcessOutboundEmailUseCase) *SubmissionBackend {
return &SubmissionBackend{useCase: uc}
}
func (b *SubmissionBackend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
return &submissionSession{useCase: b.useCase}, nil
}
type submissionSession struct {
useCase *usecase.ProcessOutboundEmailUseCase
// authenticated is nil until AUTH succeeds; every other command checks it.
authenticated *port.MailboxAuth
from string
recipients []string
}
// AuthMechanisms advertises PLAIN and LOGIN. The server only offers them once
// the connection is encrypted - go-smtp withholds AUTH before TLS unless
// AllowInsecureAuth is set, which this deployment never sets (PLAN.md
// section 4: credentials never travel in the clear).
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain, loginMechanism}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
switch mech {
case sasl.Plain:
return sasl.NewPlainServer(func(identity, username, password string) error {
return s.authenticate(username, password)
}), nil
case loginMechanism:
return newLoginServer(s.authenticate), nil
default:
return nil, gosmtp.ErrAuthUnsupported
}
}
func (s *submissionSession) authenticate(username, password string) error {
rec, err := s.useCase.Authenticate(context.Background(), username, password)
if err != nil {
return &gosmtp.SMTPError{
Code: 535,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 8},
Message: "Authentication credentials invalid",
}
}
s.authenticated = rec
return nil
}
func (s *submissionSession) Reset() {
s.from = ""
s.recipients = nil
}
func (s *submissionSession) Logout() error {
s.authenticated = nil
return nil
}
func (s *submissionSession) Mail(from string, _ *gosmtp.MailOptions) error {
if s.authenticated == nil {
return errAuthRequired()
}
if err := usecase.AuthorizeSender(s.authenticated, from); err != nil {
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
Message: "Sender address not owned by the authenticated account",
}
}
s.from = from
return nil
}
func (s *submissionSession) Rcpt(to string, _ *gosmtp.RcptOptions) error {
if s.authenticated == nil {
return errAuthRequired()
}
s.recipients = append(s.recipients, to)
return nil
}
func (s *submissionSession) Data(r io.Reader) error {
if s.authenticated == nil {
return errAuthRequired()
}
if len(s.recipients) == 0 {
return &gosmtp.SMTPError{
Code: 503,
EnhancedCode: gosmtp.EnhancedCode{5, 5, 1},
Message: "RCPT TO required before DATA",
}
}
err := s.useCase.Submit(context.Background(), usecase.ProcessOutboundEmailInput{
MailboxID: s.authenticated.ID,
SenderAddr: s.from,
DomainName: s.authenticated.DomainName,
Recipients: s.recipients,
Body: r,
MaxRecipientsPerHour: s.authenticated.MaxRecipientsPerHour,
})
if err == nil {
return nil
}
// A rate-limited sender gets a 4xx: the message is legitimate, the
// account has simply run out of budget for this hour and may retry.
if errors.Is(err, usecase.ErrSendLimitExceeded) {
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 7, 0},
Message: "Hourly recipient limit reached, try again later",
}
}
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error accepting message for delivery",
}
}
func errAuthRequired() error {
return &gosmtp.SMTPError{
Code: 530,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 0},
Message: "Authentication required",
}
}
// Ensure the session satisfies both the base and the auth add-on interface.
var (
_ gosmtp.Session = (*submissionSession)(nil)
_ gosmtp.AuthSession = (*submissionSession)(nil)
)
@@ -0,0 +1,94 @@
package smtppresentation
import (
"strings"
"testing"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
func smtpCode(t *testing.T, err error) int {
t.Helper()
if err == nil {
return 0
}
var smtpErr *gosmtp.SMTPError
if !ok(err, &smtpErr) {
t.Fatalf("error is not an SMTPError: %v", err)
}
return smtpErr.Code
}
func ok(err error, target **gosmtp.SMTPError) bool {
e, is := err.(*gosmtp.SMTPError)
if is {
*target = e
}
return is
}
// Every command before AUTH must be refused: submission that accepted an
// unauthenticated MAIL FROM would be an open relay.
func TestSubmissionSession_RefusesCommandsBeforeAuth(t *testing.T) {
s := &submissionSession{}
if got := smtpCode(t, s.Mail("user@example.test", nil)); got != 530 {
t.Errorf("MAIL FROM before AUTH = %d, want 530", got)
}
if got := smtpCode(t, s.Rcpt("rcpt@remote.test", nil)); got != 530 {
t.Errorf("RCPT TO before AUTH = %d, want 530", got)
}
if got := smtpCode(t, s.Data(strings.NewReader("body"))); got != 530 {
t.Errorf("DATA before AUTH = %d, want 530", got)
}
}
// An authenticated account must not be able to send as anybody else.
func TestSubmissionSession_RefusesForeignSender(t *testing.T) {
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
if err := s.Mail("user@example.test", nil); err != nil {
t.Fatalf("sending as itself was refused: %v", err)
}
if got := smtpCode(t, s.Mail("ceo@example.test", nil)); got != 550 {
t.Errorf("MAIL FROM a foreign address = %d, want 550", got)
}
}
func TestSubmissionSession_AdvertisesPlainAndLogin(t *testing.T) {
mechs := (&submissionSession{}).AuthMechanisms()
joined := strings.Join(mechs, " ")
if !strings.Contains(joined, "PLAIN") || !strings.Contains(joined, "LOGIN") {
t.Errorf("AuthMechanisms = %v, want PLAIN and LOGIN", mechs)
}
}
func TestSubmissionSession_RejectsUnsupportedMechanism(t *testing.T) {
if _, err := (&submissionSession{}).Auth("SCRAM-SHA-256"); err == nil {
t.Fatal("expected an unsupported-mechanism error")
}
}
// Reset must clear the envelope but keep the session authenticated: RSET
// starts a new transaction, it does not log the user out.
func TestSubmissionSession_ResetKeepsAuthentication(t *testing.T) {
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
s.Mail("user@example.test", nil)
s.Rcpt("rcpt@remote.test", nil)
s.Reset()
if s.from != "" || len(s.recipients) != 0 {
t.Errorf("envelope survived RSET: from=%q recipients=%v", s.from, s.recipients)
}
if s.authenticated == nil {
t.Error("RSET logged the session out")
}
}
// Compile-time proof that the use case wiring matches what the session needs.
var _ = usecase.NewProcessOutboundEmailUseCase