package email import ( "bufio" "errors" "fmt" "net" "strconv" "strings" "testing" "time" ) // fakeIMAP is a scripted server: enough of IMAP to exercise the client, and // nothing more. It records the commands it received so a test can assert on the // protocol (BODY.PEEK rather than BODY, EXAMINE rather than SELECT). type fakeIMAP struct { msgs map[uint32]string uids []uint32 cmds []string failOn string // substring of a command to answer NO // oversize — UIDs answered with a literal of this many bytes, which the // server then actually sends. Used to exercise the read cap. oversize map[uint32]int // quoted — UIDs answered with a quoted string instead of a literal. quoted map[uint32]bool } func (f *fakeIMAP) serve(t *testing.T, c net.Conn) { t.Helper() defer c.Close() fmt.Fprint(c, "* OK fake IMAP ready\r\n") r := bufio.NewReader(c) for { line, err := r.ReadString('\n') if err != nil { return } line = strings.TrimRight(line, "\r\n") parts := strings.SplitN(line, " ", 2) if len(parts) != 2 { return } tag, cmd := parts[0], parts[1] f.cmds = append(f.cmds, cmd) if f.failOn != "" && strings.Contains(cmd, f.failOn) { fmt.Fprintf(c, "%s NO computer says no\r\n", tag) continue } upper := strings.ToUpper(cmd) switch { case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"): fmt.Fprintf(c, "%s OK done\r\n", tag) case strings.HasPrefix(upper, "UID SEARCH"): var ids []string for _, u := range f.uids { ids = append(ids, strconv.FormatUint(uint64(u), 10)) } fmt.Fprintf(c, "* SEARCH %s\r\n", strings.Join(ids, " ")) fmt.Fprintf(c, "%s OK search done\r\n", tag) case strings.HasPrefix(upper, "UID FETCH"): uid64, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32) uid := uint32(uid64) if n, big := f.oversize[uid]; big { fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, n) fmt.Fprint(c, strings.Repeat("x", n)) fmt.Fprint(c, ")\r\n") fmt.Fprintf(c, "%s OK fetch done\r\n", tag) continue } if f.quoted[uid] { fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] \"short\")\r\n", uid64) fmt.Fprintf(c, "%s OK fetch done\r\n", tag) continue } raw, ok := f.msgs[uid] if ok { fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, len(raw)) fmt.Fprint(c, raw) fmt.Fprint(c, ")\r\n") } fmt.Fprintf(c, "%s OK fetch done\r\n", tag) case strings.HasPrefix(upper, "LOGOUT"): fmt.Fprint(c, "* BYE\r\n") fmt.Fprintf(c, "%s OK bye\r\n", tag) return default: fmt.Fprintf(c, "%s BAD unknown\r\n", tag) } } } // dialFake wires a client Conn to an in-process server over net.Pipe. func dialFake(t *testing.T, f *fakeIMAP) *Conn { t.Helper() cli, srv := net.Pipe() go f.serve(t, srv) c, err := NewConn(cli, 5*time.Second) if err != nil { t.Fatalf("greeting: %v", err) } t.Cleanup(func() { c.Close() }) return c } func TestIMAPRoundTrip(t *testing.T) { body := "Subject: hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCall the bank.\r\n" f := &fakeIMAP{uids: []uint32{4, 9}, msgs: map[uint32]string{4: body, 9: body}} c := dialFake(t, f) if err := c.Login("kami", `pa"ss\word`); err != nil { t.Fatalf("login: %v", err) } if err := c.Select("INBOX"); err != nil { t.Fatalf("select: %v", err) } uids, err := c.SearchSince(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("search: %v", err) } if len(uids) != 2 || uids[0] != 4 || uids[1] != 9 { t.Fatalf("uids = %v, want [4 9]", uids) } raw, err := c.Fetch(9) if err != nil { t.Fatalf("fetch: %v", err) } if string(raw) != body { t.Errorf("fetched %q, want the literal verbatim", raw) } c.Logout() joined := strings.Join(f.cmds, "\n") // Read-only at the protocol level, and peeking — Maven must leave no trace // of having read his mail. if !strings.Contains(joined, "EXAMINE") || strings.Contains(joined, "SELECT ") { t.Errorf("want EXAMINE (read-only), got:\n%s", joined) } if !strings.Contains(joined, "BODY.PEEK[]") { t.Errorf("want BODY.PEEK, got:\n%s", joined) } // The password must have been quoted and escaped, not truncated at the quote. if !strings.Contains(joined, `"pa\"ss\\word"`) { t.Errorf("password not quoted correctly:\n%s", joined) } // SINCE must carry the IMAP date form. if !strings.Contains(joined, "SINCE 1-Aug-2026") { t.Errorf("want a SINCE date, got:\n%s", joined) } } func TestIMAPServerNoIsAnError(t *testing.T) { f := &fakeIMAP{failOn: "LOGIN"} c := dialFake(t, f) err := c.Login("kami", "wrong") if err == nil { t.Fatal("a NO completion must be an error") } // The error is the server's text; it must not echo the credential. if strings.Contains(err.Error(), "wrong") { t.Errorf("error leaks the password: %v", err) } } func TestIMAPFetchMissingUID(t *testing.T) { f := &fakeIMAP{uids: []uint32{1}, msgs: map[uint32]string{}} c := dialFake(t, f) raw, err := c.Fetch(1) if err != nil { t.Fatalf("fetch: %v", err) } if raw != nil { t.Errorf("a vanished UID should give nil, got %q", raw) } } func TestQuoteStripsNewlines(t *testing.T) { if got := quote("pass\r\nA1 LOGOUT"); strings.ContainsAny(got, "\r\n") { t.Errorf("quote kept a line break: %q", got) } } // A credential with a line break in it is a broken password file, not a // password. Stripping it silently authenticates as a different string and the // server answers its generic NO. func TestLoginRejectsCredentialWithNewline(t *testing.T) { f := &fakeIMAP{} c := dialFake(t, f) err := c.Login("kami", "s3cr3t\nA1 LOGOUT") if err == nil { t.Fatal("a password with a line break must be rejected") } if strings.Contains(err.Error(), "s3cr3t") { t.Errorf("error leaks the password: %v", err) } if len(f.cmds) != 0 { t.Errorf("nothing should have been sent, got %v", f.cmds) } } // The literal size comes off the wire. Without a cap the server picks the // allocation, and one 60MB attachment is 60MB of peak RSS on a box holding a // 1.7B model, all of it thrown away by plaintextBody afterwards. func TestFetchRefusesOversizedLiteral(t *testing.T) { f := &fakeIMAP{uids: []uint32{1}, oversize: map[uint32]int{1: MaxMessageBytes + 1}} c := dialFake(t, f) raw, err := c.Fetch(1) if !errors.Is(err, ErrMessageTooLarge) { t.Fatalf("fetch err = %v, want ErrMessageTooLarge", err) } if raw != nil { t.Errorf("an oversized message must not be kept, got %d bytes", len(raw)) } // The stream stayed aligned: the connection is still usable. if err := c.Select("INBOX"); err != nil { t.Errorf("connection unusable after draining: %v", err) } } // A FETCH that answered without a literal is not a vanished message, and must // not be skipped as silently as one. func TestFetchNonLiteralResponseIsAnError(t *testing.T) { f := &fakeIMAP{uids: []uint32{1}, quoted: map[uint32]bool{1: true}} c := dialFake(t, f) if _, err := c.Fetch(1); err == nil { t.Fatal("a FETCH response with no literal must be reported, not dropped") } } func TestUntaggedMatchesWholeKeyOnly(t *testing.T) { if _, ok := untagged("* SEARCHRES 1 2 3", "SEARCH"); ok { t.Error("SEARCH must not match SEARCHRES") } if rest, ok := untagged("* SEARCH 1 2 3", "SEARCH"); !ok || rest != "1 2 3" { t.Errorf("untagged = (%q, %v), want (\"1 2 3\", true)", rest, ok) } }