Files
LambdaMail/services/protocols/internal/presentation/smtp/backend.go
T
Lucas RayanandClaude Opus 5 33d91fa247 #19: fix F1-F3 defects found auditing the phases against PLAN.md
Every fix here is a case where the code and the plan disagreed, and the
code was wrong.

Certificates (section 8.2, 8.4). The parser documented that it skipped
expired entries but never checked, so an expired certificate would be
served until every handshake failed. LastReload() only advanced when
acme.json changed - and it does not change for sixty days - which made
the timestamp risk R2 monitors indistinguishable from a watcher gone
blind. Expiry is now checked on every pass, LastReload records the check
and LastChange the swap, and the <7d/<24h alerts of section 8.4 exist.

ARC (section 5). buildArcSealer resolved the DKIM key at startup, before
runDnsReconciler had provisioned one. On a first boot ARC was disabled
until someone restarted the process, which the log even admitted while
offering no way for it to start. A LazySealer binds on first use instead.

Health (section 8.4). There was no degraded state at all. /health now
reports one - still 200, because restarting cannot fix a certificate
Traefik never issued.

Bounces. DSNs were sent From postmaster@lambdamail.local, a domain that
does not resolve, so the notification was itself undeliverable. They now
carry the configured mail host, a Message-ID, and Auto-Submitted so a
vacation responder cannot loop against them.

Fan-out atomicity (section 6.1). Persist ran one transaction per
recipient, so a failure halfway through an alias fan-out left earlier
recipients with a copy while the sender was told to retry - and the retry
delivered to them twice. PersistAll commits the whole delivery or none of
it. Verified against Postgres by reverting the repository to the old
behaviour and confirming the new test fails on it.

SMTP replies. The use case encoded replies in error text ("550 5.7.1 ...")
which the adapter re-parsed by prefix, so rewording a message could turn a
permanent refusal into a retry. SmtpRejection carries the decision and the
adapter renders it.

Repository tests. These only run with TEST_DATABASE_URL, which CI does
set - so the job was red: copy and expunge seeded a 68-character digest
into a CHAR(64) column, and three others used fixed digests that collide
with the UNIQUE index on any re-run. A shared testBlobDigest() helper
fixes both; the suite now passes twice in a row against one database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:57:22 -03:00

168 lines
4.6 KiB
Go

// Package smtppresentation adapts github.com/emersion/go-smtp's Backend/
// Session interfaces to ProcessInboundEmailUseCase (Clean Architecture
// layer 4 - PLAN.md section 3).
package smtppresentation
import (
"context"
"errors"
"io"
"net"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
// Backend is the go-smtp entry point: one Session per connection.
type Backend struct {
useCase *usecase.ProcessInboundEmailUseCase
}
func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend {
return &Backend{useCase: uc}
}
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
s.recipientAddresses = nil
}
func (s *session) Logout() error {
return nil
}
func (s *session) Mail(from string, _ *gosmtp.MailOptions) error {
s.from = from
return nil
}
func (s *session) Rcpt(to string, _ *gosmtp.RcptOptions) error {
for _, existing := range s.recipientAddresses {
if existing == to {
// Duplicate RCPT TO for an address already accepted in this
// transaction (some MTAs retry): accept silently without
// persisting a second delivery.
return nil
}
}
targets, err := s.useCase.ResolveRecipient(context.Background(), to)
if err != nil {
switch {
case errors.Is(err, usecase.ErrRecipientNotFound):
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 1, 1},
Message: "User unknown",
}
case errors.Is(err, usecase.ErrMailboxQuotaExceeded):
return &gosmtp.SMTPError{
Code: 452,
EnhancedCode: gosmtp.EnhancedCode{4, 2, 2},
Message: "Mailbox full",
}
default:
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error resolving recipient",
}
}
}
// One RCPT TO can resolve to multiple mailboxes (alias fan-out) - each
// gets its own delivery, all recorded under the same envelope address.
for _, rec := range targets {
s.recipients = append(s.recipients, rec)
s.recipientAddresses = append(s.recipientAddresses, to)
}
return nil
}
func (s *session) Data(r io.Reader) error {
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.Handle(context.Background(), usecase.ProcessInboundEmailInput{
Sender: s.from,
Recipients: s.recipients,
RecipientAddresses: s.recipientAddresses,
Body: r,
ClientIP: s.clientIP(),
HeloDomain: s.heloDomain(),
})
if err != nil {
var smtpErr *gosmtp.SMTPError
if errors.As(err, &smtpErr) {
return smtpErr
}
// A refusal the use case decided on carries its own reply. Rendering
// it here is the only place the wire format is chosen.
var rejection *usecase.SmtpRejection
if errors.As(err, &rejection) {
return &gosmtp.SMTPError{
Code: rejection.Code,
EnhancedCode: gosmtp.EnhancedCode{
rejection.EnhancedCode[0], rejection.EnhancedCode[1], rejection.EnhancedCode[2],
},
Message: rejection.Message,
}
}
// Anything else is an internal failure. It is reported as temporary so
// the sender retries: the message was not stored, and telling the peer
// it failed permanently would discard mail over a bug on this side.
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error storing message",
}
}
return nil
}