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) + `"` }