// 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 \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 }