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.
281 lines
8.9 KiB
Go
281 lines
8.9 KiB
Go
package email
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// A minimal IMAP4rev1 client — LOGIN, SELECT, UID SEARCH, UID FETCH with
|
|
// BODY.PEEK, LOGOUT, and nothing else.
|
|
//
|
|
// Why hand-rolled instead of go-imap: the whole surface Maven needs is five
|
|
// commands, and this is the one code path that holds his mailbox credential and
|
|
// reads his private mail. A ~200-line client with no dependencies is auditable
|
|
// in one sitting; a general-purpose IMAP library is a much larger amount of
|
|
// code doing much more than we asked, in the most sensitive place in the tree.
|
|
// If IDLE, CONDSTORE or server-side threading ever become worth having, that
|
|
// trade should be re-made deliberately.
|
|
//
|
|
// BODY.PEEK[] rather than BODY[] is load-bearing: Maven reads his mail and must
|
|
// leave no trace of having done so. Reading a message here does not mark it
|
|
// \Seen, so the unread state in his own mail client stays his.
|
|
|
|
// DefaultIMAPPort — implicit-TLS IMAP. There is no cleartext and no STARTTLS
|
|
// path in this client: an option to send his password over a plain socket is an
|
|
// option to get it wrong once.
|
|
const DefaultIMAPPort = "993"
|
|
|
|
// Conn — one authenticated IMAP connection. Not safe for concurrent use; the
|
|
// poller drives one connection at a time.
|
|
type Conn struct {
|
|
rwc io.ReadWriteCloser
|
|
r *bufio.Reader
|
|
tag int
|
|
timeout time.Duration
|
|
}
|
|
|
|
// Dial opens an implicit-TLS connection and reads the server greeting.
|
|
func Dial(addr string, timeout time.Duration) (*Conn, error) {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
host, addr = addr, net.JoinHostPort(addr, DefaultIMAPPort)
|
|
}
|
|
d := &net.Dialer{Timeout: timeout}
|
|
// ServerName is set from the host we asked for: certificate verification is
|
|
// the only thing standing between his password and a MITM on the way out.
|
|
c, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("email: dial %s: %w", addr, err)
|
|
}
|
|
return NewConn(c, timeout)
|
|
}
|
|
|
|
// NewConn wraps an already-open stream (the tests speak IMAP over a pipe) and
|
|
// consumes the greeting.
|
|
func NewConn(rwc io.ReadWriteCloser, timeout time.Duration) (*Conn, error) {
|
|
c := &Conn{rwc: rwc, r: bufio.NewReaderSize(rwc, 64<<10), timeout: timeout}
|
|
line, err := c.readLine()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("email: greeting: %w", err)
|
|
}
|
|
if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
|
|
c.rwc.Close()
|
|
return nil, fmt.Errorf("email: server refused connection: %s", line)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Conn) Close() error { return c.rwc.Close() }
|
|
|
|
// Login authenticates with LOGIN. The password is passed as an argument and
|
|
// never stored on the Conn: nothing in this package keeps a credential alive
|
|
// past the command that uses it, so no struct dump or panic trace can carry it.
|
|
func (c *Conn) Login(user, pass string) error {
|
|
// The command line itself is never logged (see exec) — a LOGIN line IS the
|
|
// credential.
|
|
if _, err := c.exec(fmt.Sprintf("LOGIN %s %s", quote(user), quote(pass))); err != nil {
|
|
return fmt.Errorf("email: login: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Select opens a mailbox read-only. EXAMINE, not SELECT: read-only at the
|
|
// protocol level means no command in this session can change a flag, expunge a
|
|
// message, or move anything, even by mistake.
|
|
func (c *Conn) Select(mailbox string) error {
|
|
if _, err := c.exec(fmt.Sprintf("EXAMINE %s", quote(mailbox))); err != nil {
|
|
return fmt.Errorf("email: examine %s: %w", mailbox, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SearchSince returns the UIDs of messages received on or after since. An
|
|
// unlimited search is not offered: the first poll against a years-old mailbox
|
|
// would otherwise fetch everything and hand a decade of mail to the model.
|
|
//
|
|
// The IMAP SINCE key has date granularity (and compares the server's internal
|
|
// date), so the result can include messages slightly older than since. The
|
|
// caller dedupes by UID anyway, so a wider window costs one extra fetch.
|
|
func (c *Conn) SearchSince(since time.Time) ([]uint32, error) {
|
|
cmd := fmt.Sprintf("UID SEARCH SINCE %s", since.Format("2-Jan-2006"))
|
|
lines, err := c.exec(cmd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("email: search: %w", err)
|
|
}
|
|
var uids []uint32
|
|
for _, l := range lines {
|
|
rest, ok := untagged(l, "SEARCH")
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, f := range strings.Fields(rest) {
|
|
n, err := strconv.ParseUint(f, 10, 32)
|
|
if err == nil {
|
|
uids = append(uids, uint32(n))
|
|
}
|
|
}
|
|
}
|
|
return uids, nil
|
|
}
|
|
|
|
var literalSize = regexp.MustCompile(`\{(\d+)\}$`)
|
|
|
|
// Fetch returns the raw RFC 5322 bytes of one message, by UID.
|
|
//
|
|
// Returns (nil, nil) when the UID no longer exists — a message he deleted
|
|
// between SEARCH and FETCH is normal, not an error.
|
|
func (c *Conn) Fetch(uid uint32) ([]byte, error) {
|
|
tag := c.nextTag()
|
|
if err := c.send(fmt.Sprintf("%s UID FETCH %d (BODY.PEEK[])", tag, uid)); err != nil {
|
|
return nil, err
|
|
}
|
|
var raw []byte
|
|
for {
|
|
line, err := c.readLine()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
|
}
|
|
if done, err := c.tagged(tag, line); done {
|
|
if err != nil {
|
|
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
m := literalSize.FindStringSubmatch(strings.TrimSpace(line))
|
|
if m == nil {
|
|
continue
|
|
}
|
|
n, err := strconv.Atoi(m[1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
buf := make([]byte, n)
|
|
if _, err := io.ReadFull(c.r, buf); err != nil {
|
|
return nil, fmt.Errorf("email: fetch %d: literal: %w", uid, err)
|
|
}
|
|
if raw == nil {
|
|
raw = buf
|
|
}
|
|
}
|
|
}
|
|
|
|
// Logout ends the session politely. A failure is not worth reporting — the
|
|
// connection is being closed either way.
|
|
func (c *Conn) Logout() {
|
|
_, _ = c.exec("LOGOUT")
|
|
}
|
|
|
|
// ---- protocol plumbing -----------------------------------------------------
|
|
|
|
func (c *Conn) nextTag() string {
|
|
c.tag++
|
|
return fmt.Sprintf("a%03d", c.tag)
|
|
}
|
|
|
|
// exec sends one command and returns the untagged response lines.
|
|
//
|
|
// Neither the command nor the response is ever logged here. LOGIN goes through
|
|
// this function, and a debug line "sent: a001 LOGIN ..." is how a credential
|
|
// ends up in a log file forever.
|
|
func (c *Conn) exec(cmd string) ([]string, error) {
|
|
tag := c.nextTag()
|
|
if err := c.send(tag + " " + cmd); err != nil {
|
|
return nil, err
|
|
}
|
|
var lines []string
|
|
for {
|
|
line, err := c.readLine()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if done, err := c.tagged(tag, line); done {
|
|
return lines, err
|
|
}
|
|
lines = append(lines, line)
|
|
// A response line may carry a literal (e.g. a header FETCH). Nothing we
|
|
// send asks for one outside Fetch, but skip it if it appears so the
|
|
// stream stays aligned.
|
|
if m := literalSize.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
|
if n, err := strconv.Atoi(m[1]); err == nil {
|
|
if _, err := io.CopyN(io.Discard, c.r, int64(n)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// tagged reports whether line completes the command with this tag, and turns a
|
|
// NO/BAD completion into an error. The error text is the server's, which never
|
|
// echoes a password.
|
|
func (c *Conn) tagged(tag, line string) (bool, error) {
|
|
if !strings.HasPrefix(line, tag+" ") {
|
|
return false, nil
|
|
}
|
|
rest := strings.TrimSpace(line[len(tag):])
|
|
switch {
|
|
case strings.HasPrefix(rest, "OK"):
|
|
return true, nil
|
|
case strings.HasPrefix(rest, "NO"), strings.HasPrefix(rest, "BAD"):
|
|
return true, fmt.Errorf("server said: %s", rest)
|
|
default:
|
|
return true, fmt.Errorf("unexpected completion: %s", rest)
|
|
}
|
|
}
|
|
|
|
func (c *Conn) send(line string) error {
|
|
c.setDeadline()
|
|
if _, err := io.WriteString(c.rwc, line+"\r\n"); err != nil {
|
|
return fmt.Errorf("email: write: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Conn) readLine() (string, error) {
|
|
c.setDeadline()
|
|
line, err := c.r.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(line, "\r\n"), nil
|
|
}
|
|
|
|
// setDeadline applies the per-connection timeout when the transport supports
|
|
// one. A hung IMAP server must not park the poller forever.
|
|
func (c *Conn) setDeadline() {
|
|
if c.timeout <= 0 {
|
|
return
|
|
}
|
|
if d, ok := c.rwc.(interface{ SetDeadline(time.Time) error }); ok {
|
|
_ = d.SetDeadline(time.Now().Add(c.timeout))
|
|
}
|
|
}
|
|
|
|
// untagged splits "* SEARCH 1 2 3" into its payload when the key matches.
|
|
func untagged(line, key string) (string, bool) {
|
|
if !strings.HasPrefix(line, "* ") {
|
|
return "", false
|
|
}
|
|
rest := strings.TrimSpace(line[2:])
|
|
if !strings.HasPrefix(rest, key) {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(rest[len(key):]), true
|
|
}
|
|
|
|
// quote renders an IMAP quoted string. Passwords routinely contain characters
|
|
// that would otherwise end the argument early, and CR/LF are stripped rather
|
|
// than escaped because there is no legal way to send them — a credential file
|
|
// with a stray newline must not become a second command.
|
|
func quote(s string) string {
|
|
s = strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
|
}
|