internal/imapc/imapc.go
387 lines · 10767 bytes
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}