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.
81 lines
3.3 KiB
Go
81 lines
3.3 KiB
Go
package email
|
|
|
|
import (
|
|
"net/mail"
|
|
"strings"
|
|
)
|
|
|
|
// The junk filter — the cheapest and most important half of reading mail.
|
|
//
|
|
// A mailbox is mostly machine-generated: newsletters, receipts nobody acts on,
|
|
// social notifications, marketing. Sending all of it to a 1.7B and asking "is
|
|
// there a task here" produces confident nonsense at a rate proportional to the
|
|
// volume, so junk is decided by HEADERS, before any model sees the message.
|
|
//
|
|
// The rules are all bulk-mail markers that senders set on themselves, never
|
|
// guesses about content:
|
|
//
|
|
// - List-Unsubscribe / List-Id — by definition a mailing list. If he can
|
|
// unsubscribe from it, it is not asking him to do anything.
|
|
// - Precedence: bulk|junk|list — the sender declaring itself bulk.
|
|
// - Auto-Submitted other than "no" (RFC 3834) — generated by a machine.
|
|
// - X-Spam-Flag: YES, X-Spam-Status: Yes — the spam filter upstream already
|
|
// decided; we do not second-guess it in the other direction.
|
|
// - X-GM-LABELS / X-Gmail-Labels containing a Gmail category — Gmail's own
|
|
// Promotions/Social/Forums/Spam classification, when the server sends it.
|
|
//
|
|
// Deliberately NOT here: sender allow/deny lists and subject keyword matching.
|
|
// Both are configuration that ages badly and both would be a place for his
|
|
// contacts to end up in a config file. If a real correspondent's mail is being
|
|
// dropped, the fix is a rule about a header, not a list of names.
|
|
//
|
|
// A junk verdict never deletes anything and never touches a flag on the server.
|
|
// It means "do not spend the model on this", nothing more.
|
|
|
|
// junkHeaders — headers whose mere presence marks bulk mail.
|
|
var junkPresence = []string{"List-Unsubscribe", "List-Id", "List-Post"}
|
|
|
|
// gmailCategories — Gmail's category labels, lowercased as they appear in
|
|
// X-GM-LABELS. "important" and "inbox" are labels too, and are NOT categories.
|
|
// Matching is by these exact tokens (substring is fine — they are namespaced
|
|
// and cannot appear in a hand-made label by accident), so a user label named
|
|
// "Social Club" is not mistaken for Gmail's Social category.
|
|
var gmailCategories = []string{
|
|
"category_promotions", "category_social", "category_forums", "category_updates",
|
|
`\spam`, `\junk`,
|
|
}
|
|
|
|
// classifyJunk returns whether the message is bulk/automated and why. The
|
|
// reason is a short header name, safe to log — it names the marker, never the
|
|
// sender or the subject.
|
|
func classifyJunk(h mail.Header) (bool, string) {
|
|
for _, name := range junkPresence {
|
|
if strings.TrimSpace(h.Get(name)) != "" {
|
|
return true, strings.ToLower(name)
|
|
}
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
|
|
case "bulk", "junk", "list":
|
|
return true, "precedence"
|
|
}
|
|
if v := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); v != "" && v != "no" {
|
|
return true, "auto-submitted"
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(h.Get("X-Spam-Flag")), "yes") {
|
|
return true, "x-spam-flag"
|
|
}
|
|
if v := strings.ToLower(strings.TrimSpace(h.Get("X-Spam-Status"))); strings.HasPrefix(v, "yes") {
|
|
return true, "x-spam-status"
|
|
}
|
|
labels := strings.ToLower(h.Get("X-GM-LABELS") + " " + h.Get("X-Gmail-Labels"))
|
|
for _, c := range gmailCategories {
|
|
if c == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(labels, c) {
|
|
return true, "gmail-category"
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|