first commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
// 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, `"`, `\"`) + `"`
|
||||
}
|
||||
Reference in New Issue
Block a user