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>
379 lines
12 KiB
Go
379 lines
12 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
|
|
}
|
|
|
|
// 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),
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
}
|