b4646155b4
internal/email is the reading half of the email reader: a ~200-line IMAP client (LOGIN, EXAMINE, UID SEARCH SINCE, UID FETCH BODY.PEEK, LOGOUT), a MIME-to-plaintext converter, and a header-only junk filter. Two protocol choices are the design, not shortcuts. EXAMINE instead of SELECT means the session is read-only at the protocol level, so no command in it can flip a flag or expunge anything by mistake. BODY.PEEK instead of BODY means reading a message does not mark it \Seen — Maven reads his mail and leaves no trace of having done so, and the unread state in his own client stays his. Hand-rolled rather than go-imap because this is the one path that holds his mailbox credential and reads his private mail: five commands with no dependencies is auditable in a sitting. No IDLE and no cleartext/STARTTLS either — an option to send his password over a plain socket is an option to get it wrong once. Junk is decided by headers alone, before any model is involved: List-Unsubscribe/List-Id, Precedence: bulk, Auto-Submitted, the spam headers, and Gmail's own category labels. Sender lists and subject keywords are deliberately absent — they age badly and they would put his contacts in a config file. A junk verdict only means "do not spend the model on this"; nothing is deleted and no server flag is touched. Nothing here logs a body, a subject or an address, the junk reason names a header rather than content, and an undecodable charset degrades to headers-only instead of feeding the model mojibake. Verified against recorded .eml fixtures and an in-process fake IMAP server.
163 lines
4.5 KiB
Go
163 lines
4.5 KiB
Go
package email
|
|
|
|
import (
|
|
"bufio"
|
|
"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
|
|
}
|
|
|
|
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)
|
|
raw, ok := f.msgs[uint32(uid64)]
|
|
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)
|
|
}
|
|
}
|