From 6ae35243e3f7a25852483e0e8f69022a444e51cb Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 4 Oct 2026 08:17:55 +0300 Subject: [PATCH] test: share the fake IMAP and SMTP servers across packages internal/imaptest and internal/smtptest replace three copies of the go-imap memory-server setup and two fake SMTP relays (Go can't import _test.go helpers across packages). Also drops a dead leftover in cmd_gaps_test, reads captured stdout with io.ReadAll and finds a free port with net.Listen. Codecov ignores the two helper packages. --- cmd/eraser/cli_test.go | 98 +------------------- cmd/eraser/cmd_gaps_test.go | 40 ++++---- cmd/eraser/cmd_ops_test.go | 55 ++--------- cmd/eraser/cmd_send_flow_test.go | 29 +++--- codecov.yml | 4 +- internal/imaptest/imaptest.go | 142 +++++++++++++++++++++++++++++ internal/inbox/imap_server_test.go | 141 +++++----------------------- internal/smtptest/smtptest.go | 119 ++++++++++++++++++++++++ internal/web/handlers_more_test.go | 19 ++-- internal/web/handlers_send_test.go | 129 ++++---------------------- internal/web/inbox_scan_test.go | 45 ++------- 11 files changed, 363 insertions(+), 458 deletions(-) create mode 100644 internal/imaptest/imaptest.go create mode 100644 internal/smtptest/smtptest.go diff --git a/cmd/eraser/cli_test.go b/cmd/eraser/cli_test.go index 726859a..8c95afa 100644 --- a/cmd/eraser/cli_test.go +++ b/cmd/eraser/cli_test.go @@ -1,15 +1,14 @@ package main import ( - "bufio" "io" - "net" "os" "path/filepath" "strconv" "strings" - "sync" "testing" + + "github.com/drumandbytes/eraser/internal/smtptest" ) // cliEnv is an isolated install: a temp HOME, a config file and a small @@ -115,95 +114,8 @@ func mustContain(t *testing.T, out string, wants ...string) { } } -// relay is a plaintext SMTP server for send tests; recipients in reject get -// a 550. -type relay struct { - host string - port int - mu sync.Mutex - got []string - reject map[string]bool - authFail bool -} - -func newRelay(t *testing.T) *relay { - t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = ln.Close() }) - host, portStr, _ := net.SplitHostPort(ln.Addr().String()) - port, _ := strconv.Atoi(portStr) - rl := &relay{host: host, port: port, reject: map[string]bool{}} - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go rl.serve(conn) - } - }() - return rl -} - -func (rl *relay) serve(conn net.Conn) { - defer func() { _ = conn.Close() }() - r := bufio.NewReader(conn) - reply := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) } - reply("220 test") - for { - line, err := r.ReadString('\n') - if err != nil { - return - } - cmd := strings.TrimSpace(line) - rl.mu.Lock() - authFail := rl.authFail - rl.mu.Unlock() - switch up := strings.ToUpper(cmd); { - case strings.HasPrefix(up, "MAIL") && authFail: - reply("535 5.7.8 authentication failed") - case strings.HasPrefix(up, "RCPT"): - to := strings.Trim(cmd[strings.Index(cmd, ":")+1:], "<> ") - rl.mu.Lock() - rejected := rl.reject[to] - if !rejected { - rl.got = append(rl.got, to) - } - rl.mu.Unlock() - if rejected { - reply("550 no such user") - } else { - reply("250 ok") - } - case strings.HasPrefix(up, "DATA"): - reply("354 go ahead") - for { - l, err := r.ReadString('\n') - if err != nil || l == ".\r\n" { - break - } - } - reply("250 queued") - case strings.HasPrefix(up, "QUIT"): - reply("221 bye") - return - default: - reply("250 ok") - } - } -} - -func (rl *relay) recipients() []string { - rl.mu.Lock() - defer rl.mu.Unlock() - return append([]string(nil), rl.got...) -} - // relayConfig is a valid single-profile config sending through rl. -func relayConfig(rl *relay, extra string) string { +func relayConfig(rl *smtptest.Relay, extra string) string { return `profiles: - id: default first_name: Jane @@ -213,8 +125,8 @@ func relayConfig(rl *relay, extra string) string { email: from: jane@example.com smtp: - host: ` + rl.host + ` - port: ` + strconv.Itoa(rl.port) + ` + host: ` + rl.Host + ` + port: ` + strconv.Itoa(rl.Port) + ` use_tls: false options: template: gdpr diff --git a/cmd/eraser/cmd_gaps_test.go b/cmd/eraser/cmd_gaps_test.go index ab2ca4f..892cba0 100644 --- a/cmd/eraser/cmd_gaps_test.go +++ b/cmd/eraser/cmd_gaps_test.go @@ -3,6 +3,8 @@ package main import ( "errors" "fmt" + "io" + "net" "net/http" "net/http/httptest" "os" @@ -20,6 +22,7 @@ import ( "github.com/drumandbytes/eraser/internal/history" "github.com/drumandbytes/eraser/internal/inbox" "github.com/drumandbytes/eraser/internal/schedule" + "github.com/drumandbytes/eraser/internal/smtptest" "github.com/drumandbytes/eraser/internal/web" ) @@ -44,9 +47,7 @@ func TestCommandSetupErrors(t *testing.T) { } } - rl := newRelay(t) - two := newCLIEnv(t, relayConfig(rl, "")+" - id: x\n") - _ = two + rl := smtptest.Start(t) multi := newCLIEnv(t, manualConfig) for _, args := range [][]string{{"send"}, {"export", "-o", "x"}, {"fill", "--url", "x"}, {"confirm", "--url", "x"}, {"draft", "acme"}, {"mark-sent", "acme"}} { if _, err := multi.run(t, "", args...); err == nil || !strings.Contains(err.Error(), "multiple profiles") { @@ -71,7 +72,7 @@ func TestCommandSetupErrors(t *testing.T) { } func TestSendOutputBranches(t *testing.T) { - rl := newRelay(t) + rl := smtptest.Start(t) cfg := strings.Replace(relayConfig(rl, ""), "profiles:\n", "profiles:\n - id: spouse\n first_name: John\n last_name: Doe\n email: john@example.com\n", 1) e := newCLIEnv(t, cfg) if err := os.WriteFile(e.brokersPath, []byte(testBrokersYAML+" - {id: formonly, name: Form Only, opt_out_url: https://form.example/optout, region: eu}\n"), 0o600); err != nil { @@ -227,7 +228,7 @@ func TestAutoLoopAndModes(t *testing.T) { waits := 0 waitForNextCycle = func(time.Duration) bool { waits++; return waits >= 2 } - rl := newRelay(t) + rl := smtptest.Start(t) e := newCLIEnv(t, relayConfig(rl, "")) t.Setenv("ERASER_AUTO_MODE", "") t.Setenv("INVOCATION_ID", "") @@ -275,7 +276,7 @@ func TestAutoLoopAndModes(t *testing.T) { } func TestAutoCycleFailures(t *testing.T) { - rl := newRelay(t) + rl := smtptest.Start(t) blocker := filepath.Join(t.TempDir(), "file") _ = os.WriteFile(blocker, nil, 0o600) @@ -332,7 +333,7 @@ func TestMonitorExtras(t *testing.T) { mustContain(t, out, "Monitoring 2 configured inboxes", "Archived 1 emails") for typ, icon := range map[inbox.ResponseType]string{inbox.ResponsePending: "⏳", inbox.ResponseRejected: "❌", inbox.ResponseUnknown: "❓", inbox.ResponseConfirmationRequired: "🔗"} { - got, _ := captureStdout(t, func() { + got := captureStdout(t, func() { printClassifiedResponse(inbox.ClassifiedResponse{Type: typ, Email: &inbox.Email{BrokerName: "X"}, ConfirmURL: "https://x.example/c", NeedsReview: true}) }) if !strings.Contains(got, icon) { @@ -341,7 +342,7 @@ func TestMonitorExtras(t *testing.T) { } } -func captureStdout(t *testing.T, fn func()) (string, error) { +func captureStdout(t *testing.T, fn func()) string { t.Helper() r, w, err := os.Pipe() if err != nil { @@ -352,16 +353,8 @@ func captureStdout(t *testing.T, fn func()) (string, error) { fn() os.Stdout = orig _ = w.Close() - var b strings.Builder - buf := make([]byte, 4096) - for { - n, err := r.Read(buf) - b.Write(buf[:n]) - if err != nil { - break - } - } - return b.String(), nil + out, _ := io.ReadAll(r) + return string(out) } // serve runs until Ctrl+C, then shuts down cleanly. @@ -407,9 +400,10 @@ func TestServeUntilInterrupted(t *testing.T) { func freeTCPPort(t *testing.T) int { t.Helper() - srv := httptest.NewServer(http.NotFoundHandler()) - port := srv.Listener.Addr().(interface{ String() string }).String() - srv.Close() - n, _ := strconv.Atoi(port[strings.LastIndex(port, ":")+1:]) - return n + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + return ln.Addr().(*net.TCPAddr).Port } diff --git a/cmd/eraser/cmd_ops_test.go b/cmd/eraser/cmd_ops_test.go index b1fb1b7..57b7dfe 100644 --- a/cmd/eraser/cmd_ops_test.go +++ b/cmd/eraser/cmd_ops_test.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "net" "net/http" "net/http/httptest" "os" @@ -13,58 +12,24 @@ import ( "testing" "time" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/backend" - "github.com/emersion/go-imap/backend/memory" - "github.com/emersion/go-imap/server" - "github.com/drumandbytes/eraser/internal/broker" "github.com/drumandbytes/eraser/internal/history" + "github.com/drumandbytes/eraser/internal/imaptest" "github.com/drumandbytes/eraser/internal/schedule" + "github.com/drumandbytes/eraser/internal/smtptest" ) -// imapInbox serves go-imap's in-memory backend over STARTTLS on loopback -// (TLS verification is skipped for loopback) and returns the inbox: YAML. +// imapInbox serves INBOX with the given (from, subject, body) messages and +// returns its inbox: YAML. func imapInbox(t *testing.T, mails ...[3]string) string { t.Helper() - be := memory.New() - u, _ := be.Login(nil, "username", "password") - mb, _ := u.GetMailbox("INBOX") - for i, m := range mails { - msg := fmt.Sprintf("From: %s\r\nTo: jane@example.com\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%d@t>\r\nContent-Type: text/plain\r\n\r\n%s", - m[0], m[1], time.Now().Format(time.RFC1123Z), i, m[2]) - if err := mb.(*memory.Mailbox).CreateMessage(nil, time.Now(), strings.NewReader(msg)); err != nil { - t.Fatal(err) - } - } - ts := httptest.NewTLSServer(http.NotFoundHandler()) - t.Cleanup(ts.Close) - srv := server.New(anyUser{be}) - srv.TLSConfig = ts.TLS.Clone() - srv.ErrorLog = quietLog{} - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) + srv := imaptest.Start(t, false) + for _, m := range mails { + srv.Deliver(t, "INBOX", m[0], m[1], m[2]) } - go func() { _ = srv.Serve(ln) }() - t.Cleanup(func() { _ = srv.Close() }) - _, port, _ := net.SplitHostPort(ln.Addr().String()) - return "inbox:\n enabled: true\n server: 127.0.0.1\n port: " + port + "\n email: username\n password: password\n" + return "inbox:\n enabled: true\n server: 127.0.0.1\n port: " + strconv.Itoa(srv.Inbox.Port) + "\n email: username\n password: password\n" } -// anyUser lets any login name in with the memory backend's password, so -// two profiles' inboxes can have different addresses. -type anyUser struct{ *memory.Backend } - -func (b anyUser) Login(ci *imap.ConnInfo, _, pass string) (backend.User, error) { - return b.Backend.Login(ci, "username", pass) -} - -type quietLog struct{} - -func (quietLog) Printf(string, ...interface{}) {} -func (quietLog) Println(...interface{}) {} - func addSent(t *testing.T, e *cliEnv, profile, brokerID, email string) { t.Helper() store, err := history.NewStore(history.DBPathFor(e.cfgPath)) @@ -79,7 +44,7 @@ func addSent(t *testing.T, e *cliEnv, profile, brokerID, email string) { } func TestMonitor(t *testing.T) { - rl := newRelay(t) + rl := smtptest.Start(t) inboxYAML := imapInbox(t, [3]string{"privacy@acme.example", "Re: Erasure request", "We have deleted your personal data from our systems."}, [3]string{"dpo@globex.example", "Your request", "Please complete our opt-out form at https://globex.example/optout"}, @@ -408,7 +373,7 @@ func TestScheduleStatusAndInstall(t *testing.T) { } func TestAutoOnce(t *testing.T) { - rl := newRelay(t) + rl := smtptest.Start(t) e := newCLIEnv(t, relayConfig(rl, imapInbox(t, [3]string{"privacy@acme.example", "Re: request", "We deleted your data."}))) t.Setenv("XPC_SERVICE_NAME", "") t.Setenv("INVOCATION_ID", "") diff --git a/cmd/eraser/cmd_send_flow_test.go b/cmd/eraser/cmd_send_flow_test.go index 9b4093c..57f3047 100644 --- a/cmd/eraser/cmd_send_flow_test.go +++ b/cmd/eraser/cmd_send_flow_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/drumandbytes/eraser/internal/history" + "github.com/drumandbytes/eraser/internal/smtptest" ) func stats(t *testing.T, e *cliEnv, profile string) (total, sent, failed int) { @@ -21,7 +22,7 @@ func stats(t *testing.T, e *cliEnv, profile string) (total, sent, failed int) { } func TestSendDryRunAndReal(t *testing.T) { - rl := newRelay(t) + rl := smtptest.Start(t) e := newCLIEnv(t, relayConfig(rl, "")) out, err := e.run(t, "", "send", "--dry-run") @@ -29,22 +30,22 @@ func TestSendDryRunAndReal(t *testing.T) { t.Fatal(err) } mustContain(t, out, "DRY RUN MODE", "Would send: GDPR Data Erasure Request", "No email on file - see notes", "Dry run complete: 2 brokers") - if len(rl.recipients()) != 0 { + if len(rl.Recipients()) != 0 { t.Fatal("dry run sent mail") } - rl.reject["dpo@globex.example"] = true + rl.Reject("dpo@globex.example", true) out, err = e.run(t, "", "send") if err != nil { t.Fatal(err) } mustContain(t, out, "Sent successfully", "Failed:", "Complete: 1 sent, 1 failed") - if got := rl.recipients(); len(got) != 1 || got[0] != "privacy@acme.example" { + if got := rl.Recipients(); len(got) != 1 || got[0] != "privacy@acme.example" { t.Errorf("relay got %v", got) } // Only the failed broker is retried. - delete(rl.reject, "dpo@globex.example") + rl.Reject("dpo@globex.example", false) out, _ = e.run(t, "", "send", "--status", "failed") mustContain(t, out, "Processing 1 brokers", "Complete: 1 sent, 0 failed") if _, sent, failed := stats(t, e, "default"); sent != 2 || failed != 1 { @@ -58,7 +59,7 @@ func TestSendDryRunAndReal(t *testing.T) { } func TestSendFiltersAndRefusals(t *testing.T) { - rl := newRelay(t) + rl := smtptest.Start(t) e := newCLIEnv(t, relayConfig(rl, "")) cases := []struct { args []string @@ -84,13 +85,13 @@ func TestSendFiltersAndRefusals(t *testing.T) { if _, err := noEmail.run(t, "", "send"); err == nil || !strings.Contains(err.Error(), "invalid config") { t.Errorf("send without email config: %v", err) } - if len(rl.recipients()) != 0 { + if len(rl.Recipients()) != 0 { t.Error("a refused send reached the relay") } } func TestSendDailyLimit(t *testing.T) { - rl := newRelay(t) + rl := smtptest.Start(t) e := newCLIEnv(t, relayConfig(rl, " daily_send_limit: 1\n")) out, _ := e.run(t, "", "send") mustContain(t, out, "sending 1 of 2 remaining brokers", "Complete: 1 sent") @@ -98,14 +99,14 @@ func TestSendDailyLimit(t *testing.T) { mustContain(t, out, "Daily send limit reached (1/1") out, _ = e.run(t, "", "send", "--ignore-daily-limit") mustContain(t, out, "Complete: 1 sent") - if len(rl.recipients()) != 2 { - t.Errorf("relay got %v", rl.recipients()) + if len(rl.Recipients()) != 2 { + t.Errorf("relay got %v", rl.Recipients()) } } func TestSendStopsOnRepeatedAuthFailures(t *testing.T) { - rl := newRelay(t) - rl.authFail = true + rl := smtptest.Start(t) + rl.FailAuth() e := newCLIEnv(t, relayConfig(rl, "")) var list strings.Builder list.WriteString("brokers:\n") @@ -140,11 +141,11 @@ func TestSendManual(t *testing.T) { out, _ = e.run(t, lines("n", "n", "q"), "--profile", "spouse", "send", "--broker", "globex,noemail,acme") mustContain(t, out, "Recorded 0 as sent, 2 left") // --manual on a sending config never touches SMTP either. - rl := newRelay(t) + rl := smtptest.Start(t) smtp := newCLIEnv(t, relayConfig(rl, "")) out, _ = smtp.run(t, lines("s", "s", "n"), "send", "--manual") mustContain(t, out, "Recorded 2 as sent") - if len(rl.recipients()) != 0 { + if len(rl.Recipients()) != 0 { t.Error("manual mode sent mail") } } diff --git a/codecov.yml b/codecov.yml index b015a9e..2087dd2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,8 +1,10 @@ # Coverage of the code that ships. scripts/ is a maintainer import tool, -# data/ is embedded YAML. +# data/ is embedded YAML, *test/ are test-only helper packages. ignore: - "scripts/**" - "data/**" + - "internal/imaptest/**" + - "internal/smtptest/**" coverage: status: diff --git a/internal/imaptest/imaptest.go b/internal/imaptest/imaptest.go new file mode 100644 index 0000000..d41dc7e --- /dev/null +++ b/internal/imaptest/imaptest.go @@ -0,0 +1,142 @@ +// Package imaptest runs go-imap's in-memory server for tests: STARTTLS on +// loopback (config.TLSFor skips verification there), any login name with +// password "password". +package imaptest + +import ( + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/backend/memory" + "github.com/emersion/go-imap/server" + + "github.com/drumandbytes/eraser/internal/config" +) + +type Server struct { + // Inbox connects to this server's INBOX, archiving to "Eraser". + Inbox config.InboxConfig + backend *memory.Backend +} + +// Start serves an empty INBOX (plus the memory backend's one sample message). +// move adds MOVE support, which the memory backend lacks, so archiving can +// take either the MOVE or the COPY+EXPUNGE path. +func Start(t testing.TB, move bool) *Server { + t.Helper() + s := &Server{backend: memory.New()} + ts := httptest.NewTLSServer(http.NotFoundHandler()) + t.Cleanup(ts.Close) + + srv := server.New(&wrapper{Backend: s.backend, move: move}) + srv.TLSConfig = ts.TLS.Clone() + srv.ErrorLog = log.New(io.Discard, "", 0) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + + s.Inbox = config.InboxConfig{Enabled: true, Server: "127.0.0.1", Port: ln.Addr().(*net.TCPAddr).Port, + Email: "username", Password: "password", Folder: "INBOX", ArchiveFolder: "Eraser"} + return s +} + +// Mailbox returns the named mailbox, creating it if needed. +func (s *Server) Mailbox(t testing.TB, name string) *memory.Mailbox { + t.Helper() + u, err := s.backend.Login(nil, "username", "password") + if err != nil { + t.Fatal(err) + } + if _, err := u.GetMailbox(name); err != nil { + if err := u.CreateMailbox(name); err != nil { + t.Fatal(err) + } + } + mb, err := u.GetMailbox(name) + if err != nil { + t.Fatal(err) + } + return mb.(*memory.Mailbox) +} + +// Deliver puts a plain-text message dated now into folder. +func (s *Server) Deliver(t testing.TB, folder, from, subject, body string) { + t.Helper() + msg := fmt.Sprintf("From: %s\r\nTo: username@example.com\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%d@test>\r\nContent-Type: text/plain\r\n\r\n%s", + from, subject, time.Now().Format(time.RFC1123Z), time.Now().UnixNano(), body) + if err := s.Mailbox(t, folder).CreateMessage(nil, time.Now(), strings.NewReader(msg)); err != nil { + t.Fatal(err) + } +} + +// MailboxNames lists every mailbox. +func (s *Server) MailboxNames(t testing.TB) []string { + t.Helper() + u, err := s.backend.Login(nil, "username", "password") + if err != nil { + t.Fatal(err) + } + boxes, err := u.ListMailboxes(false) + if err != nil { + t.Fatal(err) + } + names := make([]string, len(boxes)) + for i, b := range boxes { + names[i] = b.Name() + } + return names +} + +func (s *Server) Count(t testing.TB, folder string) int { + t.Helper() + return len(s.Mailbox(t, folder).Messages) +} + +// wrapper lets any login name in (so two profiles' inboxes can differ) and +// optionally adds MOVE. +type wrapper struct { + *memory.Backend + move bool +} + +func (b *wrapper) Login(ci *imap.ConnInfo, _, pass string) (backend.User, error) { + u, err := b.Backend.Login(ci, "username", pass) + if err != nil || !b.move { + return u, err + } + return moveUser{u}, nil +} + +type moveUser struct{ backend.User } + +func (u moveUser) GetMailbox(name string) (backend.Mailbox, error) { + mb, err := u.User.GetMailbox(name) + if err != nil { + return nil, err + } + return moveMailbox{mb}, nil +} + +type moveMailbox struct{ backend.Mailbox } + +func (m moveMailbox) MoveMessages(uid bool, seq *imap.SeqSet, dest string) error { + if err := m.CopyMessages(uid, seq, dest); err != nil { + return err + } + if err := m.UpdateMessagesFlags(uid, seq, imap.AddFlags, []string{imap.DeletedFlag}); err != nil { + return err + } + return m.Expunge() +} diff --git a/internal/inbox/imap_server_test.go b/internal/inbox/imap_server_test.go index 2467ca2..016866c 100644 --- a/internal/inbox/imap_server_test.go +++ b/internal/inbox/imap_server_test.go @@ -3,134 +3,37 @@ package inbox import ( "context" "fmt" + "io" + "log" "net" - "net/http" - "net/http/httptest" "path/filepath" "strconv" "strings" "testing" "time" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/backend/memory" "github.com/emersion/go-imap/server" "github.com/drumandbytes/eraser/internal/broker" "github.com/drumandbytes/eraser/internal/config" "github.com/drumandbytes/eraser/internal/history" + "github.com/drumandbytes/eraser/internal/imaptest" ) -// testBackend is go-imap's in-memory backend, optionally with MOVE (the -// memory backend has none, so ArchiveEmails falls back to COPY+EXPUNGE). -type testBackend struct { - *memory.Backend - move bool -} - -func (b *testBackend) Login(ci *imap.ConnInfo, user, pass string) (backend.User, error) { - u, err := b.Backend.Login(ci, user, pass) - if err != nil || !b.move { - return u, err - } - return moveUser{u}, nil -} - -type moveUser struct{ backend.User } - -func (u moveUser) GetMailbox(name string) (backend.Mailbox, error) { - mb, err := u.User.GetMailbox(name) - if err != nil { - return nil, err - } - return moveMailbox{mb}, nil -} - -type moveMailbox struct{ backend.Mailbox } - -func (m moveMailbox) MoveMessages(uid bool, seq *imap.SeqSet, dest string) error { - if err := m.CopyMessages(uid, seq, dest); err != nil { - return err - } - if err := m.UpdateMessagesFlags(uid, seq, imap.AddFlags, []string{imap.DeletedFlag}); err != nil { - return err - } - return m.Expunge() -} - type imapFixture struct { + *imaptest.Server cfg config.InboxConfig - be *testBackend brokers []broker.Broker } -// newIMAPFixture serves the backend over STARTTLS on a loopback port (TLS -// verification is skipped for loopback, see config.TLSFor). func newIMAPFixture(t *testing.T, move bool) *imapFixture { t.Helper() - be := &testBackend{Backend: memory.New(), move: move} - ts := httptest.NewTLSServer(http.NotFoundHandler()) - t.Cleanup(ts.Close) - - srv := server.New(be) - srv.TLSConfig = ts.TLS.Clone() - srv.ErrorLog = discardLogger{} - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - go func() { _ = srv.Serve(ln) }() - t.Cleanup(func() { _ = srv.Close() }) - - _, portStr, _ := net.SplitHostPort(ln.Addr().String()) - port, _ := strconv.Atoi(portStr) - return &imapFixture{ - be: be, - cfg: config.InboxConfig{Enabled: true, Server: "127.0.0.1", Port: port, - Email: "username", Password: "password", Folder: "INBOX", ArchiveFolder: "Eraser"}, - brokers: []broker.Broker{ - {ID: "acme", Name: "Acme", Email: "privacy@acme.example"}, - {ID: "globex", Name: "Globex", Website: "https://www.globex.example/privacy"}, - }, - } -} - -type discardLogger struct{} - -func (discardLogger) Printf(string, ...interface{}) {} -func (discardLogger) Println(...interface{}) {} - -func (f *imapFixture) mailbox(t *testing.T, name string) backend.Mailbox { - t.Helper() - u, err := f.be.Backend.Login(nil, "username", "password") - if err != nil { - t.Fatal(err) - } - if _, err := u.GetMailbox(name); err != nil { - if err := u.CreateMailbox(name); err != nil { - t.Fatal(err) - } - } - mb, err := u.GetMailbox(name) - if err != nil { - t.Fatal(err) - } - return mb -} - -func (f *imapFixture) deliver(t *testing.T, folder, from, subject, body string) { - t.Helper() - msg := fmt.Sprintf("From: %s\r\nTo: username@example.com\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%d@test>\r\nContent-Type: text/plain\r\n\r\n%s", - from, subject, time.Now().Format(time.RFC1123Z), time.Now().UnixNano(), body) - if err := f.mailbox(t, folder).(*memory.Mailbox).CreateMessage(nil, time.Now(), strings.NewReader(msg)); err != nil { - t.Fatal(err) - } -} - -func (f *imapFixture) count(t *testing.T, folder string) int { - t.Helper() - return len(f.mailbox(t, folder).(*memory.Mailbox).Messages) + srv := imaptest.Start(t, move) + return &imapFixture{Server: srv, cfg: srv.Inbox, brokers: []broker.Broker{ + {ID: "acme", Name: "Acme", Email: "privacy@acme.example"}, + {ID: "globex", Name: "Globex", Website: "https://www.globex.example/privacy"}, + }} } func (f *imapFixture) connect(t *testing.T) *Monitor { @@ -164,7 +67,7 @@ func TestConnectErrors(t *testing.T) { func TestConnectRefusesPlaintextServer(t *testing.T) { srv := server.New(memory.New()) srv.AllowInsecureAuth = true - srv.ErrorLog = discardLogger{} + srv.ErrorLog = log.New(io.Discard, "", 0) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) @@ -182,10 +85,10 @@ func TestConnectRefusesPlaintextServer(t *testing.T) { func TestFetchBrokerAndBounceEmails(t *testing.T) { f := newIMAPFixture(t, false) - f.deliver(t, "INBOX", "Acme Privacy ", "Re: Erasure request", "Your data has been deleted.") - f.deliver(t, "INBOX", "dpo@globex.example", "Your request", "Please use our form.") - f.deliver(t, "INBOX", "friend@example.com", "Lunch?", "Tomorrow?") - f.deliver(t, "INBOX", "Mail Delivery Subsystem ", "Delivery Status Notification (Failure)", + f.Deliver(t, "INBOX", "Acme Privacy ", "Re: Erasure request", "Your data has been deleted.") + f.Deliver(t, "INBOX", "dpo@globex.example", "Your request", "Please use our form.") + f.Deliver(t, "INBOX", "friend@example.com", "Lunch?", "Tomorrow?") + f.Deliver(t, "INBOX", "Mail Delivery Subsystem ", "Delivery Status Notification (Failure)", "Delivery to the following recipient failed permanently: gone@deadbroker.example") m := f.connect(t) @@ -215,7 +118,7 @@ func TestFetchBrokerAndBounceEmails(t *testing.T) { if _, err := m.FetchBrokerEmailsFromFolder(context.Background(), "Nope", 7); err == nil { t.Error("fetching a missing folder should fail") } - f.mailbox(t, "Empty") + f.Mailbox(t, "Empty") if got, err := m.FetchBrokerEmailsFromFolder(context.Background(), "Empty", 7); err != nil || got != nil { t.Errorf("empty folder = %v, %v", got, err) } @@ -247,9 +150,7 @@ func TestEnsureFolderExists(t *testing.T) { if err := m.EnsureFolderExists("eraser"); err != nil { // case-insensitive match, no duplicate t.Fatal(err) } - u, _ := f.be.Backend.Login(nil, "username", "password") - boxes, _ := u.ListMailboxes(false) - if len(boxes) != 2 { + if boxes := f.MailboxNames(t); len(boxes) != 2 { t.Errorf("mailboxes = %d, want INBOX + Eraser", len(boxes)) } } @@ -261,9 +162,9 @@ func TestScanAndStoreArchives(t *testing.T) { t.Run(fmt.Sprintf("move=%v", move), func(t *testing.T) { f := newIMAPFixture(t, move) f.cfg.AutoArchive = true - f.deliver(t, "INBOX", "privacy@acme.example", "Re: Erasure request", "We have deleted your personal data from our systems.") - f.deliver(t, "INBOX", "dpo@globex.example", "Action required", "Please complete our opt-out form at https://www.globex.example/optout") - before := f.count(t, "INBOX") + f.Deliver(t, "INBOX", "privacy@acme.example", "Re: Erasure request", "We have deleted your personal data from our systems.") + f.Deliver(t, "INBOX", "dpo@globex.example", "Action required", "Please complete our opt-out form at https://www.globex.example/optout") + before := f.Count(t, "INBOX") m := f.connect(t) store, err := history.NewStore(filepath.Join(t.TempDir(), "history.db")) @@ -283,8 +184,8 @@ func TestScanAndStoreArchives(t *testing.T) { if res.Summary.Total != 2 || len(res.New) != 2 || res.Archived != 2 || res.Summary.Success != 1 || res.Summary.FormRequired != 1 { t.Fatalf("result = %+v", res) } - if f.count(t, "INBOX") != before-2 || f.count(t, "Eraser") != 2 { - t.Errorf("INBOX %d (was %d), Eraser %d", f.count(t, "INBOX"), before, f.count(t, "Eraser")) + if f.Count(t, "INBOX") != before-2 || f.Count(t, "Eraser") != 2 { + t.Errorf("INBOX %d (was %d), Eraser %d", f.Count(t, "INBOX"), before, f.Count(t, "Eraser")) } if jane, _ := store.GetBrokerResponses("jane", "", false, 10); len(jane) != 1 || jane[0].BrokerID != "acme" { t.Errorf("acme reply not attributed to jane: %+v", jane) diff --git a/internal/smtptest/smtptest.go b/internal/smtptest/smtptest.go new file mode 100644 index 0000000..c383013 --- /dev/null +++ b/internal/smtptest/smtptest.go @@ -0,0 +1,119 @@ +// Package smtptest is a plaintext SMTP relay for send tests. +package smtptest + +import ( + "bufio" + "net" + "strings" + "sync" + "testing" + + "github.com/drumandbytes/eraser/internal/config" +) + +type Relay struct { + Host string + Port int + + mu sync.Mutex + rejected map[string]bool + authFail bool + got []string +} + +// Start accepts any number of connections until the test ends. +func Start(t testing.TB) *Relay { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + r := &Relay{Host: "127.0.0.1", Port: ln.Addr().(*net.TCPAddr).Port, rejected: map[string]bool{}} + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go r.serve(conn) + } + }() + return r +} + +// Reject makes RCPT TO addr answer 550 (or accept again with false). +func (r *Relay) Reject(addr string, reject bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.rejected[addr] = reject +} + +// FailAuth answers MAIL FROM with 535, like a provider that locked the account. +func (r *Relay) FailAuth() { + r.mu.Lock() + defer r.mu.Unlock() + r.authFail = true +} + +// Recipients lists every accepted RCPT TO, in order. +func (r *Relay) Recipients() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.got...) +} + +// Email is a sending config for this relay (no TLS, no auth). +func (r *Relay) Email(from string) config.EmailConfig { + noTLS := false + return config.EmailConfig{From: from, SMTP: config.SMTPConfig{Host: r.Host, Port: r.Port, UseTLS: &noTLS}} +} + +func (r *Relay) serve(conn net.Conn) { + defer func() { _ = conn.Close() }() + br := bufio.NewReader(conn) + reply := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) } + reply("220 test") + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + cmd := strings.TrimSpace(line) + up := strings.ToUpper(cmd) + r.mu.Lock() + authFail := r.authFail + r.mu.Unlock() + switch { + case strings.HasPrefix(up, "MAIL") && authFail: + reply("535 5.7.8 authentication failed") + case strings.HasPrefix(up, "RCPT"): + to := strings.Trim(cmd[strings.Index(cmd, ":")+1:], "<> ") + r.mu.Lock() + rejected := r.rejected[to] + if !rejected { + r.got = append(r.got, to) + } + r.mu.Unlock() + if rejected { + reply("550 no such user") + } else { + reply("250 ok") + } + case strings.HasPrefix(up, "DATA"): + reply("354 go ahead") + for { + l, err := br.ReadString('\n') + if err != nil || l == ".\r\n" { + break + } + } + reply("250 queued") + case strings.HasPrefix(up, "QUIT"): + reply("221 bye") + return + default: + reply("250 ok") + } + } +} diff --git a/internal/web/handlers_more_test.go b/internal/web/handlers_more_test.go index 400162f..2f8ed29 100644 --- a/internal/web/handlers_more_test.go +++ b/internal/web/handlers_more_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "os" "path/filepath" - "strconv" "strings" "testing" "time" @@ -16,6 +15,7 @@ import ( "github.com/drumandbytes/eraser/internal/config" "github.com/drumandbytes/eraser/internal/history" "github.com/drumandbytes/eraser/internal/schedule" + "github.com/drumandbytes/eraser/internal/smtptest" ) // sessionRequest builds a request carrying a wizard session set up by fill. @@ -40,13 +40,6 @@ func serve(s *Server, req *http.Request) *httptest.ResponseRecorder { return rec } -func relayEmail(relay *fakeRelay) config.Email { - host, portStr, _ := strings.Cut(relay.addr, ":") - port, _ := strconv.Atoi(portStr) - noTLS := false - return config.Email{From: "test@example.com", SMTP: config.SMTPConfig{Host: host, Port: port, UseTLS: &noTLS}} -} - func TestSetupTestPage(t *testing.T) { s := newTestServer(t, nil) profile := config.Profile{FirstName: "Test", LastName: "User", Email: "test@example.com"} @@ -72,7 +65,7 @@ func TestSetupTestPage(t *testing.T) { } func TestSetupTestSend(t *testing.T) { - relay := newFakeRelay(t) + relay := smtptest.Start(t) s := newTestServer(t, nil) profile := config.Profile{FirstName: "Test", LastName: "User", Email: "me@example.com"} withEmail := func(e config.Email) func(*Session) { @@ -87,7 +80,7 @@ func TestSetupTestSend(t *testing.T) { }{ {"no session", nil, http.StatusBadRequest, "Email not configured"}, {"bad provider", withEmail(config.Email{Provider: "fax", SMTP: config.SMTPConfig{Host: "x"}}), http.StatusOK, "Configuration error"}, - {"delivered", withEmail(relayEmail(relay)), http.StatusOK, "Test email sent"}, + {"delivered", withEmail(relay.Email("test@example.com")), http.StatusOK, "Test email sent"}, } for _, c := range cases { rec := serve(s, sessionRequest(t, s, http.MethodPost, "/setup/test/send", c.fill)) @@ -95,12 +88,12 @@ func TestSetupTestSend(t *testing.T) { t.Errorf("%s: %d %s", c.name, rec.Code, rec.Body.String()) } } - if got := relay.recipients(); len(got) != 1 || got[0] != "me@example.com" { + if got := relay.Recipients(); len(got) != 1 || got[0] != "me@example.com" { t.Errorf("test email went to %v, want the profile address", got) } - relay.reject["me@example.com"] = true - rec := serve(s, sessionRequest(t, s, http.MethodPost, "/setup/test/send", withEmail(relayEmail(relay)))) + relay.Reject("me@example.com", true) + rec := serve(s, sessionRequest(t, s, http.MethodPost, "/setup/test/send", withEmail(relay.Email("test@example.com")))) if !strings.Contains(rec.Body.String(), "Test failed") { t.Errorf("rejected: %s", rec.Body.String()) } diff --git a/internal/web/handlers_send_test.go b/internal/web/handlers_send_test.go index 5d25853..616a0ad 100644 --- a/internal/web/handlers_send_test.go +++ b/internal/web/handlers_send_test.go @@ -1,117 +1,26 @@ package web import ( - "bufio" "encoding/json" - "net" "net/http" "net/http/httptest" "net/url" - "strconv" "strings" - "sync" "testing" "time" "github.com/drumandbytes/eraser/internal/config" "github.com/drumandbytes/eraser/internal/email" "github.com/drumandbytes/eraser/internal/history" + "github.com/drumandbytes/eraser/internal/smtptest" ) -// fakeRelay is plaintext SMTP. reject -> 550 on RCPT; authFail -> 535 on -// MAIL FROM, like a provider that locked the account. -type fakeRelay struct { - addr string - mu sync.Mutex - reject map[string]bool - authFail bool - rcpts []string -} - -func newFakeRelay(t *testing.T) *fakeRelay { - t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - t.Cleanup(func() { _ = ln.Close() }) - f := &fakeRelay{addr: ln.Addr().String(), reject: map[string]bool{}} - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go f.serve(conn) - } - }() - return f -} - -func (f *fakeRelay) serve(conn net.Conn) { - defer func() { _ = conn.Close() }() - r := bufio.NewReader(conn) - reply := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) } - reply("220 fake") - for { - line, err := r.ReadString('\n') - if err != nil { - return - } - cmd := strings.TrimSpace(line) - upper := strings.ToUpper(cmd) - f.mu.Lock() - authFail, reject := f.authFail, f.reject - f.mu.Unlock() - switch { - case strings.HasPrefix(upper, "MAIL") && authFail: - reply("535 5.7.8 authentication failed") - case strings.HasPrefix(upper, "RCPT"): - to := strings.Trim(cmd[strings.Index(cmd, ":")+1:], "<> ") - if reject[to] { - reply("550 no such user") - continue - } - f.mu.Lock() - f.rcpts = append(f.rcpts, to) - f.mu.Unlock() - reply("250 ok") - case strings.HasPrefix(upper, "DATA"): - reply("354 go ahead") - for { - l, err := r.ReadString('\n') - if err != nil { - return - } - if l == ".\r\n" { - break - } - } - reply("250 queued") - case strings.HasPrefix(upper, "QUIT"): - reply("221 bye") - return - default: - reply("250 ok") - } - } -} - -func (f *fakeRelay) recipients() []string { - f.mu.Lock() - defer f.mu.Unlock() - return append([]string(nil), f.rcpts...) -} - // sendServer is smokeServer with email pointed at relay (plaintext, no auth). -func sendServer(t *testing.T, relay *fakeRelay) *Server { +func sendServer(t *testing.T, relay *smtptest.Relay) *Server { t.Helper() s := smokeServer(t) - host, portStr, _ := net.SplitHostPort(relay.addr) - port, _ := strconv.Atoi(portStr) - noTLS := false cfg := *s.getConfig() - cfg.Email = config.EmailConfig{From: "test@example.com", SMTP: config.SMTPConfig{Host: host, Port: port, UseTLS: &noTLS}} + cfg.Email = relay.Email("test@example.com") cfg.Options.Template = "gdpr" cfg.Options.RateLimitMs = 1 s.config.Store(&cfg) @@ -141,14 +50,14 @@ func post(t *testing.T, s *Server, target string, form url.Values) *httptest.Res } func TestSendOneDeliversAndRecords(t *testing.T) { - relay := newFakeRelay(t) + relay := smtptest.Start(t) s := sendServer(t, relay) rec := post(t, s, "/api/send/spokeo", nil) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Sent") { t.Fatalf("send: %d %s", rec.Code, rec.Body.String()) } - if got := relay.recipients(); len(got) != 1 || got[0] != "privacy@spokeo.com" { + if got := relay.Recipients(); len(got) != 1 || got[0] != "privacy@spokeo.com" { t.Errorf("relay got %v", got) } recs, _ := s.historyStore.GetRecentRequests("default", 10) @@ -158,8 +67,8 @@ func TestSendOneDeliversAndRecords(t *testing.T) { } func TestSendOneRecordsRejectedRecipient(t *testing.T) { - relay := newFakeRelay(t) - relay.reject["privacy@spokeo.com"] = true + relay := smtptest.Start(t) + relay.Reject("privacy@spokeo.com", true) s := sendServer(t, relay) rec := post(t, s, "/api/send/spokeo", nil) @@ -173,7 +82,7 @@ func TestSendOneRecordsRejectedRecipient(t *testing.T) { } func TestSendOneRefusals(t *testing.T) { - relay := newFakeRelay(t) + relay := smtptest.Start(t) cases := []struct { name, target string mutate func(s *Server) @@ -216,13 +125,13 @@ func TestSendOneRefusals(t *testing.T) { } }) } - if got := relay.recipients(); len(got) != 0 { + if got := relay.Recipients(); len(got) != 0 { t.Errorf("a refused send reached the relay: %v", got) } } func TestSendOneRateLimited(t *testing.T) { - s := sendServer(t, newFakeRelay(t)) + s := sendServer(t, smtptest.Start(t)) s.rateLimiter = NewRateLimiter(0, time.Minute) if rec := post(t, s, "/api/send/spokeo", nil); rec.Code != http.StatusTooManyRequests { t.Errorf("status = %d, want 429", rec.Code) @@ -242,8 +151,8 @@ func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { } func TestSendAllRunsJobToCompletion(t *testing.T) { - relay := newFakeRelay(t) - relay.reject["dpo@acme.example"] = true + relay := smtptest.Start(t) + relay.Reject("dpo@acme.example", true) s := sendServer(t, relay) rec := post(t, s, "/api/send-all", nil) @@ -275,7 +184,7 @@ func TestSendAllRunsJobToCompletion(t *testing.T) { } func TestSendAllRefusals(t *testing.T) { - relay := newFakeRelay(t) + relay := smtptest.Start(t) cases := []struct { name string form url.Values @@ -317,7 +226,7 @@ func TestSendAllRefusals(t *testing.T) { } }) } - if got := relay.recipients(); len(got) != 0 { + if got := relay.Recipients(); len(got) != 0 { t.Errorf("a refused batch reached the relay: %v", got) } } @@ -333,8 +242,8 @@ func brokersToSend(s *Server, ids ...string) []BrokerWithStatus { // Three consecutive auth failures mean the provider has locked the account: // stop rather than burn through the rest of the list. func TestProcessSendJobStopsOnRepeatedAuthFailures(t *testing.T) { - relay := newFakeRelay(t) - relay.authFail = true + relay := smtptest.Start(t) + relay.FailAuth() s := sendServer(t, relay) s.brokers().Brokers = append(s.brokers().Brokers, s.brokers().Brokers[0], s.brokers().Brokers[0], s.brokers().Brokers[0]) @@ -354,7 +263,7 @@ func TestProcessSendJobStopsOnRepeatedAuthFailures(t *testing.T) { // A job whose profile was deleted mid-flight falls back to the first // profile rather than crashing; a cancelled job sends nothing. func TestProcessSendJobProfileFallbackAndCancel(t *testing.T) { - relay := newFakeRelay(t) + relay := smtptest.Start(t) s := sendServer(t, relay) sender, _ := email.NewSender(s.getConfig().Email) @@ -370,14 +279,14 @@ func TestProcessSendJobProfileFallbackAndCancel(t *testing.T) { cancelled := s.jobManager.Create(1, "default") cancelled.Cancel() s.processSendJob(cancelled, brokersToSend(s, "acme-eu"), sender) - if got := relay.recipients(); len(got) != 1 { + if got := relay.Recipients(); len(got) != 1 { t.Errorf("cancelled job still sent: %v", got) } } // A template error skips the broker as failed without recording anything. func TestProcessSendJobTemplateError(t *testing.T) { - s := sendServer(t, newFakeRelay(t)) + s := sendServer(t, smtptest.Start(t)) c := *s.getConfig() c.Options.Template = "nope" s.config.Store(&c) diff --git a/internal/web/inbox_scan_test.go b/internal/web/inbox_scan_test.go index 8a2dda7..0ad6362 100644 --- a/internal/web/inbox_scan_test.go +++ b/internal/web/inbox_scan_test.go @@ -1,56 +1,23 @@ package web import ( - "fmt" - "net" "net/http" - "net/http/httptest" - "strconv" "strings" "testing" - "time" - - "github.com/emersion/go-imap/backend/memory" - "github.com/emersion/go-imap/server" "github.com/drumandbytes/eraser/internal/config" "github.com/drumandbytes/eraser/internal/history" + "github.com/drumandbytes/eraser/internal/imaptest" ) -type quietLog struct{} - -func (quietLog) Printf(string, ...interface{}) {} -func (quietLog) Println(...interface{}) {} - -// imapServer serves go-imap's in-memory backend over STARTTLS on loopback -// with the given (from, subject, body) messages in INBOX. +// imapServer serves INBOX with the given (from, subject, body) messages. func imapServer(t *testing.T, mails ...[3]string) config.InboxConfig { t.Helper() - be := memory.New() - u, _ := be.Login(nil, "username", "password") - mb, _ := u.GetMailbox("INBOX") - for i, m := range mails { - msg := fmt.Sprintf("From: %s\r\nTo: test@example.com\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%d@t>\r\nContent-Type: text/plain\r\n\r\n%s", - m[0], m[1], time.Now().Format(time.RFC1123Z), i, m[2]) - if err := mb.(*memory.Mailbox).CreateMessage(nil, time.Now(), strings.NewReader(msg)); err != nil { - t.Fatal(err) - } - } - ts := httptest.NewTLSServer(http.NotFoundHandler()) - t.Cleanup(ts.Close) - srv := server.New(be) - srv.TLSConfig = ts.TLS.Clone() - srv.ErrorLog = quietLog{} - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) + srv := imaptest.Start(t, false) + for _, m := range mails { + srv.Deliver(t, "INBOX", m[0], m[1], m[2]) } - go func() { _ = srv.Serve(ln) }() - t.Cleanup(func() { _ = srv.Close() }) - _, portStr, _ := net.SplitHostPort(ln.Addr().String()) - port, _ := strconv.Atoi(portStr) - return config.InboxConfig{Enabled: true, Server: "127.0.0.1", Port: port, Email: "username", Password: "password", - Folder: "INBOX", ArchiveFolder: "Eraser"} + return srv.Inbox } func withInbox(t *testing.T, in config.InboxConfig) *Server {