Files
gomail/internal/imapclient/client.go
T
2026-08-09 18:03:09 +01:00

294 lines
8.6 KiB
Go

// 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 <uidSet> <items>" — 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<N bytes>) 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, `"`, `\"`) + `"`
}