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>
301 lines
11 KiB
Go
301 lines
11 KiB
Go
package smtppresentation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
|
|
gosmtp "github.com/emersion/go-smtp"
|
|
"github.com/google/uuid"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
"lambdamail/protocols/internal/application/usecase"
|
|
"lambdamail/protocols/internal/domain/valueobject"
|
|
)
|
|
|
|
type fakeMailboxRepository struct {
|
|
byAddress map[string]port.MailboxRecord
|
|
}
|
|
|
|
func (f *fakeMailboxRepository) FindActiveByAddress(_ context.Context, address string) (*port.MailboxRecord, error) {
|
|
rec, ok := f.byAddress[address]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &rec, nil
|
|
}
|
|
|
|
func (f *fakeMailboxRepository) ResolveDeliveryTargets(_ context.Context, address string) ([]port.MailboxRecord, error) {
|
|
rec, ok := f.byAddress[address]
|
|
if !ok {
|
|
return []port.MailboxRecord{}, nil
|
|
}
|
|
return []port.MailboxRecord{rec}, nil
|
|
}
|
|
|
|
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) {
|
|
data, err := io.ReadAll(r)
|
|
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) 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 {
|
|
panic(err)
|
|
}
|
|
return session
|
|
}
|
|
|
|
func TestSession_Rcpt_AcceptsKnownActiveRecipient(t *testing.T) {
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
|
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000, QuotaBytes: 1000},
|
|
}}
|
|
session := newTestSession(mailboxes, &recordingBlobStorage{}, &recordingMessageRepository{})
|
|
|
|
if err := session.Mail("sender@example.test", &gosmtp.MailOptions{}); err != nil {
|
|
t.Fatalf("Mail: %v", err)
|
|
}
|
|
if err := session.Rcpt("user@example.test", &gosmtp.RcptOptions{}); err != nil {
|
|
t.Fatalf("Rcpt: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSession_Rcpt_Rejects550ForUnknownRecipient(t *testing.T) {
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{}}
|
|
session := newTestSession(mailboxes, &recordingBlobStorage{}, &recordingMessageRepository{})
|
|
|
|
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
|
|
err := session.Rcpt("nobody@example.test", &gosmtp.RcptOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected an error for an unknown recipient, got nil")
|
|
}
|
|
var smtpErr *gosmtp.SMTPError
|
|
if !errors.As(err, &smtpErr) {
|
|
t.Fatalf("error is not a *gosmtp.SMTPError: %v", err)
|
|
}
|
|
if smtpErr.Code != 550 {
|
|
t.Errorf("Code = %d, want 550", smtpErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestSession_Data_PersistsOnceForSingleRecipient(t *testing.T) {
|
|
mailboxID := uuid.New()
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
|
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000, QuotaBytes: 1000},
|
|
}}
|
|
blobs := &recordingBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc", SizeBytes: 5}}
|
|
messages := &recordingMessageRepository{}
|
|
session := newTestSession(mailboxes, blobs, messages)
|
|
|
|
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
|
|
_ = session.Rcpt("user@example.test", &gosmtp.RcptOptions{})
|
|
|
|
body := "Subject: hi\r\n\r\nhello"
|
|
if err := session.Data(bytes.NewBufferString(body)); err != nil {
|
|
t.Fatalf("Data: %v", err)
|
|
}
|
|
|
|
if string(blobs.stored) != body {
|
|
t.Errorf("stored blob = %q, want %q", blobs.stored, body)
|
|
}
|
|
if len(messages.persisted) != 1 {
|
|
t.Fatalf("persisted %d messages, want 1", len(messages.persisted))
|
|
}
|
|
if messages.persisted[0].MailboxID != mailboxID {
|
|
t.Errorf("persisted MailboxID = %v, want %v", messages.persisted[0].MailboxID, mailboxID)
|
|
}
|
|
}
|
|
|
|
func TestSession_Rcpt_Rejects452ForFullMailbox(t *testing.T) {
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
|
"full@example.test": {ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 1000},
|
|
}}
|
|
session := newTestSession(mailboxes, &recordingBlobStorage{}, &recordingMessageRepository{})
|
|
|
|
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
|
|
err := session.Rcpt("full@example.test", &gosmtp.RcptOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected an error for a full mailbox, got nil")
|
|
}
|
|
var smtpErr *gosmtp.SMTPError
|
|
if !errors.As(err, &smtpErr) {
|
|
t.Fatalf("error is not a *gosmtp.SMTPError: %v", err)
|
|
}
|
|
if smtpErr.Code != 452 {
|
|
t.Errorf("Code = %d, want 452", smtpErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestSession_Rcpt_DeduplicatesRepeatedAddress(t *testing.T) {
|
|
mailboxID := uuid.New()
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
|
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000, QuotaBytes: 1000},
|
|
}}
|
|
blobs := &recordingBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc", SizeBytes: 5}}
|
|
messages := &recordingMessageRepository{}
|
|
session := newTestSession(mailboxes, blobs, messages)
|
|
|
|
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
|
|
if err := session.Rcpt("user@example.test", &gosmtp.RcptOptions{}); err != nil {
|
|
t.Fatalf("first Rcpt: %v", err)
|
|
}
|
|
if err := session.Rcpt("user@example.test", &gosmtp.RcptOptions{}); err != nil {
|
|
t.Fatalf("duplicate Rcpt: %v", err)
|
|
}
|
|
|
|
if err := session.Data(bytes.NewBufferString("Subject: hi\r\n\r\nhello")); err != nil {
|
|
t.Fatalf("Data: %v", err)
|
|
}
|
|
|
|
if len(messages.persisted) != 1 {
|
|
t.Fatalf("persisted %d messages, want 1 (duplicate RCPT should not double-persist)", len(messages.persisted))
|
|
}
|
|
}
|
|
|
|
func TestSession_Reset_ClearsAccumulatedRecipients(t *testing.T) {
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
|
"user@example.test": {ID: uuid.New(), MaxMessageBytes: 1000, QuotaBytes: 1000},
|
|
}}
|
|
messages := &recordingMessageRepository{}
|
|
session := newTestSession(mailboxes, &recordingBlobStorage{}, messages)
|
|
|
|
_ = session.Mail("sender@example.test", &gosmtp.MailOptions{})
|
|
_ = session.Rcpt("user@example.test", &gosmtp.RcptOptions{})
|
|
session.Reset()
|
|
|
|
// After Reset, Data with no prior Mail/Rcpt in this transaction must not
|
|
// silently persist anything.
|
|
err := session.Data(bytes.NewBufferString("Subject: x\r\n\r\nbody"))
|
|
if err == nil {
|
|
t.Fatal("expected an error calling Data after Reset with no recipients, got nil")
|
|
}
|
|
if len(messages.persisted) != 0 {
|
|
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)
|
|
}
|
|
}
|