169 lines
4.9 KiB
Go
169 lines
4.9 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())
|
|
|
|
mailboxes := &fakeAliasFanOutRepository{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)
|
|
}
|
|
}
|
|
}
|