diff --git a/imapclient/client.go b/imapclient/client.go index 620bce36..fdf97c77 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -371,6 +371,8 @@ func (c *Client) setCaps(caps imap.CapSet) { c.mutex.Lock() c.caps = caps c.pendingCapCh = capCh + quotedUTF8 := c.caps.Has(imap.CapIMAP4rev2) || c.enabled.Has(imap.CapUTF8Accept) + c.dec.QuotedUTF8 = quotedUTF8 c.mutex.Unlock() } diff --git a/imapclient/enable.go b/imapclient/enable.go index 89576664..717d374e 100644 --- a/imapclient/enable.go +++ b/imapclient/enable.go @@ -43,6 +43,9 @@ func (c *Client) handleEnabled() error { for name := range caps { c.enabled[name] = struct{}{} } + + quotedUTF8 := c.caps.Has(imap.CapIMAP4rev2) || c.enabled.Has(imap.CapUTF8Accept) + c.dec.QuotedUTF8 = quotedUTF8 c.mutex.Unlock() if cmd := findPendingCmdByType[*EnableCommand](c); cmd != nil { diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..d6c8e8f8 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -179,6 +179,15 @@ func (c *Conn) serve() { dec.MaxSize = maxCommandSize dec.CheckBufferedLiteralFunc = c.checkBufferedLiteral + c.mutex.Lock() + // IMAP4rev2 is automatically enabled when advertised in capabilities + // UTF8=ACCEPT must be explicitly enabled + imap4rev2Available := c.server.options.caps().Has(imap.CapIMAP4rev2) + utf8AcceptEnabled := c.enabled.Has(imap.CapUTF8Accept) + quotedUTF8 := imap4rev2Available || utf8AcceptEnabled + c.mutex.Unlock() + dec.QuotedUTF8 = quotedUTF8 + if c.state == imap.ConnStateLogout || dec.EOF() { break } @@ -492,7 +501,11 @@ type responseEncoder struct { func newResponseEncoder(conn *Conn) *responseEncoder { conn.mutex.Lock() - quotedUTF8 := conn.enabled.Has(imap.CapIMAP4rev2) || conn.enabled.Has(imap.CapUTF8Accept) + // IMAP4rev2 is automatically enabled when advertised in capabilities + // UTF8=ACCEPT must be explicitly enabled + imap4rev2Available := conn.server.options.caps().Has(imap.CapIMAP4rev2) + utf8AcceptEnabled := conn.enabled.Has(imap.CapUTF8Accept) + quotedUTF8 := imap4rev2Available || utf8AcceptEnabled conn.mutex.Unlock() wireEnc := imapwire.NewEncoder(conn.bw, imapwire.ConnSideServer) diff --git a/imapserver/list.go b/imapserver/list.go index 1aab73d7..3fe73f93 100644 --- a/imapserver/list.go +++ b/imapserver/list.go @@ -206,7 +206,12 @@ func readListMailbox(dec *imapwire.Decoder) (string, error) { return "", dec.Err() } } - return utf7.Decode(mailbox) + + if dec.QuotedUTF8 { + return utf7.Unescape(mailbox) + } else { + return utf7.Decode(mailbox) + } } func isListChar(ch byte) bool { diff --git a/internal/imapwire/decoder.go b/internal/imapwire/decoder.go index cfd2995c..24afd56b 100644 --- a/internal/imapwire/decoder.go +++ b/internal/imapwire/decoder.go @@ -55,6 +55,9 @@ type Decoder struct { // MaxSize defines a maximum number of bytes to be read from the input. // Literals are ignored. MaxSize int64 + // QuotedUTF8 allows raw UTF-8 in quoted strings. This requires IMAP4rev2 + // to be available, or UTF8=ACCEPT to be enabled. + QuotedUTF8 bool r *bufio.Reader side ConnSide @@ -517,7 +520,13 @@ func (dec *Decoder) ExpectMailbox(ptr *string) bool { *ptr = "INBOX" return true } - name, err := utf7.Decode(name) + + var err error + if dec.QuotedUTF8 { + name, err = utf7.Unescape(name) + } else { + name, err = utf7.Decode(name) + } if err == nil { *ptr = name } diff --git a/internal/utf7/decoder.go b/internal/utf7/decoder.go index b8e906e4..205dd14e 100644 --- a/internal/utf7/decoder.go +++ b/internal/utf7/decoder.go @@ -116,3 +116,34 @@ func decode(b64 []byte) []byte { } return s[:j] } + +// Unescape passes through raw UTF-8 as-is and unescapes the special UTF-7 marker +// (the "&-" sequence back to "&"). +func Unescape(src string) (string, error) { + if !utf8.ValidString(src) { + return "", errors.New("invalid UTF-8") + } + + var sb strings.Builder + sb.Grow(len(src)) + + for i := 0; i < len(src); i++ { + ch := src[i] + + if ch != '&' { + sb.WriteByte(ch) + continue + } + + // Check if this is an escape sequence "&-" + if i+1 < len(src) && src[i+1] == '-' { + sb.WriteByte('&') + i++ // Skip the '-' + } else { + // This is not an escape sequence, keep the '&' as-is + sb.WriteByte(ch) + } + } + + return sb.String(), nil +}