first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+334
View File
@@ -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
}