diff --git a/go.mod b/go.mod index 4700a303..49935675 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,5 @@ go 1.18 require ( github.com/emersion/go-message v0.18.1 github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 + golang.org/x/text v0.14.0 ) diff --git a/go.sum b/go.sum index 1a059484..ce241ed1 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/imapserver/capability.go b/imapserver/capability.go index 37da104b..d76ebb8d 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -112,3 +112,12 @@ func addAvailableCaps(caps *[]imap.Cap, available imap.CapSet, l []imap.Cap) { } } } + +func (c *Conn) availableCapsSet() imap.CapSet { + caps := c.availableCaps() + capSet := make(imap.CapSet) + for _, cap := range caps { + capSet[cap] = struct{}{} + } + return capSet +} diff --git a/imapserver/search.go b/imapserver/search.go index 91466818..7545f075 100644 --- a/imapserver/search.go +++ b/imapserver/search.go @@ -8,6 +8,8 @@ import ( "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/internal" "github.com/emersion/go-imap/v2/internal/imapwire" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) func (c *Conn) handleSearch(tag string, dec *imapwire.Decoder, numKind NumKind) error { @@ -85,7 +87,22 @@ func (c *Conn) handleSearch(tag string, dec *imapwire.Decoder, numKind NumKind) return err } - if c.enabled.Has(imap.CapIMAP4rev2) || extended { + var supportsESEARCH bool + if capSession, ok := c.session.(SessionCapabilities); ok { + sessionCaps := capSession.GetCapabilities() + supportsESEARCH = sessionCaps.Has(imap.CapESearch) || sessionCaps.Has(imap.CapIMAP4rev2) + } else { + availableCaps := c.availableCaps() + for _, cap := range availableCaps { + if cap == imap.CapESearch || cap == imap.CapIMAP4rev2 { + supportsESEARCH = true + break + } + } + } + + // Use ESEARCH format only if session supports it AND client used extended syntax + if supportsESEARCH && extended { return c.writeESearch(tag, data, &options, numKind) } else { return c.writeSearch(data.All) @@ -98,15 +115,17 @@ func (c *Conn) writeESearch(tag string, data *imap.SearchData, options *imap.Sea enc.Atom("*").SP().Atom("ESEARCH") if tag != "" { - enc.SP().Special('(').Atom("TAG").SP().Atom(tag).Special(')') + enc.SP().Special('(').Atom("TAG").SP().String(tag).Special(')') } if numKind == NumKindUID { enc.SP().Atom("UID") } - // When there is no result, we need to send an ESEARCH response with no ALL - // keyword - if options.ReturnAll && !isNumSetEmpty(data.All) { - enc.SP().Atom("ALL").SP().NumSet(data.All) + // When there is no result, we need to send an ESEARCH response with no sequence set after ALL. + if options.ReturnAll { + enc.SP().Atom("ALL") + if data.All != nil && !isNumSetEmpty(data.All) { + enc.SP().NumSet(data.All) + } } if options.ReturnMin && data.Min > 0 { enc.SP().Atom("MIN").SP().Number(data.Min) @@ -136,24 +155,28 @@ func (c *Conn) writeSearch(numSet imap.NumSet) error { defer enc.end() enc.Atom("*").SP().Atom("SEARCH") - var ok bool - switch numSet := numSet.(type) { - case imap.SeqSet: - var nums []uint32 - nums, ok = numSet.Nums() - for _, num := range nums { - enc.SP().Number(num) + + if numSet != nil { + var ok bool + switch numSet := numSet.(type) { + case imap.SeqSet: + var nums []uint32 + nums, ok = numSet.Nums() + for _, num := range nums { + enc.SP().Number(num) + } + case imap.UIDSet: + var uids []imap.UID + uids, ok = numSet.Nums() + for _, uid := range uids { + enc.SP().UID(uid) + } } - case imap.UIDSet: - var uids []imap.UID - uids, ok = numSet.Nums() - for _, uid := range uids { - enc.SP().UID(uid) + if !ok { + return fmt.Errorf("imapserver: failed to enumerate message numbers in SEARCH response (dynamic set?)") } } - if !ok { - return fmt.Errorf("imapserver: failed to enumerate message numbers in SEARCH response") - } + return enc.CRLF() } @@ -178,7 +201,7 @@ func readSearchReturnOpts(dec *imapwire.Decoder, options *imap.SearchOptions) er case "SAVE": options.ReturnSave = true default: - return newClientBugError("unknown SEARCH RETURN option") + // RFC 4731: A server MUST ignore any unrecognized return options. } return nil }) @@ -196,7 +219,15 @@ func readSearchKey(criteria *imap.SearchCriteria, dec *imapwire.Decoder) error { return readSearchKeyWithAtom(criteria, dec, key) } return dec.ExpectList(func() error { - return readSearchKey(criteria, dec) + for { + if err := readSearchKey(criteria, dec); err != nil { + return err + } + if !dec.SP() { + break + } + } + return nil }) } @@ -241,7 +272,7 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, return dec.Err() } criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{ - Key: strings.Title(strings.ToLower(key)), + Key: cases.Title(language.English).String(strings.ToLower(key)), Value: value, }) case "HEADER": @@ -308,7 +339,7 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, } var not imap.SearchCriteria if err := readSearchKey(¬, dec); err != nil { - return nil + return err } criteria.Not = append(criteria.Not, not) case "OR": @@ -317,13 +348,13 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, } var or [2]imap.SearchCriteria if err := readSearchKey(&or[0], dec); err != nil { - return nil + return err } if !dec.ExpectSP() { return dec.Err() } if err := readSearchKey(&or[1], dec); err != nil { - return nil + return err } criteria.Or = append(criteria.Or, or) case "$": @@ -339,5 +370,5 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, } func searchKeyFlag(key string) imap.Flag { - return imap.Flag("\\" + strings.Title(strings.ToLower(key))) + return imap.Flag("\\" + cases.Title(language.English).String(strings.ToLower(key))) } diff --git a/imapserver/session.go b/imapserver/session.go index 35b40e8d..45a94e3d 100644 --- a/imapserver/session.go +++ b/imapserver/session.go @@ -124,3 +124,14 @@ type SessionAppendLimit interface { // this server in an APPEND command. AppendLimit() uint32 } + +// SessionCapabilities is an IMAP session which can provide its current +// capabilities for capability filtering. +type SessionCapabilities interface { + Session + + // GetCapabilities returns the session-specific capabilities. + // This allows sessions to filter capabilities based on client behavior + // or other session-specific factors. + GetCapabilities() imap.CapSet +}