From 479e50b4d962a0c4944ddf2a2e310dd6ccb5770a Mon Sep 17 00:00:00 2001 From: Francis Date: Sat, 7 Nov 2026 21:18:02 +0000 Subject: [PATCH 1/3] Issue certificates in the image and correct silent mail failures. Operators should get STARTTLS from the published image alone, and a failed certificate, a redirect, or a DNS error should not be reported as a successful delivery. Co-authored-by: Cursor --- .env.example | 2 +- Dockerfile | 7 +- README.md | 9 +- docs/providers/fly.io/README.md | 5 +- go.mod | 2 +- internal/app/app.go | 54 ++---- internal/app/app_test.go | 236 +++++++++++++++++++------ internal/auth/async.go | 31 ++++ internal/auth/dkim.go | 23 ++- internal/auth/dkim_test.go | 17 ++ internal/auth/dmarc.go | 165 +++++++++++------ internal/auth/dmarc_test.go | 152 +++++++++++++++- internal/auth/spf.go | 50 +++++- internal/auth/spf_test.go | 74 ++++++++ internal/domain/email.go | 2 +- internal/domain/email_test.go | 12 ++ internal/observability/metrics.go | 3 +- internal/observability/metrics_test.go | 28 +++ internal/processor/webhook.go | 14 +- internal/processor/webhook_test.go | 29 +++ internal/smtp/backend.go | 3 + internal/smtp/backend_test.go | 25 +++ internal/smtp/server.go | 167 +++++++++++++---- internal/smtp/server_test.go | 102 +++++++++++ start.sh | 211 ++++++++++++---------- 25 files changed, 1124 insertions(+), 299 deletions(-) create mode 100644 internal/auth/async.go mode change 100644 => 100755 start.sh diff --git a/.env.example b/.env.example index a67b46a..eb87710 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,5 @@ SENTRY_RELEASE= CF_Token= CF_Zone_ID= DNS_API=dns_cf -ACME_SH_EMAIL= +ACME_SH_EMAIL=devnull@localhost MXDOMAIN=ineedatest.email \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0f5521f..23c66f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ FROM alpine:3.21 ARG ACME_SH_VERSION=3.0.7 # Install ca-certificates, openssl, socat, git, bash; pin acme.sh via git tag (no curl|sh) -RUN apk add --no-cache ca-certificates openssl socat git bash curl \ +RUN apk add --no-cache ca-certificates openssl socat git bash curl su-exec \ && git clone --depth 1 --branch "${ACME_SH_VERSION}" https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \ && addgroup -S inboundparse \ && adduser -S -G inboundparse -h /home/inboundparse inboundparse \ @@ -39,14 +39,15 @@ COPY start.sh /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/inboundparse /usr/local/bin/start.sh \ && chown inboundparse:inboundparse /usr/local/bin/inboundparse /usr/local/bin/start.sh -USER inboundparse +# Stay root for the entrypoint. start.sh writes a root-owned /cert volume, +# then drops to inboundparse before the SMTP process. ENV HOME=/home/inboundparse # Environment variables for acme.sh and SMTP server ENV CF_Token="" ENV CF_Zone_ID="" ENV DNS_API="" -ENV ACME_SH_EMAIL="" +ENV ACME_SH_EMAIL="devnull@localhost" ENV MXDOMAIN="" ENV WEBHOOK_URL="" ENV ENABLE_SPF="true" diff --git a/README.md b/README.md index e807afb..b9edf35 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,15 @@ You can configure InboundParse using environment variables or command line flags #### TLS Settings -- `CERT_FILE` Path to TLS certificate file (optional) +The image entrypoint requests a Let's Encrypt certificate on startup when DNS credentials are set, then starts SMTP. No extra script is required. If those credentials are unset, or issuance fails, SMTP still starts without STARTTLS. A volume mounted at `/cert` keeps the certificate across restarts. + +- `CERT_FILE` Path to TLS certificate file (optional; the entrypoint sets this when it has a certificate) - `KEY_FILE` Path to TLS private key file (optional) +- `MXDOMAIN` Certificate domain (default: `SERVER_NAME`, then `mx.inboundparse.com`) +- `ACME_SH_EMAIL` Let's Encrypt account email (default: `devnull@localhost`) +- `DNS_API` acme.sh DNS provider (default: `dns_cf`) +- `CF_Token` Cloudflare API token (required for `dns_cf`) +- `CF_Zone_ID` Cloudflare zone id (required for `dns_cf`) #### Feature Flags diff --git a/docs/providers/fly.io/README.md b/docs/providers/fly.io/README.md index 0493aa1..75d90c0 100644 --- a/docs/providers/fly.io/README.md +++ b/docs/providers/fly.io/README.md @@ -6,11 +6,14 @@ The sample Fly config in this directory is [`fly.toml`](./fly.toml). Adjust app # From the directory that contains fly.toml fly deploy -# Core secrets (adjust for your webhook and DNS/API provider — see `start.sh` / `Dockerfile` for ACME) +# The image entrypoint issues the certificate. No extra start script. +# ACME_SH_EMAIL defaults to devnull@localhost. SMTP still starts without +# STARTTLS when the DNS secrets are unset or issuance fails. fly secrets set WEBHOOK_URL=https://your-webhook-url.com/api/webhook fly secrets set CF_Token=your-cloudflare-token fly secrets set CF_Zone_ID=your-zone-id fly secrets set MXDOMAIN=your-domain.com +fly secrets set ACME_SH_EMAIL=you@your-domain.com # Optional: error tracking fly secrets set SENTRY_DSN=https://your-dsn@sentry.io/project-id diff --git a/go.mod b/go.mod index ea7bbac..7afcd2a 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.11.1 github.com/zaccone/spf v0.0.0-20170817004109-76747b8658d9 + golang.org/x/net v0.54.0 golang.org/x/sync v0.20.0 ) @@ -36,7 +37,6 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index 1054ddc..a0705f9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,8 +12,6 @@ import ( "os/signal" "syscall" "time" - - "golang.org/x/sync/errgroup" ) // App represents the application @@ -190,45 +188,31 @@ func (a *App) Start() error { return nil } -// Run runs the application with graceful shutdown +// Run runs the application with graceful shutdown. +// Signal handlers are installed before Start is launched. Start blocks in SMTP +// Serve for the life of the process, so waiting for it to return would leave +// SIGINT and SIGTERM unhandled while mail is accepted. func (a *App) Run() error { - // Start the application - if err := a.Start(); err != nil { - return err - } - - // Set up graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Create error group for managing goroutines - g, ctx := errgroup.WithContext(ctx) - - // Handle shutdown signals sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigChan) - g.Go(func() error { - select { - case sig := <-sigChan: - a.logger.Info(). - Str("signal", sig.String()). - Msg("Received shutdown signal") - cancel() - return nil - case <-ctx.Done(): - return ctx.Err() - } - }) + // Buffered so the Start goroutine can send after Run has already returned + // on a signal. An unbuffered send would leak that goroutine. + startErr := make(chan error, 1) + go func() { + startErr <- a.Start() + }() - // Wait for context cancellation - g.Go(func() error { - <-ctx.Done() + select { + case sig := <-sigChan: + a.logger.Info(). + Str("signal", sig.String()). + Msg("Received shutdown signal") return a.Stop() - }) - - // Wait for all goroutines to complete - return g.Wait() + case err := <-startErr: + return err + } } // Stop stops the application gracefully diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d57ea4f..61cffa3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,6 +1,7 @@ package app import ( + "bytes" "context" "errors" "inboundparse/internal/config" @@ -8,13 +9,35 @@ import ( "inboundparse/internal/observability" "io" "net" + "os" + "os/exec" "reflect" "strings" + "sync" + "syscall" "testing" "time" "unsafe" ) +// lockedBuf is safe for exec to write while the test reads failure output. +type lockedBuf struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuf) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuf) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + // Mock implementations for testing type mockLogger struct{} @@ -232,40 +255,7 @@ func TestApp_Start_WithTLS(t *testing.T) { } func TestApp_Run_WithSignalHandling(t *testing.T) { - config := &config.Config{ - ListenAddr: "127.0.0.1:0", - WebhookURL: "http://localhost:8080/webhook", - ServerName: "test", - Verbose: true, - } - - app, err := NewApp(config) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Test Run method - this will test signal handling setup - // We can't easily test the actual signal handling without complex setup - // but we can test that the method doesn't panic - go func() { - // Simulate a signal after a short delay - time.Sleep(10 * time.Millisecond) - // The actual signal handling is complex to test in unit tests - }() - - // Start the app in a goroutine and then stop it quickly - go func() { - app.Run() - }() - - // Give it a moment to start - time.Sleep(50 * time.Millisecond) - - // Stop the app - err = app.Stop() - if err != nil { - t.Fatalf("Expected no error stopping app, got %v", err) - } + testRunShutsDownOnSignal(t, syscall.SIGTERM) } func TestApp_Stop_WithoutMetrics(t *testing.T) { @@ -401,15 +391,19 @@ func TestApp_Start_MetricsServerError(t *testing.T) { } } -// TestApp_Run_GoRoutinesCoverage exercises the Run() method goroutine setup paths. -// It starts the app and then stops the SMTP server to allow Run() to proceed past -// Start() and into its goroutine setup code. +// TestApp_Run_GoRoutinesCoverage runs Start in the background and shuts down +// on SIGINT. The handler must already be installed while SMTP is accepting. func TestApp_Run_GoRoutinesCoverage(t *testing.T) { + testRunShutsDownOnSignal(t, syscall.SIGINT) +} + +// TestApp_Run_ReturnsStartError checks that a bind failure from Start is what +// Run returns, instead of waiting for a signal. +func TestApp_Run_ReturnsStartError(t *testing.T) { cfg := &config.Config{ - ListenAddr: "127.0.0.1:0", + ListenAddr: "invalid-address:99999", WebhookURL: "http://localhost:8080/webhook", ServerName: "test", - Verbose: false, EnableMetrics: false, EnableSentry: false, WebhookRetryMultiplier: config.DefaultWebhookRetryMultiplier, @@ -425,29 +419,156 @@ func TestApp_Run_GoRoutinesCoverage(t *testing.T) { done <- app.Run() }() - // Give Run() time to enter Start() (which blocks on SMTP listen) - time.Sleep(50 * time.Millisecond) + select { + case err := <-done: + if err == nil { + t.Fatal("Expected Run to return Start error") + } + if !strings.Contains(err.Error(), "failed to start SMTP server") { + t.Fatalf("Expected SMTP start error, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not return the SMTP start error") + } +} - // Stop the SMTP server to unblock Start(), allowing Run() to proceed to goroutine setup - app.smtpServer.Stop() +// testRunShutsDownOnSignal re-executes this test in a child process so a +// missed SIGINT/SIGTERM cannot kill the parent test run. The child blocks in +// Run; the parent signals only after the SMTP port accepts a connection. +func testRunShutsDownOnSignal(t *testing.T, sig syscall.Signal) { + t.Helper() - // Run() should now set up its goroutines and wait for signal/ctx.Done() - // Give it a moment then stop - time.Sleep(50 * time.Millisecond) + if os.Getenv("INBOUNDPARSE_RUN_SIGNAL_CHILD") == "1" { + serveUntilShutdownSignal(t) + return + } - // Stop gracefully - app.Stop() + addr := reserveLoopbackAddr(t) + cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$", "-test.count=1", "-test.timeout=30s") + cmd.Env = append(os.Environ(), + "INBOUNDPARSE_RUN_SIGNAL_CHILD=1", + "INBOUNDPARSE_LISTEN_ADDR="+addr, + ) + var stdout, stderr lockedBuf + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + t.Fatalf("start shutdown child: %v", err) + } + + finished := false + t.Cleanup(func() { + if finished || cmd.Process == nil { + return + } + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }) + + logs := func() string { + return stdout.String() + stderr.String() + } + + if err := dialUntil(addr, 5*time.Second); err != nil { + t.Fatalf("child never accepted SMTP connections on %s: %v\n%s", addr, err, logs()) + } + + if err := cmd.Process.Signal(sig); err != nil { + t.Fatalf("signal child: %v", err) + } + + waitDone := make(chan error, 1) + go func() { + waitDone <- cmd.Wait() + }() - // The goroutines in Run() are still waiting for signal, so we just wait briefly select { - case runErr := <-done: - t.Logf("Run() returned: %v", runErr) - case <-time.After(2 * time.Second): - // This is expected - Run's goroutines don't terminate without a signal - // but the code paths are covered + case err := <-waitDone: + finished = true + if err != nil { + t.Fatalf("child exited with %v\n%s", err, logs()) + } + case <-time.After(10 * time.Second): + t.Fatalf("child did not exit after %s\n%s", sig, logs()) + } + + output := logs() + if !strings.Contains(output, "Received shutdown signal") { + t.Fatalf("shutdown log missing from child output:\n%s", output) + } + if !strings.Contains(output, "Application stopped") { + t.Fatalf("stop log missing from child output:\n%s", output) + } + + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + conn.Close() + t.Fatalf("SMTP listener %s still accepting after shutdown", addr) } } +func serveUntilShutdownSignal(t *testing.T) { + t.Helper() + + addr := os.Getenv("INBOUNDPARSE_LISTEN_ADDR") + if addr == "" { + t.Fatal("INBOUNDPARSE_LISTEN_ADDR is required") + } + + cfg := &config.Config{ + ListenAddr: addr, + WebhookURL: "http://localhost:8080/webhook", + ServerName: "test", + EnableMetrics: false, + EnableSentry: false, + WebhookRetryMultiplier: config.DefaultWebhookRetryMultiplier, + } + + app, err := NewApp(cfg) + if err != nil { + t.Fatalf("NewApp: %v", err) + } + if err := app.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + // Signal path already called Stop. A second call must stay a no-op. + if err := app.Stop(); err != nil { + t.Fatalf("Stop after shutdown: %v", err) + } +} + +func reserveLoopbackAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + addr := ln.Addr().String() + if err := ln.Close(); err != nil { + t.Fatalf("close reserved port: %v", err) + } + return addr +} + +func dialUntil(addr string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var last error + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) + if err == nil { + conn.Close() + return nil + } + last = err + time.Sleep(10 * time.Millisecond) + } + if last == nil { + last = errors.New("no connection attempt") + } + return last +} + // TestApp_Stop_WithMetricsEnabled tests Stop() when metrics are enabled func TestApp_Stop_WithMetricsEnabled(t *testing.T) { cfg := &config.Config{ @@ -548,9 +669,14 @@ func TestApp_Stop_AfterNoStart(t *testing.T) { t.Fatalf("Expected no error creating app, got %v", err) } - // Stop the app without starting it — exercises the Stop() happy path + // Stop the app without starting it — exercises the Stop() happy path. + // A second call must also succeed; shutdown can run more than once. err = app.Stop() if err != nil { t.Fatalf("Expected no error stopping app, got %v", err) } + err = app.Stop() + if err != nil { + t.Fatalf("Expected Stop to be idempotent, got %v", err) + } } diff --git a/internal/auth/async.go b/internal/auth/async.go new file mode 100644 index 0000000..e8eefaf --- /dev/null +++ b/internal/auth/async.go @@ -0,0 +1,31 @@ +package auth + +import "context" + +// callWithContext runs fn in a new goroutine. The libraries used for SPF, +// DKIM, and DMARC do not accept a context, so a canceled context cannot stop +// the resolver. ok is false when ctx ends before fn returns. The goroutine +// is left running until fn returns. +func callWithContext[T any](ctx context.Context, fn func() T) (T, bool) { + ch := make(chan T, 1) + go func() { + ch <- fn() + }() + + // A context that is already done must win even if fn has finished and + // the result is buffered. Otherwise select would choose at random. + select { + case <-ctx.Done(): + var zero T + return zero, false + default: + } + + select { + case <-ctx.Done(): + var zero T + return zero, false + case v := <-ch: + return v, true + } +} diff --git a/internal/auth/dkim.go b/internal/auth/dkim.go index 40452ef..b3e72cd 100644 --- a/internal/auth/dkim.go +++ b/internal/auth/dkim.go @@ -26,7 +26,28 @@ func (d *dkimChecker) CheckDKIM(ctx context.Context, rawMessage string, logger o duration := time.Since(start) metrics.TrackDKIMDuration(duration) }() - verifications, err := dkim.Verify(strings.NewReader(rawMessage)) + // Verify does not take a context. Run it aside so a canceled + // MESSAGE_PROCESSING_TIMEOUT can return without waiting on DNS. + type dkimLookup struct { + verifications []*dkim.Verification + err error + } + lookup, ok := callWithContext(ctx, func() dkimLookup { + verifications, err := dkim.Verify(strings.NewReader(rawMessage)) + return dkimLookup{verifications: verifications, err: err} + }) + if !ok { + metrics.TrackAuthError("dkim", "timeout_error") + logger.Warn(). + Err(ctx.Err()). + Msg("DKIM verification canceled") + return &domain.DKIMResult{ + Valid: false, + Raw: fmt.Sprintf("DKIM verification error: %v", ctx.Err()), + Error: ctx.Err().Error(), + }, nil + } + verifications, err := lookup.verifications, lookup.err if err != nil { // Determine error type for metrics errorType := "verification_error" diff --git a/internal/auth/dkim_test.go b/internal/auth/dkim_test.go index 1fd6cb1..6f564f0 100644 --- a/internal/auth/dkim_test.go +++ b/internal/auth/dkim_test.go @@ -245,3 +245,20 @@ This is a test message with a malformed DKIM signature.` t.Error("Expected signatures in result") } } + +func TestDKIMChecker_CheckDKIM_ContextCanceled(t *testing.T) { + checker := NewDKIMChecker() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := checker.CheckDKIM(ctx, "not a message", &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Valid { + t.Fatal("Expected Valid false when the context is canceled") + } + if result.Error == "" || result.Error != ctx.Err().Error() { + t.Fatalf("Expected error %q, got %q", ctx.Err().Error(), result.Error) + } +} diff --git a/internal/auth/dmarc.go b/internal/auth/dmarc.go index fd5ae04..89e200c 100644 --- a/internal/auth/dmarc.go +++ b/internal/auth/dmarc.go @@ -12,6 +12,7 @@ import ( "github.com/emersion/go-msgauth/dmarc" "github.com/mnako/letters" + "golang.org/x/net/publicsuffix" ) // dmarcChecker implements DMARCChecker interface @@ -36,8 +37,36 @@ func (d *dmarcChecker) CheckDMARC(ctx context.Context, rawMessage string, header return result, nil } - // Look up DMARC policy with domain hierarchy traversal - record, actualDomain, lookupDetails, err := d.lookupDMARCRecord(fromDomain) + // Lookup does not take a context. Run it aside so a canceled + // MESSAGE_PROCESSING_TIMEOUT can return without waiting on DNS. + type dmarcLookup struct { + record *dmarc.Record + actualDomain string + details []domain.DMARCLookupDetail + err error + } + lookup, ok := callWithContext(ctx, func() dmarcLookup { + record, actualDomain, lookupDetails, err := d.lookupDMARCRecord(fromDomain) + return dmarcLookup{ + record: record, + actualDomain: actualDomain, + details: lookupDetails, + err: err, + } + }) + if !ok { + metrics.TrackAuthError("dmarc", "timeout_error") + logger.Warn(). + Err(ctx.Err()). + Str("domain", fromDomain). + Msg("DMARC lookup canceled") + return &domain.DMARCResult{ + Result: "temperror", + Raw: fmt.Sprintf("DMARC lookup error: %v", ctx.Err()), + Error: ctx.Err().Error(), + }, nil + } + record, actualDomain, lookupDetails, err := lookup.record, lookup.actualDomain, lookup.details, lookup.err if err != nil { return d.handleLookupError(err, fromDomain, lookupDetails, logger, metrics), nil } @@ -67,22 +96,44 @@ func (d *dmarcChecker) CheckDMARC(ctx context.Context, rawMessage string, header return d.buildDMARCResult(record, actualDomain, policyToApply, dmarcResult, rawMsg, spfAligned, dkimAligned, spfDomain, dkimDomain, lookupDetails, metrics), nil } -// extractFromDomain extracts and validates the From domain from headers +// extractFromDomain extracts and validates the From domain from headers. +// RFC 7489 section 6.6.1: every mailbox in From must share one domain. +// More than one domain is a permanent error and does not query DNS. func (d *dmarcChecker) extractFromDomain(headers letters.Headers, logger observability.Logger) (string, *domain.DMARCResult) { - if len(headers.From) == 0 || headers.From[0] == nil || len(headers.From[0].Address) == 0 { + if len(headers.From) == 0 { + logger.Debug().Msg("DMARC check: No From header found") + return "", &domain.DMARCResult{ + Result: "none", + Raw: "No From header found", + } + } + + type mailbox struct { + address string + domain string + } + var boxes []mailbox + for _, addr := range headers.From { + if addr == nil || len(addr.Address) == 0 { + continue + } + boxes = append(boxes, mailbox{ + address: addr.Address, + domain: ExtractDomainFromEmail(addr.Address), + }) + } + if len(boxes) == 0 { logger.Debug().Msg("DMARC check: No From header found") return "", &domain.DMARCResult{ Result: "none", Raw: "No From header found", } } - fromHeader := headers.From[0].Address // RFC 7489: The domain in the RFC5322.From field is the primary identifier - fromDomain := ExtractDomainFromEmail(fromHeader) - if fromDomain == "" { + if len(boxes) == 1 && boxes[0].domain == "" { logger.Debug(). - Str("from_header", fromHeader). + Str("from_header", boxes[0].address). Msg("DMARC check: No domain found in From header") return "", &domain.DMARCResult{ Result: "none", @@ -90,7 +141,18 @@ func (d *dmarcChecker) extractFromDomain(headers letters.Headers, logger observa } } - return fromDomain, nil + shared := boxes[0].domain + for _, box := range boxes[1:] { + if shared == "" || box.domain == "" || !strings.EqualFold(shared, box.domain) { + logger.Debug().Msg("DMARC check: multiple From domains") + return "", &domain.DMARCResult{ + Result: "permerror", + Raw: "Multiple domains in From header", + } + } + } + + return shared, nil } // handleLookupError handles DMARC lookup errors @@ -273,65 +335,55 @@ func (d *dmarcChecker) buildDMARCResult(record *dmarc.Record, actualDomain strin } } -// lookupDMARCRecord performs hierarchical DMARC record lookup per RFC 7489 +// dmarcPolicyDomains is the From domain and, when that name is not already +// the organizational domain, the public-suffix-plus-one name. RFC 7489 +// section 6.6.3 consults only those two. Intermediate parents are not +// queried, and neither is the public suffix itself. +func dmarcPolicyDomains(fromDomain string) []string { + fromDomain = strings.ToLower(strings.TrimSpace(fromDomain)) + if fromDomain == "" { + return nil + } + org, err := organizationalDomain(fromDomain) + if err != nil || org == "" || org == fromDomain { + return []string{fromDomain} + } + return []string{fromDomain, org} +} + +// lookupDMARCRecord looks up the DMARC policy for domainName. +// A DNS error stops the lookup. Only a missing record falls through to the +// organizational domain. func (d *dmarcChecker) lookupDMARCRecord(domainName string) (*dmarc.Record, string, []domain.DMARCLookupDetail, error) { - currentDomain := domainName var details []domain.DMARCLookupDetail - for { + for _, currentDomain := range dmarcPolicyDomains(domainName) { record, err := dmarc.Lookup(currentDomain) - // Create lookup detail for this attempt detail := domain.DMARCLookupDetail{ Domain: currentDomain, } if err == nil && record != nil { - // Found a record detail.RecordFound = true details = append(details, detail) return record, currentDomain, details, nil } - // Record not found or error occurred detail.RecordFound = false if err != nil { detail.Error = err.Error() } details = append(details, detail) - // If the error is "dmarc: failed to lookup TXT record", continue as if not found - if err != nil && strings.Contains(err.Error(), "dmarc: failed to lookup TXT record") { - // continue down to parent domain - } else if err != nil && !d.isDMARCNotFoundError(err) { - // Early return for other errors (let outer logic handle) + if err != nil && !d.isDMARCNotFoundError(err) { return nil, "", details, err } - - // RFC 7489: Move up the domain hierarchy - nextDomain := d.getParentDomain(currentDomain) - if nextDomain == "" || nextDomain == currentDomain { - // Reached the top or can't go further - break - } - currentDomain = nextDomain } return nil, "", details, nil } -// getParentDomain returns the parent domain or empty string if at top level -func (d *dmarcChecker) getParentDomain(domain string) string { - parts := strings.Split(domain, ".") - if len(parts) <= 2 { - // Already at top level domain (e.g., "example.com") - return "" - } - - // Remove the leftmost subdomain - return strings.Join(parts[1:], ".") -} - // isDMARCNotFoundError checks if the error indicates DMARC record not found func (d *dmarcChecker) isDMARCNotFoundError(err error) bool { errorMsg := strings.ToLower(err.Error()) @@ -371,12 +423,10 @@ func (d *dmarcChecker) checkDKIMAlignment(signatures []string, fromDomain string return false } -// isOrganizationalDomainMatch checks if two domains belong to the same organization per RFC 7489 +// isOrganizationalDomainMatch checks if two domains belong to the same organization per RFC 7489. +// Relaxed alignment compares organizational domains from the public suffix list. +// If either domain has no eTLD+1, they align only when the domains are equal. func (d *dmarcChecker) isOrganizationalDomainMatch(domain1, domain2 string) bool { - // RFC 7489: Organizational domain matching for relaxed alignment - // NOTE: Production implementation should use Public Suffix List (PSL) - // This is a simplified implementation for basic organizational domain matching - domain1 = strings.ToLower(domain1) domain2 = strings.ToLower(domain2) @@ -384,24 +434,27 @@ func (d *dmarcChecker) isOrganizationalDomainMatch(domain1, domain2 string) bool return true } - // Get organizational domains (simplified - should use PSL in production) - orgDomain1 := d.getOrganizationalDomain(domain1) - orgDomain2 := d.getOrganizationalDomain(domain2) + orgDomain1, err1 := organizationalDomain(domain1) + orgDomain2, err2 := organizationalDomain(domain2) + if err1 != nil || err2 != nil { + return false + } return orgDomain1 == orgDomain2 && orgDomain1 != "" } -// getOrganizationalDomain extracts the organizational domain -// NOTE: This is a simplified implementation. Production should use Public Suffix List +// getOrganizationalDomain extracts the organizational domain using the public suffix list. +// It returns an empty string when the domain has no registrable eTLD+1. func (d *dmarcChecker) getOrganizationalDomain(domain string) string { - parts := strings.Split(domain, ".") - if len(parts) < 2 { - return domain + org, err := organizationalDomain(domain) + if err != nil { + return "" } + return org +} - // Simple heuristic: take last two parts as organizational domain - // In production, use Public Suffix List to determine the actual organizational domain - return strings.Join(parts[len(parts)-2:], ".") +func organizationalDomain(domain string) (string, error) { + return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(domain)) } // extractDKIMDomain extracts domain(s) from DKIM signatures for alignment reporting diff --git a/internal/auth/dmarc_test.go b/internal/auth/dmarc_test.go index 91affe3..0e96b1b 100644 --- a/internal/auth/dmarc_test.go +++ b/internal/auth/dmarc_test.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "inboundparse/internal/domain" "net/mail" "testing" @@ -10,14 +11,33 @@ import ( "github.com/mnako/letters" ) -func TestDMARCChecker_GetParentDomain(t *testing.T) { - checker := &dmarcChecker{} +func TestDMARCPolicyDomains(t *testing.T) { + got := dmarcPolicyDomains("a.mail.example.com") + if len(got) != 2 || got[0] != "a.mail.example.com" || got[1] != "example.com" { + t.Fatalf("dmarcPolicyDomains(a.mail.example.com) = %v, want [a.mail.example.com example.com]", got) + } - if got := checker.getParentDomain("a.b.example.com"); got != "b.example.com" { - t.Fatalf("getParentDomain('a.b.example.com') = %q, want 'b.example.com'", got) + got = dmarcPolicyDomains("a.example.co.uk") + if len(got) != 2 || got[0] != "a.example.co.uk" || got[1] != "example.co.uk" { + t.Fatalf("dmarcPolicyDomains(a.example.co.uk) = %v, want [a.example.co.uk example.co.uk]", got) } - if got := checker.getParentDomain("example.com"); got != "" { - t.Fatalf("getParentDomain('example.com') = %q, want ''", got) + + got = dmarcPolicyDomains("user.blogspot.com") + if len(got) != 1 || got[0] != "user.blogspot.com" { + t.Fatalf("dmarcPolicyDomains(user.blogspot.com) = %v, want [user.blogspot.com]", got) + } + + got = dmarcPolicyDomains("example.com") + if len(got) != 1 || got[0] != "example.com" { + t.Fatalf("dmarcPolicyDomains(example.com) = %v, want [example.com]", got) + } +} + +func TestDMARCNotFoundError_DNSFailureIsNotMissing(t *testing.T) { + checker := &dmarcChecker{} + err := errors.New("dmarc: failed to lookup TXT record: REFUSED") + if checker.isDMARCNotFoundError(err) { + t.Fatal("a TXT lookup failure must not be treated as a missing DMARC record") } } @@ -33,6 +53,28 @@ func TestDMARCChecker_IsOrganizationalDomainMatch(t *testing.T) { if checker.isOrganizationalDomainMatch("example.com", "other.com") { t.Fatal("Expected no match for example.com and other.com") } + if checker.isOrganizationalDomainMatch("attacker.blogspot.com", "victim.blogspot.com") { + t.Fatal("Expected no match for different blogspot.com tenants") + } + if !checker.isOrganizationalDomainMatch("a.example.co.uk", "b.example.co.uk") { + t.Fatal("Expected match for a.example.co.uk and b.example.co.uk") + } + if !checker.isOrganizationalDomainMatch("mail.example.com", "example.com") { + t.Fatal("Expected match for mail.example.com and example.com") + } + if !checker.isOrganizationalDomainMatch("MAIL.EXAMPLE.COM", "example.com") { + t.Fatal("Expected case-insensitive match for MAIL.EXAMPLE.COM and example.com") + } + // Public suffixes have no eTLD+1. Unequal names must not align. + if checker.isOrganizationalDomainMatch("blogspot.com", "attacker.blogspot.com") { + t.Fatal("Expected no match when one domain has no organizational domain") + } + if checker.isOrganizationalDomainMatch("com", "co.uk") { + t.Fatal("Expected no match for public suffixes that fail eTLD+1") + } + if !checker.isOrganizationalDomainMatch("Blogspot.com", "blogspot.com") { + t.Fatal("Expected equal domains to align when eTLD+1 cannot be derived") + } } func TestDMARCChecker_CheckDMARC_NoFromHeader(t *testing.T) { @@ -362,7 +404,16 @@ func TestDMARCChecker_GetOrganizationalDomain(t *testing.T) { {"sub.example.com", "example.com"}, {"a.b.example.com", "example.com"}, {"a.b.c.example.com", "example.com"}, - {"localhost", "localhost"}, + {"mail.example.com", "example.com"}, + {"a.example.co.uk", "example.co.uk"}, + {"b.example.co.uk", "example.co.uk"}, + {"attacker.blogspot.com", "attacker.blogspot.com"}, + {"victim.blogspot.com", "victim.blogspot.com"}, + {"Mail.Example.COM", "example.com"}, + // No registrable domain: single labels and public suffixes themselves. + {"localhost", ""}, + {"blogspot.com", ""}, + {"co.uk", ""}, {"", ""}, } @@ -459,7 +510,7 @@ func TestDMARCChecker_CheckDMARC_AlignmentBugFix(t *testing.T) { func TestDMARCChecker_CheckDMARC_WithMultipleFromAddresses(t *testing.T) { checker := NewDMARCChecker() - // Test with multiple From addresses (should use the first one) + // Several From addresses that share one domain still use that domain. headers := letters.Headers{ From: []*mail.Address{ {Address: "test@example.com"}, @@ -477,6 +528,91 @@ func TestDMARCChecker_CheckDMARC_WithMultipleFromAddresses(t *testing.T) { } } +func TestDMARCChecker_CheckDMARC_MultipleFromDomains(t *testing.T) { + checker := NewDMARCChecker() + + headers := letters.Headers{ + From: []*mail.Address{ + {Address: "a@example.com"}, + {Address: "b@other.com"}, + }, + } + + result, err := checker.CheckDMARC(context.Background(), "raw message", headers, nil, nil, &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Result != "permerror" { + t.Fatalf("Expected result 'permerror', got %s", result.Result) + } + if len(result.Details) != 0 { + t.Fatalf("Expected no DNS lookup, got %d details", len(result.Details)) + } +} + +func TestDMARCChecker_ExtractFromDomain_SharedAndDistinct(t *testing.T) { + checker := &dmarcChecker{} + + shared, result := checker.extractFromDomain(letters.Headers{ + From: []*mail.Address{ + {Address: "a@Example.com"}, + {Address: "b@example.com"}, + }, + }, &MockLogger{}) + if result != nil { + t.Fatalf("Expected one shared domain, got result %s", result.Result) + } + if shared != "Example.com" { + t.Fatalf("Expected domain Example.com, got %q", shared) + } + + _, result = checker.extractFromDomain(letters.Headers{ + From: []*mail.Address{ + {Address: "a@a.example.com"}, + {Address: "b@b.example.com"}, + }, + }, &MockLogger{}) + if result == nil || result.Result != "permerror" { + t.Fatal("Expected permerror for distinct From domains") + } +} + +func TestDMARCChecker_CheckDMARC_ContextCanceled(t *testing.T) { + checker := NewDMARCChecker() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + headers := letters.Headers{ + From: []*mail.Address{ + {Address: "test@example.com"}, + }, + } + result, err := checker.CheckDMARC(ctx, "raw message", headers, nil, nil, &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Result != "temperror" { + t.Fatalf("Expected result 'temperror', got %s", result.Result) + } + if result.Error != ctx.Err().Error() { + t.Fatalf("Expected error %q, got %q", ctx.Err().Error(), result.Error) + } +} + +func TestDMARCChecker_CheckDMARC_CanceledContextNoFrom(t *testing.T) { + checker := NewDMARCChecker() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := checker.CheckDMARC(ctx, "raw message", letters.Headers{}, nil, nil, &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Result != "none" { + t.Fatalf("Expected result 'none', got %s", result.Result) + } +} + func TestDMARCChecker_CheckDMARC_WithLookupDetails(t *testing.T) { checker := NewDMARCChecker() diff --git a/internal/auth/spf.go b/internal/auth/spf.go index d2ccba7..04b2551 100644 --- a/internal/auth/spf.go +++ b/internal/auth/spf.go @@ -52,8 +52,35 @@ func (s *spfChecker) CheckSPF(ctx context.Context, remoteIP net.IP, domainName, }, nil } - // RFC 7208 Section 2.4: Check MAIL FROM identity - result, mechanism, err := spf.CheckHost(remoteIP, domainName, from) + // RFC 7208 Section 2.4: Check MAIL FROM identity. + // CheckHost does not take a context. Run it aside so a canceled + // MESSAGE_PROCESSING_TIMEOUT can return without waiting on DNS. + type spfLookup struct { + result spf.Result + mechanism string + err error + } + lookup, ok := callWithContext(ctx, func() spfLookup { + result, mechanism, err := spf.CheckHost(remoteIP, domainName, from) + return spfLookup{result: result, mechanism: mechanism, err: err} + }) + if !ok { + metrics.TrackAuthError("spf", "timeout_error") + logger.Warn(). + Err(ctx.Err()). + Str("domain", domainName). + Str("remote_ip", remoteIP.String()). + Str("identity", from). + Msg("SPF check canceled") + return &domain.SPFResult{ + Result: "temperror", + Raw: fmt.Sprintf("SPF check error: %v", ctx.Err()), + Problem: fmt.Sprintf("DNS lookup or processing error: %v", ctx.Err()), + Sender: from, + IpAddress: remoteIP.String(), + }, nil + } + result, mechanism, err := lookup.result, lookup.mechanism, lookup.err if err != nil { // Determine error type for metrics errorType := "dns_error" @@ -71,7 +98,7 @@ func (s *spfChecker) CheckSPF(ctx context.Context, remoteIP net.IP, domainName, Str("identity", from). Msg("SPF check error") return &domain.SPFResult{ - Result: "temperror", + Result: spfResultOnError(result, err), Raw: fmt.Sprintf("SPF check error: %v", err), Problem: fmt.Sprintf("DNS lookup or processing error: %v", err), Sender: from, @@ -144,3 +171,20 @@ func (s *spfChecker) extractSPFExplanation(domain string, result spf.Result) str } return "" } + +// spfResultOnError maps a CheckHost result that arrived with a non-nil error. +// None, Permerror, and Temperror keep the library result (RFC 7208: no record +// or NXDOMAIN is none, a permanent evaluation error is permerror, and only a +// transient failure is temperror). A zero or unknown result falls back to the +// error text. +func spfResultOnError(result spf.Result, err error) string { + switch result { + case spf.None, spf.Neutral, spf.Pass, spf.Fail, spf.Softfail, spf.Temperror, spf.Permerror: + return result.String() + default: + if err != nil && strings.Contains(err.Error(), "permanent") { + return "permerror" + } + return "temperror" + } +} diff --git a/internal/auth/spf_test.go b/internal/auth/spf_test.go index 777561c..f4e66a6 100644 --- a/internal/auth/spf_test.go +++ b/internal/auth/spf_test.go @@ -2,8 +2,12 @@ package auth import ( "context" + "errors" "net" + "strings" "testing" + + "github.com/zaccone/spf" ) func TestExtractDomainFromEmail(t *testing.T) { @@ -229,3 +233,73 @@ func TestSPFChecker_CheckSPF_WithSubdomain(t *testing.T) { t.Error("Expected non-nil result") } } + +func TestSPFResultOnError(t *testing.T) { + cases := []struct { + name string + result spf.Result + err error + want string + }{ + {"none with permanent DNS error", spf.None, errors.New("permanent DNS error"), "none"}, + {"none when record missing", spf.None, errors.New("SPF record not found"), "none"}, + {"permerror", spf.Permerror, errors.New("too many SPF records"), "permerror"}, + {"temperror", spf.Temperror, errors.New("temporary DNS error"), "temperror"}, + {"zero result permanent text", 0, errors.New("permanent DNS error"), "permerror"}, + {"zero result other text", 0, errors.New("temporary DNS error"), "temperror"}, + {"unknown result permanent text", spf.Result(99), errors.New("permanent DNS error"), "permerror"}, + {"unknown result other text", spf.Result(99), errors.New("lookup failed"), "temperror"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := spfResultOnError(tc.result, tc.err); got != tc.want { + t.Fatalf("spfResultOnError(%v, %v) = %q, want %q", tc.result, tc.err, got, tc.want) + } + }) + } +} + +func TestSPFChecker_CheckSPF_NoSuchHost(t *testing.T) { + checker := NewSPFChecker() + remoteIP := net.ParseIP("192.168.1.1") + result, err := checker.CheckSPF(context.Background(), remoteIP, "no-such-host.invalid", "test@no-such-host.invalid", &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Result != "none" { + t.Fatalf("Expected result 'none' for NXDOMAIN, got %s (%s)", result.Result, result.Raw) + } +} + +func TestSPFChecker_CheckSPF_ContextCanceled(t *testing.T) { + checker := NewSPFChecker() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + remoteIP := net.ParseIP("192.168.1.1") + result, err := checker.CheckSPF(ctx, remoteIP, "example.com", "test@example.com", &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Result != "temperror" { + t.Fatalf("Expected result 'temperror', got %s", result.Result) + } + if !strings.Contains(result.Problem, ctx.Err().Error()) { + t.Fatalf("Expected problem to describe %v, got %q", ctx.Err(), result.Problem) + } +} + +func TestSPFChecker_CheckSPF_CanceledContextEmptyDomain(t *testing.T) { + checker := NewSPFChecker() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + remoteIP := net.ParseIP("192.168.1.1") + result, err := checker.CheckSPF(ctx, remoteIP, "", "test@", &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Result != "none" { + t.Fatalf("Expected result 'none', got %s", result.Result) + } +} diff --git a/internal/domain/email.go b/internal/domain/email.go index a8f27f6..9392f47 100644 --- a/internal/domain/email.go +++ b/internal/domain/email.go @@ -67,7 +67,7 @@ type DMARCResult struct { Policy string `json:"policy,omitempty"` Error string `json:"error,omitempty"` Domain string `json:"domain,omitempty"` // Domain where policy was found - Percentage int `json:"percentage,omitempty"` // pct= tag value (RFC 7489 Section 6.3) + Percentage int `json:"percentage"` // pct= tag value (RFC 7489 Section 6.3) SubdomainPolicy string `json:"subdomain_policy,omitempty"` // sp= tag value SPFAligned bool `json:"spf_aligned"` // Whether SPF identifier aligned DKIMAligned bool `json:"dkim_aligned"` // Whether DKIM identifier aligned diff --git a/internal/domain/email_test.go b/internal/domain/email_test.go index e4e33b3..df7ec9d 100644 --- a/internal/domain/email_test.go +++ b/internal/domain/email_test.go @@ -1,7 +1,9 @@ package domain import ( + "encoding/json" "net/mail" + "strings" "testing" "time" @@ -149,6 +151,16 @@ func TestAuthenticationResults_Structure(t *testing.T) { } } +func TestDMARCResult_PercentageZeroMarshals(t *testing.T) { + b, err := json.Marshal(DMARCResult{Result: "fail", Percentage: 0}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"percentage":0`) { + t.Fatalf("pct=0 omitted from JSON: %s", b) + } +} + func TestAttachment_Structure(t *testing.T) { attachment := Attachment{ Filename: "test.txt", diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index f2f5a0c..961cdcb 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -6,6 +6,7 @@ import ( "log" "net" "net/http" + "strconv" "strings" "sync" "time" @@ -428,7 +429,7 @@ func (m *metricsCollector) TrackDMARCResult(result, policy string) { // TrackWebhookRequest records webhook request metrics func (m *metricsCollector) TrackWebhookRequest(statusCode int, duration time.Duration) { - m.webhookRequests.WithLabelValues(http.StatusText(statusCode)).Inc() + m.webhookRequests.WithLabelValues(strconv.Itoa(statusCode)).Inc() m.webhookDuration.Observe(duration.Seconds()) } diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go index 1910ff3..62d1f7f 100644 --- a/internal/observability/metrics_test.go +++ b/internal/observability/metrics_test.go @@ -426,6 +426,34 @@ func TestMetricsCollector_TrackWebhookRequest(t *testing.T) { collector.TrackWebhookRequest(500, 200*time.Millisecond) } +func TestTrackWebhookRequest_StatusCodeLabelIsNumeric(t *testing.T) { + registry := prometheus.NewRegistry() + collector := NewMetricsCollectorWithRegistry(registry) + + collector.TrackWebhookRequest(500, time.Millisecond) + + families, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + for _, fam := range families { + if fam.GetName() != "webhook_requests_total" { + continue + } + for _, m := range fam.GetMetric() { + for _, lp := range m.GetLabel() { + if lp.GetName() == "status_code" && lp.GetValue() == "500" { + return + } + if lp.GetName() == "status_code" { + t.Fatalf("status_code label = %q, want 500", lp.GetValue()) + } + } + } + } + t.Fatal("webhook_requests_total not found") +} + func TestMetricsServer_Stop_NotStarted(t *testing.T) { config := MetricsConfig{ EnableMetrics: false, diff --git a/internal/processor/webhook.go b/internal/processor/webhook.go index 2527029..919a7e1 100644 --- a/internal/processor/webhook.go +++ b/internal/processor/webhook.go @@ -112,8 +112,14 @@ func NewWebhookSender(config WebhookConfig, logger observability.Logger, metrics Build() return &webhookSender{ - config: config, - client: &http.Client{Timeout: config.Timeout}, + config: config, + // ErrUseLastResponse keeps a 301/302 from being replayed as a GET with the body dropped. + client: &http.Client{ + Timeout: config.Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, logger: logger, metrics: metrics, retryPolicy: retryPolicy, @@ -177,8 +183,8 @@ func (w *webhookSender) SendWebhook(ctx context.Context, payload interface{}) er return httpErr } - // Check for HTTP error status codes - if httpResp.StatusCode >= 400 { + // Only 2xx means the webhook accepted the JSON. 3xx and above use the same error as 4xx/5xx. + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { if closeErr := httpResp.Body.Close(); closeErr != nil { w.logger.Warn(). Err(closeErr). diff --git a/internal/processor/webhook_test.go b/internal/processor/webhook_test.go index 8658e71..fc02a60 100644 --- a/internal/processor/webhook_test.go +++ b/internal/processor/webhook_test.go @@ -56,6 +56,35 @@ func TestWebhookSender_SendWebhook_Success(t *testing.T) { } } +func TestWebhookSender_SendWebhook_Redirect(t *testing.T) { + var landedMethod string + mux := http.NewServeMux() + mux.HandleFunc("/hook", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/landed", http.StatusFound) + }) + mux.HandleFunc("/landed", func(w http.ResponseWriter, r *http.Request) { + landedMethod = r.Method + w.WriteHeader(http.StatusOK) + }) + server := httptest.NewServer(mux) + defer server.Close() + + config := WebhookConfig{ + URL: server.URL + "/hook", + Timeout: 5 * time.Second, + MaxRetries: 0, + } + + sender := NewWebhookSender(config, &mockLogger{}, &mockMetricsCollector{}) + + err := sender.SendWebhook(context.Background(), map[string]interface{}{ + "test": "data", + }) + if err == nil { + t.Fatalf("redirect followed as %s was treated as delivery", landedMethod) + } +} + func TestWebhookSender_SendWebhook_WithAuth(t *testing.T) { handler := &recordingHandler{t: t} server := httptest.NewServer(handler) diff --git a/internal/smtp/backend.go b/internal/smtp/backend.go index 67e6167..d80d009 100644 --- a/internal/smtp/backend.go +++ b/internal/smtp/backend.go @@ -106,7 +106,10 @@ func (s *Session) Mail(from string, opts *smtp.MailOptions) error { // Track MAIL command s.metrics.TrackSessionCommandCount("MAIL") + // RFC 5321: MAIL starts a new transaction. A client may send it again + // to abort the one in progress, and the previous recipients must go. s.from = from + s.to = nil return nil } diff --git a/internal/smtp/backend_test.go b/internal/smtp/backend_test.go index 0ef9d39..87330ff 100644 --- a/internal/smtp/backend_test.go +++ b/internal/smtp/backend_test.go @@ -314,6 +314,31 @@ func TestSession_Rcpt(t *testing.T) { } } +func TestSession_MailClearsRecipients(t *testing.T) { + config := BackendConfig{MaxRecipients: 2} + backend := NewBackend(config, createMockMessageProcessor(), &mockMetricsCollector{}, &mockLogger{}) + session, err := backend.NewSession(nil) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + + if err := session.Mail("first@example.com", nil); err != nil { + t.Fatalf("first MAIL: %v", err) + } + if err := session.Rcpt("old@example.com", nil); err != nil { + t.Fatalf("first RCPT: %v", err) + } + if err := session.Mail("second@example.com", nil); err != nil { + t.Fatalf("second MAIL: %v", err) + } + if err := session.Rcpt("new1@example.com", nil); err != nil { + t.Fatalf("first recipient of the new transaction: %v", err) + } + if err := session.Rcpt("new2@example.com", nil); err != nil { + t.Fatalf("second recipient of the new transaction: %v", err) + } +} + func TestSession_Rcpt_MaxRecipients(t *testing.T) { config := BackendConfig{MaxRecipients: 2} messageProcessor := createMockMessageProcessor() diff --git a/internal/smtp/server.go b/internal/smtp/server.go index ff8ece3..85b87a6 100644 --- a/internal/smtp/server.go +++ b/internal/smtp/server.go @@ -13,6 +13,7 @@ import ( "github.com/emersion/go-smtp" "github.com/pires/go-proxyproto" + "golang.org/x/sync/errgroup" ) // ServerConfig holds SMTP server configuration @@ -37,6 +38,11 @@ type SMTPServer struct { logger observability.Logger trustedProxyCIDRs []*net.IPNet wg sync.WaitGroup + closeOnce sync.Once + lnMu sync.Mutex + lns []net.Listener + closing bool + closeErr error } // NewSMTPServer creates a new SMTP server @@ -61,7 +67,10 @@ func NewSMTPServer( server.Addr = config.ListenAddr server.Domain = config.ServerName server.MaxMessageBytes = config.MaxMessageSize - server.MaxRecipients = config.MaxRecipients + // go-smtp counts recipients on the connection and does not clear that + // count when MAIL starts a new transaction. Session.Rcpt enforces + // config.MaxRecipients on the transaction slice instead. + server.MaxRecipients = 0 server.AllowInsecureAuth = false // Disallow AUTH before TLS server.ReadTimeout = config.ReadTimeout server.WriteTimeout = config.WriteTimeout @@ -82,7 +91,7 @@ func NewSMTPServer( tlsServer.WriteTimeout = config.WriteTimeout tlsServer.ReadTimeout = config.ReadTimeout tlsServer.MaxMessageBytes = config.MaxMessageSize - tlsServer.MaxRecipients = config.MaxRecipients + tlsServer.MaxRecipients = 0 tlsServer.AllowInsecureAuth = false tlsServer.TLSConfig = &tls.Config{ ServerName: config.ServerName, @@ -101,30 +110,55 @@ func NewSMTPServer( }, nil } -// Start starts the SMTP server +// Start starts the SMTP server. +// With TLS configured, the plain and TLS listeners run together. If either +// returns, both are closed and Start returns the first real error. func (s *SMTPServer) Start() error { if s.tlsServer != nil { - s.wg.Add(1) - go func() { - defer s.wg.Done() - s.logger.Info(). - Str("address", s.config.ListenAddr). - Msg("Starting plain SMTP server") - if err := s.listenAndServeWithProxy(s.server, s.config.ListenAddr); err != nil { - s.logger.Error(). - Err(err). - Msg("Plain SMTP server stopped with error") - } - }() - s.logger.Info(). - Str("address", s.config.ListenAddrTLS). - Msg("Starting TLS SMTP server") - return s.listenAndServeWithProxy(s.tlsServer, s.config.ListenAddrTLS) + return s.startPlainAndTLS() } s.logger.Info(). Str("address", s.config.ListenAddr). Msg("Starting plain SMTP server") - return s.listenAndServeWithProxy(s.server, s.config.ListenAddr) + return serveResult(s.listenAndServeWithProxy(s.server, s.config.ListenAddr)) +} + +// startPlainAndTLS serves the plain and implicit-TLS listeners until one +// of them returns, then closes both so a failed bind cannot leave the +// sibling accepting connections. +func (s *SMTPServer) startPlainAndTLS() error { + g := new(errgroup.Group) + s.wg.Add(2) + + g.Go(func() error { + defer s.wg.Done() + defer func() { _ = s.closeServers() }() + s.logger.Info(). + Str("address", s.config.ListenAddr). + Msg("Starting plain SMTP server") + err := serveResult(s.listenAndServeWithProxy(s.server, s.config.ListenAddr)) + if err != nil { + s.logger.Error(). + Err(err). + Msg("Plain SMTP server stopped with error") + } + return err + }) + g.Go(func() error { + defer s.wg.Done() + defer func() { _ = s.closeServers() }() + s.logger.Info(). + Str("address", s.config.ListenAddrTLS). + Msg("Starting TLS SMTP server") + err := serveResult(s.listenAndServeWithProxy(s.tlsServer, s.config.ListenAddrTLS)) + if err != nil { + s.logger.Error(). + Err(err). + Msg("TLS SMTP server stopped with error") + } + return err + }) + return g.Wait() } // listenAndServeWithProxy starts the SMTP server with PROXY protocol support @@ -136,6 +170,10 @@ func (s *SMTPServer) listenAndServeWithProxy(server *smtp.Server, addr string) e proxyListener := &proxyproto.Listener{ Listener: ln, + // go-proxyproto v0.12 treats 0 as DefaultReadHeaderTimeout (10s). + // Conn.Write waits that long before the SMTP 220 when the client + // sends no PROXY header. + ReadHeaderTimeout: 200 * time.Millisecond, Policy: func(upstream net.Addr) (proxyproto.Policy, error) { if len(s.trustedProxyCIDRs) == 0 { // No trusted proxies configured — ignore PROXY headers so clients @@ -159,31 +197,84 @@ func (s *SMTPServer) listenAndServeWithProxy(server *smtp.Server, addr string) e }, } + if err := s.registerListener(proxyListener); err != nil { + _ = proxyListener.Close() + return err + } + defer func() { _ = proxyListener.Close() }() return server.Serve(proxyListener) } -// Stop stops the SMTP server and waits for all goroutines to finish -func (s *SMTPServer) Stop() error { - var closeErrors error - if s.server != nil { - if err := s.server.Close(); err != nil { - // Ignore "server already closed" errors for idempotency - if !strings.Contains(err.Error(), "already closed") { - closeErrors = errors.Join(closeErrors, err) - } - } +// registerListener records ln so closeServers can release it even when +// smtp.Server.Serve has not registered the listener yet. +func (s *SMTPServer) registerListener(ln net.Listener) error { + s.lnMu.Lock() + defer s.lnMu.Unlock() + if s.closing { + return net.ErrClosed } - if s.tlsServer != nil { - if err := s.tlsServer.Close(); err != nil { - // Ignore "server already closed" errors for idempotency - if !strings.Contains(err.Error(), "already closed") { - closeErrors = errors.Join(closeErrors, err) - } + s.lns = append(s.lns, ln) + return nil +} + +// closeServers closes both SMTP servers and any listeners still held here. +// Already-closed errors are ignored. Concurrent calls share one close. +func (s *SMTPServer) closeServers() error { + s.closeOnce.Do(func() { + s.lnMu.Lock() + s.closing = true + lns := make([]net.Listener, len(s.lns)) + copy(lns, s.lns) + s.lnMu.Unlock() + + s.closeErr = joinCloseErr(nil, closeSMTPServer(s.server)) + s.closeErr = joinCloseErr(s.closeErr, closeSMTPServer(s.tlsServer)) + for _, ln := range lns { + s.closeErr = joinCloseErr(s.closeErr, ln.Close()) } + }) + return s.closeErr +} + +func closeSMTPServer(server *smtp.Server) error { + if server == nil { + return nil + } + return server.Close() +} + +// serveResult drops errors that only mean the server was closed so a sibling +// shutdown does not hide the first real failure. +func serveResult(err error) error { + if ignorableCloseErr(err) { + return nil } - // Wait for the plain SMTP goroutine (if any) to exit cleanly + return err +} + +func joinCloseErr(dst, err error) error { + if ignorableCloseErr(err) { + return dst + } + return errors.Join(dst, err) +} + +func ignorableCloseErr(err error) bool { + if err == nil { + return true + } + if errors.Is(err, net.ErrClosed) || errors.Is(err, smtp.ErrServerClosed) { + return true + } + msg := err.Error() + return strings.Contains(msg, "already closed") || strings.Contains(msg, "use of closed network connection") +} + +// Stop stops the SMTP server and waits for all goroutines to finish +func (s *SMTPServer) Stop() error { + err := s.closeServers() s.wg.Wait() - return closeErrors + return err } // NewSMTPServerFromConfig creates a new SMTP server from application config diff --git a/internal/smtp/server_test.go b/internal/smtp/server_test.go index da5df3b..87eb5db 100644 --- a/internal/smtp/server_test.go +++ b/internal/smtp/server_test.go @@ -11,6 +11,7 @@ import ( "math/big" "net" "os" + "strings" "testing" "time" @@ -734,6 +735,107 @@ func TestSMTPServer_Start_TLSAndStop(t *testing.T) { } } +func TestSMTPServer_GreetingWithinOneSecond(t *testing.T) { + addr := getFreeAddr(t) + config := ServerConfig{ + ListenAddr: addr, + ServerName: "test", + } + s, err := NewSMTPServer(config, createMockBackend(), &mockLogger{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + errCh := make(chan error, 1) + go func() { errCh <- s.Start() }() + t.Cleanup(func() { _ = s.Stop() }) + + deadline := time.Now().Add(2 * time.Second) + var conn net.Conn + for { + conn, err = net.DialTimeout("tcp", addr, 50*time.Millisecond) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("dial %s: %v", addr, err) + } + time.Sleep(5 * time.Millisecond) + } + defer conn.Close() + + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("read greeting: %v", err) + } + if line := string(buf[:n]); !strings.HasPrefix(line, "220") { + t.Fatalf("greeting %q, want prefix 220", line) + } + + if err := s.Stop(); err != nil { + t.Fatalf("Stop() error: %v", err) + } + select { + case <-errCh: + case <-time.After(2 * time.Second): + t.Fatal("Start() did not return after Stop()") + } +} + +func TestSMTPServer_Start_PlainBindFailureReturns(t *testing.T) { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("occupy port: %v", err) + } + t.Cleanup(func() { _ = occupied.Close() }) + + certFile, keyFile := createTempCertFiles(t) + t.Cleanup(func() { + _ = os.Remove(certFile) + _ = os.Remove(keyFile) + }) + + tlsAddr := getFreeAddr(t) + config := ServerConfig{ + ListenAddr: occupied.Addr().String(), + ListenAddrTLS: tlsAddr, + ServerName: "test", + CertFile: certFile, + KeyFile: keyFile, + } + s, err := NewSMTPServer(config, createMockBackend(), &mockLogger{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Cleanup(func() { _ = s.Stop() }) + + errCh := make(chan error, 1) + go func() { errCh <- s.Start() }() + + var startErr error + select { + case startErr = <-errCh: + case <-time.After(2 * time.Second): + t.Fatal("Start() did not return within 2s") + } + if startErr == nil { + t.Fatal("expected Start() to return the plain bind error") + } + if !strings.Contains(startErr.Error(), "address already in use") { + t.Fatalf("Start() error = %v, want address already in use", startErr) + } + + ln, listenErr := net.Listen("tcp", tlsAddr) + if listenErr != nil { + t.Fatalf("TLS listener still bound after plain bind failure: %v", listenErr) + } + _ = ln.Close() +} + func TestSMTPServer_ProxyPolicy_NoTrustedProxies(t *testing.T) { addr := getFreeAddr(t) config := ServerConfig{ diff --git a/start.sh b/start.sh old mode 100644 new mode 100755 index 0242820..ac735a5 --- a/start.sh +++ b/start.sh @@ -1,112 +1,143 @@ #!/bin/sh +# Request a Let's Encrypt certificate when DNS credentials are present, then +# start SMTP. Issuance is optional: a missing credential or a failed request +# must not stop the server. It listens in plaintext until a certificate exists. +set -eu -ACME_SH_EMAIL=${ACME_SH_EMAIL:-dev@inboundparse.com} -DOMAIN=${MXDOMAIN:-mx.inboundparse.com} +DOMAIN=${MXDOMAIN:-${SERVER_NAME:-mx.inboundparse.com}} DNS_API=${DNS_API:-dns_cf} -CERT_FILE=${CERT_FILE:-/cert/cert.pem} -KEY_FILE=${KEY_FILE:-/cert/key.pem} +ACME_SH_EMAIL=${ACME_SH_EMAIL:-devnull@localhost} +CERT_DIR=/cert +CERT_PATH="${CERT_DIR}/cert.pem" +KEY_PATH="${CERT_DIR}/key.pem" +FULLCHAIN_PATH="${CERT_DIR}/fullchain.pem" -# Ensure cert home exists -mkdir -p /cert +mkdir -p "${CERT_DIR}" -# Validate required environment variables for Cloudflare DNS -if [ "${DNS_API}" = "dns_cf" ]; then - if [ -z "${CF_Token}" ] || [ -z "${CF_Zone_ID}" ]; then - echo "ERROR: CF_Token and CF_Zone_ID are required when using dns_cf API" - echo "Please set these environment variables in your .env file" - exit 1 - fi +# An empty file left by a failed install is not a certificate. +if [ -f "${CERT_PATH}" ] && [ ! -s "${CERT_PATH}" ]; then + rm -f "${CERT_PATH}" fi - -echo "Starting with configuration:" -echo " Domain: ${DOMAIN}" -echo " DNS API: ${DNS_API}" -echo " ACME Email: ${ACME_SH_EMAIL}" - -# acme.sh must be present in the image (installed at build time); never curl|sh at runtime -if ! command -v acme.sh >/dev/null 2>&1; then - echo 'ERROR: acme.sh is not installed or not on PATH' - exit 1 +if [ -f "${KEY_PATH}" ] && [ ! -s "${KEY_PATH}" ]; then + rm -f "${KEY_PATH}" +fi +if [ -f "${FULLCHAIN_PATH}" ] && [ ! -s "${FULLCHAIN_PATH}" ]; then + rm -f "${FULLCHAIN_PATH}" fi -# Set up account on first startup -if [ ! -f /cert/account.conf ]; then - echo "First startup - registering Let's Encrypt account for ${ACME_SH_EMAIL}" - # Use Let's Encrypt as default CA to avoid ZeroSSL EAB requirements - acme.sh --home /cert --set-default-ca --server letsencrypt - if ! acme.sh --home /cert --register-account -m "${ACME_SH_EMAIL}" --server letsencrypt; then - echo "ERROR: Failed to register ACME account with Let's Encrypt" - exit 1 +# Cloudflare is the default DNS provider. Other acme.sh DNS APIs bring their +# own credentials, so only dns_cf is checked here. +dns_ready() { + if [ "${DNS_API}" != "dns_cf" ]; then + return 0 fi - echo "Requesting certificates for ${DOMAIN} using ${DNS_API}" - if ! acme.sh --home /cert --issue -d "${DOMAIN}" --dns "${DNS_API}" --server letsencrypt --log; then - echo "ERROR: Failed to issue certificate for ${DOMAIN}" - echo "Check your DNS API credentials and domain configuration" - exit 1 + if [ -n "${CF_Token:-}" ] && [ -n "${CF_Zone_ID:-}" ]; then + return 0 fi + return 1 +} + +install_cert() { + acme.sh --home "${CERT_DIR}" --install-cert -d "${DOMAIN}" \ + --cert-file "${CERT_PATH}" \ + --key-file "${KEY_PATH}" \ + --fullchain-file "${FULLCHAIN_PATH}" +} + +if dns_ready; then + if ! command -v acme.sh >/dev/null 2>&1; then + echo "acme.sh is not installed. Skipping certificate request." + else + echo "Certificate configuration:" + echo " Domain: ${DOMAIN}" + echo " DNS API: ${DNS_API}" + echo " ACME Email: ${ACME_SH_EMAIL}" + + # Pin the CA to Let's Encrypt. ZeroSSL asks for an external account + # binding that this image does not have. + acme.sh --home "${CERT_DIR}" --set-default-ca --server letsencrypt || true + if [ ! -f "${CERT_DIR}/account.conf" ]; then + acme.sh --home "${CERT_DIR}" --register-account -m "${ACME_SH_EMAIL}" --server letsencrypt || true + fi + + if [ ! -s "${CERT_PATH}" ] || [ ! -s "${KEY_PATH}" ]; then + # A partial acme.sh directory makes the next --issue refuse to run. + if [ ! -s "${CERT_DIR}/${DOMAIN}_ecc/${DOMAIN}.cer" ]; then + rm -rf "${CERT_DIR}/${DOMAIN}_ecc" + fi + echo "Requesting certificate for ${DOMAIN}" + if acme.sh --home "${CERT_DIR}" --issue -d "${DOMAIN}" --dns "${DNS_API}" --server letsencrypt; then + if ! install_cert; then + echo "Certificate issued, but installing it failed." + fi + else + echo "Certificate request failed." + fi + else + echo "Certificate files found. Renewing if due." + acme.sh --home "${CERT_DIR}" --cron || true + install_cert || true + fi + fi +else + echo "Cloudflare DNS credentials are not set. Skipping certificate request." fi -# Check if certificates are already issued and install them -if acme.sh --home /cert --list | grep -q "${DOMAIN}"; then - echo "Certificate for ${DOMAIN} found, installing..." - if ! acme.sh --home /cert --install-cert -d "${DOMAIN}" \ - --cert-file /cert/cert.pem \ - --key-file /cert/key.pem \ - --fullchain-file /cert/fullchain.pem; then - echo "ERROR: Failed to install existing certificate for ${DOMAIN}" - echo "Starting server without SSL certificates..." +# A non-empty file is not necessarily a certificate. A partial install leaves +# garbage that tls.LoadX509KeyPair rejects, and that rejection exits the process. +cert_pair_ok() { + cert="$1" + key="$2" + if [ ! -s "${cert}" ] || [ ! -s "${key}" ]; then + return 1 + fi + if ! openssl x509 -in "${cert}" -noout >/dev/null 2>&1; then + return 1 fi + if ! openssl pkey -in "${key}" -noout >/dev/null 2>&1; then + return 1 + fi + cert_pub=$(openssl x509 -in "${cert}" -noout -pubkey 2>/dev/null) || return 1 + key_pub=$(openssl pkey -in "${key}" -pubout 2>/dev/null) || return 1 + [ "${cert_pub}" = "${key_pub}" ] +} + +# Prefer the chain file so SMTP clients can validate without fetching it. +# The image ENV may already point CERT_FILE at a path that does not exist; +# unset it unless the files on disk are a matching pair. +if ! command -v openssl >/dev/null 2>&1; then + echo "openssl is not installed. Starting SMTP without STARTTLS." + unset CERT_FILE + unset KEY_FILE +elif cert_pair_ok "${FULLCHAIN_PATH}" "${KEY_PATH}"; then + echo "Starting SMTP with STARTTLS." + export CERT_FILE="${FULLCHAIN_PATH}" + export KEY_FILE="${KEY_PATH}" +elif cert_pair_ok "${CERT_PATH}" "${KEY_PATH}"; then + echo "Starting SMTP with STARTTLS." + export CERT_FILE="${CERT_PATH}" + export KEY_FILE="${KEY_PATH}" else - echo "No certificate found for ${DOMAIN}, attempting to issue..." - if acme.sh --home /cert --issue -d "${DOMAIN}" --dns "${DNS_API}" --server letsencrypt --log; then - echo "Certificate issued successfully, installing..." - if ! acme.sh --home /cert --install-cert -d "${DOMAIN}" \ - --cert-file /cert/cert.pem \ - --key-file /cert/key.pem \ - --fullchain-file /cert/fullchain.pem; then - echo "ERROR: Failed to install newly issued certificate for ${DOMAIN}" - echo "Starting server without SSL certificates..." - fi + if [ -s "${FULLCHAIN_PATH}" ] || [ -s "${CERT_PATH}" ] || [ -s "${KEY_PATH}" ]; then + echo "Certificate files are not a usable pair. Starting SMTP without STARTTLS." else - echo "Failed to issue certificate for ${DOMAIN}" - echo "Check your DNS API credentials and domain configuration" - echo "Starting server without SSL certificates..." + echo "Starting SMTP without STARTTLS." fi + unset CERT_FILE + unset KEY_FILE fi -# Upgrade and list certificates -echo 'Upgrading and listing certificates' -acme.sh --home /cert --upgrade --auto-upgrade -acme.sh --home /cert --list - - -# Wait for certificates to be available (with timeout) -echo "Checking for certificates..." -CERT_WAIT_COUNT=0 -CERT_WAIT_MAX=30 # 5 minutes max wait +# The DNS token is only needed to issue the certificate. The mail process +# parses untrusted SMTP and must not keep a Cloudflare credential. +unset CF_Token CF_Zone_ID -while [ ! -f /cert/cert.pem ] || [ ! -f /cert/key.pem ]; do - if [ $CERT_WAIT_COUNT -ge $CERT_WAIT_MAX ]; then - echo "Timeout waiting for certificates. Starting server without SSL..." - break +# Volumes mounted at /cert are root-owned. Issue as root, then drop to the +# unprivileged user once that user can read the directory. +if [ "$(id -u)" -eq 0 ] && command -v su-exec >/dev/null 2>&1 && id inboundparse >/dev/null 2>&1; then + if chown -R inboundparse:inboundparse "${CERT_DIR}"; then + exec su-exec inboundparse:inboundparse /usr/local/bin/inboundparse fi - echo "Still waiting for certificates... (${CERT_WAIT_COUNT}/${CERT_WAIT_MAX})" - sleep 10 - CERT_WAIT_COUNT=$((CERT_WAIT_COUNT + 1)) -done - -if [ -f /cert/cert.pem ] && [ -f /cert/key.pem ]; then - echo "Certificates found, starting SMTP server with SSL..." -else - echo "Starting SMTP server without SSL certificates..." + echo "Could not change ownership of ${CERT_DIR}. Starting SMTP as root." fi -# Start SMTP server with environment variables -exec /usr/local/bin/inboundparse \ - -webhook="${WEBHOOK_URL}" \ - -enable-spf="${ENABLE_SPF}" \ - -enable-dkim="${ENABLE_DKIM}" \ - -enable-dmarc="${ENABLE_DMARC}" \ - -cert-file="${CERT_FILE}" \ - -key-file="${KEY_FILE}" \ - -verbose="${VERBOSE}" +exec /usr/local/bin/inboundparse From 7dcc9721dea60aa48d9d283b0c2ab84d8bff7c7b Mon Sep 17 00:00:00 2001 From: Francis Date: Sat, 7 Nov 2026 21:35:44 +0000 Subject: [PATCH 2/3] Replace the deprecated PROXY policy hook and use the DMARC helper. golangci-lint v2.15 rejects Listener.Policy, and the organizational-domain helper was unused after alignment moved to the public suffix list. Co-authored-by: Cursor --- internal/auth/dmarc.go | 8 ++------ internal/smtp/server.go | 6 +++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/auth/dmarc.go b/internal/auth/dmarc.go index 89e200c..8f78d39 100644 --- a/internal/auth/dmarc.go +++ b/internal/auth/dmarc.go @@ -434,12 +434,8 @@ func (d *dmarcChecker) isOrganizationalDomainMatch(domain1, domain2 string) bool return true } - orgDomain1, err1 := organizationalDomain(domain1) - orgDomain2, err2 := organizationalDomain(domain2) - if err1 != nil || err2 != nil { - return false - } - + orgDomain1 := d.getOrganizationalDomain(domain1) + orgDomain2 := d.getOrganizationalDomain(domain2) return orgDomain1 == orgDomain2 && orgDomain1 != "" } diff --git a/internal/smtp/server.go b/internal/smtp/server.go index 85b87a6..20f1b6a 100644 --- a/internal/smtp/server.go +++ b/internal/smtp/server.go @@ -174,12 +174,16 @@ func (s *SMTPServer) listenAndServeWithProxy(server *smtp.Server, addr string) e // Conn.Write waits that long before the SMTP 220 when the client // sends no PROXY header. ReadHeaderTimeout: 200 * time.Millisecond, - Policy: func(upstream net.Addr) (proxyproto.Policy, error) { + ConnPolicy: func(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { if len(s.trustedProxyCIDRs) == 0 { // No trusted proxies configured — ignore PROXY headers so clients // cannot spoof source IPs (SPF/DMARC rely on the real TCP peer). return proxyproto.IGNORE, nil } + upstream := opts.Upstream + if upstream == nil { + return proxyproto.IGNORE, nil + } host, _, err := net.SplitHostPort(upstream.String()) if err != nil { return proxyproto.IGNORE, nil From f4d0c1253171645b213839aa8a21c07ced3dc5ce Mon Sep 17 00:00:00 2001 From: Francis Date: Sat, 7 Nov 2026 21:37:57 +0000 Subject: [PATCH 3/3] Patch the Go toolchain and x/net reported by govulncheck. Go 1.26.3 and golang.org/x/net v0.54.0 are in the vulnerable range for GO-2026-6809 and GO-2026-6617. Co-authored-by: Cursor --- Dockerfile | 4 ++-- go.mod | 16 ++++++++-------- go.sum | 28 ++++++++++++++-------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index 23c66f7..8b39f2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Build stage -# Official images publish 1.26.3-alpine / 1.26.3-alpine3.22+ — not alpine3.21. -FROM golang:1.26.3-alpine AS builder +# Matches the patch in go.mod. This tag is Alpine 3.24; the runtime stage stays on 3.21. +FROM golang:1.26.10-alpine AS builder WORKDIR /app diff --git a/go.mod b/go.mod index 7afcd2a..3064ab4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module inboundparse -go 1.26.3 +go 1.26.10 require ( github.com/emersion/go-msgauth v0.7.0 @@ -15,8 +15,8 @@ require ( github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.11.1 github.com/zaccone/spf v0.0.0-20170817004109-76747b8658d9 - golang.org/x/net v0.54.0 - golang.org/x/sync v0.20.0 + golang.org/x/net v0.60.0 + golang.org/x/sync v0.23.0 ) require ( @@ -35,11 +35,11 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.49.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6ac03db..b4a5371 100644 --- a/go.sum +++ b/go.sum @@ -73,20 +73,20 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=