package mailrelay import ( "bytes" "context" "crypto/tls" "io" "log" netsmtp "net/smtp" "sync" "testing" "github.com/emersion/go-smtp" ) // fakeForwarder records every Forward call so tests can assert call count + byte // fidelity, and returns a programmable status/err. type fakeForwarder struct { mu sync.Mutex calls int lastRaw []byte lastFrom string lastTo []string status int body string err error } func (f *fakeForwarder) Forward(_ context.Context, raw []byte, from string, to []string) (int, string, error) { f.mu.Lock() defer f.mu.Unlock() f.calls++ f.lastRaw = append([]byte(nil), raw...) f.lastFrom = from f.lastTo = append([]string(nil), to...) return f.status, f.body, f.err } func (f *fakeForwarder) callCount() int { f.mu.Lock() defer f.mu.Unlock() return f.calls } func quietLogger() *log.Logger { return log.New(io.Discard, "", 0) } func rawMsg(from string) []byte { return []byte("From: " + from + "\r\nTo: recipient@example.com\r\nSubject: test\r\n\r\nhello world\r\n") } func newTestServer(t *testing.T, allowed []string, fwd Forwarder) *Server { t.Helper() s, err := New(Options{ Policy: NewPolicy(allowed), Forwarder: fwd, Logger: quietLogger(), }) if err != nil { t.Fatalf("New: %v", err) } return s } // dataResult drives a session.Data with the given raw message and returns the reply. func dataResult(srv *Server, from string, raw []byte) error { sess := &session{srv: srv, from: from, to: []string{"recipient@example.com"}} return sess.Data(bytes.NewReader(raw)) } // --- §7 A: happy path — passthrough, NOT parse (§10 byte-equality companion) --- func TestData_HappyPath_ForwardsRawBytesUnchanged(t *testing.T) { fwd := &fakeForwarder{status: 200} srv := newTestServer(t, []string{"felhom.eu"}, fwd) raw := rawMsg("vaultwarden@felhom.eu") if err := dataResult(srv, "vaultwarden@felhom.eu", raw); err != nil { t.Fatalf("expected success (250), got %v", err) } if fwd.callCount() != 1 { t.Fatalf("expected exactly 1 forward, got %d", fwd.callCount()) } // Passthrough: the bytes handed to the hub must be byte-for-byte the input — no // MIME parse/re-encode (the parse path silently drops inline CID images; §10). if !bytes.Equal(fwd.lastRaw, raw) { t.Fatalf("forwarded bytes differ from input:\n got=%q\nwant=%q", fwd.lastRaw, raw) } if fwd.lastFrom != "vaultwarden@felhom.eu" { t.Fatalf("envelope from = %q", fwd.lastFrom) } } // --- §7 B: From outside the allowlist is refused BEFORE any hub call --- func TestData_FromOutsideAllowlist_Rejected_NoForward(t *testing.T) { fwd := &fakeForwarder{status: 200} srv := newTestServer(t, []string{"felhom.eu"}, fwd) err := dataResult(srv, "vaultwarden@felhom.eu", rawMsg("evil@notfelhom.example")) smtpErr, ok := err.(*smtp.SMTPError) if !ok { t.Fatalf("expected *smtp.SMTPError, got %T (%v)", err, err) } if smtpErr.Code != 550 { t.Fatalf("expected 550, got %d", smtpErr.Code) } // The hub/Resend must NEVER be dialed for a doomed message. if fwd.callCount() != 0 { t.Fatalf("forwarder must not be called on From-reject, got %d calls", fwd.callCount()) } } // Companion red-proof for §7 B: REMOVE the gate (allow the bad domain) and the same // message reaches the forwarder. Proves the allowlist check is load-bearing. func TestData_FromReject_CompanionProof_GateRemovedLetsItThrough(t *testing.T) { fwd := &fakeForwarder{status: 200} srv := newTestServer(t, []string{"notfelhom.example"}, fwd) // gate effectively removed if err := dataResult(srv, "x@notfelhom.example", rawMsg("evil@notfelhom.example")); err != nil { t.Fatalf("with the domain allowed the message should pass, got %v", err) } if fwd.callCount() != 1 { t.Fatalf("companion: expected the wrong-From to reach the forwarder once, got %d", fwd.callCount()) } } func TestData_UnparseableFrom_Rejected(t *testing.T) { fwd := &fakeForwarder{status: 200} srv := newTestServer(t, []string{"felhom.eu"}, fwd) // No From header at all → fail-closed reject, no forward. noFrom := []byte("To: recipient@example.com\r\nSubject: test\r\n\r\nbody\r\n") err := dataResult(srv, "x@felhom.eu", noFrom) if smtpErr, ok := err.(*smtp.SMTPError); !ok || smtpErr.Code != 550 { t.Fatalf("expected 550 for missing From, got %v", err) } if fwd.callCount() != 0 { t.Fatalf("forwarder must not be called when From is unparseable, got %d", fwd.callCount()) } } // --- §7 D: transient hub outage — single-shot, surfaces transient error --- func TestData_HubUnreachable_SingleShot_Transient(t *testing.T) { fwd := &fakeForwarder{err: io.ErrUnexpectedEOF} // simulate hub unreachable srv := newTestServer(t, []string{"felhom.eu"}, fwd) err := dataResult(srv, "vaultwarden@felhom.eu", rawMsg("vaultwarden@felhom.eu")) smtpErr, ok := err.(*smtp.SMTPError) if !ok { t.Fatalf("expected *smtp.SMTPError, got %T", err) } if smtpErr.Code/100 != 4 { t.Fatalf("expected transient 4xx, got %d", smtpErr.Code) } // §10 single-shot companion: EXACTLY one attempt — no retry loop. if fwd.callCount() != 1 { t.Fatalf("v1 forward must be single-shot; got %d attempts", fwd.callCount()) } } // --- status mapping (spike §6) --- func TestMapStatusToSMTP(t *testing.T) { cases := []struct { status int wantNil bool wantCode int }{ {200, true, 0}, {202, true, 0}, {403, false, 451}, // hub From-reject backstop → transient per the prompt mapping {429, false, 451}, // rate limit → retry later {451, false, 451}, {500, false, 554}, {503, false, 554}, } for _, c := range cases { got := mapStatusToSMTP(c.status, "") if c.wantNil { if got != nil { t.Errorf("status %d: expected nil, got %v", c.status, got) } continue } smtpErr, ok := got.(*smtp.SMTPError) if !ok { t.Errorf("status %d: expected *smtp.SMTPError, got %T", c.status, got) continue } if smtpErr.Code != c.wantCode { t.Errorf("status %d: expected SMTP %d, got %d", c.status, c.wantCode, smtpErr.Code) } } } func TestSanitizeReason(t *testing.T) { if got := sanitizeReason("line one\r\nleaked@addr.example"); got != "line one" { t.Fatalf("sanitizeReason should keep only the first line, got %q", got) } long := make([]byte, 500) for i := range long { long[i] = 'a' } if got := sanitizeReason(string(long)); len(got) != 200 { t.Fatalf("sanitizeReason should cap at 200, got len %d", len(got)) } } // --- end-to-end over a real socket (Q1/Q2: real SMTP, STARTTLS, no auth) --- func TestLifecycle_StartStopIdempotent(t *testing.T) { fwd := &fakeForwarder{status: 200} lc := NewLifecycle(Options{ PlainAddr: "127.0.0.1:0", TLSAddr: "127.0.0.1:0", PlainNoTLSAddr: "127.0.0.1:0", Policy: NewPolicy([]string{"felhom.eu"}), Forwarder: fwd, Logger: quietLogger(), }) if lc.Running() { t.Fatal("should not be running before Apply") } if err := lc.Apply(true); err != nil { t.Fatalf("Apply(true): %v", err) } if !lc.Running() { t.Fatal("should be running after Apply(true)") } // idempotent enable if err := lc.Apply(true); err != nil { t.Fatalf("second Apply(true): %v", err) } if err := lc.Apply(false); err != nil { t.Fatalf("Apply(false): %v", err) } if lc.Running() { t.Fatal("should be stopped after Apply(false)") } // re-enable after disable works (uses a fresh server) if err := lc.Apply(true); err != nil { t.Fatalf("re-enable: %v", err) } if !lc.Running() { t.Fatal("should run after re-enable") } lc.Close() if lc.Running() { t.Fatal("Close should stop the shim") } // Apply(true) after Close is refused (no resurrection on shutdown) if err := lc.Apply(true); err != nil { t.Fatalf("Apply after Close should be a no-op nil, got %v", err) } if lc.Running() { t.Fatal("must stay stopped after Close") } } // Gap-1: the :2526 listener must NOT advertise STARTTLS (TLSConfig nil), while :2525 must. // Proven both at the config level and over a real EHLO. func TestServer_PlainNoTLSListener_NoSTARTTLS(t *testing.T) { fwd := &fakeForwarder{status: 200} s, err := New(Options{ PlainAddr: "127.0.0.1:0", TLSAddr: "127.0.0.1:0", PlainNoTLSAddr: "127.0.0.1:0", Policy: NewPolicy([]string{"felhom.eu"}), Forwarder: fwd, Logger: quietLogger(), }) if err != nil { t.Fatalf("New: %v", err) } // Config-level: STARTTLS is advertised iff TLSConfig != nil. if s.plainNoTLSSrv.TLSConfig != nil { t.Fatal(":2526 server must have TLSConfig==nil (so STARTTLS is not advertised)") } if s.plainSrv.TLSConfig == nil { t.Fatal(":2525 server must keep TLSConfig (STARTTLS advertised)") } if err := s.Start(); err != nil { t.Fatalf("Start: %v", err) } defer s.Close() // Real EHLO: :2526 must NOT offer STARTTLS; :2525 must. advertises := func(addr string) bool { c, err := netsmtp.Dial(addr) if err != nil { t.Fatalf("dial %s: %v", addr, err) } defer c.Close() if err := c.Hello("test.local"); err != nil { t.Fatalf("EHLO %s: %v", addr, err) } ok, _ := c.Extension("STARTTLS") return ok } if advertises(s.PlainNoTLSAddr()) { t.Error(":2526 must NOT advertise STARTTLS over EHLO") } if !advertises(s.PlainAddr()) { t.Error(":2525 must advertise STARTTLS over EHLO") } } func TestServer_EndToEnd_STARTTLS(t *testing.T) { fwd := &fakeForwarder{status: 200} s, err := New(Options{ PlainAddr: "127.0.0.1:0", TLSAddr: "127.0.0.1:0", PlainNoTLSAddr: "127.0.0.1:0", Policy: NewPolicy([]string{"felhom.eu"}), Forwarder: fwd, Logger: quietLogger(), }) if err != nil { t.Fatalf("New: %v", err) } if err := s.Start(); err != nil { t.Fatalf("Start: %v", err) } defer s.Close() c, err := netsmtp.Dial(s.PlainAddr()) if err != nil { t.Fatalf("dial: %v", err) } defer c.Close() if err := c.StartTLS(&tls.Config{InsecureSkipVerify: true}); err != nil { t.Fatalf("starttls: %v", err) } if err := c.Mail("vaultwarden@felhom.eu"); err != nil { t.Fatalf("mail: %v", err) } if err := c.Rcpt("recipient@example.com"); err != nil { t.Fatalf("rcpt: %v", err) } wc, err := c.Data() if err != nil { t.Fatalf("data: %v", err) } msg := rawMsg("vaultwarden@felhom.eu") if _, err := wc.Write(msg); err != nil { t.Fatalf("write: %v", err) } if err := wc.Close(); err != nil { t.Fatalf("close data (expected 250): %v", err) } _ = c.Quit() if fwd.callCount() != 1 { t.Fatalf("expected 1 forward through the real listener, got %d", fwd.callCount()) } }