Files
LambdaMail/services/protocols/internal/application/usecase/outbound_worker.go
T
Lucas RayanandClaude Opus 5 c83f2f75b2 #25: deliver mail addressed to this server without going out to the internet
Every message was resolved through MX and delivered over SMTP, including one
addressed to a mailbox on this very host. Two users on the same server could
not write to each other without a working outbound port 25 - which the
provider blocks, so on this deployment they could not write to each other at
all. It also made internal mail take a pointless round trip through the public
internet.

The worker now checks whether the recipient resolves to a local mailbox and,
if so, hands the message to the inbound path directly. The check runs ahead of
the relay branch on purpose: sending local mail out to a smarthost only to
have it delivered back is slower and has more ways to fail.

The shortcut is narrow. It relies on ResolveDeliveryTargets, which only
resolves addresses on an active local domain, and a test covers that a remote
recipient still goes out over SMTP - otherwise local delivery would swallow
mail meant for the internet. A resolver error is treated as retry rather than
as not-local: falling through to SMTP on an unknown answer would send to the
internet a message that may belong here. Local delivery is still
content-scanned; a virus between two accounts on one host is no less a virus.

This also surfaced a permissive test double: fakeAliasFanOutRepository claimed
every address was local, which was harmless while nothing consulted it while
routing and wrong the moment something did. It now hosts one address, as the
real repository does, and the DSN fan-out test names it explicitly.

Verified: the local-delivery test fails without the change (the recipient
triggers an MX lookup) and passes with it; 22 Go packages green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:16:53 -03:00

446 lines
14 KiB
Go

