diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa2d7d..790622c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version sender was empty, so a bounce could be refused or allowed by an entry the Filter lists page never showed as used. They now look at the envelope sender only (assumptions C-R4). +- **Stopping or restarting the container stops Postfix as Postfix means it + to be stopped.** Supervisord's stop signal reached only the shell that + started Postfix, never Postfix's master process: a restart of the program + found Postfix "already running", and a container stop cut Postfix off at + the end. The start wrapper now runs `postfix stop` (assumptions C-P42). +- **Postfix that cannot start takes the container down instead of waiting + for ever.** When the milter sockets never appear, the wrapper gives up + after `MILTER_WAIT_TIMEOUT`; that exit was taken for an ordinary one and + the wrapper started again, endlessly. It now counts as a failed start, + and once supervisord's three retries are spent the container exits for + Docker to restart it. For `MILTER_WAIT_TIMEOUT` plus ten seconds after + each start, Server > Health shows Postfix as starting (assumptions C-V6). +- **A logrotate configuration it would ignore ends in a fault shown on + Server > Health,** where logrotate was restarted every second for ever; + the container stays up, mail is not affected (assumptions C-V5). +- **A failed Postfix reload is reported.** The panel took a reload as done + as soon as it was started, and a reload already running as its own. It now + waits for `postfix reload` to finish and shows its failure on the page that + saved; a reload while Postfix is still starting is left to the start + (assumptions B-U2). +- **Changing an application's password no longer rewrites the live SASL + database.** `saslpasswd2` wrote into the file Postfix reads at every + `AUTH` and the backup copies, with no lock between them: an application + signing in at that moment could be refused, and a backup taken then could + carry a damaged database whose restore would refuse every application. + The change is now made to a copy that replaces the file whole (assumptions + C-S3, C-Q3). ### Changed diff --git a/docs/audit-2026-10-12.md b/docs/audit-2026-10-12.md index c949f38..0b226f3 100644 --- a/docs/audit-2026-10-12.md +++ b/docs/audit-2026-10-12.md @@ -962,7 +962,7 @@ those after it move up one patch number each. - **Opus** *Done 2026-10-14:* `e174537`; cleanup takes port 25's limit when it is larger than main.cf's; Postfix refuses the address literal at RCPT. -- [ ] **Supervision as supervisord does it** (C-P42, C-V5, C-V6, C-V10, +- [x] **Supervision as supervisord does it** (C-P42, C-V5, C-V6, C-V10, B-U2, C-P34): master leaves the shell's process group, so stopping the program never stops Postfix - the wrapper traps TERM and runs `postfix stop`; `startsecs` above the milter wait for Postfix and 1 for logrotate @@ -970,11 +970,16 @@ those after it move up one patch number each. postfix-reload rests in EXITED after a run; a reload's exit status is read, not assumed; the dead set-permissions branch goes. e2e: restart of the program, stop of the container. - **Opus** -- [ ] **sasldb written in place** (C-S3, C-Q3): Cyrus opens sasldb2 without + *Done 2026-10-14:* `f2ad699`; startsecs is MILTER_WAIT_TIMEOUT + 10, set + by the entrypoint; the reload's status is read over supervisord's XML-RPC. +- [x] **sasldb written in place** (C-S3, C-Q3): Cyrus opens sasldb2 without a Berkeley DB environment, so without locks, and `saslpasswd2` writes in place; an AUTH or a backup during a change can read it torn. The panel writes a copy and renames it, and the backup reads it under the same lock. - **Opus** + *Done 2026-10-15:* no lock for the backup after all: it reads each file + through one descriptor, and a rename leaves that file whole; the panel's + own changes are serialised by a mutex. - [ ] **Health says what is served** (A-38, A-65, D-P1, D-P3): a socket file is not a listener - unix sockets are dialled as inet ones are; the certificate shown is the one Postfix serves (`postconf -h diff --git a/docs/roadmap.md b/docs/roadmap.md index 045c5ad..9a7f905 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | -| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 38/81 | [audit-2026-10-12.md](audit-2026-10-12.md) | +| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 40/81 | [audit-2026-10-12.md](audit-2026-10-12.md) | **Recommended order** (not binding; owner, 2026-10-09: feature order follows development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and diff --git a/internal/app/sasl.go b/internal/app/sasl.go index 9631c71..ff49fbe 100644 --- a/internal/app/sasl.go +++ b/internal/app/sasl.go @@ -4,23 +4,45 @@ import ( "bufio" "bytes" "encoding/hex" + "errors" "fmt" + "io" + "os" "os/exec" "strings" + "sync" ) // SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains // for application credentials (architecture.md § Mail path). The panel is the // only writer; Postfix reads it to authenticate SMTP clients. Accounts are // created and removed with the standard saslpasswd2 tool. +// +// saslpasswd2 is never pointed at the live file. Cyrus SASL 2.1.28 opens +// sasldb2 with no Berkeley DB environment, so without locks +// (sasldb/db_berkeley.c, berkeleydb_open(): db_create(mbdb, NULL, 0)), and a +// write puts and deletes in the file itself (_sasldb_putdata()); smtpd opens +// and closes it for every lookup (_sasldb_getdata()), and the backup reads it +// whenever it runs. Either could read a page half written: a spurious 535 for +// an application, or a torn sasldb2 in a backup whose restore breaks every +// application's AUTH (assumptions C-S3, C-Q3). So each change is made to a +// copy, which is then renamed over the file (replaceSASLDB): a reader opens +// the old file or the new one, whole, and needs no lock. mu keeps the +// panel's own changes from copying the same old file at once. type SASLDB struct { path string // sasldb2 file, under /data so it survives restarts realm string // SASL realm, so lookups match what Postfix's SASL uses + mu sync.Mutex + // run executes saslpasswd2. It is a field so tests can substitute a fake; // the default shells out to the real binary via runSaslpasswd2. run func(args []string, stdin []byte) error + // replace makes a change to the database through a copy: edit gets the + // path to give saslpasswd2. The default is replaceSASLDB. + replace func(path string, edit func(path string) error) error + // dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is // a field so tests can substitute a fake; the default runs db_dump. dump func(path string) ([]byte, error) @@ -36,12 +58,89 @@ type SASLDB struct { // sasldb_auxprop_lookup(), then looks a login without "@" up under the // server's name (common/plugin_common.c, _plug_parseuser()). func NewSASLDB(path, realm string) *SASLDB { - return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB} + return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB, replace: replaceSASLDB} +} + +// change runs saslpasswd2 with args on a copy of the database and puts the +// copy in its place; at in args stands for the path of the copy. +func (s *SASLDB) change(args []string, at int, stdin []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.replace(s.path, func(path string) error { + a := append([]string(nil), args...) + a[at] = path + return s.run(a, stdin) + }) +} + +// replaceSASLDB copies the database at path to path+".new", lets edit change +// the copy, and renames it over path. With no database yet, edit gets a path +// where none exists, and saslpasswd2 creates one there (db_berkeley.c, +// berkeleydb_open(): DB_CREATE for a writer). The copy is a byte copy of a +// file no one else writes, which is a whole Berkeley DB file: smtpd and +// db_dump only read it. The new file is 0640 in the setgid directory, group +// selfpost, as build/entrypoint.sh sets the old one, so Postfix reads it. +// A copy left by a panel killed mid-change is removed first. +func replaceSASLDB(path string, edit func(path string) error) error { + tmp := path + ".new" + if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", tmp, err) + } + if err := copySASLDB(path, tmp); err != nil { + _ = os.Remove(tmp) + return err + } + if err := edit(tmp); err != nil { + _ = os.Remove(tmp) + return err + } + // Read-write: fsync of a file opened read-only is refused on Windows. + f, err := os.OpenFile(tmp, os.O_RDWR, 0) + if err == nil { + err = f.Chmod(0o640) + if err == nil { + err = f.Sync() + } + if cerr := f.Close(); err == nil { + err = cerr + } + } + if err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("finish %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename %s -> %s: %w", tmp, path, err) + } + return nil +} + +// copySASLDB copies path to tmp; with no file at path it makes no copy. +func copySASLDB(path, tmp string) error { + src, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer src.Close() + dst, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if err != nil { + return fmt.Errorf("create %s: %w", tmp, err) + } + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + return fmt.Errorf("copy %s: %w", path, err) + } + return dst.Close() } // Set creates or updates an application's SASL account with the given password // (architecture.md § Mail path). Used both at creation and when a password is -// regenerated; saslpasswd2 overwrites an existing entry in place. +// regenerated; saslpasswd2 overwrites an existing entry (in the copy, see +// SASLDB). // // The password is passed to saslpasswd2 on stdin (never as an argument, so it // cannot leak through the process table or logs). The login is passed as a @@ -65,7 +164,7 @@ func (s *SASLDB) Set(login, password string) error { // whitelist already forbids nothing that getopt would eat, but a login // starting with '-' is legal there — this keeps it an operand). args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, "--", login} - if err := s.run(args, []byte(password)); err != nil { + if err := s.change(args, 3, []byte(password)); err != nil { return fmt.Errorf("saslpasswd2 set %q: %w", login, err) } return nil @@ -84,7 +183,7 @@ func (s *SASLDB) Delete(login string) error { } // -d: delete the account. "--" as in Set: the login is always an operand. args := []string{"-d", "-f", s.path, "-u", s.realm, "--", login} - if err := s.run(args, nil); err != nil { + if err := s.change(args, 2, nil); err != nil { return fmt.Errorf("saslpasswd2 delete %q: %w", login, err) } return nil @@ -213,8 +312,13 @@ func dumpSASLDB(path string) ([]byte, error) { // WithRunner replaces the command that runs saslpasswd2 and returns the same // manager. It exists for tests outside this package that need an application -// service without the real binary; the panel never calls it. +// service without the real binary; the panel never calls it. With no real +// database either, the runner is given the database's own path, not a copy. func (s *SASLDB) WithRunner(run func(args []string, stdin []byte) error) *SASLDB { s.run = run + s.replace = inPlace return s } + +// inPlace hands edit the path itself: for tests whose runner touches no file. +func inPlace(path string, edit func(path string) error) error { return edit(path) } diff --git a/internal/app/sasl_replace_test.go b/internal/app/sasl_replace_test.go new file mode 100644 index 0000000..75ba2d2 --- /dev/null +++ b/internal/app/sasl_replace_test.go @@ -0,0 +1,114 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +// A change is made to a copy and renamed over the database: saslpasswd2 +// never gets the live file, and a reader that opened the old file keeps +// reading it whole (assumptions C-S3). +func TestSASLChangeGoesThroughACopy(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "sasldb2") + if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil { + t.Fatal(err) + } + // smtpd in the middle of a lookup. Windows refuses a rename over an open + // file; the container is Linux, where the reader keeps the old inode. + var reader *os.File + if runtime.GOOS != "windows" { + var err error + if reader, err = os.Open(db); err != nil { + t.Fatal(err) + } + defer reader.Close() + } + + s := NewSASLDB(db, "mail.example.com") + var gotPath string + s.run = func(args []string, stdin []byte) error { + gotPath = args[3] + before, err := os.ReadFile(gotPath) + if err != nil { + return err + } + if string(before) != "old database" { + t.Errorf("the copy holds %q, want the database's bytes", before) + } + return os.WriteFile(gotPath, []byte("new database"), 0o600) + } + if err := s.Set("alerts", "pw"); err != nil { + t.Fatalf("Set: %v", err) + } + if gotPath == db { + t.Fatal("saslpasswd2 was given the live database") + } + if got, _ := os.ReadFile(db); string(got) != "new database" { + t.Fatalf("database after the change: %q", got) + } + if runtime.GOOS != "windows" { + if fi, _ := os.Stat(db); fi.Mode().Perm() != 0o640 { + t.Fatalf("database mode %v, want 0640 for Postfix's group", fi.Mode().Perm()) + } + old := make([]byte, 64) + n, _ := reader.ReadAt(old, 0) + if string(old[:n]) != "old database" { + t.Fatalf("a reader of the old file read %q", old[:n]) + } + } + if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the copy is left behind: %v", err) + } +} + +func TestSASLFailedChangeLeavesTheDatabase(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "sasldb2") + if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil { + t.Fatal(err) + } + // A copy a killed panel left behind is not taken for the database. + if err := os.WriteFile(db+".new", []byte("stale"), 0o640); err != nil { + t.Fatal(err) + } + s := NewSASLDB(db, "mail.example.com") + s.run = func(args []string, stdin []byte) error { + if b, _ := os.ReadFile(args[2]); string(b) != "old database" { + t.Errorf("the copy holds %q", b) + } + _ = os.WriteFile(args[2], []byte("half"), 0o640) + return errors.New("saslpasswd2: exit status 1") + } + if err := s.Delete("alerts"); err == nil { + t.Fatal("Delete succeeded with saslpasswd2 failing") + } + if got, _ := os.ReadFile(db); string(got) != "old database" { + t.Fatalf("database after a failed change: %q", got) + } + if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the copy is left behind: %v", err) + } +} + +// The first account: there is no database to copy, and saslpasswd2 creates +// one at the path it is given. +func TestSASLFirstAccountCreatesTheDatabase(t *testing.T) { + db := filepath.Join(t.TempDir(), "sasldb2") + s := NewSASLDB(db, "mail.example.com") + s.run = func(args []string, stdin []byte) error { + if _, err := os.Stat(args[3]); !errors.Is(err, os.ErrNotExist) { + t.Errorf("saslpasswd2 given an existing file %s: %v", args[3], err) + } + return os.WriteFile(args[3], []byte("created"), 0o660) + } + if err := s.Set("alerts", "pw"); err != nil { + t.Fatalf("Set: %v", err) + } + if got, _ := os.ReadFile(db); string(got) != "created" { + t.Fatalf("database: %q", got) + } +} diff --git a/internal/app/sasl_test.go b/internal/app/sasl_test.go index e1ef8fb..e1a933b 100644 --- a/internal/app/sasl_test.go +++ b/internal/app/sasl_test.go @@ -17,6 +17,7 @@ type fakeRun struct { func newFakeSASL() (*SASLDB, *fakeRun) { fr := &fakeRun{} s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com") + s.replace = inPlace s.run = func(args []string, stdin []byte) error { fr.calls++ fr.args = args diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 39aa4d7..2b5b544 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -44,6 +44,7 @@ func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fa rec := &saslRecorder{set: map[string]string{}} sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com") + sasl.replace = inPlace sasl.run = func(args []string, stdin []byte) error { if rec.failNext { rec.failNext = false diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 72769d8..15f85e7 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -323,7 +323,13 @@ func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool } return tw.WriteHeader(hdr) case info.Mode().IsRegular(): - return writeTarFile(tw, name, info.Mode().Perm(), path) + err := writeTarFile(tw, name, info.Mode().Perm(), path) + if errors.Is(err, fs.ErrNotExist) { + // A writer's temporary file, renamed into place between + // the listing and the open: not part of /data's state. + return nil + } + return err default: return nil } @@ -349,6 +355,14 @@ func writeTarBytes(tw *tar.Writer, name string, mode int64, data []byte) error { } // writeTarFile streams a file from disk into the archive under name. +// +// The file is read through one descriptor, sized by that descriptor: the +// panel's writers of what a backup carries put a new file in place by rename +// (atomicfile.Write; sasldb2 through a copy, app.replaceSASLDB - the setup +// token alone is written in place, and it is not carried; the database and +// the queue are not walked), and a rename leaves the file open here as it +// was: the archive holds the old file or the new one, whole (assumptions +// C-Q3). func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error { f, err := os.Open(srcPath) if err != nil { diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 8dbf546..6c162a3 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -334,6 +334,9 @@ func TestE2E(t *testing.T) { run("session_survives_restart", func(t *testing.T) { testSessionSurvivesRestart(t, sc) }) + run("sasldb_changed_by_rename", func(t *testing.T) { + testSASLDBChangedByRename(t, sc) + }) run("supervision_stops_postfix_and_gives_up", func(t *testing.T) { testSupervision(t, sc) }) diff --git a/test/e2e/sasldb_test.go b/test/e2e/sasldb_test.go new file mode 100644 index 0000000..85966f0 --- /dev/null +++ b/test/e2e/sasldb_test.go @@ -0,0 +1,155 @@ +package e2e + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "net/smtp" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +// testSASLDBChangedByRename: the panel changes sasldb2 through a copy renamed +// over it (internal/app, replaceSASLDB), so an AUTH while another +// application's password changes reads the old database or the new one, +// whole. Before, saslpasswd2 wrote in the live file, which Cyrus SASL 2.1.28 +// opens with no lock (sasldb/db_berkeley.c, berkeleydb_open()) and smtpd +// reads at every AUTH (_sasldb_getdata()) - assumptions C-S3. A torn read is +// a matter of timing, so this guards the mechanism (the inode changes, the +// mode and group stay) and that nothing fails under the load; it cannot prove +// a race absent. +func testSASLDBChangedByRename(t *testing.T, sc *scenario) { + churnLogin, churnPassword, err := sc.panel.addApplication(sc.domainID, "churn", "wildcard", "") + if err != nil { + t.Fatalf("add application: %v", err) + } + churnID, err := sc.panel.applicationID(sc.domainID, churnLogin) + if err != nil { + t.Fatal(err) + } + stat := func() string { + t.Helper() + out, err := h.execIn("selfpost", "stat", "-c", "%i %a %U %G", "/data/sasl/sasldb2") + if err != nil { + t.Fatalf("stat sasldb2: %v\n%s", err, out) + } + return strings.TrimSpace(out) + } + newPassword := func(i int) (string, error) { + resp, body, err := sc.panel.postForm("/outbound/domains/"+sc.domainID+"/applications/"+churnID+"/password", url.Values{}) + if err != nil || resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("new password %d: %v, status %v\n%.600s", i, err, resp, body) + } + pw, ok := extractCodeRow(body, "Password") + if !ok { + return "", fmt.Errorf("new password %d: no password on the page\n%.600s", i, body) + } + return pw, nil + } + + // One change, the inode looked at on both sides of it: the copy is made + // while the old file still exists, so it cannot have the old file's + // number. (After many changes it may: a freed inode number is reused.) + before := strings.Fields(stat()) + if churnPassword, err = newPassword(0); err != nil { + t.Fatal(err) + } + after := strings.Fields(stat()) + if len(before) != 4 || len(after) != 4 { + t.Fatalf("stat sasldb2: %v, %v", before, after) + } + if before[0] == after[0] { + t.Fatalf("sasldb2 kept inode %s across a password change: it was written in place", after[0]) + } + + // Then changes until other AUTHs have run alongside them for a while. + const wantAuths, maxChanges = 30, 300 + stop := make(chan struct{}) + var wg sync.WaitGroup + var mu sync.Mutex + var auths int + var failures []string + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + err := authOnly(sc.appLogin, sc.appPassword) + mu.Lock() + auths++ + if err != nil { + failures = append(failures, err.Error()) + } + mu.Unlock() + } + }() + } + changes := 1 + for ; changes < maxChanges; changes++ { + pw, err := newPassword(changes) + if err != nil { + close(stop) + wg.Wait() + t.Fatal(err) + } + churnPassword = pw + mu.Lock() + enough := auths >= wantAuths + mu.Unlock() + if enough { + break + } + } + close(stop) + wg.Wait() + if len(failures) > 0 { + t.Fatalf("%d of %d AUTHs of %s failed while %s's password changed %d times:\n%s", + len(failures), auths, sc.appLogin, churnLogin, changes, strings.Join(failures, "\n")) + } + t.Logf("%d AUTHs of %s during %d password changes of %s, none refused", auths, sc.appLogin, changes, churnLogin) + + after = strings.Fields(stat()) + if strings.Join(after[1:], " ") != "640 panel selfpost" { + t.Fatalf("sasldb2 after the changes: mode, owner, group %v; want 640 panel selfpost (Postfix reads it through the group)", after[1:]) + } + if out, _ := h.execIn("selfpost", "sh", "-c", "ls /data/sasl"); strings.TrimSpace(out) != "sasldb2" { + t.Fatalf("/data/sasl holds %q, want sasldb2 alone (no copy left behind)", out) + } + if err := authOnly(churnLogin, churnPassword); err != nil { + t.Fatalf("%s with its last password: %v", churnLogin, err) + } +} + +// authOnly connects to the submission port, authenticates and quits. +func authOnly(login, password string) error { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{ + InsecureSkipVerify: true, + ServerName: selfpostHostname, + }) + if err != nil { + return err + } + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + c, err := smtp.NewClient(conn, selfpostHostname) + if err != nil { + _ = conn.Close() + return err + } + defer c.Close() + if err := c.Hello("e2e-client.e2e.test"); err != nil { + return err + } + if err := c.Auth(smtp.PlainAuth("", login, password, selfpostHostname)); err != nil { + return fmt.Errorf("AUTH %s: %w", login, err) + } + return c.Quit() +}