294 lines
8.6 KiB
Go
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, `"`, `\"`) + `"`
|
|
}
|