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.
51 lines
1.4 KiB
Go
51 lines
1.4 KiB
Go
package email
|
|
|
|
import (
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestFetchSinceRun(t *testing.T) {
|
|
mk := func(subject string) string {
|
|
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
|
|
}
|
|
f := &fakeIMAP{
|
|
uids: []uint32{1, 2, 3},
|
|
msgs: map[uint32]string{1: mk("one"), 2: mk("two"), 3: mk("three")},
|
|
}
|
|
fs := FetchSince{
|
|
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
|
|
Timeout: 5 * time.Second,
|
|
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
|
|
Max: 2,
|
|
Skip: func(uid uint32) bool { return uid == 3 },
|
|
dial: func(addr string, timeout time.Duration) (*Conn, error) {
|
|
cli, srv := net.Pipe()
|
|
go f.serve(t, srv)
|
|
return NewConn(cli, timeout)
|
|
},
|
|
}
|
|
msgs, err := fs.Run("secret")
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
// Newest first, the already-seen UID skipped, Max respected.
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("got %d messages, want 2: %+v", len(msgs), msgs)
|
|
}
|
|
if msgs[0].Subject != "two" || msgs[1].Subject != "one" {
|
|
t.Errorf("subjects = %q,%q, want two,one (newest first)", msgs[0].Subject, msgs[1].Subject)
|
|
}
|
|
if strings.Contains(strings.Join(f.cmds, " "), "UID FETCH 3") {
|
|
t.Error("a skipped UID must not be fetched again")
|
|
}
|
|
}
|
|
|
|
func TestFetchSinceRequiresConfig(t *testing.T) {
|
|
if _, err := (FetchSince{}).Run("secret"); err == nil {
|
|
t.Fatal("an unconfigured mailbox must not be read")
|
|
}
|
|
}
|