package email import ( "bufio" "crypto/tls" "errors" "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" // MaxMessageBytes — the largest message this client will read into memory. // // The literal size comes off the wire, so an unbounded read is an allocation // the server picks: "{2147483647}" is a 2GB make() before a single byte // arrives, and one ordinary mail with a 60MB attachment is a 60MB peak RSS on a // box already holding a 1.7B model resident. All of it would then be thrown // away, because plaintextBody skips attachments and the body is truncated to // MaxBodyBytes anyway. // // 2 MiB is well above what prose plus quoted history plus base64 HTML needs and // well below what hurts. A larger message is drained and reported as // ErrMessageTooLarge rather than read. const MaxMessageBytes = 2 << 20 // readChunk — how much of a literal is read between deadline refreshes. The // per-connection timeout must stay an IDLE timeout: with one deadline around // the whole read it becomes a whole-message budget, and a healthy but slow // uplink then fails the same message on every poll forever. const readChunk = 64 << 10 // ErrMessageTooLarge — the server announced a literal above MaxMessageBytes. // The connection stays usable (the bytes are drained), and the caller decides // what to do with the UID. FetchSince retires it rather than retrying it. var ErrMessageTooLarge = errors.New("email: message larger than the read cap") // 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 { // A credential with a line break in it is rejected here, not silently // repaired. quote() strips CR and LF so a stray newline can never become a // second command, but stripping alone means a password file that picked up // a newline authenticates as a DIFFERENT string and comes back as the // server's generic NO, which is a long debugging session. This error names // the problem and cannot leak the value. if strings.ContainsAny(user, "\r\n") { return fmt.Errorf("email: login: username contains a line break") } if strings.ContainsAny(pass, "\r\n") { return fmt.Errorf("email: login: password contains a line break") } // 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. A FETCH response that came // back with no literal in it is NOT that case and is an error, so a message // that exists and was readable is never dropped without a log line. // // A literal above MaxMessageBytes is drained without being kept and reported as // ErrMessageTooLarge. 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 var sawFetch, tooLarge bool 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 { switch { case err != nil: return nil, fmt.Errorf("email: fetch %d: %w", uid, err) case tooLarge: return nil, fmt.Errorf("email: fetch %d: %w", uid, ErrMessageTooLarge) case sawFetch && raw == nil: // The server answered for this UID but not with a literal (a // quoted string, say). Silently skipping it would look exactly // like a vanished message. return nil, fmt.Errorf("email: fetch %d: no message literal in the FETCH response", uid) } return raw, nil } if strings.HasPrefix(line, "* ") && strings.Contains(line, " FETCH ") { sawFetch = true } m := literalSize.FindStringSubmatch(strings.TrimSpace(line)) if m == nil { continue } n, err := strconv.Atoi(m[1]) if err != nil || n < 0 { continue } if n > MaxMessageBytes { // Drained rather than read: the stream has to stay aligned for the // tagged completion, but nothing is allocated and nothing is parsed. tooLarge = true if err := c.discard(int64(n)); err != nil { return nil, fmt.Errorf("email: fetch %d: drain literal: %w", uid, err) } continue } buf, err := c.readN(n) if 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 && n > 0 { if err := c.discard(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 } // readN reads exactly n bytes, refreshing the deadline every readChunk so the // timeout stays an idle timeout rather than a budget for the whole literal. func (c *Conn) readN(n int) ([]byte, error) { buf := make([]byte, n) for off := 0; off < n; { end := off + readChunk if end > n { end = n } c.setDeadline() got, err := io.ReadFull(c.r, buf[off:end]) off += got if err != nil { return nil, err } } return buf, nil } // discard throws away n bytes of literal, same chunked deadline refresh as // readN and no allocation proportional to n. func (c *Conn) discard(n int64) error { for n > 0 { chunk := int64(readChunk) if chunk > n { chunk = n } c.setDeadline() got, err := io.CopyN(io.Discard, c.r, chunk) n -= got if err != nil { return 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. // // The key must be the whole word: a prefix test would also match a future // extension's "* SEARCHRES", and reading its payload as UIDs is the kind of // thing that ages badly next to an IMAP capability nobody asked for. 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 } rest = rest[len(key):] if rest != "" && rest[0] != ' ' && rest[0] != '\t' { return "", false } return strings.TrimSpace(rest), 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) + `"` }