#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics
This commit is contained in:
1 parent
7249419efc
commit
d56f29d084
70 files changed
+5163
-659
No files matched your search
@@ -0,0 +1,168 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user