#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>
This commit is contained in:
1 parent
2e0e65b167
commit
33d91fa247
23 files changed
+906
-139
No files matched your search
@@ -10,6 +10,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -80,7 +81,7 @@ func run(cfg config) {
|
||||
}
|
||||
|
||||
secretVault, vaultErr := vault.New(cfg.MasterKey)
|
||||
certProvider := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr)
|
||||
certProvider, certDegraded := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr)
|
||||
|
||||
// ---------------------------------------------------------------- repos
|
||||
mailboxes := postgres.NewMailboxRepository(pool)
|
||||
@@ -131,12 +132,13 @@ func run(cfg config) {
|
||||
log.Printf("lambdamail-protocols delivering through the smarthost %s", relay.Address())
|
||||
}
|
||||
|
||||
// ARC needs a signing key, so it can only be enabled where DKIM is.
|
||||
// ARC needs a signing key, so it can only be enabled where DKIM is. The
|
||||
// key is resolved on the first message rather than here: on a first boot
|
||||
// the provisioner below has not run yet, and binding now would leave ARC
|
||||
// off until someone restarted the process.
|
||||
if cfg.ArcSealEnabled && dkimRepo != nil {
|
||||
if sealer := buildArcSealer(ctx, cfg, dkimRepo); sealer != nil {
|
||||
inboundUC.SetArcSealer(sealer)
|
||||
log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain())
|
||||
}
|
||||
inboundUC.SetArcSealer(arc.NewLazySealer(dkimRepo, dkim.ParsePrivateKey, cfg.domain(), cfg.PrimaryMailHost))
|
||||
log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- access UCs
|
||||
@@ -161,6 +163,7 @@ func run(cfg config) {
|
||||
|
||||
// ------------------------------------------------------------- HTTP API
|
||||
router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) })
|
||||
router.SetDegradedCheck(certDegraded)
|
||||
applyMtaStsMode(router, cfg)
|
||||
|
||||
server := &http.Server{
|
||||
@@ -187,7 +190,11 @@ func run(cfg config) {
|
||||
// store Traefik maintains and keeps watching it, because the certificate is
|
||||
// replaced every sixty days and a stale one takes every listener down
|
||||
// (PLAN.md section 8).
|
||||
func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) port.CertProvider {
|
||||
//
|
||||
// The second return value is the probe behind /health's degraded state: a
|
||||
// certificate problem does not stop the process, so it has to be visible
|
||||
// somewhere an operator is already looking (PLAN.md section 8.4).
|
||||
func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) (port.CertProvider, func() error) {
|
||||
// Mode B: LambdaMail obtains the certificate itself. This is the only
|
||||
// mode DANE can run in, because it is the only one where the key for the
|
||||
// next certificate exists before that certificate does
|
||||
@@ -204,7 +211,7 @@ func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secr
|
||||
if err != nil {
|
||||
log.Fatalf("start self-managed ACME: %v", err)
|
||||
}
|
||||
return provider
|
||||
return provider, healthy
|
||||
}
|
||||
|
||||
if cfg.TraefikAcmeDir != "" {
|
||||
@@ -215,14 +222,14 @@ func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secr
|
||||
}
|
||||
go watcher.Watch(ctx.Done(), cfg.CertPollInterval)
|
||||
log.Printf("lambdamail-protocols reading TLS certificates from %s (polling every %s)", cfg.TraefikAcmeDir, cfg.CertPollInterval)
|
||||
return watcher
|
||||
return watcher, watcherDegradedCheck(watcher, cfg)
|
||||
}
|
||||
log.Printf("WARNING: could not read the ACME store (%v) - falling back", err)
|
||||
}
|
||||
|
||||
if cfg.StaticCertPath != "" {
|
||||
if provider, err := tlsprovider.NewLocalFileCertProvider(cfg.StaticCertPath, cfg.StaticCertKeyPath); err == nil {
|
||||
return provider
|
||||
return provider, healthy
|
||||
} else {
|
||||
log.Printf("WARNING: could not load the configured TLS certificate: %v", err)
|
||||
}
|
||||
@@ -233,7 +240,35 @@ func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secr
|
||||
if err != nil {
|
||||
log.Fatalf("generate fallback self-signed certificate: %v", err)
|
||||
}
|
||||
return provider
|
||||
return provider, func() error {
|
||||
return errors.New("serving an ephemeral self-signed certificate: no ACME store could be read, so every client sees an untrusted certificate")
|
||||
}
|
||||
}
|
||||
|
||||
// healthy is the degraded probe for a certificate source with nothing to
|
||||
// report.
|
||||
func healthy() error { return nil }
|
||||
|
||||
// watcherDegradedCheck turns the watcher's state into the conditions PLAN.md
|
||||
// section 8.4 tabulates: a missing certificate for the mail host, a store that
|
||||
// has stopped being re-read, and an expiry closing in.
|
||||
func watcherDegradedCheck(watcher *tlsprovider.AcmeCertWatcher, cfg config) func() error {
|
||||
// Two missed polls is the smallest gap that cannot be a scheduling
|
||||
// hiccup, and it is the symptom of risk R2: a watcher gone blind.
|
||||
staleAfter := 3 * cfg.CertPollInterval
|
||||
|
||||
return func() error {
|
||||
if !watcher.HasCertificateFor(cfg.PrimaryMailHost) {
|
||||
return fmt.Errorf("the ACME store holds no certificate for %s: declare a Traefik router for that host", cfg.PrimaryMailHost)
|
||||
}
|
||||
if last := watcher.LastReload(); !last.IsZero() && time.Since(last) > staleAfter {
|
||||
return fmt.Errorf("the ACME store has not been re-read since %s", last.Format(time.RFC3339))
|
||||
}
|
||||
if host, expiry, ok := watcher.EarliestExpiry(); ok && time.Until(expiry) < 24*time.Hour {
|
||||
return fmt.Errorf("the certificate for %s expires at %s", host, expiry.Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func relayConfig(cfg config) usecase.RelayConfig {
|
||||
@@ -245,29 +280,6 @@ func relayConfig(cfg config) usecase.RelayConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// buildArcSealer signs ARC sets with the domain's RSA DKIM key, which is what
|
||||
// a verifier resolves through the same selector.
|
||||
func buildArcSealer(ctx context.Context, cfg config, keys port.DkimKeyRepository) port.ArcSealer {
|
||||
domain := cfg.domain()
|
||||
|
||||
active, err := keys.FindActiveKeys(ctx, domain)
|
||||
if err != nil || len(active) == 0 {
|
||||
log.Printf("WARNING: ARC sealing requested but no DKIM key is active for %s yet; it will start once one is provisioned", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, key := range active {
|
||||
signer, err := dkim.ParsePrivateKey(key.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return arc.NewSealer(domain, key.Selector, cfg.PrimaryMailHost, signer)
|
||||
}
|
||||
|
||||
log.Printf("WARNING: none of the DKIM keys for %s could be used for ARC", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildScanner(cfg config) port.ContentScanner {
|
||||
var scanners []port.ContentScanner
|
||||
if cfg.ClamavAddr != "" {
|
||||
|
||||
@@ -19,12 +19,22 @@ type PersistInboundMessageInput struct {
|
||||
DMARCResult string
|
||||
}
|
||||
|
||||
// InboundMessageRepository durably records an accepted inbound message for
|
||||
// one recipient: allocates the next folder UID, inserts the email_messages
|
||||
// InboundMessageRepository durably records an accepted inbound message: for
|
||||
// each recipient it allocates the next folder UID, inserts the email_messages
|
||||
// row, increments the blob's ref_count, and writes the transactional outbox
|
||||
// event - all in a single transaction (PLAN.md section 6.1, section 9.4).
|
||||
// event (PLAN.md section 6.1, section 9.4).
|
||||
type InboundMessageRepository interface {
|
||||
// Persist returns the allocated IMAP UID on success. The caller may only
|
||||
// report success to the SMTP client after this returns without error.
|
||||
Persist(ctx context.Context, input PersistInboundMessageInput) (uid int64, err error)
|
||||
// PersistAll records every recipient's copy in a single transaction and
|
||||
// returns the allocated IMAP UIDs, in the order the inputs were given.
|
||||
//
|
||||
// One transaction for the whole delivery is a correctness requirement,
|
||||
// not an optimisation. An alias fanning out to several mailboxes is one
|
||||
// SMTP transaction with one reply: if the second insert failed after the
|
||||
// first had committed, the sender would be told to retry and the first
|
||||
// recipient would receive the message twice. Either all recipients get
|
||||
// it, or the sender is asked to try again.
|
||||
//
|
||||
// The caller may only report success to the SMTP client after this
|
||||
// returns without error.
|
||||
PersistAll(ctx context.Context, inputs []PersistInboundMessageInput) (uids []int64, err error)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
|
||||
return
|
||||
}
|
||||
|
||||
dsnBytes, isLoop := valueobject.BuildDsnReport(action, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
|
||||
dsnBytes, isLoop := valueobject.BuildDsnReport(action, w.localHost, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
|
||||
if isLoop || dsnBytes == nil {
|
||||
return
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
|
||||
}
|
||||
|
||||
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
|
||||
Sender: "postmaster@lambdamail.local",
|
||||
Sender: "postmaster@" + w.localHost,
|
||||
Recipients: targets,
|
||||
RecipientAddresses: addresses,
|
||||
Body: bytes.NewReader(dsnBytes),
|
||||
|
||||
@@ -171,7 +171,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
// having published it. Quarantine is left to the spam filter, which
|
||||
// files rather than refuses.
|
||||
if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" {
|
||||
return errors.New("550 5.7.1 DMARC policy rejects this message")
|
||||
return rejectDmarc()
|
||||
}
|
||||
|
||||
if authResult.AuthenticationResults != "" {
|
||||
@@ -212,11 +212,11 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
if scanRes != nil {
|
||||
switch scanRes.Verdict {
|
||||
case valueobject.ScanVerdictVirusReject:
|
||||
return errors.New("554 5.7.1 Virus detected: " + scanRes.VirusName)
|
||||
return rejectVirus(scanRes.VirusName)
|
||||
case valueobject.ScanVerdictSpamReject:
|
||||
return errors.New("554 5.7.1 Spam threshold exceeded")
|
||||
return rejectSpam()
|
||||
case valueobject.ScanVerdictGreylist:
|
||||
return errors.New("451 4.7.1 Greylisted, please try again later")
|
||||
return deferGreylisted()
|
||||
case valueobject.ScanVerdictSpamJunk:
|
||||
targetFolder = "Junk"
|
||||
}
|
||||
@@ -229,8 +229,14 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
return err
|
||||
}
|
||||
|
||||
// Every recipient is handed to the repository at once so the whole
|
||||
// delivery commits or none of it does. Persisting them one call at a time
|
||||
// would mean a failure halfway through leaves the earlier recipients with
|
||||
// a copy while the sender is told to retry - and the retry delivers to
|
||||
// them a second time.
|
||||
persistInputs := make([]port.PersistInboundMessageInput, len(input.Recipients))
|
||||
for i, recipient := range input.Recipients {
|
||||
_, err := uc.messages.Persist(ctx, port.PersistInboundMessageInput{
|
||||
persistInputs[i] = port.PersistInboundMessageInput{
|
||||
MailboxID: recipient.ID,
|
||||
Blob: blob,
|
||||
SenderAddress: input.Sender,
|
||||
@@ -239,11 +245,16 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
SPFResult: authResult.SPF,
|
||||
DKIMResult: authResult.DKIM,
|
||||
DMARCResult: authResult.DMARC,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := uc.messages.PersistAll(ctx, persistInputs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Notifications happen only after the commit: an IDLE client told about a
|
||||
// message that then rolled back would show one that does not exist.
|
||||
for _, recipient := range input.Recipients {
|
||||
if uc.trackerManager != nil && uc.folders != nil {
|
||||
folderRec, err := uc.folders.FindByName(ctx, recipient.ID.String(), targetFolder)
|
||||
if err == nil && folderRec != nil {
|
||||
|
||||
@@ -105,15 +105,31 @@ func (s *stubBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, e
|
||||
return s.ref, s.err
|
||||
}
|
||||
|
||||
// errRepositoryFailure stands in for any persistence error.
|
||||
var errRepositoryFailure = errors.New("repository is unavailable")
|
||||
|
||||
type recordingMessageRepository struct {
|
||||
persisted []port.PersistInboundMessageInput
|
||||
nextUID int64
|
||||
// failAfter makes PersistAll reject a batch larger than this many
|
||||
// recipients, standing in for a partial write in the real repository.
|
||||
failAfter int
|
||||
}
|
||||
|
||||
func (r *recordingMessageRepository) Persist(_ context.Context, input port.PersistInboundMessageInput) (int64, error) {
|
||||
r.persisted = append(r.persisted, input)
|
||||
r.nextUID++
|
||||
return r.nextUID, nil
|
||||
func (r *recordingMessageRepository) PersistAll(_ context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
|
||||
// The real repository commits once for the whole batch, so a failure
|
||||
// records nothing. Mirroring that here keeps the mock honest.
|
||||
if r.failAfter > 0 && len(inputs) > r.failAfter {
|
||||
return nil, errRepositoryFailure
|
||||
}
|
||||
|
||||
uids := make([]int64, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
r.persisted = append(r.persisted, input)
|
||||
r.nextUID++
|
||||
uids = append(uids, r.nextUID)
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
func TestUseCase_Handle_StoresBlobOnceAndPersistsOncePerRecipient(t *testing.T) {
|
||||
@@ -171,3 +187,31 @@ func TestUseCase_Handle_PropagatesBlobStorageError(t *testing.T) {
|
||||
t.Fatal("expected error when blob storage fails, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// One SMTP transaction gets one reply, so a fan-out delivery has to be
|
||||
// all-or-nothing: if the sender is told to retry, no recipient may already
|
||||
// hold a copy, or the retry duplicates it for them.
|
||||
func TestUseCase_Handle_DoesNotPartiallyDeliverOnFanOut(t *testing.T) {
|
||||
blobs := &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc123", SizeBytes: 42}}
|
||||
messages := &recordingMessageRepository{failAfter: 1}
|
||||
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
|
||||
|
||||
recipients := []port.MailboxRecord{
|
||||
{ID: uuid.New()},
|
||||
{ID: uuid.New()},
|
||||
}
|
||||
|
||||
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
||||
Sender: "sender@remote.test",
|
||||
Recipients: recipients,
|
||||
RecipientAddresses: []string{"alias@example.test", "alias@example.test"},
|
||||
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"),
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Handle reported success even though persistence failed")
|
||||
}
|
||||
if len(messages.persisted) != 0 {
|
||||
t.Errorf("%d recipients were left with a copy after a failed delivery; the sender's retry would duplicate it", len(messages.persisted))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package usecase
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SmtpRejection is a use case's decision to refuse a message, carrying the
|
||||
// reply the SMTP layer must send back.
|
||||
//
|
||||
// The decision belongs to the application layer - only it knows that a virus
|
||||
// is permanent and a greylist is temporary - but the wire format belongs to
|
||||
// the presentation layer. This type is the seam: the use case names the
|
||||
// outcome, the adapter renders it. Encoding the reply in the error *text* and
|
||||
// re-parsing it downstream would make a reworded message silently change the
|
||||
// code a remote MTA receives, turning a permanent refusal into a retry loop.
|
||||
type SmtpRejection struct {
|
||||
// Code is the three-digit reply code (RFC 5321 section 4.2.1).
|
||||
Code int
|
||||
// EnhancedCode is the class.subject.detail triplet of RFC 3463.
|
||||
EnhancedCode [3]int
|
||||
// Message is the human-readable text, without any code prefix.
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *SmtpRejection) Error() string {
|
||||
return fmt.Sprintf("%d %d.%d.%d %s",
|
||||
e.Code, e.EnhancedCode[0], e.EnhancedCode[1], e.EnhancedCode[2], e.Message)
|
||||
}
|
||||
|
||||
// IsPermanent reports whether the sender must give up rather than retry.
|
||||
func (e *SmtpRejection) IsPermanent() bool {
|
||||
return e.Code >= 500 && e.Code < 600
|
||||
}
|
||||
|
||||
// rejectDmarc refuses a message whose sender publishes p=reject and whose
|
||||
// DMARC evaluation failed.
|
||||
func rejectDmarc() *SmtpRejection {
|
||||
return &SmtpRejection{Code: 550, EnhancedCode: [3]int{5, 7, 1}, Message: "DMARC policy rejects this message"}
|
||||
}
|
||||
|
||||
// rejectVirus refuses an infected message. It is permanent: the same bytes
|
||||
// will still be infected on a retry.
|
||||
func rejectVirus(virusName string) *SmtpRejection {
|
||||
return &SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Virus detected: " + virusName}
|
||||
}
|
||||
|
||||
// rejectSpam refuses a message that scored above the reject threshold.
|
||||
func rejectSpam() *SmtpRejection {
|
||||
return &SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Spam threshold exceeded"}
|
||||
}
|
||||
|
||||
// deferGreylisted asks the sender to try again. It is deliberately temporary:
|
||||
// greylisting works precisely because a legitimate MTA retries and a spam
|
||||
// cannon does not.
|
||||
func deferGreylisted() *SmtpRejection {
|
||||
return &SmtpRejection{Code: 451, EnhancedCode: [3]int{4, 7, 1}, Message: "Greylisted, please try again later"}
|
||||
}
|
||||
@@ -13,14 +13,32 @@ const (
|
||||
DsnActionDelayed DsnAction = "delayed"
|
||||
)
|
||||
|
||||
// BuildDsnReport constructs an RFC 3464 compliant delivery status notification report payload.
|
||||
// Returns (payload, isLoopPrevented). If envelopeFrom is empty or "<>", isLoopPrevented is true and payload is nil.
|
||||
func BuildDsnReport(action DsnAction, envelopeFrom string, envelopeTo string, messageID string, reason string) ([]byte, bool) {
|
||||
// defaultReportingMTA is the last resort when no mail host was configured. It
|
||||
// is deliberately obviously wrong rather than plausible, so a misconfiguration
|
||||
// shows up in the report instead of hiding in it.
|
||||
const defaultReportingMTA = "mail.lambdamail.local"
|
||||
|
||||
// BuildDsnReport constructs an RFC 3464 compliant delivery status notification
|
||||
// report payload.
|
||||
//
|
||||
// reportingMTA is this server's own mail host. It has to be the real one: a
|
||||
// notification whose From and Reporting-MTA name a domain that does not
|
||||
// resolve is itself undeliverable at the next hop, which loses exactly the
|
||||
// message the sender most needs to see.
|
||||
//
|
||||
// Returns (payload, isLoopPrevented). If envelopeFrom is empty or "<>",
|
||||
// isLoopPrevented is true and payload is nil.
|
||||
func BuildDsnReport(action DsnAction, reportingMTA string, envelopeFrom string, envelopeTo string, messageID string, reason string) ([]byte, bool) {
|
||||
cleanFrom := strings.Trim(strings.TrimSpace(envelopeFrom), "<>")
|
||||
if cleanFrom == "" {
|
||||
return nil, true // Loop prevention per RFC 5321 section 4.5.5
|
||||
}
|
||||
|
||||
reportingMTA = strings.TrimSpace(reportingMTA)
|
||||
if reportingMTA == "" {
|
||||
reportingMTA = defaultReportingMTA
|
||||
}
|
||||
|
||||
boundary := fmt.Sprintf("=_Boundary_DSN_%d", time.Now().UnixNano())
|
||||
nowStr := time.Now().Format(time.RFC1123Z)
|
||||
|
||||
@@ -32,10 +50,15 @@ func BuildDsnReport(action DsnAction, envelopeFrom string, envelopeTo string, me
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("From: Mail Delivery Subsystem <postmaster@lambdamail.local>\r\n"))
|
||||
sb.WriteString(fmt.Sprintf("From: Mail Delivery Subsystem <postmaster@%s>\r\n", reportingMTA))
|
||||
sb.WriteString(fmt.Sprintf("To: <%s>\r\n", cleanFrom))
|
||||
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", nowStr))
|
||||
sb.WriteString(fmt.Sprintf("Message-ID: <dsn-%s@%s>\r\n", messageID, reportingMTA))
|
||||
// RFC 3834 section 5: an automatic notification has to say so, or a
|
||||
// vacation responder on the other side answers it and the two bounce back
|
||||
// and forth forever.
|
||||
sb.WriteString("Auto-Submitted: auto-replied\r\n")
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/report; report-type=delivery-status; boundary=\"%s\"\r\n\r\n", boundary))
|
||||
|
||||
// Part 1: Human-readable narrative
|
||||
@@ -50,7 +73,7 @@ func BuildDsnReport(action DsnAction, envelopeFrom string, envelopeTo string, me
|
||||
// Part 2: Machine-readable delivery-status
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
sb.WriteString("Content-Type: message/delivery-status\r\n\r\n")
|
||||
sb.WriteString("Reporting-MTA: dns; mail.lambdamail.local\r\n")
|
||||
sb.WriteString(fmt.Sprintf("Reporting-MTA: dns; %s\r\n", reportingMTA))
|
||||
sb.WriteString(fmt.Sprintf("Arrival-Date: %s\r\n\r\n", nowStr))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Final-Recipient: rfc822; %s\r\n", envelopeTo))
|
||||
|
||||
@@ -6,19 +6,19 @@ import (
|
||||
)
|
||||
|
||||
func TestBuildDsnReport_PreventsBounceLoopForEmptySender(t *testing.T) {
|
||||
_, isLoop := BuildDsnReport(DsnActionFailed, "", "user@external.test", "msg123", "550 User unknown")
|
||||
_, isLoop := BuildDsnReport(DsnActionFailed, "mail.local.test", "", "user@external.test", "msg123", "550 User unknown")
|
||||
if !isLoop {
|
||||
t.Errorf("expected loop prevention for empty sender")
|
||||
}
|
||||
|
||||
_, isLoop = BuildDsnReport(DsnActionFailed, "<>", "user@external.test", "msg123", "550 User unknown")
|
||||
_, isLoop = BuildDsnReport(DsnActionFailed, "mail.local.test", "<>", "user@external.test", "msg123", "550 User unknown")
|
||||
if !isLoop {
|
||||
t.Errorf("expected loop prevention for <> sender")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDsnReport_GeneratesValidMIME(t *testing.T) {
|
||||
payload, isLoop := BuildDsnReport(DsnActionFailed, "sender@local.test", "recipient@remote.test", "msg123", "550 User unknown")
|
||||
payload, isLoop := BuildDsnReport(DsnActionFailed, "mail.local.test", "sender@local.test", "recipient@remote.test", "msg123", "550 User unknown")
|
||||
if isLoop || payload == nil {
|
||||
t.Fatalf("unexpected loop signal for valid sender")
|
||||
}
|
||||
@@ -34,3 +34,31 @@ func TestBuildDsnReport_GeneratesValidMIME(t *testing.T) {
|
||||
t.Errorf("missing Diagnostic-Code: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A notification whose From and Reporting-MTA name a domain that does not
|
||||
// resolve is itself undeliverable, which loses the very message the sender
|
||||
// most needs to see.
|
||||
func TestBuildDsnReport_UsesTheConfiguredMailHost(t *testing.T) {
|
||||
payload, _ := BuildDsnReport(DsnActionFailed, "mail.example.test", "sender@example.test", "recipient@remote.test", "job-1", "550 User unknown")
|
||||
|
||||
raw := string(payload)
|
||||
if !strings.Contains(raw, "From: Mail Delivery Subsystem <postmaster@mail.example.test>") {
|
||||
t.Errorf("From does not use the configured mail host: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "Reporting-MTA: dns; mail.example.test") {
|
||||
t.Errorf("Reporting-MTA does not use the configured mail host: %s", raw)
|
||||
}
|
||||
if strings.Contains(raw, "lambdamail.local") {
|
||||
t.Errorf("the placeholder host leaked into the report: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 3834 section 5: without Auto-Submitted a vacation responder answers the
|
||||
// bounce and the two loop against each other.
|
||||
func TestBuildDsnReport_MarksItselfAutomatic(t *testing.T) {
|
||||
payload, _ := BuildDsnReport(DsnActionDelayed, "mail.example.test", "sender@example.test", "recipient@remote.test", "job-1", "451 deferred")
|
||||
|
||||
if !strings.Contains(string(payload), "Auto-Submitted: auto-replied") {
|
||||
t.Errorf("the report is not marked as automatic: %s", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package arc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"sync"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// KeyParser turns a stored private key into a signer. It is injected so this
|
||||
// package stays independent of how DKIM keys are encoded.
|
||||
type KeyParser func(privateKeyPEM []byte) (crypto.Signer, error)
|
||||
|
||||
// LazySealer resolves the signing key on first use instead of at construction.
|
||||
//
|
||||
// The composition root builds the sealer before the DKIM provisioner has run,
|
||||
// so on a first boot no key exists yet. Binding to that empty result would
|
||||
// disable ARC until the process was restarted by hand - the failure PLAN.md
|
||||
// section 5 cannot tolerate, because the whole point of ARC is that it is
|
||||
// always applied, not applied when the ordering happened to work out.
|
||||
type LazySealer struct {
|
||||
keys port.DkimKeyRepository
|
||||
parse KeyParser
|
||||
domain string
|
||||
authServID string
|
||||
|
||||
mu sync.Mutex
|
||||
resolved port.ArcSealer
|
||||
}
|
||||
|
||||
// NewLazySealer returns a sealer that binds to the domain's active DKIM key
|
||||
// the first time a message needs sealing.
|
||||
func NewLazySealer(keys port.DkimKeyRepository, parse KeyParser, domain, authServID string) *LazySealer {
|
||||
return &LazySealer{keys: keys, parse: parse, domain: domain, authServID: authServID}
|
||||
}
|
||||
|
||||
// Seal delegates to the underlying sealer, resolving it if needed. Until a key
|
||||
// exists the message is returned untouched: an unsealed message is delivered,
|
||||
// which is strictly better than a rejected one.
|
||||
func (l *LazySealer) Seal(ctx context.Context, message []byte, authResult port.InboundAuthResult) ([]byte, error) {
|
||||
sealer, err := l.sealer(ctx)
|
||||
if err != nil || sealer == nil {
|
||||
return message, err
|
||||
}
|
||||
return sealer.Seal(ctx, message, authResult)
|
||||
}
|
||||
|
||||
func (l *LazySealer) sealer(ctx context.Context) (port.ArcSealer, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.resolved != nil {
|
||||
return l.resolved, nil
|
||||
}
|
||||
|
||||
active, err := l.keys.FindActiveKeys(ctx, l.domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range active {
|
||||
signer, err := l.parse(key.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l.resolved = NewSealer(l.domain, key.Selector, l.authServID, signer)
|
||||
return l.resolved, nil
|
||||
}
|
||||
|
||||
// Still no usable key. Leaving resolved nil means the next message tries
|
||||
// again, which is what turns "not provisioned yet" into a delay rather
|
||||
// than a permanent loss of sealing.
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package arc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// stubKeyRepo serves whatever the test decides is provisioned at that moment.
|
||||
type stubKeyRepo struct {
|
||||
mu sync.Mutex
|
||||
keys []port.DkimSigningKey
|
||||
// calls counts lookups, to prove the resolved key is cached.
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubKeyRepo) FindActiveKeys(_ context.Context, _ string) ([]port.DkimSigningKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls++
|
||||
return s.keys, nil
|
||||
}
|
||||
|
||||
func (s *stubKeyRepo) provision(t *testing.T) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.keys = []port.DkimSigningKey{{
|
||||
DomainName: "example.test",
|
||||
Selector: "lmail",
|
||||
Algorithm: "rsa2048",
|
||||
PrivateKeyPEM: pemBytes,
|
||||
}}
|
||||
}
|
||||
|
||||
func parseTestKey(pemBytes []byte) (crypto.Signer, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
}
|
||||
|
||||
const testMessage = "From: sender@remote.test\r\n" +
|
||||
"To: user@example.test\r\n" +
|
||||
"Subject: hello\r\n" +
|
||||
"\r\n" +
|
||||
"body\r\n"
|
||||
|
||||
// The composition root builds the sealer before the DKIM provisioner has run.
|
||||
// Binding at construction would leave ARC off until the next restart.
|
||||
func TestLazySealer_StartsSealingOnceAKeyIsProvisioned(t *testing.T) {
|
||||
repo := &stubKeyRepo{}
|
||||
sealer := NewLazySealer(repo, parseTestKey, "example.test", "mail.example.test")
|
||||
ctx := context.Background()
|
||||
authResult := port.InboundAuthResult{SPF: port.AuthResultPass}
|
||||
|
||||
// No key yet: the message goes through unsealed rather than failing.
|
||||
out, err := sealer.Seal(ctx, []byte(testMessage), authResult)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal without a key: %v", err)
|
||||
}
|
||||
if strings.Contains(string(out), "ARC-Seal") {
|
||||
t.Fatal("a message was sealed before any key existed")
|
||||
}
|
||||
|
||||
repo.provision(t)
|
||||
|
||||
out, err = sealer.Seal(ctx, []byte(testMessage), authResult)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal after provisioning: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "ARC-Seal") {
|
||||
t.Fatal("sealing never started after the key was provisioned")
|
||||
}
|
||||
}
|
||||
|
||||
// Once bound, the key is not looked up again on every message.
|
||||
func TestLazySealer_CachesTheResolvedKey(t *testing.T) {
|
||||
repo := &stubKeyRepo{}
|
||||
repo.provision(t)
|
||||
sealer := NewLazySealer(repo, parseTestKey, "example.test", "mail.example.test")
|
||||
ctx := context.Background()
|
||||
|
||||
for range 3 {
|
||||
if _, err := sealer.Seal(ctx, []byte(testMessage), port.InboundAuthResult{}); err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if repo.calls != 1 {
|
||||
t.Errorf("FindActiveKeys called %d times, want 1", repo.calls)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestCopyRepository_CopyMessages_AllocatesNewUIDsAndPreservesFlags(t *testin
|
||||
}
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes, ref_count) VALUES ($1, $2, 'local', '/tmp/x', 10, 1)`,
|
||||
blobID, uuid.New().String()+uuid.New().String()[:32])
|
||||
blobID, testBlobDigest())
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestExpungeRepository_Expunge_SetsExpungedAtAndDecrementsCounters(t *testin
|
||||
}
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||
blobID, uuid.New().String()+uuid.New().String()[:32])
|
||||
blobID, testBlobDigest())
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestFlagRepository_SetFlags_AddThenDelThenSet(t *testing.T) {
|
||||
}
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||
blobID, "1111110000000000000000000000000000000000000000000000000000000000"[:64])
|
||||
blobID, testBlobDigest())
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
@@ -21,13 +22,37 @@ func NewInboundMessageRepository(pool *pgxpool.Pool) *InboundMessageRepository {
|
||||
return &InboundMessageRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *InboundMessageRepository) Persist(ctx context.Context, input port.PersistInboundMessageInput) (int64, error) {
|
||||
// PersistAll records every recipient's copy in one transaction, so a delivery
|
||||
// that fans out is all-or-nothing and a retried SMTP transaction cannot leave
|
||||
// an earlier recipient with a duplicate.
|
||||
func (r *InboundMessageRepository) PersistAll(ctx context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx) // no-op if Commit succeeds
|
||||
|
||||
uids := make([]int64, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
uid, err := persistOne(ctx, tx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
// persistOne writes one recipient's copy inside the caller's transaction.
|
||||
func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessageInput) (int64, error) {
|
||||
targetFolder := input.TargetFolderName
|
||||
if targetFolder == "" {
|
||||
targetFolder = "INBOX"
|
||||
@@ -35,7 +60,7 @@ func (r *InboundMessageRepository) Persist(ctx context.Context, input port.Persi
|
||||
|
||||
var folderID string
|
||||
var uid int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id, uid_next FROM folders
|
||||
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
|
||||
ORDER BY special_use IS NOT NULL DESC
|
||||
@@ -84,8 +109,5 @@ func (r *InboundMessageRepository) Persist(ctx context.Context, input port.Persi
|
||||
return 0, fmt.Errorf("insert outbox event: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
+85
-14
@@ -38,7 +38,7 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
|
||||
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||
blobID, "deadbeef00000000000000000000000000000000000000000000000000000000"[:64])
|
||||
blobID, testBlobDigest())
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
@@ -46,22 +46,23 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
|
||||
repo := NewInboundMessageRepository(pool)
|
||||
blob := port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10}
|
||||
|
||||
firstUID, err := repo.Persist(ctx, port.PersistInboundMessageInput{
|
||||
firstUIDs, err := repo.PersistAll(ctx, []port.PersistInboundMessageInput{{
|
||||
MailboxID: mailboxID, Blob: blob,
|
||||
SenderAddress: "sender@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid",
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
|
||||
})
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("first Persist: %v", err)
|
||||
t.Fatalf("first PersistAll: %v", err)
|
||||
}
|
||||
secondUID, err := repo.Persist(ctx, port.PersistInboundMessageInput{
|
||||
secondUIDs, err := repo.PersistAll(ctx, []port.PersistInboundMessageInput{{
|
||||
MailboxID: mailboxID, Blob: blob,
|
||||
SenderAddress: "sender2@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid",
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
|
||||
})
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("second Persist: %v", err)
|
||||
t.Fatalf("second PersistAll: %v", err)
|
||||
}
|
||||
firstUID, secondUID := firstUIDs[0], secondUIDs[0]
|
||||
if secondUID <= firstUID {
|
||||
t.Errorf("secondUID (%d) must be strictly greater than firstUID (%d) - RFC 3501 section 2.3.1.1", secondUID, firstUID)
|
||||
}
|
||||
@@ -71,7 +72,7 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
|
||||
t.Fatalf("query ref_count: %v", err)
|
||||
}
|
||||
if refCount != 2 {
|
||||
t.Errorf("ref_count = %d, want 2 after two Persist calls referencing the same blob", refCount)
|
||||
t.Errorf("ref_count = %d, want 2 after two PersistAll calls referencing the same blob", refCount)
|
||||
}
|
||||
|
||||
var unreadCount, totalCount int
|
||||
@@ -79,10 +80,10 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
|
||||
t.Fatalf("query folder counters: %v", err)
|
||||
}
|
||||
if unreadCount != 2 {
|
||||
t.Errorf("folder unread_count = %d, want 2 after two Persist calls", unreadCount)
|
||||
t.Errorf("folder unread_count = %d, want 2 after two PersistAll calls", unreadCount)
|
||||
}
|
||||
if totalCount != 2 {
|
||||
t.Errorf("folder total_count = %d, want 2 after two Persist calls", totalCount)
|
||||
t.Errorf("folder total_count = %d, want 2 after two PersistAll calls", totalCount)
|
||||
}
|
||||
|
||||
var usedBytes int64
|
||||
@@ -90,7 +91,7 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
|
||||
t.Fatalf("query mailbox used_bytes: %v", err)
|
||||
}
|
||||
if usedBytes != blob.SizeBytes*2 {
|
||||
t.Errorf("mailbox used_bytes = %d, want %d (two Persist calls of %d bytes each)", usedBytes, blob.SizeBytes*2, blob.SizeBytes)
|
||||
t.Errorf("mailbox used_bytes = %d, want %d (two PersistAll calls of %d bytes each)", usedBytes, blob.SizeBytes*2, blob.SizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,18 +119,88 @@ func TestInboundMessageRepository_Persist_ReturnsErrorWhenNoInboxFolderExists(t
|
||||
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||
blobID, "cafebabe00000000000000000000000000000000000000000000000000000000"[:64])
|
||||
blobID, testBlobDigest())
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
repo := NewInboundMessageRepository(pool)
|
||||
_, err = repo.Persist(ctx, port.PersistInboundMessageInput{
|
||||
_, err = repo.PersistAll(ctx, []port.PersistInboundMessageInput{{
|
||||
MailboxID: mailboxID, Blob: port.BlobRef{ID: blobID, SizeBytes: 10},
|
||||
SenderAddress: "sender@example.test", RecipientAddress: "nofolder@example.test",
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
|
||||
})
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the mailbox has no INBOX folder, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// An alias fanning out is one SMTP transaction with one reply. If a later
|
||||
// recipient's insert fails, the earlier ones must not remain: the sender is
|
||||
// told to retry, and the retry would deliver to them a second time.
|
||||
func TestInboundMessageRepository_PersistAll_RollsBackEveryRecipientOnFailure(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
domainName := "fanout-test-" + domainID.String() + ".invalid"
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
||||
domainID, domainName, domainName); err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||
|
||||
// The first mailbox has an INBOX, the second deliberately does not, so
|
||||
// persisting the pair fails on the second entry.
|
||||
goodMailboxID, badMailboxID := uuid.New(), uuid.New()
|
||||
for i, id := range []uuid.UUID{goodMailboxID, badMailboxID} {
|
||||
local := []string{"good", "bad"}[i]
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, $3, $4, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
|
||||
id, domainID, local, local+"@"+domainName); err != nil {
|
||||
t.Fatalf("seed mailbox %s: %v", local, err)
|
||||
}
|
||||
}
|
||||
|
||||
folderID := uuid.New()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`,
|
||||
folderID, goodMailboxID); err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
|
||||
blobID := uuid.New()
|
||||
blobDigest := testBlobDigest()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||
blobID, blobDigest); err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM message_blobs WHERE id = $1`, blobID)
|
||||
blob := port.BlobRef{ID: blobID, SHA256: blobDigest, SizeBytes: 10}
|
||||
|
||||
repo := NewInboundMessageRepository(pool)
|
||||
_, err := repo.PersistAll(ctx, []port.PersistInboundMessageInput{
|
||||
{MailboxID: goodMailboxID, Blob: blob, SenderAddress: "sender@remote.test", RecipientAddress: "alias@" + domainName,
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none"},
|
||||
{MailboxID: badMailboxID, Blob: blob, SenderAddress: "sender@remote.test", RecipientAddress: "alias@" + domainName,
|
||||
SPFResult: "none", DKIMResult: "none", DMARCResult: "none"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("PersistAll succeeded even though the second recipient has no INBOX")
|
||||
}
|
||||
|
||||
var messageCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM email_messages WHERE mailbox_id = $1`, goodMailboxID).Scan(&messageCount); err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if messageCount != 0 {
|
||||
t.Errorf("the first recipient kept %d message(s) after a failed fan-out; the sender's retry would duplicate them", messageCount)
|
||||
}
|
||||
|
||||
var refCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT ref_count FROM message_blobs WHERE id = $1`, blobID).Scan(&refCount); err != nil {
|
||||
t.Fatalf("query ref_count: %v", err)
|
||||
}
|
||||
if refCount != 0 {
|
||||
t.Errorf("ref_count = %d after a rolled-back delivery, want 0", refCount)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -309,3 +310,10 @@ func TestMailboxRepository_ResolveDeliveryTargets_SkipsAliasDestinationThatIsNot
|
||||
t.Errorf("targets = %+v, want empty slice - external forwarding destinations are not internal mailboxes and outbound delivery does not exist yet", targets)
|
||||
}
|
||||
}
|
||||
|
||||
// testBlobDigest produces a unique 64-character value for message_blobs'
|
||||
// content_sha256, which is UNIQUE in the schema. A hardcoded digest makes the
|
||||
// test pass exactly once per database and fail on every re-run.
|
||||
func testBlobDigest() string {
|
||||
return strings.ReplaceAll(uuid.New().String()+uuid.New().String(), "-", "")[:64]
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ func TestMessageQueryRepository_ListMessages_ReturnsRowsOrderedByUID(t *testing.
|
||||
}
|
||||
blobID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 42)`,
|
||||
blobID, "abcdef0000000000000000000000000000000000000000000000000000000000"[:64])
|
||||
blobID, testBlobDigest())
|
||||
if err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package tlsprovider
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -37,8 +38,15 @@ type AcmeCertWatcher struct {
|
||||
// using a consistent map.
|
||||
certificates atomic.Pointer[map[string]*tls.Certificate]
|
||||
|
||||
lastHash atomic.Pointer[[32]byte]
|
||||
lastHash atomic.Pointer[[32]byte]
|
||||
// lastReloadAt is the last time the store was successfully read and found
|
||||
// usable, whether or not its content had changed. It is what PLAN.md risk
|
||||
// R2 monitors: acme.json normally sits untouched for sixty days, so a
|
||||
// timestamp that only advanced on change could not distinguish a healthy
|
||||
// idle watcher from one that has gone blind.
|
||||
lastReloadAt atomic.Pointer[time.Time]
|
||||
// lastChangeAt is the last time the certificates themselves were swapped.
|
||||
lastChangeAt atomic.Pointer[time.Time]
|
||||
|
||||
// unknownSNI counts handshakes for names we hold no certificate for; it
|
||||
// backs the tls_unknown_sni_total metric of PLAN.md section 8.4.
|
||||
@@ -110,31 +118,80 @@ func (w *AcmeCertWatcher) reload() error {
|
||||
return fmt.Errorf("read %s: %w", w.path, err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(raw)
|
||||
if previous := w.lastHash.Load(); previous != nil && *previous == hash {
|
||||
return nil
|
||||
}
|
||||
|
||||
certificates, err := parseAcmeStore(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(certificates) == 0 {
|
||||
return fmt.Errorf("acme watcher: %s holds no usable certificate", w.path)
|
||||
}
|
||||
|
||||
w.certificates.Store(&certificates)
|
||||
w.lastHash.Store(&hash)
|
||||
now := time.Now()
|
||||
w.lastReloadAt.Store(&now)
|
||||
|
||||
hash := sha256.Sum256(raw)
|
||||
if previous := w.lastHash.Load(); previous == nil || *previous != hash {
|
||||
certificates, err := parseAcmeStore(raw, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(certificates) == 0 {
|
||||
return fmt.Errorf("acme watcher: %s holds no usable certificate", w.path)
|
||||
}
|
||||
|
||||
w.certificates.Store(&certificates)
|
||||
w.lastHash.Store(&hash)
|
||||
w.lastChangeAt.Store(&now)
|
||||
}
|
||||
|
||||
// Expiry is checked on every pass, not only on change: a certificate ages
|
||||
// out while the file it lives in stays byte-identical.
|
||||
w.reportExpiry(now)
|
||||
|
||||
w.lastReloadAt.Store(&now)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reportExpiry raises the alerts of PLAN.md section 8.4 for certificates that
|
||||
// are close to expiring. Renewal is Traefik's job, so all this side can do is
|
||||
// make the failure loud before it becomes an outage.
|
||||
func (w *AcmeCertWatcher) reportExpiry(now time.Time) {
|
||||
host, expiry, ok := w.EarliestExpiry()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch remaining := expiry.Sub(now); {
|
||||
case remaining <= 0:
|
||||
log.Printf("CRITICAL: the certificate for %s expired at %s and Traefik has not replaced it", host, expiry.Format(time.RFC3339))
|
||||
case remaining < 24*time.Hour:
|
||||
log.Printf("CRITICAL: the certificate for %s expires in %s", host, remaining.Round(time.Minute))
|
||||
case remaining < 7*24*time.Hour:
|
||||
log.Printf("WARNING: the certificate for %s expires in %s", host, remaining.Round(time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// EarliestExpiry reports the soonest expiry across the loaded certificates,
|
||||
// which is the one that decides when the deployment breaks.
|
||||
func (w *AcmeCertWatcher) EarliestExpiry() (string, time.Time, bool) {
|
||||
loaded := w.certificates.Load()
|
||||
if loaded == nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
var (
|
||||
soonestHost string
|
||||
soonest time.Time
|
||||
)
|
||||
for host, cert := range *loaded {
|
||||
if cert.Leaf == nil {
|
||||
continue
|
||||
}
|
||||
if soonest.IsZero() || cert.Leaf.NotAfter.Before(soonest) {
|
||||
soonest, soonestHost = cert.Leaf.NotAfter, host
|
||||
}
|
||||
}
|
||||
if soonest.IsZero() {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
return soonestHost, soonest, true
|
||||
}
|
||||
|
||||
// parseAcmeStore walks every resolver in the file. A single unusable entry -
|
||||
// an expired certificate, or one whose key does not match - is skipped rather
|
||||
// than failing the whole load, so one stale domain cannot take the server down.
|
||||
func parseAcmeStore(raw []byte) (map[string]*tls.Certificate, error) {
|
||||
func parseAcmeStore(raw []byte, now time.Time) (map[string]*tls.Certificate, error) {
|
||||
var store acmeFile
|
||||
if err := json.Unmarshal(raw, &store); err != nil {
|
||||
return nil, fmt.Errorf("parse acme store: %w", err)
|
||||
@@ -161,6 +218,23 @@ func parseAcmeStore(raw []byte) (map[string]*tls.Certificate, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// X509KeyPair leaves Leaf nil. Parsing it here is what makes the
|
||||
// expiry checks below and in reportExpiry possible at all.
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
log.Printf("acme watcher: skipping %s in resolver %q: leaf certificate is unparseable: %v", entry.Domain.Main, resolverName, err)
|
||||
continue
|
||||
}
|
||||
// An expired entry is worse than a missing one: it would be served
|
||||
// in a handshake every peer then rejects. Skipping it lets the SNI
|
||||
// fallback or the self-signed provider take over instead
|
||||
// (PLAN.md section 8.2, trap 3).
|
||||
if now.After(leaf.NotAfter) {
|
||||
log.Printf("acme watcher: skipping %s in resolver %q: the certificate expired at %s", entry.Domain.Main, resolverName, leaf.NotAfter.Format(time.RFC3339))
|
||||
continue
|
||||
}
|
||||
pair.Leaf = leaf
|
||||
|
||||
for _, name := range append([]string{entry.Domain.Main}, entry.Domain.SANs...) {
|
||||
if name != "" {
|
||||
certificates[strings.ToLower(name)] = &pair
|
||||
@@ -210,9 +284,9 @@ func wildcardOf(name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// LastReload reports when the store was last successfully refreshed. A
|
||||
// timestamp that stops advancing is the signal that the watcher went blind
|
||||
// (PLAN.md risk R2).
|
||||
// LastReload reports when the store was last read successfully, changed or
|
||||
// not. A timestamp that stops advancing is the signal that the watcher went
|
||||
// blind (PLAN.md risk R2).
|
||||
func (w *AcmeCertWatcher) LastReload() time.Time {
|
||||
if t := w.lastReloadAt.Load(); t != nil {
|
||||
return *t
|
||||
@@ -220,6 +294,15 @@ func (w *AcmeCertWatcher) LastReload() time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// LastChange reports when the served certificates were last swapped, which is
|
||||
// how a renewal that never arrived is detected.
|
||||
func (w *AcmeCertWatcher) LastChange() time.Time {
|
||||
if t := w.lastChangeAt.Load(); t != nil {
|
||||
return *t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// UnknownSNICount backs the tls_unknown_sni_total metric.
|
||||
func (w *AcmeCertWatcher) UnknownSNICount() int64 {
|
||||
return w.unknownSNI.Load()
|
||||
|
||||
@@ -20,6 +20,13 @@ import (
|
||||
// makeCertPEM issues a self-signed certificate for the given names.
|
||||
func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
return makeCertPEMWithValidity(t, -time.Hour, 90*24*time.Hour, names...)
|
||||
}
|
||||
|
||||
// makeCertPEMWithValidity issues a certificate whose validity window is given
|
||||
// relative to now, so a test can produce an expired or nearly expired one.
|
||||
func makeCertPEMWithValidity(t *testing.T, notBefore, notAfter time.Duration, names ...string) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
@@ -29,8 +36,8 @@ func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) {
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: names[0]},
|
||||
DNSNames: names,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
NotBefore: time.Now().Add(notBefore),
|
||||
NotAfter: time.Now().Add(notAfter),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
@@ -49,6 +56,13 @@ func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) {
|
||||
// resolver name to that resolver's account and certificates.
|
||||
func writeAcmeStore(t *testing.T, dir, resolverName string, entries map[string][]string) string {
|
||||
t.Helper()
|
||||
return writeAcmeStoreWithValidity(t, dir, resolverName, entries, -time.Hour, 90*24*time.Hour)
|
||||
}
|
||||
|
||||
// writeAcmeStoreWithValidity is writeAcmeStore with control over how long the
|
||||
// issued certificates are valid for.
|
||||
func writeAcmeStoreWithValidity(t *testing.T, dir, resolverName string, entries map[string][]string, notBefore, notAfter time.Duration) string {
|
||||
t.Helper()
|
||||
|
||||
type domain struct {
|
||||
Main string `json:"main"`
|
||||
@@ -62,7 +76,7 @@ func writeAcmeStore(t *testing.T, dir, resolverName string, entries map[string][
|
||||
|
||||
var certificates []certificate
|
||||
for main, sans := range entries {
|
||||
certPEM, keyPEM := makeCertPEM(t, append([]string{main}, sans...)...)
|
||||
certPEM, keyPEM := makeCertPEMWithValidity(t, notBefore, notAfter, append([]string{main}, sans...)...)
|
||||
certificates = append(certificates, certificate{
|
||||
Domain: domain{Main: main, SANs: sans},
|
||||
Certificate: base64.StdEncoding.EncodeToString(certPEM),
|
||||
@@ -240,21 +254,63 @@ func TestAcmeCertWatcher_FailsWhenStoreMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An identical file must not cause a pointless swap; LastReload only advances
|
||||
// when the content actually changed.
|
||||
func TestAcmeCertWatcher_SkipsReloadWhenContentUnchanged(t *testing.T) {
|
||||
// An identical file must not cause a pointless swap, but the watcher must
|
||||
// still record that it looked. acme.json sits untouched for sixty days, so a
|
||||
// LastReload that only advanced on change could not tell a healthy idle
|
||||
// watcher apart from one that went blind (PLAN.md risk R2).
|
||||
func TestAcmeCertWatcher_SkipsSwapButRecordsCheckWhenContentUnchanged(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"mail.example.test": nil})
|
||||
|
||||
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||
first := watcher.LastReload()
|
||||
firstReload := watcher.LastReload()
|
||||
firstChange := watcher.LastChange()
|
||||
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if err := watcher.reload(); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
if !watcher.LastReload().Equal(first) {
|
||||
t.Error("an unchanged file triggered a reload")
|
||||
if !watcher.LastChange().Equal(firstChange) {
|
||||
t.Error("an unchanged file swapped the certificate store")
|
||||
}
|
||||
if !watcher.LastReload().After(firstReload) {
|
||||
t.Error("LastReload did not advance, so a blind watcher would look identical to an idle one")
|
||||
}
|
||||
}
|
||||
|
||||
// An expired entry must not be served: every peer would reject the handshake,
|
||||
// whereas skipping it lets the SNI fallback take over (PLAN.md section 8.2).
|
||||
func TestAcmeCertWatcher_SkipsExpiredCertificate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAcmeStoreWithValidity(t, dir, "letsencrypt",
|
||||
map[string][]string{"expired.example.test": nil}, -48*time.Hour, -24*time.Hour)
|
||||
|
||||
if _, err := NewAcmeCertWatcher(dir, "acme.json", "expired.example.test"); err == nil {
|
||||
t.Fatal("a store holding only an expired certificate must not load as usable")
|
||||
}
|
||||
}
|
||||
|
||||
// The soonest expiry is what decides when the deployment breaks, so it has to
|
||||
// be readable for the alerts of PLAN.md section 8.4.
|
||||
func TestAcmeCertWatcher_ReportsEarliestExpiry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAcmeStoreWithValidity(t, dir, "letsencrypt",
|
||||
map[string][]string{"mail.example.test": nil}, -time.Hour, 72*time.Hour)
|
||||
|
||||
watcher, err := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("NewAcmeCertWatcher: %v", err)
|
||||
}
|
||||
|
||||
host, expiry, ok := watcher.EarliestExpiry()
|
||||
if !ok {
|
||||
t.Fatal("EarliestExpiry reported nothing for a loaded store")
|
||||
}
|
||||
if host != "mail.example.test" {
|
||||
t.Errorf("EarliestExpiry host = %q, want mail.example.test", host)
|
||||
}
|
||||
if remaining := time.Until(expiry); remaining > 73*time.Hour || remaining < 71*time.Hour {
|
||||
t.Errorf("expiry is %s away, want roughly 72h", remaining)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ type Router struct {
|
||||
reportUseCase *appusecase.IngestReportsUseCase
|
||||
pingFunc func() error
|
||||
mtaStsMode valueobject.MtaStsMode
|
||||
// degradedFunc reports a condition that leaves the service running but
|
||||
// not fit for production, the clearest case being a self-signed
|
||||
// certificate standing in for one Traefik never issued
|
||||
// (PLAN.md section 8.4).
|
||||
degradedFunc func() error
|
||||
}
|
||||
|
||||
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
|
||||
@@ -62,6 +67,13 @@ func (r *Router) registerRoutes() {
|
||||
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
|
||||
}
|
||||
|
||||
// SetDegradedCheck registers the probe behind the "degraded" health state.
|
||||
// Returning an error means the process is serving but something an operator
|
||||
// must fix is wrong.
|
||||
func (r *Router) SetDegradedCheck(check func() error) {
|
||||
r.degradedFunc = check
|
||||
}
|
||||
|
||||
func (r *Router) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
if r.pingFunc != nil {
|
||||
if err := r.pingFunc(); err != nil {
|
||||
@@ -69,6 +81,21 @@ func (r *Router) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Degraded still answers 200: the container is alive and must not be
|
||||
// restarted in a loop over a certificate problem a restart cannot fix.
|
||||
// The distinction is carried in the body and the header so monitoring can
|
||||
// alert on it (PLAN.md section 8.4).
|
||||
if r.degradedFunc != nil {
|
||||
if err := r.degradedFunc(); err != nil {
|
||||
w.Header().Set("X-LambdaMail-Health", "degraded")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "DEGRADED: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("X-LambdaMail-Health", "ok")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK\n"))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httppresentation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -67,3 +68,38 @@ func TestRouter_MtaSts_Autoconfig_SecurityTxt_Ingest(t *testing.T) {
|
||||
t.Errorf("dmarc ingest status = %d, body = %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A certificate problem must be visible without taking the container down:
|
||||
// restarting cannot fix a certificate Traefik never issued, so /health stays
|
||||
// 200 and carries the degraded state instead (PLAN.md section 8.4).
|
||||
func TestRouter_HealthReportsDegradedWithoutFailing(t *testing.T) {
|
||||
router := NewRouter(appusecase.NewIngestReportsUseCase(nil), func() error { return nil })
|
||||
router.SetDegradedCheck(func() error { return errors.New("self-signed certificate in use") })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("degraded health status = %d, want 200 so the orchestrator does not restart-loop", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("X-LambdaMail-Health"); got != "degraded" {
|
||||
t.Errorf("X-LambdaMail-Health = %q, want degraded", got)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "self-signed certificate in use") {
|
||||
t.Errorf("the degraded body does not name the cause: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A dependency that is actually down is a different state: that one must fail.
|
||||
func TestRouter_HealthFailsWhenDependencyIsDown(t *testing.T) {
|
||||
router := NewRouter(appusecase.NewIngestReportsUseCase(nil), func() error { return errors.New("database unreachable") })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("health status = %d, want 503", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
|
||||
@@ -141,28 +140,23 @@ func (s *session) Data(r io.Reader) error {
|
||||
if errors.As(err, &smtpErr) {
|
||||
return smtpErr
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if strings.HasPrefix(errMsg, "550 ") {
|
||||
|
||||
// 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: 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,
|
||||
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
|
||||
Message: strings.TrimPrefix(errMsg, "554 5.7.1 "),
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(errMsg, "451 ") {
|
||||
return &gosmtp.SMTPError{
|
||||
Code: 451,
|
||||
EnhancedCode: gosmtp.EnhancedCode{4, 7, 1},
|
||||
Message: strings.TrimPrefix(errMsg, "451 4.7.1 "),
|
||||
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},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
type fakeMailboxRepository struct {
|
||||
@@ -37,6 +38,8 @@ func (f *fakeMailboxRepository) ResolveDeliveryTargets(_ context.Context, addres
|
||||
type recordingBlobStorage struct {
|
||||
stored []byte
|
||||
ref port.BlobRef
|
||||
// err makes Store fail, standing in for an unexpected internal failure.
|
||||
err error
|
||||
}
|
||||
|
||||
func (b *recordingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
|
||||
@@ -44,21 +47,44 @@ func (b *recordingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobR
|
||||
if err != nil {
|
||||
return port.BlobRef{}, err
|
||||
}
|
||||
if b.err != nil {
|
||||
return port.BlobRef{}, b.err
|
||||
}
|
||||
b.stored = data
|
||||
return b.ref, nil
|
||||
}
|
||||
|
||||
// rejectingScanner stands in for the spam or virus scanner refusing a message.
|
||||
type rejectingScanner struct{ rejection error }
|
||||
|
||||
func (s *rejectingScanner) Scan(_ context.Context, _ port.ScanInput) (*valueobject.ScanResult, error) {
|
||||
return nil, s.rejection
|
||||
}
|
||||
|
||||
type recordingMessageRepository struct {
|
||||
persisted []port.PersistInboundMessageInput
|
||||
}
|
||||
|
||||
func (m *recordingMessageRepository) Persist(_ context.Context, input port.PersistInboundMessageInput) (int64, error) {
|
||||
m.persisted = append(m.persisted, input)
|
||||
return int64(len(m.persisted)), nil
|
||||
func (m *recordingMessageRepository) PersistAll(_ context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
|
||||
uids := make([]int64, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
m.persisted = append(m.persisted, input)
|
||||
uids = append(uids, int64(len(m.persisted)))
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
func newTestSession(mailboxes *fakeMailboxRepository, blobs *recordingBlobStorage, messages *recordingMessageRepository) gosmtp.Session {
|
||||
return newTestSessionWithScanner(mailboxes, blobs, messages, nil)
|
||||
}
|
||||
|
||||
// newTestSessionWithScanner builds a session whose scanner refuses the message
|
||||
// with the given error, which is how a rejection reaches the Data handler.
|
||||
func newTestSessionWithScanner(mailboxes *fakeMailboxRepository, blobs *recordingBlobStorage, messages *recordingMessageRepository, rejection error) gosmtp.Session {
|
||||
uc := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
|
||||
if rejection != nil {
|
||||
uc.SetScanner(&rejectingScanner{rejection: rejection})
|
||||
}
|
||||
backend := NewBackend(uc)
|
||||
session, err := backend.NewSession(nil)
|
||||
if err != nil {
|
||||
@@ -194,3 +220,81 @@ func TestSession_Reset_ClearsAccumulatedRecipients(t *testing.T) {
|
||||
t.Errorf("persisted %d messages after Reset, want 0", len(messages.persisted))
|
||||
}
|
||||
}
|
||||
|
||||
// The reply a remote MTA receives must come from the use case's decision, not
|
||||
// from parsing its error text: rewording a message would otherwise change a
|
||||
// permanent refusal into a retry, or the reverse.
|
||||
func TestSession_Data_RendersUseCaseRejectionVerbatim(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rejection *usecase.SmtpRejection
|
||||
wantCode int
|
||||
wantEnhancedCode gosmtp.EnhancedCode
|
||||
}{
|
||||
{"virus is permanent", &usecase.SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Virus detected: Eicar-Test"}, 554, gosmtp.EnhancedCode{5, 7, 1}},
|
||||
{"greylist is temporary", &usecase.SmtpRejection{Code: 451, EnhancedCode: [3]int{4, 7, 1}, Message: "Greylisted, please try again later"}, 451, gosmtp.EnhancedCode{4, 7, 1}},
|
||||
{"dmarc reject is permanent", &usecase.SmtpRejection{Code: 550, EnhancedCode: [3]int{5, 7, 1}, Message: "DMARC policy rejects this message"}, 550, gosmtp.EnhancedCode{5, 7, 1}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mailboxID := uuid.New()
|
||||
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
||||
"user@example.test": {ID: mailboxID, QuotaBytes: 1000},
|
||||
}}
|
||||
session := newTestSessionWithScanner(mailboxes,
|
||||
&recordingBlobStorage{ref: port.BlobRef{ID: uuid.New()}}, &recordingMessageRepository{}, tc.rejection)
|
||||
|
||||
if err := session.Mail("sender@remote.test", nil); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := session.Rcpt("user@example.test", nil); err != nil {
|
||||
t.Fatalf("RCPT TO: %v", err)
|
||||
}
|
||||
|
||||
err := session.Data(bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"))
|
||||
|
||||
var smtpErr *gosmtp.SMTPError
|
||||
if !errors.As(err, &smtpErr) {
|
||||
t.Fatalf("Data returned %v, want an *gosmtp.SMTPError", err)
|
||||
}
|
||||
if smtpErr.Code != tc.wantCode {
|
||||
t.Errorf("reply code = %d, want %d", smtpErr.Code, tc.wantCode)
|
||||
}
|
||||
if smtpErr.EnhancedCode != tc.wantEnhancedCode {
|
||||
t.Errorf("enhanced code = %v, want %v", smtpErr.EnhancedCode, tc.wantEnhancedCode)
|
||||
}
|
||||
if smtpErr.Message != tc.rejection.Message {
|
||||
t.Errorf("message = %q, want %q", smtpErr.Message, tc.rejection.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unexpected internal failure must be temporary: the message was not
|
||||
// stored, and a permanent reply would discard mail over a bug on this side.
|
||||
func TestSession_Data_ReportsInternalFailureAsTemporary(t *testing.T) {
|
||||
mailboxID := uuid.New()
|
||||
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
||||
"user@example.test": {ID: mailboxID, QuotaBytes: 1000},
|
||||
}}
|
||||
session := newTestSession(mailboxes,
|
||||
&recordingBlobStorage{err: errors.New("disk full")}, &recordingMessageRepository{})
|
||||
|
||||
if err := session.Mail("sender@remote.test", nil); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := session.Rcpt("user@example.test", nil); err != nil {
|
||||
t.Fatalf("RCPT TO: %v", err)
|
||||
}
|
||||
|
||||
err := session.Data(bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"))
|
||||
|
||||
var smtpErr *gosmtp.SMTPError
|
||||
if !errors.As(err, &smtpErr) {
|
||||
t.Fatalf("Data returned %v, want an *gosmtp.SMTPError", err)
|
||||
}
|
||||
if smtpErr.Code != 451 {
|
||||
t.Errorf("reply code = %d, want 451 so the sender retries", smtpErr.Code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user