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.
60 lines
2.0 KiB
Go
60 lines
2.0 KiB
Go
package email
|
|
|
|
import (
|
|
"net/mail"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func headers(t *testing.T, raw string) mail.Header {
|
|
t.Helper()
|
|
m, err := mail.ReadMessage(strings.NewReader(strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n\r\nbody\r\n"))
|
|
if err != nil {
|
|
t.Fatalf("read headers: %v", err)
|
|
}
|
|
return m.Header
|
|
}
|
|
|
|
func TestClassifyJunk(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
junk bool
|
|
reason string
|
|
}{
|
|
{"personal", "From: a@b.c\nSubject: привет", false, ""},
|
|
{"list-unsubscribe", "From: a@b.c\nList-Unsubscribe: <mailto:u@b.c>", true, "list-unsubscribe"},
|
|
{"list-id", "From: a@b.c\nList-Id: <golang-nuts.example>", true, "list-id"},
|
|
{"precedence bulk", "From: a@b.c\nPrecedence: bulk", true, "precedence"},
|
|
{"auto-submitted", "From: a@b.c\nAuto-Submitted: auto-generated", true, "auto-submitted"},
|
|
{"auto-submitted no", "From: a@b.c\nAuto-Submitted: no", false, ""},
|
|
{"spam flag", "From: a@b.c\nX-Spam-Flag: YES", true, "x-spam-flag"},
|
|
{"spam status", "From: a@b.c\nX-Spam-Status: Yes, score=9.1", true, "x-spam-status"},
|
|
{"spam status no", "From: a@b.c\nX-Spam-Status: No, score=0.1", false, ""},
|
|
{"gmail promo", "From: a@b.c\nX-Gmail-Labels: Inbox,CATEGORY_PROMOTIONS", true, "gmail-category"},
|
|
{"user label", "From: a@b.c\nX-Gmail-Labels: Social Club,Important", false, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
junk, reason := classifyJunk(headers(t, c.raw))
|
|
if junk != c.junk || reason != c.reason {
|
|
t.Errorf("classifyJunk = (%v, %q), want (%v, %q)", junk, reason, c.junk, c.reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewsletterFixtureIsJunk(t *testing.T) {
|
|
msg, err := ParseMessage(9, fixture(t, "newsletter.eml"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if !msg.Junk {
|
|
t.Fatal("a newsletter with List-Unsubscribe + Precedence: bulk must be junk")
|
|
}
|
|
// The reason is what gets logged, so it must never carry mail content.
|
|
if strings.Contains(msg.JunkReason, "@") || strings.Contains(msg.JunkReason, "Скидки") {
|
|
t.Errorf("junk reason leaks content: %q", msg.JunkReason)
|
|
}
|
|
}
|