// Package imapclient is a deliberately minimal IMAP4rev1 reader: connect, authenticate, select a mailbox, // search by date, and fetch a NAMED SET OF HEADERS. It cannot fetch a message body, and that is a property of // the code rather than a convention somebody has to remember. // // WHY IT IS WRITTEN HERE RATHER THAN TAKEN FROM A LIBRARY. This codebase already made this call once, for // TOTP, and recorded the reasoning: implemented in house on the standard library so every primitive stays // provably inside the FIPS validated module and there is no external crypto to re verify under fips140=on. // The same argument applies with more force to a mail client, which is a network client handling // authentication on a platform holding controlled unclassified information. Every byte of crypto here is // crypto/tls. The subset of IMAP needed is small enough that writing it is cheaper than auditing somebody // else's. // // IT IS NOT A GENERAL IMAP CLIENT AND MUST NOT GROW INTO ONE. It does the five commands the activity sync // needs. A feature that needs more of IMAP should be a conversation about whether it needs IMAP. package imapclient import ( "bufio" "crypto/tls" "errors" "fmt" "net" "strconv" "strings" "time" ) var ( ErrAuth = errors.New("imap: the mail server refused those credentials") ErrNoMailbox = errors.New("imap: no such mailbox") ErrProtocol = errors.New("imap: unexpected response from the mail server") ErrBlockedHost = errors.New("imap: that mail host resolves to an address inside this network and is refused") ) // Config is one mailbox connection. There is no security mode: it is ALWAYS implicit TLS, because port 993 // with TLS from the first byte is universally supported and a negotiated upgrade is one more thing that can // silently fail open. type Config struct { Host string Port int Username string Password string } // Header is what one message yields. No body, ever. type Header struct { MessageID string From string To []string Cc []string Subject string Date time.Time } // Client is a live connection. Always Close it. type Client struct { conn net.Conn r *bufio.Reader seq int } const ( dialTimeout = 15 * time.Second ioTimeout = 45 * time.Second // maxLine bounds a single response line. A hostile or broken server must not be able to make this // allocate without limit. maxLine = 1 << 20 // maxFetch bounds how many messages one sync pulls, so a first connection to a very busy mailbox is a // bounded amount of work rather than an afternoon of traffic. maxFetch = 500 ) // Dial connects and authenticates. // // THE HOST IS RESOLVED AND CHECKED BEFORE ANYTHING IS DIALLED, then the CHECKED ADDRESS is connected to // rather than the name. That is the same rebinding safe rule the mail sender follows, and it exists because a // customer supplied host name is a server side request forgery primitive unless it is bounded. func Dial(cfg Config) (*Client, error) { if cfg.Host == "" || cfg.Port <= 0 { return nil, errors.New("imap: no mail host configured") } ip, err := resolveAllowed(cfg.Host) if err != nil { return nil, err } addr := net.JoinHostPort(ip.String(), strconv.Itoa(cfg.Port)) // ServerName is the NAME the customer configured, never the resolved address, so certificate validation // still checks what it is supposed to check. conn, err := tls.DialWithDialer( &net.Dialer{Timeout: dialTimeout}, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}) if err != nil { return nil, fmt.Errorf("imap: connect to %s: %w", cfg.Host, err) } _ = conn.SetDeadline(time.Now().Add(ioTimeout)) c := &Client{conn: conn, r: bufio.NewReaderSize(conn, 64<<10)} // The greeting. line, err := c.readLine() if err != nil { conn.Close() return nil, fmt.Errorf("imap: greeting: %w", err) } if !strings.HasPrefix(line, "* OK") { conn.Close() return nil, fmt.Errorf("%w: greeting was %q", ErrProtocol, clip(line, 120)) } if _, err := c.cmd(fmt.Sprintf("LOGIN %s %s", quote(cfg.Username), quote(cfg.Password))); err != nil { conn.Close() // The server's own words are NOT returned here. An authentication failure message can echo the // credentials it rejected, and this error is shown to a user and stored on a row. return nil, ErrAuth } return c, nil } func (c *Client) Close() error { if c.conn == nil { return nil } _, _ = c.cmd("LOGOUT") return c.conn.Close() } // Select opens a mailbox read only. EXAMINE rather than SELECT, deliberately: it opens without marking // anything read, so syncing a recruiter's mailbox never changes what their mail client shows them. A sync // that silently marked mail read would be noticed once and switched off forever. func (c *Client) Select(mailbox string) error { if _, err := c.cmd("EXAMINE " + quote(mailbox)); err != nil { return fmt.Errorf("%w: %s", ErrNoMailbox, mailbox) } return nil } // SearchSince returns the identifiers of messages since a date, newest bounded by maxFetch. func (c *Client) SearchSince(since time.Time) ([]string, error) { // The IMAP date format is fixed and locale free. lines, err := c.cmd("UID SEARCH SINCE " + since.Format("02-Jan-2006")) if err != nil { return nil, err } var ids []string for _, l := range lines { if !strings.HasPrefix(l, "* SEARCH") { continue } for _, f := range strings.Fields(strings.TrimPrefix(l, "* SEARCH")) { if _, e := strconv.Atoi(f); e == nil { ids = append(ids, f) } } } // The newest are the interesting ones, so an oversized result keeps the tail rather than the head. if len(ids) > maxFetch { ids = ids[len(ids)-maxFetch:] } return ids, nil } // FetchHeaders retrieves the named header fields for the given identifiers. // // BODY.PEEK is used rather than BODY, so fetching does not set the Seen flag. PEEK and EXAMINE together mean // this is a genuinely read only operation on somebody's mailbox. // // THE FIELD LIST IS FIXED AND CANNOT BE WIDENED BY A CALLER. That is what makes "no bodies" a property of // the code: there is no parameter through which a body could be requested. func (c *Client) FetchHeaders(uids []string) ([]Header, error) { out := make([]Header, 0, len(uids)) // Batched, because one FETCH per message is one round trip per message and a mailbox has thousands. const batch = 50 for i := 0; i < len(uids); i += batch { end := i + batch if end > len(uids) { end = len(uids) } set := strings.Join(uids[i:end], ",") lines, err := c.cmd("UID FETCH " + set + " (BODY.PEEK[HEADER.FIELDS (MESSAGE-ID FROM TO CC SUBJECT DATE)])") if err != nil { return out, err } out = append(out, parseFetch(lines)...) } return out, nil } // cmd sends a tagged command and reads until its completion line. func (c *Client) cmd(s string) ([]string, error) { c.seq++ tag := fmt.Sprintf("a%03d", c.seq) _ = c.conn.SetDeadline(time.Now().Add(ioTimeout)) if _, err := c.conn.Write([]byte(tag + " " + s + "\r\n")); err != nil { return nil, fmt.Errorf("imap: write: %w", err) } var lines []string for { line, err := c.readLine() if err != nil { return lines, fmt.Errorf("imap: read: %w", err) } if strings.HasPrefix(line, tag+" ") { rest := strings.TrimPrefix(line, tag+" ") if strings.HasPrefix(rest, "OK") { return lines, nil } // The server's failure text is deliberately NOT wrapped into the error, for the same reason the // login path does not: it can echo what was sent. return lines, ErrProtocol } lines = append(lines, line) if len(lines) > 200000 { return lines, fmt.Errorf("%w: response did not terminate", ErrProtocol) } } } func (c *Client) readLine() (string, error) { line, err := c.r.ReadString('\n') if len(line) > maxLine { return "", fmt.Errorf("%w: response line exceeded %d bytes", ErrProtocol, maxLine) } return strings.TrimRight(line, "\r\n"), err } // quote produces an IMAP quoted string. Backslash and quote are escaped, and a control character is REMOVED // rather than escaped, because a newline inside a command is command injection against the IMAP session in // exactly the way a newline in a mail header is header injection. func quote(s string) string { var b strings.Builder b.WriteByte('"') for i := 0; i < len(s); i++ { ch := s[i] if ch < 0x20 || ch == 0x7f { continue } if ch == '"' || ch == '\\' { b.WriteByte('\\') } b.WriteByte(ch) } b.WriteByte('"') return b.String() } func clip(s string, n int) string { if len(s) > n { return s[:n] } return s } // resolveAllowed and blockedIP mirror the mail sender's reachability boundary exactly. They are duplicated // rather than shared because the two packages have no other reason to depend on each other, and a boundary // this important is better stated twice than reached for through an import that could later be weakened for // an unrelated caller. Both are tested independently. func resolveAllowed(host string) (net.IP, error) { if ip := net.ParseIP(host); ip != nil { if blockedIP(ip) { return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host) } return ip, nil } ips, err := net.LookupIP(host) if err != nil { return nil, fmt.Errorf("imap: resolve %s: %w", host, err) } for _, ip := range ips { if !blockedIP(ip) { return ip, nil } } return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host) } func blockedIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() { return true } if v4 := ip.To4(); v4 != nil { switch { case v4[0] == 169 && v4[1] == 254: // cloud instance metadata return true case v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127: // carrier grade NAT return true case v4[0] >= 240: return true } return false } return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc }