pax_global_header00006660000000000000000000000064137423261170014520gustar00rootroot0000000000000052 comment=6c9a8293c60df0d0944b09ddce6213f32a6e7d3f go-imap-sortthread-1.2.0/000077500000000000000000000000001374232611700152265ustar00rootroot00000000000000go-imap-sortthread-1.2.0/.build.yml000066400000000000000000000002461374232611700171300ustar00rootroot00000000000000image: alpine/edge packages: - go sources: - https://github.com/emersion/go-imap-sortthread tasks: - test: | cd go-imap-sortthread go test -v ./... go-imap-sortthread-1.2.0/.gitignore000066400000000000000000000003001374232611700172070ustar00rootroot00000000000000# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out go-imap-sortthread-1.2.0/LICENSE000066400000000000000000000020511374232611700162310ustar00rootroot00000000000000MIT License Copyright (c) 2019 emersion Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. go-imap-sortthread-1.2.0/README.md000066400000000000000000000007301374232611700165050ustar00rootroot00000000000000# go-imap-sortthread [![GoDoc](https://godoc.org/github.com/emersion/go-imap-sortthread?status.svg)](https://godoc.org/github.com/emersion/go-imap-sortthread) [![builds.sr.ht status](https://builds.sr.ht/~emersion/go-imap-sortthread/commits.svg)](https://builds.sr.ht/~emersion/go-imap-sortthread/commits?) [SORT and THREAD] extensions for [go-imap] ## License MIT [SORT and THREAD]: https://tools.ietf.org/html/rfc5256 [go-imap]: https://github.com/emersion/go-imap go-imap-sortthread-1.2.0/base_subject_test.go000066400000000000000000000031071374232611700212460ustar00rootroot00000000000000package sortthread import "testing" var baseSubjectTests = []struct { name string subject string expected string isReplyFwd bool }{ { name: "simple", subject: "No Replacement", expected: "No Replacement", isReplyFwd: false, }, { name: "reply", subject: "Re: [ocf/puppet] Fix kerberos not booting up correctly [needs testing] (#781)", expected: "[ocf/puppet] Fix kerberos not booting up correctly [needs testing] (#781)", isReplyFwd: true, }, { name: "forward", subject: "Fwd: waifus", expected: "waifus", isReplyFwd: true, }, { name: "forward_reply", subject: "Fwd: Re: ugh", expected: "ugh", isReplyFwd: true, }, { name: "forward_header_simple", subject: "[FWD: simple [extraction]]", expected: "simple [extraction]", isReplyFwd: true, }, { name: "foward_header_nested", subject: "Re: [fwd: Re: [OCF] Service update during PG&E outage]", expected: "[OCF] Service update during PG&E outage", isReplyFwd: true, }, } func TestBaseSubject(t *testing.T) { for _, test := range baseSubjectTests { t.Run(test.name, func(t *testing.T) { baseSubject, isReplyFwd := GetBaseSubject(test.subject) if baseSubject != test.expected { t.Errorf("Got %s, Expected %s.", baseSubject, test.expected) } if !isReplyFwd && test.isReplyFwd { t.Errorf("Subject %s should be flagged as reply or forward", test.subject) } else if isReplyFwd && !test.isReplyFwd { t.Errorf("Subject %s was incorrectly flagged as reply or forward", test.subject) } }) } } go-imap-sortthread-1.2.0/client.go000066400000000000000000000053411374232611700170360ustar00rootroot00000000000000package sortthread import ( "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" "github.com/emersion/go-imap/commands" ) // SortClient is a SORT client. type SortClient struct { c *client.Client } // ThreadClient is a THREAD client. type ThreadClient struct { c *client.Client } // NewClient creates a new SORT client. func NewSortClient(c *client.Client) *SortClient { return &SortClient{c: c} } // SupportSort returns true if the remote server supports the extension. func (c *SortClient) SupportSort() (bool, error) { return c.c.Support(SortCapability) } func (c *SortClient) sort(uid bool, sortCriteria []SortCriterion, searchCriteria *imap.SearchCriteria) ([]uint32, error) { if c.c.State() != imap.SelectedState { return nil, client.ErrNoMailboxSelected } var cmd imap.Commander cmd = &SortCommand{ SortCriteria: sortCriteria, Charset: "UTF-8", SearchCriteria: searchCriteria, } if uid { cmd = &commands.Uid{Cmd: cmd} } res := new(SortResponse) status, err := c.c.Execute(cmd, res) if err != nil { return nil, err } return res.Ids, status.Err() } func (c *SortClient) Sort(sortCriteria []SortCriterion, searchCriteria *imap.SearchCriteria) ([]uint32, error) { return c.sort(false, sortCriteria, searchCriteria) } func (c *SortClient) UidSort(sortCriteria []SortCriterion, searchCriteria *imap.SearchCriteria) ([]uint32, error) { return c.sort(true, sortCriteria, searchCriteria) } // NewClient creates a new THREAD client func NewThreadClient(c *client.Client) *ThreadClient { return &ThreadClient{c: c} } // SupportThread returns true if the remote server supports the extension. func (c *ThreadClient) SupportThread() (bool, error) { for _, capability := range ThreadCapabilities { ok, err := c.c.Support(capability) if err != nil { return false, err } else if ok { return true, nil } } return false, nil } func (c *ThreadClient) thread(uid bool, algorithm ThreadAlgorithm, searchCriteria *imap.SearchCriteria) ([]*Thread, error) { if c.c.State() != imap.SelectedState { return nil, client.ErrNoMailboxSelected } var cmd imap.Commander cmd = &ThreadCommand{ Algorithm: algorithm, Charset: "UTF-8", SearchCriteria: searchCriteria, } if uid { cmd = &commands.Uid{Cmd: cmd} } res := new(ThreadResponse) status, err := c.c.Execute(cmd, res) if err != nil { return nil, err } return res.Threads, status.Err() } func (c *ThreadClient) Thread(algorithm ThreadAlgorithm, searchCriteria *imap.SearchCriteria) ([]*Thread, error) { return c.thread(false, algorithm, searchCriteria) } func (c *ThreadClient) UidThread(algorithm ThreadAlgorithm, searchCriteria *imap.SearchCriteria) ([]*Thread, error) { return c.thread(true, algorithm, searchCriteria) } go-imap-sortthread-1.2.0/commands.go000066400000000000000000000064471374232611700173710ustar00rootroot00000000000000package sortthread import ( "errors" "io" "strings" "github.com/emersion/go-imap" ) // SortCommand is a SORT command. type SortCommand struct { SortCriteria []SortCriterion Charset string SearchCriteria *imap.SearchCriteria } func (cmd *SortCommand) Command() *imap.Command { args := []interface{}{ formatSortCriteria(cmd.SortCriteria), cmd.Charset, } args = append(args, cmd.SearchCriteria.Format()...) return &imap.Command{ Name: "SORT", Arguments: args, } } func parseSortCriteria(fields interface{}) ([]SortCriterion, error) { list, ok := fields.([]interface{}) if !ok { return nil, errors.New("List is required as a sort criteria") } result := make([]SortCriterion, 0, len(list)) reverse := false for _, crit := range list { crit, ok := crit.(string) if !ok { return nil, errors.New("String is required as a sort key") } if strings.EqualFold(crit, "REVERSE") { reverse = true continue } crit = strings.ToUpper(crit) switch crit { // TODO: Fix types for constants. case string(SortArrival), SortCc, SortDate, SortFrom, SortSize, SortSubject, SortTo: default: return nil, errors.New("Unknown sort criteria: " + crit) } result = append(result, SortCriterion{ Field: SortField(crit), Reverse: reverse, }) reverse = false } if reverse { return nil, errors.New("Missing sort key after REVERSE") } return result, nil } func (cmd *SortCommand) Parse(fields []interface{}) error { if len(fields) < 3 { return errors.New("Not enough SORT arguments") } var err error cmd.SortCriteria, err = parseSortCriteria(fields[0]) if err != nil { return err } // Charset parameter for SORT is specified without "CHARSET" // and is required. charset, ok := fields[1].(string) if !ok { return errors.New("String is required as a charset") } charset = strings.ToLower(charset) var charsetReader func(io.Reader) io.Reader if charset != "utf-8" && charset != "us-ascii" && charset != "" { charsetReader = func(r io.Reader) io.Reader { r, _ = imap.CharsetReader(charset, r) return r } } cmd.SearchCriteria = &imap.SearchCriteria{} return cmd.SearchCriteria.ParseWithCharset(fields[2:], charsetReader) } // ThreadCommand is a THREAD command. type ThreadCommand struct { Algorithm ThreadAlgorithm Charset string SearchCriteria *imap.SearchCriteria } func (cmd *ThreadCommand) Command() *imap.Command { return &imap.Command{ Name: "THREAD", Arguments: []interface{}{ formatThreadAlgorithm(cmd.Algorithm), cmd.Charset, cmd.SearchCriteria.Format(), }, } } func (cmd *ThreadCommand) Parse(fields []interface{}) error { if len(fields) < 3 { return errors.New("Not enough THREAD argments") } algo, ok := fields[0].(string) if !ok { return errors.New("First argument should be a string") } charset, ok := fields[1].(string) if !ok { return errors.New("Second argument should be a string") } charset = strings.ToLower(charset) var charsetReader func(io.Reader) io.Reader if charset != "utf-8" && charset != "us-ascii" && charset != "" { charsetReader = func(r io.Reader) io.Reader { r, _ = imap.CharsetReader(charset, r) return r } } cmd.Algorithm = ThreadAlgorithm(algo) cmd.SearchCriteria = &imap.SearchCriteria{} return cmd.SearchCriteria.ParseWithCharset(fields[2:], charsetReader) } go-imap-sortthread-1.2.0/example_test.go000066400000000000000000000030531374232611700202500ustar00rootroot00000000000000package sortthread_test import ( "log" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap-sortthread" "github.com/emersion/go-imap/client" ) func ExampleSortClient() { // Assuming c is an IMAP client var c *client.Client // Create a new SORT client sc := sortthread.NewSortClient(c) // Check the server supports the extension ok, err := sc.SupportSort() if err != nil { log.Fatal(err) } else if !ok { log.Fatal("Server doesn't support SORT") } // Send a SORT command: search for the first 10 messages, sort them by // ascending sender and then by descending size sortCriteria := []sortthread.SortCriterion{ {Field: sortthread.SortFrom}, {Field: sortthread.SortSize, Reverse: true}, } searchCriteria := imap.NewSearchCriteria() searchCriteria.SeqNum = new(imap.SeqSet) searchCriteria.SeqNum.AddRange(1, 10) uids, err := sc.UidSort(sortCriteria, searchCriteria) if err != nil { log.Fatal(err) } log.Println(uids) } func ExampleThreadClient() { // Assuming c is an IMAP client var c *client.Client // Create a new THREAD client sc := sortthread.NewThreadClient(c) // Check the server supports the extension ok, err := sc.SupportThread() if err != nil { log.Fatal(err) } else if !ok { log.Fatal("Server doesn't support THREAD") } layoutISO := "2006-01-02" searchCriteria := imap.NewSearchCriteria() date, _ := time.Parse(layoutISO, "2019-07-05") searchCriteria.Since = date threads, err := sc.UidThread(sortthread.References, searchCriteria) if err != nil { log.Fatal(err) } log.Println(threads) } go-imap-sortthread-1.2.0/go.mod000066400000000000000000000003321374232611700163320ustar00rootroot00000000000000module github.com/emersion/go-imap-sortthread go 1.12 require ( github.com/emersion/go-imap v1.0.5 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect golang.org/x/text v0.3.3 // indirect ) go-imap-sortthread-1.2.0/go.sum000066400000000000000000000042731374232611700163670ustar00rootroot00000000000000github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-imap v1.0.5 h1:8xg/d2wo2BBP3AEP5AOaM/6i8887RGyVW2st/IVHWUw= github.com/emersion/go-imap v1.0.5/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= github.com/emersion/go-message v0.11.1 h1:0C/S4JIXDTSfXB1vpqdimAYyK4+79fgEAMQ0dSL+Kac= github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg= github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/martinlindhe/base36 v1.0.0 h1:eYsumTah144C0A8P1T/AVSUk5ZoLnhfYFM3OGQxB52A= github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= go-imap-sortthread-1.2.0/responses.go000066400000000000000000000052631374232611700176040ustar00rootroot00000000000000package sortthread import ( "strconv" "github.com/emersion/go-imap" "github.com/emersion/go-imap/responses" ) type SortResponse struct { Ids []uint32 } type ThreadResponse struct { Threads []*Thread } func (r *SortResponse) Handle(resp imap.Resp) error { name, fields, ok := imap.ParseNamedResp(resp) if !ok || name != "SORT" { return responses.ErrUnhandled } r.Ids = make([]uint32, len(fields)) for i, f := range fields { if id, err := imap.ParseNumber(f); err != nil { return err } else { r.Ids[i] = id } } return nil } func (r *SortResponse) WriteTo(w *imap.Writer) error { fields := make([]interface{}, 0, len(r.Ids)+1) fields = append(fields, imap.RawString("SORT")) for _, id := range r.Ids { fields = append(fields, imap.RawString(strconv.FormatInt(int64(id), 10))) } return imap.NewUntaggedResp(fields).WriteTo(w) } func (r *ThreadResponse) Handle(resp imap.Resp) error { name, fields, ok := imap.ParseNamedResp(resp) if !ok || name != "THREAD" { return responses.ErrUnhandled } if threads, err := parseThreadResp(fields); err != nil { return err } else { r.Threads = threads } return nil } func parseThreadResp(fields []interface{}) ([]*Thread, error) { var parent *Thread var siblings []*Thread for _, f := range fields { switch f := f.(type) { case string, imap.RawString: id, err := imap.ParseNumber(f) if err != nil { return nil, err } t := Thread{Id: id} if parent == nil { siblings = append(siblings, &t) } else { parent.Children = append(t.Children, &t) } parent = &t case []interface{}: t, err := parseThreadResp(f) if err != nil { return nil, err } // Parent doesn't exist, e.g. didn't match the search // criteria. Let's ignore the parent thread. if parent == nil { siblings = append(siblings, t...) } else { parent.Children = append(parent.Children, t...) } default: return nil, responses.ErrUnhandled } } return siblings, nil } func formatThread(thread *Thread) []interface{} { f := make([]interface{}, 0, 1+len(thread.Children)) f = append(f, imap.RawString(strconv.FormatInt(int64(thread.Id), 10))) if len(thread.Children) == 1 { f = append(f, formatThread(thread.Children[0])...) } else { for _, c := range thread.Children { f = append(f, formatThread(c)) } } return f } func formatThreadResp(threads []*Thread) []interface{} { fields := make([]interface{}, 0, len(threads)+1) fields = append(fields, imap.RawString("THREAD")) for _, t := range threads { fields = append(fields, formatThread(t)) } return fields } func (r *ThreadResponse) WriteTo(w *imap.Writer) error { return imap.NewUntaggedResp(formatThreadResp(r.Threads)).WriteTo(w) } go-imap-sortthread-1.2.0/server.go000066400000000000000000000055731374232611700170750ustar00rootroot00000000000000package sortthread import ( "errors" "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" "github.com/emersion/go-imap/server" ) var ErrUnsupportedBackend = errors.New("sortthread: backend not supported") type SortMailbox interface { backend.Mailbox Sort(uid bool, sortCrit []SortCriterion, searchCrit *imap.SearchCriteria) ([]uint32, error) } type ThreadBackend interface { backend.Backend SupportedThreadAlgorithms() []ThreadAlgorithm } type ThreadMailbox interface { backend.Mailbox Thread(uid bool, threading ThreadAlgorithm, searchCrit *imap.SearchCriteria) ([]*Thread, error) } type SortHandler struct { SortCommand } func (h *SortHandler) handle(uid bool, conn server.Conn) error { if conn.Context().Mailbox == nil { return server.ErrNoMailboxSelected } mbox, ok := conn.Context().Mailbox.(SortMailbox) if !ok { return ErrUnsupportedBackend } ids, err := mbox.Sort(uid, h.SortCriteria, h.SearchCriteria) if err != nil { return err } return conn.WriteResp(&SortResponse{Ids: ids}) } func (h *SortHandler) Handle(conn server.Conn) error { return h.handle(false, conn) } func (h *SortHandler) UidHandle(conn server.Conn) error { return h.handle(true, conn) } type ThreadHandler struct { ThreadCommand } func (h *ThreadHandler) handle(uid bool, conn server.Conn) error { if conn.Context().Mailbox == nil { return server.ErrNoMailboxSelected } mbox, ok := conn.Context().Mailbox.(ThreadMailbox) if !ok { return ErrUnsupportedBackend } thr, err := mbox.Thread(uid, h.Algorithm, h.SearchCriteria) if err != nil { return err } return conn.WriteResp(&ThreadResponse{Threads: thr}) } func (h *ThreadHandler) Handle(conn server.Conn) error { return h.handle(false, conn) } func (h *ThreadHandler) UidHandle(conn server.Conn) error { return h.handle(true, conn) } type sortExtension struct{} func NewSortExtension() server.Extension { return &sortExtension{} } func (s *sortExtension) Capabilities(c server.Conn) []string { if c.Context().State&imap.AuthenticatedState != 0 { return []string{SortCapability} } return nil } func (s *sortExtension) Command(name string) server.HandlerFactory { if name == "SORT" { return func() server.Handler { return &SortHandler{} } } return nil } type threadExtension struct{} func NewThreadExtension() server.Extension { return &threadExtension{} } func (s *threadExtension) Capabilities(c server.Conn) []string { if c.Context().State&imap.AuthenticatedState == 0 { return nil } be, ok := c.Server().Backend.(ThreadBackend) if !ok { // No backend support, no-op. return nil } var caps []string for _, algo := range be.SupportedThreadAlgorithms() { caps = append(caps, string("THREAD="+algo)) } return caps } func (s *threadExtension) Command(name string) server.HandlerFactory { if name == "THREAD" { return func() server.Handler { return &ThreadHandler{} } } return nil } go-imap-sortthread-1.2.0/sortthread.go000066400000000000000000000114501374232611700177350ustar00rootroot00000000000000// Package sortthread implements SORT and THREAD for go-imap. // // SORT and THREAD are defined in RFC 5256. package sortthread import ( "fmt" "regexp" "strings" "github.com/emersion/go-imap" ) const SortCapability = "SORT" var ThreadCapabilities = []string{"THREAD=ORDEREDSUBJECT", "THREAD=REF", "THREAD=REFERENCES"} // ThreadAlgorithm is the algorithm used by the server to sort messages type ThreadAlgorithm string const ( OrderedSubject ThreadAlgorithm = "ORDEREDSUBJECT" References = "REFERENCES" ) func formatThreadAlgorithm(algorithm ThreadAlgorithm) imap.RawString { return imap.RawString(algorithm) } // SortField is a field that can be used to sort messages. type SortField string const ( SortArrival SortField = "ARRIVAL" SortCc = "CC" SortDate = "DATE" SortFrom = "FROM" SortSize = "SIZE" SortSubject = "SUBJECT" SortTo = "TO" ) // SortCriterion is a criterion that can be used to sort messages. type SortCriterion struct { Field SortField Reverse bool } func formatSortCriteria(criteria []SortCriterion) interface{} { fields := make([]interface{}, 0, len(criteria)) for _, c := range criteria { if c.Reverse { fields = append(fields, imap.RawString("REVERSE")) } fields = append(fields, imap.RawString(c.Field)) } return fields } type Thread struct { Id uint32 Children []*Thread } var ( tabsContinuation = regexp.MustCompile(`[\t\\]`) repeatedSpaces = regexp.MustCompile("[ ]+") // Includes regex for ABNF rules relevant to base subject // Note that all ABNF strings are considered lowercase // subj-fwd-hdr = "[fwd:" // subj-fwd-trl = "]" subjFwd = regexp.MustCompile(`(?i)^\[fwd:(.*?)\]$`) // BLOBCHAR = %x01-5a / %x5c / %x5e-ff // subj-blob = "[" *BLOBCHAR "]" *WSP subjBlob = `\[\x01-\x5a\x5c\x5e-\xff]\]\s*` subjBlobPrefix = regexp.MustCompile(fmt.Sprintf("^%s", subjBlob)) // subj-refwd = ("re" / ("fw" ["d"])) *WSP [subj-blob] ":" subjReFwd = fmt.Sprintf(`(?:(?:re)|(?:fwd?))\s*(?:%s)?:`, subjBlob) // subj-leader = (*subj-blob subj-refwd) / WSP subjLeader = regexp.MustCompile(fmt.Sprintf(`(?i)^(?:(?:%s)*%s)`, subjBlob, subjReFwd)) // subj-trailer = "(fwd)" / WSP subjTrailer = regexp.MustCompile(`(?i)\(fwd\)$`) ) // Steps 2-5 in RFC Section 2.1 func replaceArtifacts(subject string, isReplyFwd *bool) string { // (2) Remove all trailing text of the subject that matches the // subj-trailer ABNF; repeat until no more matches are possible. for { noTrail := strings.TrimSuffix(subject, " ") if subjTrailer.MatchString(noTrail) { noTrail = subjTrailer.ReplaceAllString(noTrail, "") *isReplyFwd = true } if subject == noTrail { break } subject = noTrail } return replacePrefix(subject, isReplyFwd) } // Steps 3-5 in RFC Section 2.1 func replacePrefix(subject string, isReplyFwd *bool) string { // (5) Repeat (3) and (4) until no matches remain. for { // (3) Remove all prefix text of the subject that matches the subj- // leader ABNF. noLeader := strings.TrimPrefix(subject, " ") if subjLeader.MatchString(noLeader) { noLeader = subjLeader.ReplaceAllString(noLeader, "") *isReplyFwd = true } // (4) If there is prefix text of the subject that matches the subj- // blob ABNF, and removing that prefix leaves a non-empty subj- // base, then remove the prefix text. noBlob := subjBlobPrefix.ReplaceAllString(noLeader, "") if noBlob == "" { subject = noLeader break } if noBlob == subject { break } subject = noBlob } return subject } // GetBaseSubject returns the base subject of the given string according to // Section 2.1. The returned string is suitable for comparison with other base // subjects. The returned bool indicates whether the subject is a reply or a // forward. func GetBaseSubject(subject string) (string, bool) { baseSubject := subject isReplyFwd := false // (1) Convert any RFC 2047 encoded-words in the subject to [UTF-8] // as described in "Internationalization Considerations". // Convert all tabs and continuations to space. Convert all // multiple spaces to a single space. baseSubject = tabsContinuation.ReplaceAllString(baseSubject, " ") baseSubject = repeatedSpaces.ReplaceAllString(baseSubject, " ") for { // Steps 2-5 baseSubject = replaceArtifacts(baseSubject, &isReplyFwd) // (6) If the resulting text begins with the subj-fwd-hdr ABNF and // ends with the subj-fwd-trl ABNF, remove the subj-fwd-hdr and // subj-fwd-trl and repeat from step (2). submatches := subjFwd.FindStringSubmatch(baseSubject) if len(submatches) == 0 { break } else if len(submatches) != 2 { panic(fmt.Errorf("Regex undefined behavior on subject %s", baseSubject)) } baseSubject = submatches[1] isReplyFwd = true } return baseSubject, isReplyFwd } go-imap-sortthread-1.2.0/thread_test.go000066400000000000000000000066751374232611700201010ustar00rootroot00000000000000package sortthread import ( "reflect" "testing" "github.com/emersion/go-imap" ) var threadTests = []struct { name string str string expected []*Thread response []interface{} }{ { name: "simple", str: "(1 2 3 4)", expected: []*Thread{ &Thread{ Id: 1, Children: []*Thread{ &Thread{ Id: 2, Children: []*Thread{ &Thread{ Id: 3, Children: []*Thread{ &Thread{ Id: 4, Children: nil, }, }, }, }, }, }, }, }, response: []interface{}{[]interface{}{imap.RawString("1"), imap.RawString("2"), imap.RawString("3"), imap.RawString("4")}}, }, { name: "noparent", str: "(3 5)", expected: []*Thread{ &Thread{ Id: 3, Children: nil, }, &Thread{ Id: 5, Children: nil, }, }, response: []interface{}{[]interface{}{imap.RawString("3")}, []interface{}{imap.RawString("5")}}, }, { name: "nested", str: "(4 5 (6) (7 8))", expected: []*Thread{ &Thread{ Id: 4, Children: []*Thread{ &Thread{ Id: 5, Children: []*Thread{ &Thread{ Id: 6, Children: nil, }, &Thread{ Id: 7, Children: []*Thread{ &Thread{ Id: 8, Children: nil, }, }, }, }, }, }, }, }, response: []interface{}{[]interface{}{ imap.RawString("4"), imap.RawString("5"), []interface{}{imap.RawString("6")}, []interface{}{imap.RawString("7"), imap.RawString("8")}, }}, }, { name: "rfc", str: "(2)(3 6 (4 23)(44 7 96))", expected: []*Thread{ &Thread{ Id: 2, Children: nil, }, &Thread{ Id: 3, Children: []*Thread{ &Thread{ Id: 6, Children: []*Thread{ &Thread{ Id: 4, Children: []*Thread{ &Thread{ Id: 23, Children: nil, }, }, }, &Thread{ Id: 44, Children: []*Thread{ &Thread{ Id: 7, Children: []*Thread{ &Thread{ Id: 96, Children: nil, }, }, }, }, }, }, }, }, }, }, response: []interface{}{ []interface{}{imap.RawString("2")}, []interface{}{imap.RawString("3"), imap.RawString("6"), []interface{}{ imap.RawString("4"), imap.RawString("23"), }, []interface{}{ imap.RawString("44"), imap.RawString("7"), imap.RawString("96"), }, }, }, }, } func TestThreadParsing(t *testing.T) { for _, test := range threadTests { t.Run(test.name, func(t *testing.T) { threads, err := parseThreadResp(test.response) if err != nil { t.Error("Expected no error while parsing thread but got:", err) } if !reflect.DeepEqual(test.expected, threads) { t.Errorf("Could not parse %s", test.str) } }) } } func TestThreadFormatting(t *testing.T) { for _, test := range threadTests { // noparent case has destructive parsing - parser disregards 'null' parent // and resulting tree corresponds to a different response. if test.name == "noparent" { continue } t.Run(test.name, func(t *testing.T) { fields := formatThreadResp(test.expected) if !reflect.DeepEqual(fields[1:], test.response) { t.Errorf("Could not format %s properly", test.str) t.Logf("Want: %#+v", test.response) t.Logf("Got: %#+v", fields) } }) } }