Every message was resolved through MX and delivered over SMTP, including one addressed to a mailbox on this very host. Two users on the same server could not write to each other without a working outbound port 25 - which the provider blocks, so on this deployment they could not write to each other at all. It also made internal mail take a pointless round trip through the public internet. The worker now checks whether the recipient resolves to a local mailbox and, if so, hands the message to the inbound path directly. The check runs ahead of the relay branch on purpose: sending local mail out to a smarthost only to have it delivered back is slower and has more ways to fail. The shortcut is narrow. It relies on ResolveDeliveryTargets, which only resolves addresses on an active local domain, and a test covers that a remote recipient still goes out over SMTP - otherwise local delivery would swallow mail meant for the internet. A resolver error is treated as retry rather than as not-local: falling through to SMTP on an unknown answer would send to the internet a message that may belong here. Local delivery is still content-scanned; a virus between two accounts on one host is no less a virus. This also surfaced a permissive test double: fakeAliasFanOutRepository claimed every address was local, which was harmless while nothing consulted it while routing and wrong the moment something did. It now hosts one address, as the real repository does, and the DSN fan-out test names it explicitly. Verified: the local-delivery test fails without the change (the recipient triggers an MX lookup) and passes with it; 22 Go packages green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
5.1 KiB
Go
171 lines
5.1 KiB
Go
package usecase
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
"lambdamail/protocols/internal/domain/entity"
|
|
)
|
|
|
|
// startFakeMX serves one SMTP conversation, answering MAIL FROM with the given
|
|
// reply so the worker can be driven through a real permanent/temporary failure.
|
|
func startFakeMX(t *testing.T, mailReply string) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
c.SetDeadline(time.Now().Add(5 * time.Second))
|
|
r := bufio.NewReader(c)
|
|
c.Write([]byte("220 fake.mx ESMTP\r\n"))
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
cmd := strings.ToUpper(strings.TrimSpace(line))
|
|
switch {
|
|
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
|
c.Write([]byte("250 fake.mx\r\n"))
|
|
case strings.HasPrefix(cmd, "MAIL FROM"):
|
|
c.Write([]byte(mailReply + "\r\n"))
|
|
case strings.HasPrefix(cmd, "QUIT"):
|
|
c.Write([]byte("221 Bye\r\n"))
|
|
return
|
|
default:
|
|
c.Write([]byte("250 Ok\r\n"))
|
|
}
|
|
}
|
|
}(conn)
|
|
}
|
|
}()
|
|
|
|
return ln.Addr().String()
|
|
}
|
|
|
|
func newTestJob() *entity.OutboundJob {
|
|
mbID := uuid.New()
|
|
return &entity.OutboundJob{
|
|
ID: uuid.New(),
|
|
MailboxID: &mbID,
|
|
BlobID: uuid.New(),
|
|
EnvelopeFrom: "user@domain.test",
|
|
EnvelopeTo: "rcpt@remote.test",
|
|
DestinationDomain: "remote.test",
|
|
Status: entity.OutboundJobStatusQueued,
|
|
ExpiresAt: time.Now().Add(5 * 24 * time.Hour),
|
|
}
|
|
}
|
|
|
|
// PLAN.md section 6.3: a 5xx is a permanent failure and must bounce
|
|
// immediately with a DSN, not sit in the queue retrying for five days.
|
|
func TestOutboundWorker_PermanentSmtpFailureBouncesImmediately(t *testing.T) {
|
|
addr := startFakeMX(t, "550 5.1.1 User unknown")
|
|
|
|
repo := &fakeOutboundRepo{}
|
|
repo.Enqueue(context.Background(), newTestJob())
|
|
|
|
worker := NewOutboundWorkerUseCase(
|
|
repo,
|
|
&fakeMXResolver{hosts: []string{addr}},
|
|
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
|
nil, nil, "mail.local",
|
|
)
|
|
|
|
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
|
t.Fatalf("ProcessBatch: %v", err)
|
|
}
|
|
|
|
got := repo.jobs[0]
|
|
if got.Status != entity.OutboundJobStatusBounced {
|
|
t.Errorf("status = %s, want BOUNCED for a 5xx reply", got.Status)
|
|
}
|
|
if got.LastSmtpCode != "550" {
|
|
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "550")
|
|
}
|
|
}
|
|
|
|
// A 4xx is temporary and must be deferred for a later retry.
|
|
func TestOutboundWorker_TemporarySmtpFailureIsDeferred(t *testing.T) {
|
|
addr := startFakeMX(t, "451 4.3.0 Try later")
|
|
|
|
repo := &fakeOutboundRepo{}
|
|
repo.Enqueue(context.Background(), newTestJob())
|
|
|
|
worker := NewOutboundWorkerUseCase(
|
|
repo,
|
|
&fakeMXResolver{hosts: []string{addr}},
|
|
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
|
nil, nil, "mail.local",
|
|
)
|
|
|
|
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
|
t.Fatalf("ProcessBatch: %v", err)
|
|
}
|
|
|
|
got := repo.jobs[0]
|
|
if got.Status != entity.OutboundJobStatusDeferred {
|
|
t.Errorf("status = %s, want DEFERRED for a 4xx reply", got.Status)
|
|
}
|
|
if got.LastSmtpCode != "451" {
|
|
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "451")
|
|
}
|
|
}
|
|
|
|
// The DSN is delivered back to the original sender. When that sender address is
|
|
// an alias fanning out to several mailboxes, the recipient records and their
|
|
// envelope addresses must stay aligned - otherwise the delivery indexes past
|
|
// the end of the address list and takes the worker down.
|
|
func TestOutboundWorker_DsnToFanOutSenderDeliversToEveryTarget(t *testing.T) {
|
|
addr := startFakeMX(t, "550 5.1.1 User unknown")
|
|
|
|
repo := &fakeOutboundRepo{}
|
|
repo.Enqueue(context.Background(), newTestJob())
|
|
|
|
// The sender is the local, aliased address; the recipient is remote, which
|
|
// is what makes this a delivery that goes out and then bounces back.
|
|
mailboxes := &fakeAliasFanOutRepository{localAddress: "user@domain.test", targets: []port.MailboxRecord{
|
|
{ID: uuid.New(), QuotaBytes: 1000},
|
|
{ID: uuid.New(), QuotaBytes: 1000},
|
|
}}
|
|
messages := &recordingMessageRepository{}
|
|
inbound := NewProcessInboundEmailUseCase(mailboxes, &stubBlobStorage{ref: port.BlobRef{ID: uuid.New()}}, messages)
|
|
|
|
worker := NewOutboundWorkerUseCase(
|
|
repo,
|
|
&fakeMXResolver{hosts: []string{addr}},
|
|
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
|
inbound, mailboxes, "mail.local",
|
|
)
|
|
|
|
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
|
t.Fatalf("ProcessBatch: %v", err)
|
|
}
|
|
|
|
if len(messages.persisted) != 2 {
|
|
t.Fatalf("DSN persisted to %d mailboxes, want 2 (one per fan-out target)", len(messages.persisted))
|
|
}
|
|
for i, p := range messages.persisted {
|
|
if p.RecipientAddress != "user@domain.test" {
|
|
t.Errorf("persisted[%d].RecipientAddress = %q, want the original sender", i, p.RecipientAddress)
|
|
}
|
|
}
|
|
}
|