package usecase
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
"lambdamail/protocols/internal/domain/valueobject"
)
// ErrPolicyDowngradeRefused is returned when a destination's published TLS
// policy could not be satisfied. PLAN.md section 6.2 is explicit: the message
// is deferred with 4.7.5, never delivered over an unvalidated channel.
var ErrPolicyDowngradeRefused = errors.New("outbound: refusing to deliver without the TLS validation the destination requires")
type OutboundWorkerUseCase struct {
outboundRepo port.OutboundRepository
mxResolver port.MXResolver
blobReader port.BlobReader
inboundUC *ProcessInboundEmailUseCase
mailboxes port.MailboxRepository
policyResolver port.TLSPolicyResolver
relay RelayConfig
localHost string
}
// SetRelay routes delivery through a smarthost (PLAN.md section 10.4). This is
// the fallback for hosts whose provider blocks outbound port 25, and it
// replaces MX resolution entirely: every message goes to the relay.
func (w *OutboundWorkerUseCase) SetRelay(relay RelayConfig) {
w.relay = relay
}
// SetPolicyResolver enables DANE and MTA-STS on the delivery path. Without
// one the worker falls back to opportunistic TLS, which is correct for a
// destination that publishes no policy but must never be the choice for one
// that does.
func (w *OutboundWorkerUseCase) SetPolicyResolver(resolver port.TLSPolicyResolver) {
w.policyResolver = resolver
}
func NewOutboundWorkerUseCase(
outboundRepo port.OutboundRepository,
mxResolver port.MXResolver,
blobReader port.BlobReader,
inboundUC *ProcessInboundEmailUseCase,
mailboxes port.MailboxRepository,
localHost string,
) *OutboundWorkerUseCase {
if localHost == "" {
localHost = "mail.lambdamail.local"
}
return &OutboundWorkerUseCase{
outboundRepo: outboundRepo,
mxResolver: mxResolver,
blobReader: blobReader,
inboundUC: inboundUC,
mailboxes: mailboxes,
localHost: localHost,
}
}
func (w *OutboundWorkerUseCase) ProcessBatch(ctx context.Context, workerID string, limit int) (int, error) {
if limit <= 0 {
limit = 10
}
jobs, err := w.outboundRepo.FetchNextReady(ctx, workerID, limit)
if err != nil {
return 0, fmt.Errorf("fetch ready jobs: %w", err)
}
processed := 0
for _, job := range jobs {
w.processSingleJob(ctx, job)
processed++
}
return processed, nil
}
func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entity.OutboundJob) {
job.Attempt++
payload, err := w.blobReader.ReadByID(ctx, job.BlobID)
if err != nil {
w.handleFailure(ctx, job, fmt.Sprintf("read blob %s: %v", job.BlobID, err), true)
return
}
// Mail addressed to a mailbox on this server is delivered here, without
// going out to the internet and back.
//
// Nothing did this before: every message was resolved through MX and
// delivered over SMTP, including one user writing to another on the same
// host. That makes internal mail depend on outbound port 25 - so on a
// provider that blocks it, which is most of them, colleagues could not
// write to each other on their own server. It also fails ahead of the
// relay check on purpose: sending local mail out to a smarthost only to
// have it delivered back is a slower path with more ways to break.
if delivered, err := w.deliverLocally(ctx, job, payload); delivered {
if err != nil {
w.handleFailure(ctx, job, fmt.Sprintf("local delivery: %v", err), true)
return
}
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.LastSmtpCode = ""
job.TlsPolicyUsed = entity.TLSModeLocal
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
// With a smarthost every message goes to the relay regardless of
// destination, and the relay owns transport security from there on.
if w.relay.Configured() {
if err := w.deliverViaRelay(job.EnvelopeFrom, job.EnvelopeTo, payload); err != nil {
w.finishFailed(ctx, job, err)
return
}
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.LastSmtpCode = ""
job.TlsPolicyUsed = entity.TLSModeRelay
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain)
if err != nil || len(mxHosts) == 0 {
mxHosts = []string{job.DestinationDomain}
}
var lastErr error
delivered := false
policyUsed := entity.TLSModeOpportunistic
for _, mxHost := range mxHosts {
policy, err := w.resolvePolicy(ctx, job.DestinationDomain, mxHost)
if err != nil {
// A published policy we cannot evaluate is a reason to wait, not
// a reason to send in the clear.
lastErr = err
continue
}
// RFC 8461 section 4.1: a host outside the policy's mx set must not
// be used while that policy is in force.
if policy.RequiresValidation() && !policy.CoversHost(mxHost) {
lastErr = fmt.Errorf("%w: %s is not listed in the MTA-STS policy of %s",
ErrPolicyDowngradeRefused, mxHost, job.DestinationDomain)
continue
}
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload, policy); err == nil {
delivered = true
policyUsed = policy.Effective()
break
} else {
lastErr = err
}
}
if delivered {
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.LastSmtpCode = ""
job.TlsPolicyUsed = policyUsed
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
w.finishFailed(ctx, job, lastErr)
}
// finishFailed classifies a delivery failure and records the outcome.
func (w *OutboundWorkerUseCase) finishFailed(ctx context.Context, job *entity.OutboundJob, lastErr error) {
errStr := "delivery failed"
if lastErr != nil {
errStr = lastErr.Error()
}
// The remote reply code decides the outcome (PLAN.md section 6.3): 5xx is
// a permanent failure that bounces now, anything else - including a dial
// or TLS error, which carries no reply code at all - is retried.
code := smtpReplyCode(lastErr)
if code > 0 {
job.LastSmtpCode = strconv.Itoa(code)
}
// A refused downgrade is always temporary: the destination may fix its
// certificate, and bouncing would hand the attacker the outcome they
// wanted (PLAN.md section 6.2).
if errors.Is(lastErr, ErrPolicyDowngradeRefused) {
job.LastSmtpCode = "451"
job.TlsPolicyUsed = "none"
w.handleFailure(ctx, job, "4.7.5 "+errStr, false)
return
}
w.handleFailure(ctx, job, errStr, code >= 500 && code < 600)
}
// smtpReplyCode extracts the reply code from an error returned by net/smtp,
// which wraps remote replies in *textproto.Error. It returns 0 when the
// failure happened before any reply was read.
func smtpReplyCode(err error) int {
var protoErr *textproto.Error
if errors.As(err, &protoErr) {
return protoErr.Code
}
return 0
}
func (w *OutboundWorkerUseCase) handleFailure(ctx context.Context, job *entity.OutboundJob, errStr string, isPermanent bool) {
now := time.Now()
nextAttemptAt, sendDelayDsn, maxExceeded := entity.CalculateNextBackoff(now, job.Attempt, job.ExpiresAt)
if isPermanent || maxExceeded {
job.Status = entity.OutboundJobStatusBounced
job.LastError = errStr
_ = w.outboundRepo.UpdateJob(ctx, job)
w.sendDsn(ctx, valueobject.DsnActionFailed, job, errStr)
return
}
job.Status = entity.OutboundJobStatusDeferred
job.NextAttemptAt = nextAttemptAt
job.LastError = errStr
if sendDelayDsn && !job.DelayDsnSent {
w.sendDsn(ctx, valueobject.DsnActionDelayed, job, errStr)
job.DelayDsnSent = true
}
_ = w.outboundRepo.UpdateJob(ctx, job)
}
func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.DsnAction, job *entity.OutboundJob, reason string) {
if w.inboundUC == nil || job.MailboxID == nil {
return
}
dsnBytes, isLoop := valueobject.BuildDsnReport(action, w.localHost, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
if isLoop || dsnBytes == nil {
return
}
if w.mailboxes == nil {
return
}
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
if err != nil || len(targets) == 0 {
return
}
// Handle indexes the two slices in lockstep, so the envelope address is
// repeated once per resolved mailbox. An alias sender fans out to several
// mailboxes under the same address.
addresses := make([]string, len(targets))
for i := range targets {
addresses[i] = job.EnvelopeFrom
}
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@" + w.localHost,
Recipients: targets,
RecipientAddresses: addresses,
Body: bytes.NewReader(dsnBytes),
// This server wrote this message a moment ago; scanning our own
// postmaster notification only ever files it as Junk.
SystemGenerated: true,
})
}
// resolvePolicy asks the resolver what transport security the destination
// requires. With no resolver configured every destination is opportunistic.
func (w *OutboundWorkerUseCase) resolvePolicy(ctx context.Context, destinationDomain, mxHost string) (entity.TLSPolicy, error) {
if w.policyResolver == nil {
return entity.NewTLSPolicy(false, false), nil
}
return w.policyResolver.Resolve(ctx, destinationDomain, mxHost)
}
// tlsConfigFor turns a policy into a TLS configuration.
//
// Opportunistic delivery deliberately skips verification: RFC 7435 prefers an
// unauthenticated encrypted channel to a cleartext one, and the alternative -
// refusing every destination with a self-signed certificate - would lose
// legitimate mail. When a policy is in force the verification is real, and a
// failure surfaces as a refusal to deliver.
func tlsConfigFor(mxHost string, policy entity.TLSPolicy) *tls.Config {
config := &tls.Config{
ServerName: strings.TrimSuffix(mxHost, "."),
MinVersion: tls.VersionTLS12,
}
if !policy.RequiresValidation() {
config.InsecureSkipVerify = true
return config
}
if policy.Effective() == entity.TLSModeDane {
// DANE authenticates the certificate directly against the
// DNSSEC-signed association, so the PKIX chain and the name are not
// what is being trusted (RFC 7672 section 3.1). Verification is done
// by hand below instead of by the standard chain builder.
config.InsecureSkipVerify = true
config.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
chain := make([]*x509.Certificate, 0, len(rawCerts))
for _, raw := range rawCerts {
cert, err := x509.ParseCertificate(raw)
if err != nil {
return fmt.Errorf("parse peer certificate: %w", err)
}
chain = append(chain, cert)
}
if !policy.MatchesCertificate(chain) {
return fmt.Errorf("%w: certificate of %s matches no TLSA record", ErrPolicyDowngradeRefused, mxHost)
}
return nil
}
return config
}
// MTA-STS in enforce mode requires a normal, publicly trusted PKIX
// validation against the MX name (RFC 8461 section 4.1).
return config
}
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte, policy entity.TLSPolicy) error {
addr := mxHost
if _, _, err := net.SplitHostPort(mxHost); err != nil {
addr = net.JoinHostPort(mxHost, "25")
}
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial tcp %s: %w", addr, err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, mxHost)
if err != nil {
return fmt.Errorf("smtp client handshake: %w", err)
}
defer client.Close()
if err := client.Hello(w.localHost); err != nil {
return fmt.Errorf("EHLO failed: %w", err)
}
starttlsOffered, _ := client.Extension("STARTTLS")
if starttlsOffered {
if err := client.StartTLS(tlsConfigFor(mxHost, policy)); err != nil {
if policy.RequiresValidation() {
return fmt.Errorf("%w: STARTTLS to %s failed validation: %v", ErrPolicyDowngradeRefused, mxHost, err)
}
return fmt.Errorf("STARTTLS failed: %w", err)
}
} else if policy.RequiresValidation() {
// The destination's own published policy says it speaks TLS. A peer
// that does not offer STARTTLS is either broken or being stripped,
// and handing it the message either way is exactly the downgrade the
// policy exists to prevent.
return fmt.Errorf("%w: %s did not offer STARTTLS", ErrPolicyDowngradeRefused, mxHost)
}
if err := client.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO failed: %w", err)
}
wWriter, err := client.Data()
if err != nil {
return fmt.Errorf("DATA command failed: %w", err)
}
if _, err := wWriter.Write(payload); err != nil {
wWriter.Close()
return fmt.Errorf("write DATA payload failed: %w", err)
}
if err := wWriter.Close(); err != nil {
return fmt.Errorf("close DATA writer failed: %w", err)
}
return client.Quit()
}
// deliverLocally hands a message to a mailbox on this server.
//
// Returns (handled, err): handled is false when the recipient is not local, so
// the caller falls through to relay or MX delivery. A local recipient whose
// delivery then fails returns (true, err) and is retried like any other job -
// the queue row stays the durable record either way.
//
// The message is still content-scanned. It arrives DKIM-signed by this server,
// so it authenticates cleanly, and a virus mailed between two accounts on the
// same host is no less a virus.
func (w *OutboundWorkerUseCase) deliverLocally(
ctx context.Context, job *entity.OutboundJob, payload []byte,
) (bool, error) {
if w.inboundUC == nil || w.mailboxes == nil {
return false, nil
}
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeTo)
if err != nil {
// Not knowing whether the address is local is not the same as knowing
// it is not: falling through to SMTP would deliver to the internet a
// message that may belong here. Retrying is the safe answer.
return true, err
}
if len(targets) == 0 {
return false, nil
}
addresses := make([]string, len(targets))
for i := range targets {
addresses[i] = job.EnvelopeTo
}
return true, w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: job.EnvelopeFrom,
Recipients: targets,
RecipientAddresses: addresses,
Body: bytes.NewReader(payload),
})
}