internal/imapc/imapc.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/imapc/imapc.go history · blame · raw

387 lines · 10767 bytes

31 symbols in this file
  1// Package imapc is the IMAP4rev1 client the reply-by-mail poller needs
  2// (#295): LOGIN, SELECT or EXAMINE, UID SEARCH UNSEEN, UID FETCH
  3// BODY.PEEK[], UID STORE +FLAGS (\Seen), LOGOUT. Nothing else. The
  4// connection is TLS from the first byte or upgraded with STARTTLS
  5// before LOGIN; there is no plaintext mode.
  6package imapc
  7
  8import (
  9	"bufio"
 10	"crypto/tls"
 11	"crypto/x509"
 12	"errors"
 13	"fmt"
 14	"io"
 15	"net"
 16	"strconv"
 17	"strings"
 18	"time"
 19)
 20
 21// MaxMessage is the largest message Fetch returns; a larger one is
 22// refused by its RFC822.SIZE before its body is fetched (ErrTooLarge).
 23const MaxMessage = 10 << 20
 24
 25// Limits on what one command may make the client read. A server that
 26// exceeds one has its connection closed and the command returns
 27// ErrLimit.
 28const (
 29	cmdBudget   = MaxMessage + 1<<20 // bytes read for one command
 30	maxUntagged = 1000               // untagged responses to one command
 31	maxLine     = 1 << 20            // one response line outside literals
 32)
 33
 34// MaxUnseen is the most UIDs Unseen returns; the rest wait for the next
 35// poll.
 36const MaxUnseen = 10000
 37
 38// RefusedError is the server answering a command NO or BAD, or
 39// answering a FETCH with no message: a refusal of that command, with the
 40// session still usable.
 41type RefusedError struct{ Text string }
 42
 43func (e *RefusedError) Error() string { return e.Text }
 44
 45var (
 46	ErrTooLarge = errors.New("message larger than the fetch limit")
 47	ErrLimit    = errors.New("IMAP server exceeded a response limit; connection closed")
 48)
 49
 50// rootCAs verifies the server's certificate; nil is the system pool.
 51// Tests set it.
 52var rootCAs *x509.CertPool
 53
 54// Client is one authenticated IMAP session.
 55type Client struct {
 56	conn net.Conn
 57	r    *bufio.Reader
 58	tag  int
 59	left int64 // bytes the current command may still read
 60}
 61
 62// Dial connects to addr (host:port) and reads the greeting. With
 63// starttls it upgrades the connection before returning; otherwise TLS
 64// runs from the first byte.
 65func Dial(addr string, starttls bool, timeout time.Duration) (*Client, error) {
 66	host, _, err := net.SplitHostPort(addr)
 67	if err != nil {
 68		return nil, err
 69	}
 70	tlsCfg := &tls.Config{ServerName: host, RootCAs: rootCAs, MinVersion: tls.VersionTLS12}
 71	d := &net.Dialer{Timeout: timeout}
 72	var conn net.Conn
 73	if starttls {
 74		conn, err = d.Dial("tcp", addr)
 75	} else {
 76		conn, err = tls.DialWithDialer(d, "tcp", addr, tlsCfg)
 77	}
 78	if err != nil {
 79		return nil, err
 80	}
 81	c := New(conn)
 82	conn.SetDeadline(time.Now().Add(timeout))
 83	if err := c.greeting(); err != nil {
 84		conn.Close()
 85		return nil, err
 86	}
 87	if starttls {
 88		if _, err := c.cmd("STARTTLS"); err != nil {
 89			conn.Close()
 90			return nil, fmt.Errorf("STARTTLS: %w", err)
 91		}
 92		tc := tls.Client(conn, tlsCfg)
 93		if err := tc.Handshake(); err != nil {
 94			conn.Close()
 95			return nil, fmt.Errorf("STARTTLS: %w", err)
 96		}
 97		c.conn, c.r = tc, bufio.NewReader(tc)
 98	}
 99	return c, nil
100}
101
102// New wraps a connection that is already past its TLS handshake, or a
103// test's pipe. The greeting has not been read.
104func New(conn net.Conn) *Client {
105	return &Client{conn: conn, r: bufio.NewReader(conn)}
106}
107
108// SetDeadline bounds the session's remaining I/O.
109func (c *Client) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) }
110
111func (c *Client) greeting() error {
112	c.left = cmdBudget
113	resp, err := c.readResponse()
114	line := resp.line
115	if err != nil {
116		return err
117	}
118	if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
119		return fmt.Errorf("unexpected greeting %q", clip(line))
120	}
121	return nil
122}
123
124// Login authenticates. The password goes as a quoted string, so it may
125// not hold a line break or a byte outside printable ASCII.
126func (c *Client) Login(user, pass string) error {
127	u, err := quote(user)
128	if err != nil {
129		return fmt.Errorf("user: %w", err)
130	}
131	p, err := quote(pass)
132	if err != nil {
133		return fmt.Errorf("password: %w", err)
134	}
135	if _, err := c.cmd("LOGIN " + u + " " + p); err != nil {
136		// The server's text is not echoed: some quote the command.
137		return errors.New("LOGIN refused")
138	}
139	return nil
140}
141
142// Select opens mailbox for reading and writing flags; Examine opens it
143// read-only. Both return the number of messages in it.
144func (c *Client) Select(mailbox string) (int, error)  { return c.open("SELECT", mailbox) }
145func (c *Client) Examine(mailbox string) (int, error) { return c.open("EXAMINE", mailbox) }
146
147func (c *Client) open(verb, mailbox string) (int, error) {
148	m, err := quote(mailbox)
149	if err != nil {
150		return 0, err
151	}
152	untagged, err := c.cmd(verb + " " + m)
153	if err != nil {
154		return 0, fmt.Errorf("%s %s: %w", verb, mailbox, err)
155	}
156	exists := 0
157	for _, u := range untagged {
158		f := strings.Fields(u.line)
159		if len(f) >= 3 && strings.EqualFold(f[2], "EXISTS") {
160			exists, _ = strconv.Atoi(f[1])
161		}
162	}
163	return exists, nil
164}
165
166// Unseen returns the UIDs of messages without \Seen.
167func (c *Client) Unseen() ([]uint32, error) {
168	untagged, err := c.cmd("UID SEARCH UNSEEN")
169	if err != nil {
170		return nil, fmt.Errorf("UID SEARCH: %w", err)
171	}
172	var uids []uint32
173	for _, u := range untagged {
174		f := strings.Fields(u.line)
175		if len(f) < 2 || !strings.EqualFold(f[1], "SEARCH") {
176			continue
177		}
178		for _, s := range f[2:] {
179			n, err := strconv.ParseUint(s, 10, 32)
180			if err != nil {
181				return nil, fmt.Errorf("UID SEARCH: bad uid %q", clip(s))
182			}
183			if len(uids) < MaxUnseen {
184				uids = append(uids, uint32(n))
185			}
186		}
187	}
188	return uids, nil
189}
190
191// Fetch returns the whole message without setting \Seen. A message
192// over MaxMessage is refused by its size first. A server answering
193// BODY[] with NIL or "" returns an empty message.
194func (c *Client) Fetch(uid uint32) ([]byte, error) {
195	untagged, err := c.cmd(fmt.Sprintf("UID FETCH %d RFC822.SIZE", uid))
196	if err != nil {
197		return nil, fmt.Errorf("UID FETCH: %w", err)
198	}
199	for _, u := range untagged {
200		up := strings.ToUpper(u.line)
201		if i := strings.Index(up, "RFC822.SIZE "); i >= 0 && strings.Contains(up, " FETCH ") {
202			f := strings.Fields(strings.TrimRight(up[i+len("RFC822.SIZE "):], ")"))
203			if len(f) > 0 {
204				if n, err := strconv.ParseInt(strings.TrimRight(f[0], ")"), 10, 64); err == nil && n > MaxMessage {
205					return nil, ErrTooLarge
206				}
207			}
208		}
209	}
210	untagged, err = c.cmd(fmt.Sprintf("UID FETCH %d BODY.PEEK[]", uid))
211	if err != nil {
212		return nil, fmt.Errorf("UID FETCH: %w", err)
213	}
214	for _, u := range untagged {
215		f := strings.Fields(u.line)
216		if len(f) < 3 || !strings.EqualFold(f[2], "FETCH") {
217			continue
218		}
219		if u.tooLarge {
220			return nil, ErrTooLarge
221		}
222		if u.body != nil {
223			return u.body, nil
224		}
225		up := strings.ToUpper(u.line)
226		if strings.Contains(up, "BODY[] NIL") || strings.Contains(up, `BODY[] ""`) {
227			return []byte{}, nil
228		}
229	}
230	return nil, &RefusedError{fmt.Sprintf("UID FETCH %d: no message body in the response", uid)}
231}
232
233// MarkSeen sets \Seen.
234func (c *Client) MarkSeen(uid uint32) error {
235	if _, err := c.cmd(fmt.Sprintf("UID STORE %d +FLAGS.SILENT (\\Seen)", uid)); err != nil {
236		return fmt.Errorf("UID STORE: %w", err)
237	}
238	return nil
239}
240
241// Close logs out and closes the connection.
242func (c *Client) Close() error {
243	c.cmd("LOGOUT")
244	return c.conn.Close()
245}
246
247type response struct {
248	line     string // the response with each literal's bytes left out
249	body     []byte // the BODY[] literal, when the response carried one
250	tooLarge bool   // the BODY[] literal was over MaxMessage and not kept
251}
252
253// cmd sends one tagged command and collects the untagged responses up to
254// its completion. A NO or BAD completion is an error.
255func (c *Client) cmd(command string) ([]response, error) {
256	c.tag++
257	c.left = cmdBudget
258	tag := "g" + strconv.Itoa(c.tag)
259	if _, err := io.WriteString(c.conn, tag+" "+command+"\r\n"); err != nil {
260		return nil, err
261	}
262	var untagged []response
263	for {
264		resp, err := c.readResponse()
265		if err != nil {
266			return nil, err
267		}
268		line := resp.line
269		if rest, ok := strings.CutPrefix(line, tag+" "); ok {
270			status, _, _ := strings.Cut(rest, " ")
271			if strings.EqualFold(status, "OK") {
272				return untagged, nil
273			}
274			return nil, &RefusedError{clip(rest)}
275		}
276		if strings.HasPrefix(line, "* BYE") && command != "LOGOUT" {
277			return nil, fmt.Errorf("server closed the session: %s", clip(line))
278		}
279		if strings.HasPrefix(line, "*") {
280			if len(untagged) >= maxUntagged {
281				return nil, c.limit()
282			}
283			untagged = append(untagged, resp)
284		}
285		// A "+" continuation is not expected: no command here sends a
286		// literal.
287	}
288}
289
290// limit closes a connection whose server exceeded a limit.
291func (c *Client) limit() error {
292	c.conn.Close()
293	return ErrLimit
294}
295
296// readResponse reads one response: a line, and for each literal it
297// announces ("{n}" at the end of a line) the n bytes and the rest of the
298// response after them. Only the literal after "BODY[]" is kept; any
299// other is read and discarded. Everything read counts against the
300// command's budget.
301func (c *Client) readResponse() (response, error) {
302	var b strings.Builder
303	var resp response
304	for {
305		line, err := c.readLine()
306		if err != nil {
307			return response{}, err
308		}
309		b.WriteString(line)
310		n, ok := literalSize(line)
311		if !ok {
312			resp.line = b.String()
313			return resp, nil
314		}
315		if n > c.left {
316			return response{}, c.limit()
317		}
318		c.left -= n
319		prefix := strings.TrimRight(line[:strings.LastIndexByte(line, '{')], " ")
320		keep := resp.body == nil && !resp.tooLarge && strings.HasSuffix(strings.ToUpper(prefix), "BODY[]")
321		if !keep || n > MaxMessage {
322			if _, err := io.CopyN(io.Discard, c.r, n); err != nil {
323				return response{}, err
324			}
325			if keep {
326				resp.tooLarge = true
327			}
328			continue
329		}
330		buf := make([]byte, n)
331		if _, err := io.ReadFull(c.r, buf); err != nil {
332			return response{}, err
333		}
334		resp.body = buf
335	}
336}
337
338func (c *Client) readLine() (string, error) {
339	var b []byte
340	for {
341		chunk, isPrefix, err := c.r.ReadLine()
342		if err != nil {
343			return "", err
344		}
345		b = append(b, chunk...)
346		c.left -= int64(len(chunk)) + 2
347		if len(b) > maxLine || c.left < 0 {
348			return "", c.limit()
349		}
350		if !isPrefix {
351			return string(b), nil
352		}
353	}
354}
355
356// literalSize reads a trailing "{n}" (or "{n+}").
357func literalSize(line string) (int64, bool) {
358	if !strings.HasSuffix(line, "}") {
359		return 0, false
360	}
361	i := strings.LastIndexByte(line, '{')
362	if i < 0 {
363		return 0, false
364	}
365	n, err := strconv.ParseInt(strings.TrimSuffix(line[i+1:len(line)-1], "+"), 10, 64)
366	if err != nil || n < 0 {
367		return 0, false
368	}
369	return n, true
370}
371
372// quote renders s as an IMAP quoted string.
373func quote(s string) (string, error) {
374	for i := 0; i < len(s); i++ {
375		if s[i] < 0x20 || s[i] > 0x7e {
376			return "", errors.New("only printable ASCII can be sent")
377		}
378	}
379	return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`, nil
380}
381
382func clip(s string) string {
383	if len(s) > 200 {
384		return s[:200] + "…"
385	}
386	return s
387}