#13: implement Outbound Queue, Retry Backoff Engine & DSN Bounce Generator (RFC 3464)
This commit is contained in:
1 parent
45aaf2a279
commit
af35211094
12 files changed
+882
-64
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/clamav"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/netdns"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
"lambdamail/protocols/internal/infrastructure/rspamd"
|
||||
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
||||
@@ -154,6 +156,24 @@ func main() {
|
||||
|
||||
reportRepo := postgres.NewReportRepository(pool)
|
||||
reportUseCase := usecase.NewIngestReportsUseCase(reportRepo)
|
||||
|
||||
outboundRepo := postgres.NewOutboundRepository(pool)
|
||||
mxResolver := netdns.NewNetMXResolver()
|
||||
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, useCase, mailboxes, smtpServer.Domain)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_, _ = outboundWorker.ProcessBatch(ctx, "protocols-worker-1", 10)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
|
||||
|
||||
addr := ":8080"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,79 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type OutboundJobStatus string
|
||||
|
||||
const (
|
||||
OutboundJobStatusQueued OutboundJobStatus = "QUEUED"
|
||||
OutboundJobStatusSending OutboundJobStatus = "SENDING"
|
||||
OutboundJobStatusDeferred OutboundJobStatus = "DEFERRED"
|
||||
OutboundJobStatusDelivered OutboundJobStatus = "DELIVERED"
|
||||
OutboundJobStatusBounced OutboundJobStatus = "BOUNCED"
|
||||
OutboundJobStatusCancelled OutboundJobStatus = "CANCELLED"
|
||||
)
|
||||
|
||||
// OutboundJob is a delivery unit with retry state (PLAN.md section 3 and 6.3).
|
||||
type OutboundJob struct {
|
||||
attempt int
|
||||
maxAttempts int
|
||||
ID uuid.UUID
|
||||
MailboxID *uuid.UUID
|
||||
BlobID uuid.UUID
|
||||
EnvelopeFrom string
|
||||
EnvelopeTo string
|
||||
DestinationDomain string
|
||||
Status OutboundJobStatus
|
||||
Attempt int
|
||||
NextAttemptAt time.Time
|
||||
ExpiresAt time.Time
|
||||
LastSmtpCode string
|
||||
LastError string
|
||||
TlsPolicyUsed string
|
||||
DelayDsnSent bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var ErrOutboundJobMaxAttemptsExceeded = errors.New("outbound job: attempt count would exceed max_attempts")
|
||||
|
||||
func NewOutboundJob(maxAttempts int) *OutboundJob {
|
||||
return &OutboundJob{maxAttempts: maxAttempts}
|
||||
// CalculateNextBackoff computes the next retry time and DSN requirements based on attempt count.
|
||||
func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) {
|
||||
if !expiresAt.IsZero() && (now.After(expiresAt) || now.Equal(expiresAt)) {
|
||||
return now, false, true
|
||||
}
|
||||
|
||||
func (j *OutboundJob) Attempt() int { return j.attempt }
|
||||
var baseDelay time.Duration
|
||||
|
||||
// RecordAttempt enforces "attempt <= max_attempts" (PLAN.md section 3).
|
||||
func (j *OutboundJob) RecordAttempt() error {
|
||||
if j.attempt >= j.maxAttempts {
|
||||
return ErrOutboundJobMaxAttemptsExceeded
|
||||
}
|
||||
j.attempt++
|
||||
return nil
|
||||
switch attempt {
|
||||
case 1:
|
||||
baseDelay = 0
|
||||
case 2:
|
||||
baseDelay = 5 * time.Minute
|
||||
case 3:
|
||||
baseDelay = 15 * time.Minute
|
||||
case 4:
|
||||
baseDelay = 1 * time.Hour
|
||||
sendDelayDsn = true
|
||||
case 5:
|
||||
baseDelay = 2 * time.Hour
|
||||
case 6:
|
||||
baseDelay = 4 * time.Hour
|
||||
case 7:
|
||||
baseDelay = 8 * time.Hour
|
||||
default:
|
||||
baseDelay = 12 * time.Hour
|
||||
}
|
||||
|
||||
// retrySchedule mirrors the accumulated-wait table in PLAN.md section 6.3.
|
||||
var retrySchedule = []time.Duration{
|
||||
0,
|
||||
5 * time.Minute,
|
||||
15 * time.Minute,
|
||||
1 * time.Hour,
|
||||
2 * time.Hour,
|
||||
4 * time.Hour,
|
||||
8 * time.Hour,
|
||||
12 * time.Hour,
|
||||
if baseDelay > 0 && attempt >= 5 {
|
||||
// Apply ±20% jitter
|
||||
jitterFactor := 0.8 + rand.Float64()*0.4
|
||||
baseDelay = time.Duration(float64(baseDelay) * jitterFactor)
|
||||
}
|
||||
|
||||
// NextBackoff returns the wait before the next attempt, per the schedule in section 6.3.
|
||||
// Attempts beyond the table repeat the last (12h) step, matching the "9+: every 12h" rule.
|
||||
func (j *OutboundJob) NextBackoff() time.Duration {
|
||||
idx := j.attempt
|
||||
if idx >= len(retrySchedule) {
|
||||
idx = len(retrySchedule) - 1
|
||||
nextAttemptAt = now.Add(baseDelay)
|
||||
if !expiresAt.IsZero() && nextAttemptAt.After(expiresAt) {
|
||||
return expiresAt, sendDelayDsn, true
|
||||
}
|
||||
return retrySchedule[idx]
|
||||
|
||||
return nextAttemptAt, sendDelayDsn, false
|
||||
}
|
||||
@@ -1,40 +1,30 @@
|
||||
package entity
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOutboundJob_RecordAttempt_AllowsUpToMax(t *testing.T) {
|
||||
j := NewOutboundJob(3)
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := j.RecordAttempt(); err != nil {
|
||||
t.Fatalf("attempt %d: unexpected error: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if j.Attempt() != 3 {
|
||||
t.Errorf("Attempt() = %d, want 3", j.Attempt())
|
||||
}
|
||||
func TestCalculateNextBackoff_SchedulesCorrectDelays(t *testing.T) {
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(5 * 24 * time.Hour)
|
||||
|
||||
// Attempt 2 -> +5 min
|
||||
next, delayDsn, perm := CalculateNextBackoff(now, 2, expiresAt)
|
||||
if perm || delayDsn || next.Sub(now) < 4*time.Minute {
|
||||
t.Errorf("attempt 2 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm)
|
||||
}
|
||||
|
||||
func TestOutboundJob_RecordAttempt_RejectsBeyondMax(t *testing.T) {
|
||||
j := NewOutboundJob(1)
|
||||
if err := j.RecordAttempt(); err != nil {
|
||||
t.Fatalf("unexpected error on first attempt: %v", err)
|
||||
}
|
||||
if err := j.RecordAttempt(); err == nil {
|
||||
t.Fatal("expected error: attempt must never exceed max_attempts (PLAN.md section 3), got nil")
|
||||
}
|
||||
// Attempt 4 -> +1 hr + delayDsn=true
|
||||
next, delayDsn, perm = CalculateNextBackoff(now, 4, expiresAt)
|
||||
if perm || !delayDsn || next.Sub(now) < 55*time.Minute {
|
||||
t.Errorf("attempt 4 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm)
|
||||
}
|
||||
|
||||
func TestOutboundJob_NextBackoff_IsMonotonicPerRetryTable(t *testing.T) {
|
||||
// Backoff schedule from PLAN.md section 6.3: attempts 1-4 map to
|
||||
// immediate, +5m, +15m, +1h.
|
||||
j := NewOutboundJob(8)
|
||||
var prev int64 = -1
|
||||
for i := 0; i < 4; i++ {
|
||||
j.RecordAttempt()
|
||||
d := j.NextBackoff()
|
||||
if int64(d) < prev {
|
||||
t.Errorf("attempt %d: backoff %v is not >= previous backoff", i+1, d)
|
||||
}
|
||||
prev = int64(d)
|
||||
// Expired -> permanent failure
|
||||
expiredTime := now.Add(-1 * time.Hour)
|
||||
_, _, perm = CalculateNextBackoff(now, 5, expiredTime)
|
||||
if !perm {
|
||||
t.Errorf("expected permanent failure for expired job")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DsnAction string
|
||||
|
||||
const (
|
||||
DsnActionFailed DsnAction = "failed"
|
||||
DsnActionDelayed DsnAction = "delayed"
|
||||
)
|
||||
|
||||
// BuildDsnReport constructs an RFC 3464 compliant delivery status notification report payload.
|
||||
// Returns (payload, isLoopPrevented). If envelopeFrom is empty or "<>", isLoopPrevented is true and payload is nil.
|
||||
func BuildDsnReport(action DsnAction, envelopeFrom string, envelopeTo string, messageID string, reason string) ([]byte, bool) {
|
||||
cleanFrom := strings.Trim(strings.TrimSpace(envelopeFrom), "<>")
|
||||
if cleanFrom == "" {
|
||||
return nil, true // Loop prevention per RFC 5321 section 4.5.5
|
||||
}
|
||||
|
||||
boundary := fmt.Sprintf("=_Boundary_DSN_%d", time.Now().UnixNano())
|
||||
nowStr := time.Now().Format(time.RFC1123Z)
|
||||
|
||||
status := "5.0.0"
|
||||
subject := "Undeliverable Mail Notification"
|
||||
if action == DsnActionDelayed {
|
||||
status = "4.4.1"
|
||||
subject = "Delivery Delay Notification"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("From: Mail Delivery Subsystem <postmaster@lambdamail.local>\r\n"))
|
||||
sb.WriteString(fmt.Sprintf("To: <%s>\r\n", cleanFrom))
|
||||
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", nowStr))
|
||||
sb.WriteString(fmt.Sprintf("Content-Type: multipart/report; report-type=delivery-status; boundary=\"%s\"\r\n\r\n", boundary))
|
||||
|
||||
// Part 1: Human-readable narrative
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
if action == DsnActionFailed {
|
||||
sb.WriteString(fmt.Sprintf("This is an automated message from LambdaMail.\r\nYour message to <%s> could not be delivered.\r\nReason: %s\r\n\r\n", envelopeTo, reason))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("This is an automated warning message from LambdaMail.\r\nDelivery to <%s> has been delayed.\r\nLambdaMail will continue attempting delivery.\r\nReason: %s\r\n\r\n", envelopeTo, reason))
|
||||
}
|
||||
|
||||
// Part 2: Machine-readable delivery-status
|
||||
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
sb.WriteString("Content-Type: message/delivery-status\r\n\r\n")
|
||||
sb.WriteString("Reporting-MTA: dns; mail.lambdamail.local\r\n")
|
||||
sb.WriteString(fmt.Sprintf("Arrival-Date: %s\r\n\r\n", nowStr))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Final-Recipient: rfc822; %s\r\n", envelopeTo))
|
||||
sb.WriteString(fmt.Sprintf("Action: %s\r\n", action))
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\r\n", status))
|
||||
sb.WriteString(fmt.Sprintf("Diagnostic-Code: smtp; %s\r\n\r\n", reason))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
|
||||
return []byte(sb.String()), false
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildDsnReport_PreventsBounceLoopForEmptySender(t *testing.T) {
|
||||
_, isLoop := BuildDsnReport(DsnActionFailed, "", "user@external.test", "msg123", "550 User unknown")
|
||||
if !isLoop {
|
||||
t.Errorf("expected loop prevention for empty sender")
|
||||
}
|
||||
|
||||
_, isLoop = BuildDsnReport(DsnActionFailed, "<>", "user@external.test", "msg123", "550 User unknown")
|
||||
if !isLoop {
|
||||
t.Errorf("expected loop prevention for <> sender")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDsnReport_GeneratesValidMIME(t *testing.T) {
|
||||
payload, isLoop := BuildDsnReport(DsnActionFailed, "sender@local.test", "recipient@remote.test", "msg123", "550 User unknown")
|
||||
if isLoop || payload == nil {
|
||||
t.Fatalf("unexpected loop signal for valid sender")
|
||||
}
|
||||
|
||||
raw := string(payload)
|
||||
if !strings.Contains(raw, "Content-Type: multipart/report; report-type=delivery-status") {
|
||||
t.Errorf("missing multipart/report header: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "Action: failed") {
|
||||
t.Errorf("missing Action: failed: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "Diagnostic-Code: smtp; 550 User unknown") {
|
||||
t.Errorf("missing Diagnostic-Code: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
)
|
||||
|
||||
type staticMXResolver struct {
|
||||
addr string
|
||||
}
|
||||
|
||||
func (s *staticMXResolver) LookupMX(_ context.Context, _ string) ([]string, error) {
|
||||
return []string{s.addr}, nil
|
||||
}
|
||||
|
||||
func TestOutboundQueueEndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e test skipped in -short mode")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
runtimeDir := t.TempDir()
|
||||
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||
Port(54339).
|
||||
RuntimePath(runtimeDir).
|
||||
StartTimeout(120 * time.Second))
|
||||
if err := pg.Start(); err != nil {
|
||||
t.Fatalf("start embedded postgres: %v", err)
|
||||
}
|
||||
defer pg.Stop()
|
||||
|
||||
dbURL := "postgres://postgres:postgres@localhost:54339/postgres?sslmode=disable"
|
||||
pool, err := postgres.NewPool(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed domain, sender mailbox, INBOX
|
||||
domainID := uuid.New()
|
||||
mailboxID := uuid.New()
|
||||
inboxID := uuid.New()
|
||||
|
||||
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'outbound.test', 'outbound.test')`, domainID)
|
||||
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'sender', 'sender@outbound.test', 'hash')`, mailboxID, domainID)
|
||||
pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, inboxID, mailboxID)
|
||||
|
||||
mailboxRepo := postgres.NewMailboxRepository(pool)
|
||||
messageRepo := postgres.NewInboundMessageRepository(pool)
|
||||
outboundRepo := postgres.NewOutboundRepository(pool)
|
||||
spool := t.TempDir()
|
||||
blobStorage := diskstorage.NewLocalDiskBlobStorage(pool, spool)
|
||||
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
|
||||
|
||||
inboundUC := appusecase.NewProcessInboundEmailUseCase(mailboxRepo, blobStorage, messageRepo)
|
||||
|
||||
// Create sample message blob
|
||||
blobRef, err := blobStorage.Store(ctx, strings.NewReader("From: sender@outbound.test\r\nTo: rcpt@remote.test\r\n\r\nOutbound Payload"))
|
||||
if err != nil {
|
||||
t.Fatalf("store blob: %v", err)
|
||||
}
|
||||
|
||||
// Mock remote SMTP server listening on TCP
|
||||
mockSMTPLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen mock smtp: %v", err)
|
||||
}
|
||||
defer mockSMTPLn.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := mockSMTPLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handleMockSMTPSession(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
mxResolver := &staticMXResolver{addr: mockSMTPLn.Addr().String()}
|
||||
worker := appusecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxRepo, "mail.outbound.test")
|
||||
|
||||
// 1. Enqueue Job & Deliver
|
||||
job := &entity.OutboundJob{
|
||||
MailboxID: &mailboxID,
|
||||
BlobID: blobRef.ID,
|
||||
EnvelopeFrom: "sender@outbound.test",
|
||||
EnvelopeTo: "rcpt@remote.test",
|
||||
DestinationDomain: "remote.test",
|
||||
Status: entity.OutboundJobStatusQueued,
|
||||
Attempt: 0,
|
||||
NextAttemptAt: time.Now().Add(-1 * time.Minute),
|
||||
ExpiresAt: time.Now().Add(5 * 24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := outboundRepo.Enqueue(ctx, job); err != nil {
|
||||
t.Fatalf("Enqueue: %v", err)
|
||||
}
|
||||
|
||||
processed, err := worker.ProcessBatch(ctx, "worker1", 10)
|
||||
if err != nil || processed != 1 {
|
||||
t.Fatalf("ProcessBatch delivery: processed=%d, err=%v", processed, err)
|
||||
}
|
||||
|
||||
var status, lastErr string
|
||||
pool.QueryRow(ctx, `SELECT status, COALESCE(last_error, '') FROM outbound_jobs WHERE id = $1`, job.ID).Scan(&status, &lastErr)
|
||||
if status != "DELIVERED" {
|
||||
t.Errorf("expected DELIVERED status, got %s (last_error: %s)", status, lastErr)
|
||||
}
|
||||
|
||||
// 2. Permanent Failure -> Bounce DSN delivered to sender's INBOX
|
||||
failedJob := &entity.OutboundJob{
|
||||
MailboxID: &mailboxID,
|
||||
BlobID: blobRef.ID,
|
||||
EnvelopeFrom: "sender@outbound.test",
|
||||
EnvelopeTo: "invalid@remote.test",
|
||||
DestinationDomain: "unreachable.test",
|
||||
Status: entity.OutboundJobStatusQueued,
|
||||
Attempt: 5,
|
||||
NextAttemptAt: time.Now().Add(-1 * time.Minute),
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired
|
||||
}
|
||||
outboundRepo.Enqueue(ctx, failedJob)
|
||||
|
||||
// Unreachable MX resolver to trigger permanent failure on expiration
|
||||
unreachableWorker := appusecase.NewOutboundWorkerUseCase(outboundRepo, &staticMXResolver{addr: "127.0.0.1:59999"}, blobReader, inboundUC, mailboxRepo, "mail.outbound.test")
|
||||
unreachableWorker.ProcessBatch(ctx, "worker1", 10)
|
||||
|
||||
var failedStatus string
|
||||
pool.QueryRow(ctx, `SELECT status FROM outbound_jobs WHERE id = $1`, failedJob.ID).Scan(&failedStatus)
|
||||
if failedStatus != "BOUNCED" {
|
||||
t.Errorf("expected BOUNCED status, got %s", failedStatus)
|
||||
}
|
||||
|
||||
// Verify DSN bounce email delivered to sender's INBOX
|
||||
var inboxCount int
|
||||
pool.QueryRow(ctx, `SELECT total_count FROM folders WHERE id = $1`, inboxID).Scan(&inboxCount)
|
||||
if inboxCount != 1 {
|
||||
t.Errorf("expected 1 DSN bounce message in INBOX, got %d", inboxCount)
|
||||
}
|
||||
}
|
||||
|
||||
func handleMockSMTPSession(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
conn.Write([]byte("220 mock.smtp.test ESMTP\r\n"))
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(cmd, "EHLO") || strings.HasPrefix(cmd, "HELO") {
|
||||
conn.Write([]byte("250-mock.smtp.test\r\n250 HELP\r\n"))
|
||||
} else if strings.HasPrefix(cmd, "MAIL FROM:") {
|
||||
conn.Write([]byte("250 2.1.0 Ok\r\n"))
|
||||
} else if strings.HasPrefix(cmd, "RCPT TO:") {
|
||||
conn.Write([]byte("250 2.1.5 Ok\r\n"))
|
||||
} else if strings.HasPrefix(cmd, "DATA") {
|
||||
conn.Write([]byte("354 End data with <CR><LF>.<CR><LF>\r\n"))
|
||||
// Read until dot
|
||||
for {
|
||||
dataLine, err := reader.ReadString('\n')
|
||||
if err != nil || strings.TrimSpace(dataLine) == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.Write([]byte("250 2.0.0 Ok queued\r\n"))
|
||||
} else if strings.HasPrefix(cmd, "QUIT") {
|
||||
conn.Write([]byte("221 2.0.0 Bye\r\n"))
|
||||
return
|
||||
} else {
|
||||
conn.Write([]byte("250 2.0.0 Ok\r\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package netdns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type NetMXResolver struct{}
|
||||
|
||||
func NewNetMXResolver() *NetMXResolver {
|
||||
return &NetMXResolver{}
|
||||
}
|
||||
|
||||
func (r *NetMXResolver) LookupMX(ctx context.Context, domain string) ([]string, error) {
|
||||
mxs, err := net.DefaultResolver.LookupMX(ctx, domain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup mx for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
sort.Slice(mxs, func(i, j int) bool {
|
||||
return mxs[i].Pref < mxs[j].Pref
|
||||
})
|
||||
|
||||
var hosts []string
|
||||
for _, mx := range mxs {
|
||||
h := strings.TrimSuffix(mx.Host, ".")
|
||||
if h != "" {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
hosts = []string{domain}
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
type OutboundRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewOutboundRepository(pool *pgxpool.Pool) *OutboundRepository {
|
||||
return &OutboundRepository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *OutboundRepository) Enqueue(ctx context.Context, job *entity.OutboundJob) error {
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO outbound_jobs (
|
||||
mailbox_id, blob_id, envelope_from, envelope_to, destination_domain,
|
||||
status, attempt, next_attempt_at, expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, created_at
|
||||
`, job.MailboxID, job.BlobID, job.EnvelopeFrom, job.EnvelopeTo, job.DestinationDomain,
|
||||
job.Status, job.Attempt, job.NextAttemptAt, job.ExpiresAt).Scan(&job.ID, &job.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue outbound job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH ready_jobs AS (
|
||||
SELECT id FROM outbound_jobs
|
||||
WHERE status IN ('QUEUED', 'DEFERRED')
|
||||
AND next_attempt_at <= NOW()
|
||||
ORDER BY next_attempt_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE outbound_jobs
|
||||
SET status = 'SENDING', locked_by = $1, locked_until = NOW() + INTERVAL '5 minutes'
|
||||
FROM ready_jobs
|
||||
WHERE outbound_jobs.id = ready_jobs.id
|
||||
RETURNING outbound_jobs.id, outbound_jobs.mailbox_id, outbound_jobs.blob_id,
|
||||
outbound_jobs.envelope_from, outbound_jobs.envelope_to, outbound_jobs.destination_domain,
|
||||
outbound_jobs.status, outbound_jobs.attempt, outbound_jobs.next_attempt_at,
|
||||
outbound_jobs.expires_at, outbound_jobs.last_smtp_code, outbound_jobs.last_error,
|
||||
outbound_jobs.tls_policy_used, outbound_jobs.delay_dsn_sent, outbound_jobs.created_at
|
||||
`, workerID, limit)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch ready outbound jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []*entity.OutboundJob
|
||||
for rows.Next() {
|
||||
j := &entity.OutboundJob{}
|
||||
var statusStr string
|
||||
var lastSmtpCode, lastError, tlsPolicyUsed *string
|
||||
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.MailboxID, &j.BlobID, &j.EnvelopeFrom, &j.EnvelopeTo, &j.DestinationDomain,
|
||||
&statusStr, &j.Attempt, &j.NextAttemptAt, &j.ExpiresAt, &lastSmtpCode, &lastError,
|
||||
&tlsPolicyUsed, &j.DelayDsnSent, &j.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan outbound job: %w", err)
|
||||
}
|
||||
|
||||
j.Status = entity.OutboundJobStatus(statusStr)
|
||||
if lastSmtpCode != nil {
|
||||
j.LastSmtpCode = *lastSmtpCode
|
||||
}
|
||||
if lastError != nil {
|
||||
j.LastError = *lastError
|
||||
}
|
||||
if tlsPolicyUsed != nil {
|
||||
j.TlsPolicyUsed = *tlsPolicyUsed
|
||||
}
|
||||
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (r *OutboundRepository) UpdateJob(ctx context.Context, job *entity.OutboundJob) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE outbound_jobs
|
||||
SET status = $1, attempt = $2, next_attempt_at = $3, last_smtp_code = $4,
|
||||
last_error = $5, tls_policy_used = $6, delay_dsn_sent = $7, locked_by = NULL, locked_until = NULL
|
||||
WHERE id = $8
|
||||
`, job.Status, job.Attempt, job.NextAttemptAt, job.LastSmtpCode,
|
||||
job.LastError, job.TlsPolicyUsed, job.DelayDsnSent, job.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("update outbound job %s: %w", job.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user