Files
felhom-controller/controller/internal/mailrelay/mailrelay_test.go
T
admin a405505e81 v0.89.0: app-email plaintext-only listener (:2526) + split-From mapping
Gap 1: third shim listener :2526, plaintext, does NOT advertise STARTTLS (TLSConfig
nil) — for opportunistic-STARTTLS clients with no cert-skip (cal.com, nextcloud).
Gap 2: SMTPMapping tls_mode (picks port 2525/2526/2465) + from_domain_var (split
local-part + domain for nextcloud's MAIL_FROM_ADDRESS/MAIL_DOMAIN). Default keeps
existing apps on 2525. Hub untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:13:40 +02:00

358 lines
10 KiB
Go

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())
}
}