first commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
// Package queue implements the outbound delivery worker: polls due entries,
|
||||
// resolves MX records, delivers via net/smtp (stdlib), and handles retry
|
||||
// backoff and bounce generation for permanent failures.
|
||||
package queue
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/dkim"
|
||||
"gomail/internal/mailstore"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAttempts = 5
|
||||
pollInterval = 30 * time.Second
|
||||
deliveryTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// Deliverer is the interface the worker uses to actually hand a message to a
|
||||
// remote MTA — abstracted so tests can inject a fake without real network
|
||||
// access (outbound port 25 is blocked in most sandboxed/dev environments).
|
||||
type Deliverer interface {
|
||||
Deliver(from, to string, raw []byte) error
|
||||
}
|
||||
|
||||
// KeyLookup resolves the DKIM signing key for a sending domain, returning
|
||||
// (privateKeyPEM, selector, found). The worker calls this fresh on every
|
||||
// delivery attempt (not cached at startup) so key rotation via the admin
|
||||
// portal takes effect immediately without a restart.
|
||||
type KeyLookup func(fromDomain string) (privateKeyPEM []byte, selector string, ok bool)
|
||||
|
||||
// Worker polls outbound_queue and processes due entries.
|
||||
type Worker struct {
|
||||
database *db.DB
|
||||
store *mailstore.Store
|
||||
deliverer Deliverer
|
||||
keyLookup KeyLookup
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewWorker(database *db.DB, store *mailstore.Store) *Worker {
|
||||
return &Worker{
|
||||
database: database,
|
||||
store: store,
|
||||
deliverer: &MXDeliverer{Hostname: "gomail"},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WithDeliverer overrides the delivery mechanism — used by tests.
|
||||
func (w *Worker) WithDeliverer(d Deliverer) *Worker {
|
||||
w.deliverer = d
|
||||
return w
|
||||
}
|
||||
|
||||
// WithKeyLookup enables DKIM signing before every delivery attempt. Signing
|
||||
// happens here in the worker — not inside a specific Deliverer implementation
|
||||
// — so it applies uniformly regardless of transport (MX delivery, a test
|
||||
// fake, or any future alternative).
|
||||
func (w *Worker) WithKeyLookup(kl KeyLookup) *Worker {
|
||||
w.keyLookup = kl
|
||||
return w
|
||||
}
|
||||
|
||||
// Run starts the polling loop. Blocks until Stop is called.
|
||||
func (w *Worker) Run() {
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
slog.Info("outbound queue worker started", "poll_interval", pollInterval)
|
||||
w.ProcessOnce() // run immediately on start, don't wait for the first tick
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.ProcessOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Stop() {
|
||||
close(w.stopCh)
|
||||
}
|
||||
|
||||
// ProcessOnce runs a single pass: attempts delivery for all due entries,
|
||||
// then bounces anything that has exhausted its retry budget.
|
||||
func (w *Worker) ProcessOnce() {
|
||||
entries, err := w.database.DueOutboundEntries(maxAttempts, 100)
|
||||
if err != nil {
|
||||
slog.Error("queue: failed to load due entries", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
w.attemptDelivery(entry)
|
||||
}
|
||||
|
||||
failed, err := w.database.PermanentlyFailedEntries(maxAttempts)
|
||||
if err != nil {
|
||||
slog.Error("queue: failed to load permanently failed entries", "err", err)
|
||||
return
|
||||
}
|
||||
for _, entry := range failed {
|
||||
w.bounce(entry)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) attemptDelivery(entry db.OutboundQueueEntry) {
|
||||
raw, err := w.store.Read(entry.EMLPath)
|
||||
if err != nil {
|
||||
slog.Error("queue: failed to read queued message", "id", entry.ID, "err", err)
|
||||
w.scheduleRetry(entry, fmt.Sprintf("read failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if w.keyLookup != nil {
|
||||
fromDomain := domainOf(entry.FromAddress)
|
||||
if privateKeyPEM, selector, ok := w.keyLookup(fromDomain); ok {
|
||||
signed, err := dkim.Sign(privateKeyPEM, fromDomain, selector, raw)
|
||||
if err != nil {
|
||||
slog.Warn("queue: DKIM signing failed, sending unsigned", "domain", fromDomain, "err", err)
|
||||
} else {
|
||||
raw = signed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = w.deliverer.Deliver(entry.FromAddress, entry.ToAddress, raw)
|
||||
if err == nil {
|
||||
slog.Info("queue: delivered", "to", entry.ToAddress, "attempts", entry.Attempts+1)
|
||||
if delErr := w.database.DeleteOutboundEntry(entry.ID); delErr != nil {
|
||||
slog.Error("queue: failed to delete completed entry", "err", delErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isPermanentError(err) {
|
||||
slog.Warn("queue: permanent delivery failure, will bounce", "to", entry.ToAddress, "err", err)
|
||||
// Fast-forward attempts to the max so the next ProcessOnce pass bounces
|
||||
// it immediately, instead of waiting through the full retry schedule.
|
||||
remaining := maxAttempts - entry.Attempts
|
||||
for i := 0; i < remaining; i++ {
|
||||
w.database.RetryOutboundEntry(entry.ID, time.Now().UTC(), err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("queue: temporary delivery failure, will retry", "to", entry.ToAddress, "attempt", entry.Attempts+1, "err", err)
|
||||
w.scheduleRetry(entry, err.Error())
|
||||
}
|
||||
|
||||
func (w *Worker) scheduleRetry(entry db.OutboundQueueEntry, errMsg string) {
|
||||
backoff := backoffDuration(entry.Attempts + 1)
|
||||
next := time.Now().UTC().Add(backoff)
|
||||
if err := w.database.RetryOutboundEntry(entry.ID, next, errMsg); err != nil {
|
||||
slog.Error("queue: failed to schedule retry", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// backoffDuration implements exponential backoff: 5m, 20m, 1h20m, 5h20m, ~21h
|
||||
// for attempts 1 through 5, capping the total retry window near 5 days as
|
||||
// planned (RFC 5321 recommends retrying for at least 4-5 days before giving up).
|
||||
func backoffDuration(attempt int) time.Duration {
|
||||
base := 5 * time.Minute
|
||||
d := base
|
||||
for i := 1; i < attempt; i++ {
|
||||
d *= 4
|
||||
}
|
||||
max := 24 * time.Hour
|
||||
if d > max {
|
||||
d = max
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// bounce generates a DSN-style bounce message and delivers it to the local
|
||||
// sender's INBOX (the original MAIL FROM on submission is always a local
|
||||
// user, since session.go enforces that match at RCPT TO time).
|
||||
func (w *Worker) bounce(entry db.OutboundQueueEntry) {
|
||||
user, err := w.database.LookupUserByEmail(entry.FromAddress)
|
||||
if err != nil {
|
||||
slog.Error("queue: cannot bounce — original sender not found locally", "from", entry.FromAddress, "err", err)
|
||||
w.database.DeleteOutboundEntry(entry.ID)
|
||||
return
|
||||
}
|
||||
|
||||
bounceBody := fmt.Sprintf(
|
||||
"From: Mail Delivery System <postmaster@%s>\r\n"+
|
||||
"To: %s\r\n"+
|
||||
"Subject: Undelivered Mail Returned to Sender\r\n"+
|
||||
"Date: %s\r\n"+
|
||||
"\r\n"+
|
||||
"This is an automatically generated Delivery Status Notification.\r\n\r\n"+
|
||||
"Delivery to the following recipient failed permanently after %d attempts:\r\n\r\n"+
|
||||
" %s\r\n\r\n"+
|
||||
"Last error: %s\r\n\r\n"+
|
||||
"This is the final notification; no further attempts will be made.\r\n",
|
||||
domainOf(entry.FromAddress), entry.FromAddress, time.Now().UTC().Format(time.RFC1123Z),
|
||||
entry.Attempts, entry.ToAddress, entry.LastError,
|
||||
)
|
||||
|
||||
if _, err := w.store.Deliver(user.ID, user.Email, "INBOX", []byte(bounceBody)); err != nil {
|
||||
slog.Error("queue: failed to deliver bounce", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("queue: bounce delivered", "to", entry.FromAddress, "original_recipient", entry.ToAddress)
|
||||
w.database.DeleteOutboundEntry(entry.ID)
|
||||
}
|
||||
|
||||
func domainOf(email string) string {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// isPermanentError distinguishes 5xx (permanent) from 4xx/network (temporary)
|
||||
// SMTP failures — net/smtp wraps the server's textual response in the error,
|
||||
// so we inspect it for the leading status code digit.
|
||||
func isPermanentError(err error) bool {
|
||||
msg := err.Error()
|
||||
// net/smtp errors look like "553 5.1.1 User unknown" when they come from
|
||||
// the remote server's response.
|
||||
for _, code := range []string{"550", "551", "552", "553", "554"} {
|
||||
if strings.Contains(msg, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── MX-resolving deliverer (stdlib net/smtp + net.LookupMX) ────────────────────
|
||||
|
||||
// MXDeliverer is the real production Deliverer: resolves the recipient
|
||||
// domain's MX records, connects (with STARTTLS if offered), and hands off
|
||||
// via net/smtp — Go's standard library SMTP client, chosen specifically to
|
||||
// stay dependency-free for outbound delivery just as the inbound server is
|
||||
// hand-rolled from net.Listener. Pure transport — DKIM signing (if any)
|
||||
// happens in Worker.attemptDelivery before Deliver is called, so it applies
|
||||
// uniformly regardless of which Deliverer implementation is in use.
|
||||
type MXDeliverer struct {
|
||||
Hostname string // EHLO identity
|
||||
}
|
||||
|
||||
func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
|
||||
domain := domainOf(to)
|
||||
mxHosts, err := lookupMXHosts(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("451 4.4.3 MX lookup failed for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, host := range mxHosts {
|
||||
if err := d.deliverToHost(host, from, to, raw); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
|
||||
conn, err := net.DialTimeout("tcp", host+":25", deliveryTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("421 4.4.1 connect to %s failed: %w", host, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(deliveryTimeout))
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("421 4.4.1 SMTP handshake with %s failed: %w", host, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Hello(d.Hostname); err != nil {
|
||||
return fmt.Errorf("EHLO to %s failed: %w", host, err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsConf := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
if err := client.StartTLS(tlsConf); err != nil {
|
||||
slog.Warn("STARTTLS failed, continuing without encryption", "host", host, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err // preserves the remote server's status code in the error text
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func lookupMXHosts(domain string) ([]string, error) {
|
||||
mxs, err := net.LookupMX(domain)
|
||||
if err != nil || len(mxs) == 0 {
|
||||
// RFC 5321 §5.1 fallback: if no MX records, try the domain's A record directly.
|
||||
if _, aErr := net.LookupHost(domain); aErr == nil {
|
||||
return []string{domain}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no MX or A record for %s: %w", domain, err)
|
||||
}
|
||||
hosts := make([]string, len(mxs))
|
||||
for i, mx := range mxs {
|
||||
hosts[i] = strings.TrimSuffix(mx.Host, ".")
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
Reference in New Issue
Block a user