#13: implement Outbound Queue, Retry Backoff Engine & DSN Bounce Generator (RFC 3464)

This commit is contained in:
Lucas Rayan committed 2026-07-25 10:33:07 -03:00
1 parent 45aaf2a279
commit af35211094
12 files changed
+886 -68

No files matched your search

@@ -0,0 +1,9 @@
package port
import (
"context"
)
type MXResolver interface {
LookupMX(ctx context.Context, domain string) ([]string, error)
}
@@ -0,0 +1,13 @@
package port
import (
"context"
"lambdamail/protocols/internal/domain/entity"
)
type OutboundRepository interface {
Enqueue(ctx context.Context, job *entity.OutboundJob) error
FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error)
UpdateJob(ctx context.Context, job *entity.OutboundJob) error
}
@@ -0,0 +1,209 @@
package usecase
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
"lambdamail/protocols/internal/domain/valueobject"
)
type OutboundWorkerUseCase struct {
outboundRepo port.OutboundRepository
mxResolver port.MXResolver
blobReader port.BlobReader
inboundUC *ProcessInboundEmailUseCase
mailboxes port.MailboxRepository
localHost string
}
func NewOutboundWorkerUseCase(
outboundRepo port.OutboundRepository,
mxResolver port.MXResolver,
blobReader port.BlobReader,
inboundUC *ProcessInboundEmailUseCase,
mailboxes port.MailboxRepository,
localHost string,
) *OutboundWorkerUseCase {
if localHost == "" {
localHost = "mail.lambdamail.local"
}
return &OutboundWorkerUseCase{
outboundRepo: outboundRepo,
mxResolver: mxResolver,
blobReader: blobReader,
inboundUC: inboundUC,
mailboxes: mailboxes,
localHost: localHost,
}
}
func (w *OutboundWorkerUseCase) ProcessBatch(ctx context.Context, workerID string, limit int) (int, error) {
if limit <= 0 {
limit = 10
}
jobs, err := w.outboundRepo.FetchNextReady(ctx, workerID, limit)
if err != nil {
return 0, fmt.Errorf("fetch ready jobs: %w", err)
}
processed := 0
for _, job := range jobs {
w.processSingleJob(ctx, job)
processed++
}
return processed, nil
}
func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entity.OutboundJob) {
job.Attempt++
payload, err := w.blobReader.ReadByID(ctx, job.BlobID)
if err != nil {
w.handleFailure(ctx, job, fmt.Sprintf("read blob %s: %v", job.BlobID, err), true)
return
}
mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain)
if err != nil || len(mxHosts) == 0 {
mxHosts = []string{job.DestinationDomain}
}
var lastErr error
delivered := false
for _, mxHost := range mxHosts {
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload); err == nil {
delivered = true
break
} else {
lastErr = err
}
}
if delivered {
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.TlsPolicyUsed = "opportunistic"
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
errStr := "delivery failed"
if lastErr != nil {
errStr = lastErr.Error()
}
isPermanent := strings.HasPrefix(errStr, "5")
w.handleFailure(ctx, job, errStr, isPermanent)
}
func (w *OutboundWorkerUseCase) handleFailure(ctx context.Context, job *entity.OutboundJob, errStr string, isPermanent bool) {
now := time.Now()
nextAttemptAt, sendDelayDsn, maxExceeded := entity.CalculateNextBackoff(now, job.Attempt, job.ExpiresAt)
if isPermanent || maxExceeded {
job.Status = entity.OutboundJobStatusBounced
job.LastError = errStr
_ = w.outboundRepo.UpdateJob(ctx, job)
w.sendDsn(ctx, valueobject.DsnActionFailed, job, errStr)
return
}
job.Status = entity.OutboundJobStatusDeferred
job.NextAttemptAt = nextAttemptAt
job.LastError = errStr
if sendDelayDsn && !job.DelayDsnSent {
w.sendDsn(ctx, valueobject.DsnActionDelayed, job, errStr)
job.DelayDsnSent = true
}
_ = w.outboundRepo.UpdateJob(ctx, job)
}
func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.DsnAction, job *entity.OutboundJob, reason string) {
if w.inboundUC == nil || job.MailboxID == nil {
return
}
dsnBytes, isLoop := valueobject.BuildDsnReport(action, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
if isLoop || dsnBytes == nil {
return
}
if w.mailboxes != nil {
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
if err == nil && len(targets) > 0 {
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@lambdamail.local",
Recipients: targets,
RecipientAddresses: []string{job.EnvelopeFrom},
Body: bytes.NewReader(dsnBytes),
})
}
}
}
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte) error {
addr := mxHost
if _, _, err := net.SplitHostPort(mxHost); err != nil {
addr = net.JoinHostPort(mxHost, "25")
}
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial tcp %s: %w", addr, err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, mxHost)
if err != nil {
return fmt.Errorf("smtp client handshake: %w", err)
}
defer client.Close()
if err := client.Hello(w.localHost); err != nil {
return fmt.Errorf("EHLO failed: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
config := &tls.Config{InsecureSkipVerify: true, ServerName: mxHost}
if err := client.StartTLS(config); err != nil {
return fmt.Errorf("STARTTLS failed: %w", err)
}
}
if err := client.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO failed: %w", err)
}
wWriter, err := client.Data()
if err != nil {
return fmt.Errorf("DATA command failed: %w", err)
}
if _, err := wWriter.Write(payload); err != nil {
wWriter.Close()
return fmt.Errorf("write DATA payload failed: %w", err)
}
if err := wWriter.Close(); err != nil {
return fmt.Errorf("close DATA writer failed: %w", err)
}
return client.Quit()
}
@@ -0,0 +1,95 @@
package usecase
import (
"bytes"
"context"
"io"
"strings"
"testing"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
type fakeOutboundRepo struct {
jobs []*entity.OutboundJob
}
func (f *fakeOutboundRepo) Enqueue(_ context.Context, job *entity.OutboundJob) error {
f.jobs = append(f.jobs, job)
return nil
}
func (f *fakeOutboundRepo) FetchNextReady(_ context.Context, _ string, _ int) ([]*entity.OutboundJob, error) {
var ready []*entity.OutboundJob
for _, j := range f.jobs {
if j.Status == entity.OutboundJobStatusQueued || j.Status == entity.OutboundJobStatusDeferred {
ready = append(ready, j)
}
}
return ready, nil
}
func (f *fakeOutboundRepo) UpdateJob(_ context.Context, job *entity.OutboundJob) error {
for i, j := range f.jobs {
if j.ID == job.ID {
f.jobs[i] = job
break
}
}
return nil
}
type fakeMXResolver struct {
hosts []string
}
func (f *fakeMXResolver) LookupMX(_ context.Context, _ string) ([]string, error) {
return f.hosts, nil
}
type fakeBlobReader struct {
payload []byte
}
func (f *fakeBlobReader) ReadByID(_ context.Context, _ uuid.UUID) ([]byte, error) {
return f.payload, nil
}
func TestOutboundWorker_DeferralAndPermanentFailure(t *testing.T) {
repo := &fakeOutboundRepo{}
mx := &fakeMXResolver{hosts: []string{"127.0.0.1:9999"}} // unreachable port -> connection error
blob := &fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")}
mbID := uuid.New()
job := &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),
}
repo.Enqueue(context.Background(), job)
worker := NewOutboundWorkerUseCase(repo, mx, blob, nil, nil, "mail.local")
processed, err := worker.ProcessBatch(context.Background(), "w1", 10)
if err != nil || processed != 1 {
t.Fatalf("ProcessBatch failed: processed=%d, err=%v", processed, err)
}
if repo.jobs[0].Status != entity.OutboundJobStatusDeferred {
t.Errorf("expected DEFERRED status, got %s", repo.jobs[0].Status)
}
if repo.jobs[0].Attempt != 1 {
t.Errorf("attempt count = %d, want 1", repo.jobs[0].Attempt)
}
if !strings.Contains(repo.jobs[0].LastError, "dial tcp") {
t.Errorf("unexpected error message: %s", repo.jobs[0].LastError)
}
}