#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
+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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user