// Package imapclient is a minimal hand-rolled IMAP client used by // provider_imap.go to talk to external IMAP servers (and, in tests, to // GoMail's own IMAP server — proving client and server interoperate). No // third-party IMAP library, matching the project's stdlib-first principle; // this mirrors the parsing approach in internal/imap but for the client role. package imapclient import ( "bufio" "crypto/tls" "encoding/base64" "fmt" "io" "net" "regexp" "strconv" "strings" "time" ) type Client struct { conn net.Conn r *bufio.Reader w *bufio.Writer tag int } // Dial connects and reads the server greeting. useTLS=true dials directly // into TLS (implicit-TLS port); otherwise the connection starts plaintext // and the caller may call StartTLS. func Dial(addr string, useTLS bool, tlsConf *tls.Config, timeout time.Duration) (*Client, error) { conn, err := net.DialTimeout("tcp", addr, timeout) if err != nil { return nil, fmt.Errorf("dial %s: %w", addr, err) } conn.SetDeadline(time.Now().Add(timeout)) if useTLS { conn = tls.Client(conn, tlsConf) } c := &Client{conn: conn, r: bufio.NewReader(conn), w: bufio.NewWriter(conn)} if _, err := c.readLine(); err != nil { // discard greeting text, just confirm we got one return nil, fmt.Errorf("reading greeting: %w", err) } return c, nil } func (c *Client) StartTLS(tlsConf *tls.Config) error { if err := c.simpleCommand("STARTTLS"); err != nil { return err } tlsConn := tls.Client(c.conn, tlsConf) c.conn = tlsConn c.r = bufio.NewReader(tlsConn) c.w = bufio.NewWriter(tlsConn) return nil } func (c *Client) Login(username, password string) error { return c.simpleCommand(fmt.Sprintf(`LOGIN %s %s`, quote(username), quote(password))) } // LoginXOAUTH2 authenticates using an OAuth2 access token instead of a // password — the mechanism Gmail and Microsoft 365 require for IMAP once // "less secure app access" / basic auth is disabled, which is the default // on both platforms today. saslPayload is base64-encoded here; callers // build the raw payload via oauth2.XOAUTH2SASLString. func (c *Client) LoginXOAUTH2(saslPayload string) error { encoded := base64.StdEncoding.EncodeToString([]byte(saslPayload)) _, tagged, err := c.command("AUTHENTICATE XOAUTH2 " + encoded) if err != nil { return err } if !strings.Contains(tagged, "OK") { return fmt.Errorf("XOAUTH2 authentication failed: %s", tagged) } return nil } func (c *Client) Logout() { c.simpleCommand("LOGOUT") c.conn.Close() } // FolderInfo is a parsed LIST response entry. type FolderInfo struct { Name string } func (c *Client) List() ([]FolderInfo, error) { lines, tagged, err := c.command(`LIST "" "*"`) if err != nil { return nil, err } if !strings.Contains(tagged, "OK") { return nil, fmt.Errorf("LIST failed: %s", tagged) } var folders []FolderInfo for _, line := range lines { if !strings.Contains(line, "LIST") { continue } // "* LIST () "/" INBOX" — take the last whitespace-separated token, // stripping quotes if present. fields := strings.Fields(line) if len(fields) == 0 { continue } name := strings.Trim(fields[len(fields)-1], `"`) folders = append(folders, FolderInfo{Name: name}) } return folders, nil } // SelectedInfo reports what a SELECT told us about the mailbox. type SelectedInfo struct { Exists int } func (c *Client) Select(mailbox string) (*SelectedInfo, error) { lines, tagged, err := c.command("SELECT " + quote(mailbox)) if err != nil { return nil, err } if !strings.Contains(tagged, "OK") { return nil, fmt.Errorf("SELECT failed: %s", tagged) } info := &SelectedInfo{} existsRE := regexp.MustCompile(`^\* (\d+) EXISTS`) for _, line := range lines { if m := existsRE.FindStringSubmatch(line); m != nil { info.Exists, _ = strconv.Atoi(m[1]) } } return info, nil } // FetchedMessage is one parsed FETCH response. type FetchedMessage struct { Seq int UID int Flags []string Body []byte // present if BODY[] or BODY[HEADER] was requested } // Fetch runs FETCH seqSet items and parses the responses. items should be // the raw IMAP item list, e.g. "(UID FLAGS BODY[])". func (c *Client) Fetch(seqSet, items string) ([]FetchedMessage, error) { lines, tagged, err := c.command(fmt.Sprintf("FETCH %s %s", seqSet, items)) if err != nil { return nil, err } if !strings.Contains(tagged, "OK") { return nil, fmt.Errorf("FETCH failed: %s", tagged) } return parseFetchLines(lines), nil } // UIDFetch runs "UID FETCH " — the UID variant is a // different command name on the wire (RFC 3501 §6.4.8), not a sequence-set // prefix, so this is not just Fetch with a different first argument. func (c *Client) UIDFetch(uidSet, items string) ([]FetchedMessage, error) { lines, tagged, err := c.command(fmt.Sprintf("UID FETCH %s %s", uidSet, items)) if err != nil { return nil, err } if !strings.Contains(tagged, "OK") { return nil, fmt.Errorf("UID FETCH failed: %s", tagged) } return parseFetchLines(lines), nil } func (c *Client) Store(seqSet, action, flags string) error { return c.simpleCommand(fmt.Sprintf("STORE %s %s (%s)", seqSet, action, flags)) } // UIDStore is "UID STORE" — same command-name distinction as UIDFetch. func (c *Client) UIDStore(uidSet, action, flags string) error { return c.simpleCommand(fmt.Sprintf("UID STORE %s %s (%s)", uidSet, action, flags)) } func (c *Client) Expunge() error { return c.simpleCommand("EXPUNGE") } // ── Command plumbing ──────────────────────────────────────────────────────────── // command sends one tagged command and returns every untagged response line // plus the final tagged status line. func (c *Client) command(cmd string) (untagged []string, tagged string, err error) { c.tag++ tag := fmt.Sprintf("C%03d", c.tag) c.w.WriteString(tag + " " + cmd + "\r\n") if err := c.w.Flush(); err != nil { return nil, "", err } for { line, err := c.readLine() if err != nil { return nil, "", err } if strings.HasPrefix(line, tag+" ") { return untagged, line, nil } untagged = append(untagged, line) } } func (c *Client) simpleCommand(cmd string) error { _, tagged, err := c.command(cmd) if err != nil { return err } if !strings.Contains(tagged, "OK") { return fmt.Errorf("%s failed: %s", strings.Fields(cmd)[0], tagged) } return nil } var literalRE = regexp.MustCompile(`\{(\d+)\+?\}$`) // readLine reads one logical IMAP response line, transparently absorbing any // literal ({N}\r\n) that appears in it — the literal's raw bytes // (which may contain embedded CRLFs, exactly why literals exist) are spliced // directly into the returned string, and reading continues until a line with // no trailing literal marker is found, so a "BODY[] {123}\r\n<123 // bytes>)\r\n" response comes back as one complete string ending in ")". func (c *Client) readLine() (string, error) { var full strings.Builder for { chunk, err := c.r.ReadString('\n') if err != nil { return "", err } chunk = strings.TrimRight(chunk, "\r\n") full.WriteString(chunk) if m := literalRE.FindStringSubmatch(chunk); m != nil { n, _ := strconv.Atoi(m[1]) buf := make([]byte, n) if _, err := io.ReadFull(c.r, buf); err != nil { return "", fmt.Errorf("reading literal (%d bytes): %w", n, err) } full.WriteString(string(buf)) continue // keep reading — more line content may follow the literal } return full.String(), nil } } // ── Parsing ─────────────────────────────────────────────────────────────────── var fetchHeaderRE = regexp.MustCompile(`(?s)^\* (\d+) FETCH \((.*)\)$`) var uidRE = regexp.MustCompile(`UID (\d+)`) var flagsRE = regexp.MustCompile(`FLAGS \(([^)]*)\)`) var bodyRE = regexp.MustCompile(`(?s)BODY(?:\.PEEK)?\[[A-Z]*\] \{\d+\}(.*)$`) func parseFetchLines(lines []string) []FetchedMessage { var out []FetchedMessage for _, line := range lines { m := fetchHeaderRE.FindStringSubmatch(line) if m == nil { continue } seq, _ := strconv.Atoi(m[1]) rest := m[2] msg := FetchedMessage{Seq: seq} if um := uidRE.FindStringSubmatch(rest); um != nil { msg.UID, _ = strconv.Atoi(um[1]) } if fm := flagsRE.FindStringSubmatch(rest); fm != nil { if fm[1] != "" { msg.Flags = strings.Fields(fm[1]) } } if bm := bodyRE.FindStringSubmatch(rest); bm != nil { body := bm[1] body = strings.TrimSuffix(body, ")") msg.Body = []byte(body) } out = append(out, msg) } return out } func quote(s string) string { return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` }