/* Package imapclient provides an IMAP4 client, primarily for testing the IMAP4 server. Commands can be sent to the server free-form, but responses are parsed strictly. Behaviour that may not be required by the IMAP4 specification may be expected by this client. */ package imapclient /* - Try to keep the parsing method names and the types similar to the ABNF names in the RFCs. - todo: have mode for imap4rev1 vs imap4rev2, refusing what is not allowed. we are accepting too much now. - todo: stricter parsing. xnonspace() and xword() should be replaced by proper parsers. */ import ( "bufio" "crypto/tls" "fmt" "log/slog" "net" "reflect" "strings" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/moxio" ) // Conn is an IMAP connection to a server. type Conn struct { // Connection, may be original TCP or TLS connection. Reads go through c.br, and // writes through c.xbw. The "x" for the writes indicate that failed writes cause // an i/o panic, which is either turned into a returned error, or passed on (see // boolean panic). The reader and writer wrap a tracing reading/writer and may wrap // flate compression. conn net.Conn connBroken bool // If connection is broken, we won't flush (and write) again. br *bufio.Reader tr *moxio.TraceReader xbw *bufio.Writer compress bool // If compression is enabled, we must flush flateWriter and its target original bufio writer. xflateWriter *moxio.FlateWriter xflateBW *bufio.Writer xtw *moxio.TraceWriter log mlog.Log panic bool tagGen int record bool // If true, bytes read are added to recordBuf. recorded() resets. recordBuf []byte Preauth bool LastTag string CapAvailable map[Capability]struct{} // Capabilities available at server, from CAPABILITY command or response code. All uppercase. CapEnabled map[Capability]struct{} // Capabilities enabled through ENABLE command. All uppercase. } // Error is a parse or other protocol error. type Error struct{ err error } func (e Error) Error() string { return e.err.Error() } func (e Error) Unwrap() error { return e.err } // New creates a new client on conn. // // If xpanic is true, functions that would return an error instead panic. For parse // errors, the resulting stack traces show typically show what was being parsed. // // The initial untagged greeting response is read and must be "OK" or // "PREAUTH". If preauth, the connection is already in authenticated state, // typically through TLS client certificate. This is indicated in Conn.Preauth. func New(cid int64, conn net.Conn, xpanic bool) (client *Conn, rerr error) { log := mlog.New("imapclient", nil).WithCid(cid) c := Conn{ conn: conn, log: log, panic: xpanic, CapAvailable: map[Capability]struct{}{}, CapEnabled: map[Capability]struct{}{}, } c.tr = moxio.NewTraceReader(log, "CR: ", &c) c.br = bufio.NewReader(c.tr) // Writes are buffered and write to Conn, which may panic. c.xtw = moxio.NewTraceWriter(log, "CW: ", &c) c.xbw = bufio.NewWriter(c.xtw) defer c.recover(&rerr) tag := c.xnonspace() if tag != "*" { c.xerrorf("expected untagged *, got %q", tag) } c.xspace() ut := c.xuntagged() switch x := ut.(type) { case UntaggedResult: if x.Status != OK { c.xerrorf("greeting, got status %q, expected OK", x.Status) } return &c, nil case UntaggedPreauth: c.Preauth = true return &c, nil case UntaggedBye: c.xerrorf("greeting: server sent bye") default: c.xerrorf("unexpected untagged %v", ut) } panic("not reached") } func (c *Conn) recover(rerr *error) { if c.panic { return } x := recover() if x == nil { return } err, ok := x.(Error) if !ok { panic(x) } *rerr = err } func (c *Conn) xerrorf(format string, args ...any) { panic(Error{fmt.Errorf(format, args...)}) } func (c *Conn) xcheckf(err error, format string, args ...any) { if err != nil { c.xerrorf("%s: %w", fmt.Sprintf(format, args...), err) } } func (c *Conn) xcheck(err error) { if err != nil { panic(err) } } // Write writes directly to underlying connection (TCP, TLS). For internal use // only, to implement io.Writer. Write errors do take the connection's panic mode // into account, i.e. Write can panic. func (c *Conn) Write(buf []byte) (n int, rerr error) { defer c.recover(&rerr) n, rerr = c.conn.Write(buf) if rerr != nil { c.connBroken = true } c.xcheckf(rerr, "write") return n, nil } // Read reads directly from the underlying connection (TCP, TLS). For internal use // only, to implement io.Reader. func (c *Conn) Read(buf []byte) (n int, err error) { return c.conn.Read(buf) } func (c *Conn) xflush() { // Not writing any more when connection is broken. if c.connBroken { return } err := c.xbw.Flush() c.xcheckf(err, "flush") // If compression is active, we need to flush the deflate stream. if c.compress { err := c.xflateWriter.Flush() c.xcheckf(err, "flush deflate") err = c.xflateBW.Flush() c.xcheckf(err, "flush deflate buffer") } } func (c *Conn) xtraceread(level slog.Level) func() { c.tr.SetTrace(level) return func() { c.tr.SetTrace(mlog.LevelTrace) } } func (c *Conn) xtracewrite(level slog.Level) func() { c.xflush() c.xtw.SetTrace(level) return func() { c.xflush() c.xtw.SetTrace(mlog.LevelTrace) } } // SetPanic sets whether errors cause a panic instead of returning errors. func (c *Conn) SetPanic(panic bool) { c.panic = panic } // Close closes the connection, flushing and closing any compression and TLS layer. // // You may want to call Logout first. Closing a connection with a mailbox with // deleted messages not yet expunged will not expunge those messages. // // Closing a TLS connection that is logged out, or closing a TLS connection with // compression enabled (i.e. two layered streams), may cause spurious errors // because the server may immediate close the underlying connection when it sees // the connection is being closed. func (c *Conn) Close() (rerr error) { defer c.recover(&rerr) if c.conn == nil { return nil } if !c.connBroken && c.xflateWriter != nil { err := c.xflateWriter.Close() c.xcheckf(err, "close deflate writer") err = c.xflateBW.Flush() c.xcheckf(err, "flush deflate buffer") c.xflateWriter = nil c.xflateBW = nil } err := c.conn.Close() c.xcheckf(err, "close connection") c.conn = nil return } // TLSConnectionState returns the TLS connection state if the connection uses TLS. func (c *Conn) TLSConnectionState() *tls.ConnectionState { if conn, ok := c.conn.(*tls.Conn); ok { cs := conn.ConnectionState() return &cs } return nil } // Commandf writes a free-form IMAP command to the server. An ending \r\n is // written too. // If tag is empty, a next unique tag is assigned. func (c *Conn) Commandf(tag string, format string, args ...any) (rerr error) { defer c.recover(&rerr) if tag == "" { tag = c.nextTag() } c.LastTag = tag fmt.Fprintf(c.xbw, "%s %s\r\n", tag, fmt.Sprintf(format, args...)) c.xflush() return } func (c *Conn) nextTag() string { c.tagGen++ return fmt.Sprintf("x%03d", c.tagGen) } // Response reads from the IMAP server until a tagged response line is found. // The tag must be the same as the tag for the last written command. // Result holds the status of the command. The caller must check if this the status is OK. func (c *Conn) Response() (untagged []Untagged, result Result, rerr error) { defer c.recover(&rerr) for { tag := c.xnonspace() c.xspace() if tag == "*" { untagged = append(untagged, c.xuntagged()) continue } if tag != c.LastTag { c.xerrorf("got tag %q, expected %q", tag, c.LastTag) } status := c.xstatus() c.xspace() result = c.xresult(status) c.xcrlf() return } } // ReadUntagged reads a single untagged response line. // Useful for reading lines from IDLE. func (c *Conn) ReadUntagged() (untagged Untagged, rerr error) { defer c.recover(&rerr) tag := c.xnonspace() if tag != "*" { c.xerrorf("got tag %q, expected untagged", tag) } c.xspace() ut := c.xuntagged() return ut, nil } // Readline reads a line, including CRLF. // Used with IDLE and synchronous literals. func (c *Conn) Readline() (line string, rerr error) { defer c.recover(&rerr) line, err := c.br.ReadString('\n') c.xcheckf(err, "read line") return line, nil } // ReadContinuation reads a line. If it is a continuation, i.e. starts with a +, it // is returned without leading "+ " and without trailing crlf. Otherwise, a command // response is returned. A successfully read continuation can return an empty line. // Callers should check rerr and result.Status being empty to check if a // continuation was read. func (c *Conn) ReadContinuation() (line string, untagged []Untagged, result Result, rerr error) { defer c.recover(&rerr) if !c.peek('+') { untagged, result, rerr = c.Response() if result.Status == OK { c.xerrorf("unexpected OK instead of continuation") } return } c.xtake("+ ") line, err := c.Readline() c.xcheckf(err, "read line") line = strings.TrimSuffix(line, "\r\n") return } // Writelinef writes the formatted format and args as a single line, adding CRLF. // Used with IDLE and synchronous literals. func (c *Conn) Writelinef(format string, args ...any) (rerr error) { defer c.recover(&rerr) s := fmt.Sprintf(format, args...) fmt.Fprintf(c.xbw, "%s\r\n", s) c.xflush() return nil } // WriteSyncLiteral first writes the synchronous literal size, then reads the // continuation "+" and finally writes the data. func (c *Conn) WriteSyncLiteral(s string) (untagged []Untagged, rerr error) { defer c.recover(&rerr) fmt.Fprintf(c.xbw, "{%d}\r\n", len(s)) c.xflush() plus, err := c.br.Peek(1) c.xcheckf(err, "read continuation") if plus[0] == '+' { _, err = c.Readline() c.xcheckf(err, "read continuation line") defer c.xtracewrite(mlog.LevelTracedata)() _, err = c.xbw.Write([]byte(s)) c.xcheckf(err, "write literal data") c.xtracewrite(mlog.LevelTrace) return nil, nil } untagged, result, err := c.Response() if err == nil && result.Status == OK { c.xerrorf("no continuation, but invalid ok response (%q)", result.More) } return untagged, fmt.Errorf("no continuation (%s)", result.Status) } // Transactf writes format and args as an IMAP command, using Commandf with an // empty tag. I.e. format must not contain a tag. Transactf then reads a response // using ReadResponse and checks the result status is OK. func (c *Conn) Transactf(format string, args ...any) (untagged []Untagged, result Result, rerr error) { defer c.recover(&rerr) err := c.Commandf("", format, args...) if err != nil { return nil, Result{}, err } return c.ResponseOK() } func (c *Conn) ResponseOK() (untagged []Untagged, result Result, rerr error) { untagged, result, rerr = c.Response() if rerr != nil { return nil, Result{}, rerr } if result.Status != OK { c.xerrorf("response status %q, expected OK", result.Status) } return untagged, result, rerr } func (c *Conn) xgetUntagged(l []Untagged, dst any) { if len(l) != 1 { c.xerrorf("got %d untagged, expected 1: %v", len(l), l) } got := l[0] gotv := reflect.ValueOf(got) dstv := reflect.ValueOf(dst) if gotv.Type() != dstv.Type().Elem() { c.xerrorf("got %v, expected %v", gotv.Type(), dstv.Type().Elem()) } dstv.Elem().Set(gotv) }