#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

@@ -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}
}
func (j *OutboundJob) Attempt() int { return j.attempt }
// RecordAttempt enforces "attempt <= max_attempts" (PLAN.md section 3).
func (j *OutboundJob) RecordAttempt() error {
if j.attempt >= j.maxAttempts {
return ErrOutboundJobMaxAttemptsExceeded
// 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
}
j.attempt++
return nil
}
// 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,
}
var baseDelay time.Duration
// 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
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
}
return retrySchedule[idx]
if baseDelay > 0 && attempt >= 5 {
// Apply ±20% jitter
jitterFactor := 0.8 + rand.Float64()*0.4
baseDelay = time.Duration(float64(baseDelay) * jitterFactor)
}
nextAttemptAt = now.Add(baseDelay)
if !expiresAt.IsZero() && nextAttemptAt.After(expiresAt) {
return expiresAt, sendDelayDsn, true
}
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)
}
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)
}
if j.Attempt() != 3 {
t.Errorf("Attempt() = %d, want 3", j.Attempt())
}
}
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)
}
}