diff options
author | Robin Jarry <robin@jarry.cc> | 2023-06-04 14:05:22 +0200 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2023-06-06 16:12:52 +0200 |
commit | 9e27c777d4c818dd4a9f265d174686ea988f9955 (patch) | |
tree | 56724afe0b4d9c28d277ffa274b2894ce9ffb95d | |
parent | 856b3cc29b659a76ba9edf555ed162c0dd84b781 (diff) | |
download | aerc-9e27c777d4c818dd4a9f265d174686ea988f9955.tar.gz aerc-9e27c777d4c818dd4a9f265d174686ea988f9955.zip |
jmap
Signed-off-by: Robin Jarry <robin@jarry.cc>
-rw-r--r-- | .env | 1 | ||||
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | Makefile | 4 | ||||
-rw-r--r-- | README.md | 1 | ||||
-rw-r--r-- | commands/account/rmdir.go | 1 | ||||
-rw-r--r-- | doc/aerc-accounts.5.scd | 5 | ||||
-rw-r--r-- | doc/aerc-config.5.scd | 6 | ||||
-rw-r--r-- | doc/aerc-jmap.5.scd | 61 | ||||
-rw-r--r-- | doc/aerc.1.scd | 6 | ||||
-rw-r--r-- | go.mod | 1 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | worker/jmap/configure.go | 40 | ||||
-rw-r--r-- | worker/jmap/connect.go | 42 | ||||
-rw-r--r-- | worker/jmap/directories.go | 220 | ||||
-rw-r--r-- | worker/jmap/fetch.go | 158 | ||||
-rw-r--r-- | worker/jmap/jmap.go | 117 | ||||
-rw-r--r-- | worker/jmap/list.go | 53 | ||||
-rw-r--r-- | worker/jmap/mboxstate.go | 38 | ||||
-rw-r--r-- | worker/jmap/push.go | 213 | ||||
-rw-r--r-- | worker/jmap/search.go | 63 | ||||
-rw-r--r-- | worker/jmap/set.go | 118 | ||||
-rw-r--r-- | worker/jmap/worker.go | 150 | ||||
-rw-r--r-- | worker/worker_enabled.go | 1 |
23 files changed, 1294 insertions, 8 deletions
@@ -0,0 +1 @@ +GOFLAGS=-tags=notmuch diff --git a/CHANGELOG.md b/CHANGELOG.md index c321b001..656730e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - IMAP now uses the delimiter advertised by the server - Completions for `:mkdir` - `carddav-query` utility to use for `address-book-cmd`. +- JMAP support. ### Fixed @@ -30,6 +30,7 @@ DOCS := \ aerc-binds.5 \ aerc-config.5 \ aerc-imap.5 \ + aerc-jmap.5 \ aerc-maildir.5 \ aerc-sendmail.5 \ aerc-notmuch.5 \ @@ -124,6 +125,7 @@ install: $(DOCS) aerc wrap colorize install -m644 aerc-binds.5 $(DESTDIR)$(MANDIR)/man5/aerc-binds.5 install -m644 aerc-config.5 $(DESTDIR)$(MANDIR)/man5/aerc-config.5 install -m644 aerc-imap.5 $(DESTDIR)$(MANDIR)/man5/aerc-imap.5 + install -m644 aerc-jmap.5 $(DESTDIR)$(MANDIR)/man5/aerc-jmap.5 install -m644 aerc-maildir.5 $(DESTDIR)$(MANDIR)/man5/aerc-maildir.5 install -m644 aerc-sendmail.5 $(DESTDIR)$(MANDIR)/man5/aerc-sendmail.5 install -m644 aerc-notmuch.5 $(DESTDIR)$(MANDIR)/man5/aerc-notmuch.5 @@ -161,6 +163,7 @@ checkinstall: test -e $(DESTDIR)$(MANDIR)/man5/aerc-binds.5 test -e $(DESTDIR)$(MANDIR)/man5/aerc-config.5 test -e $(DESTDIR)$(MANDIR)/man5/aerc-imap.5 + test -e $(DESTDIR)$(MANDIR)/man5/aerc-jmap.5 test -e $(DESTDIR)$(MANDIR)/man5/aerc-notmuch.5 test -e $(DESTDIR)$(MANDIR)/man5/aerc-sendmail.5 test -e $(DESTDIR)$(MANDIR)/man5/aerc-smtp.5 @@ -178,6 +181,7 @@ uninstall: $(RM) $(DESTDIR)$(MANDIR)/man5/aerc-binds.5 $(RM) $(DESTDIR)$(MANDIR)/man5/aerc-config.5 $(RM) $(DESTDIR)$(MANDIR)/man5/aerc-imap.5 + $(RM) $(DESTDIR)$(MANDIR)/man5/aerc-jmap.5 $(RM) $(DESTDIR)$(MANDIR)/man5/aerc-maildir.5 $(RM) $(DESTDIR)$(MANDIR)/man5/aerc-sendmail.5 $(RM) $(DESTDIR)$(MANDIR)/man5/aerc-notmuch.5 @@ -35,6 +35,7 @@ Also available as man pages: - [aerc-binds(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-binds.5.scd) - [aerc-config(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-config.5.scd) - [aerc-imap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-imap.5.scd) +- [aerc-jmap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-jmap.5.scd) - [aerc-maildir(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-maildir.5.scd) - [aerc-notmuch(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-notmuch.5.scd) - [aerc-search(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-search.1.scd) diff --git a/commands/account/rmdir.go b/commands/account/rmdir.go index e45a7a7a..9f6fedeb 100644 --- a/commands/account/rmdir.go +++ b/commands/account/rmdir.go @@ -80,6 +80,7 @@ func (RemoveDir) Execute(aerc *widgets.Aerc, args []string) error { acct.Worker().PostAction(&types.RemoveDirectory{ Directory: curDir, + Quiet: force, }, func(msg types.WorkerMessage) { switch msg := msg.(type) { case *types.Done: diff --git a/doc/aerc-accounts.5.scd b/doc/aerc-accounts.5.scd index 8fa48647..00a575e1 100644 --- a/doc/aerc-accounts.5.scd +++ b/doc/aerc-accounts.5.scd @@ -173,6 +173,7 @@ Note that many of these configuration options are written for you, such as See each protocol's man page for more details: - *aerc-imap*(5) + - *aerc-jmap*(5) - *aerc-maildir*(5) - *aerc-notmuch*(5) @@ -212,8 +213,8 @@ Note that many of these configuration options are written for you, such as # SEE ALSO -*aerc*(1) *aerc-config*(5) *aerc-imap*(5) *aerc-maildir*(5) *aerc-notmuch*(5) -*aerc-sendmail*(5) *aerc-smtp*(5) +*aerc*(1) *aerc-config*(5) *aerc-imap*(5) *aerc-jmap*(5) *aerc-maildir*(5) +*aerc-notmuch*(5) *aerc-sendmail*(5) *aerc-smtp*(5) # AUTHORS diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index af989045..e2d19310 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -924,9 +924,9 @@ These options are configured in the *[templates]* section of _aerc.conf_. # SEE ALSO -*aerc*(1) *aerc-accounts*(5) *aerc-binds*(5) *aerc-imap*(5) *aerc-maildir*(5) -*aerc-notmuch*(5) *aerc-templates*(7) *aerc-sendmail*(5) *aerc-smtp*(5) -*aerc-stylesets*(7) *carddav-query*(1) +*aerc*(1) *aerc-accounts*(5) *aerc-binds*(5) *aerc-imap*(5) *aerc-jmap*(5) +*aerc-maildir*(5) *aerc-notmuch*(5) *aerc-templates*(7) *aerc-sendmail*(5) +*aerc-smtp*(5) *aerc-stylesets*(7) *carddav-query*(1) # AUTHORS diff --git a/doc/aerc-jmap.5.scd b/doc/aerc-jmap.5.scd new file mode 100644 index 00000000..8405debb --- /dev/null +++ b/doc/aerc-jmap.5.scd @@ -0,0 +1,61 @@ +AERC-JMAP(5) + +# NAME + +aerc-jmap - JMAP configuration for *aerc*(1) + +# SYNOPSIS + +aerc implements the JMAP protocol as specified by RFCs 8620 and 8621. + +# CONFIGURATION + +JMAP accounts currently are not supported with the *:new-account* command and +must be added manually. + +In _accounts.conf_ (see *aerc-accounts*(5)), the following JMAP-specific options +are available: + +*source* = _<scheme>_://[_<username>_][_:<password>@_]_<hostname>_[_:<port>_]/_<path>_ + Remember that all fields must be URL encoded. The _@_ symbol, when URL + encoded, is _%40_. + + _<hostname>_[_:<port>_]/_<path>_ is the HTTPS JMAP session resource as + specified in RFC 8620 section 2 without the leading _https://_ scheme. + + Possible values of _<scheme>_ are: + + _jmap_ + JMAP over HTTPS using Basic authentication. + + _jmap+oauthbearer_ + JMAP over HTTPS using OAUTHBEARER authentication + + The username is ignored any may be left empty. If specifying the + password, make sure to prefix it with _:_ to make it explicit + that the username is empty. Or set the username to any random + value. E.g.: + + ``` + source = jmap+oauthbearer://:s3cr3t@example.com/jmap/session + source = jmap+oauthbearer://me:s3cr3t@example.com/jmap/session + ``` + +*source-cred-cmd* = _<command>_ + Specifies the command to run to get the password for the JMAP account. + This command will be run using _sh -c command_. If a password is + specified in the *source* option, the password will take precedence over + this command. + + Example: + source-cred-cmd = pass hostname/username + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) + +# AUTHORS + +Originally created by Drew DeVault <sir@cmpwn.com> and maintained by Robin +Jarry <robin@jarry.cc> who is assisted by other open source contributors. For +more information about aerc development, see https://sr.ht/~rjarry/aerc/. diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd index 0c89460b..7993872e 100644 --- a/doc/aerc.1.scd +++ b/doc/aerc.1.scd @@ -626,9 +626,9 @@ in _aerc.conf_. # SEE ALSO -*aerc-config*(5) *aerc-imap*(5) *aerc-notmuch*(5) *aerc-smtp*(5) *aerc-maildir*(5) -*aerc-sendmail*(5) *aerc-search*(1) *aerc-stylesets*(7) *aerc-templates*(7) -*aerc-accounts*(5) *aerc-binds*(5) *aerc-tutorial*(7) +*aerc-config*(5) *aerc-imap*(5) *aerc-jmap*(5) *aerc-notmuch*(5) *aerc-smtp*(5) +*aerc-maildir*(5) *aerc-sendmail*(5) *aerc-search*(1) *aerc-stylesets*(7) +*aerc-templates*(7) *aerc-accounts*(5) *aerc-binds*(5) *aerc-tutorial*(7) # AUTHORS @@ -3,6 +3,7 @@ module git.sr.ht/~rjarry/aerc go 1.18 require ( + git.sr.ht/~rockorager/go-jmap v0.2.1-0.20230427134630-2d5d7dedae0e git.sr.ht/~rockorager/tcell-term v0.8.0 git.sr.ht/~sircmpwn/getopt v1.0.0 github.com/ProtonMail/go-crypto v0.0.0-20230417170513-8ee5748c52b5 @@ -1,3 +1,5 @@ +git.sr.ht/~rockorager/go-jmap v0.2.1-0.20230427134630-2d5d7dedae0e h1:jwdeTuBi/D7xyRlDWOFc30qFsYGu/+m/Ij4wxSMNC3Y= +git.sr.ht/~rockorager/go-jmap v0.2.1-0.20230427134630-2d5d7dedae0e/go.mod h1:aOTCtwpZSINpDDSOkLGpHU0Kbbm5lcSDMcobX3ZtOjY= git.sr.ht/~rockorager/tcell-term v0.8.0 h1:jAAzWgTAzMz8uMXbOLZd5WgV7qmb6zRE0Z7HUrDdVPs= git.sr.ht/~rockorager/tcell-term v0.8.0/go.mod h1:Snxh5CrziiA2CjyLOZ6tGAg5vMPlE+REMWT3rtKuyyQ= git.sr.ht/~sircmpwn/getopt v1.0.0 h1:/pRHjO6/OCbBF4puqD98n6xtPEgE//oq5U8NXjP7ROc= diff --git a/worker/jmap/configure.go b/worker/jmap/configure.go new file mode 100644 index 00000000..889ef06e --- /dev/null +++ b/worker/jmap/configure.go @@ -0,0 +1,40 @@ +package jmap + +import ( + "fmt" + "net/url" + "strings" + + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (w *JMAPWorker) handleConfigure(msg *types.Configure) error { + u, err := url.Parse(msg.Config.Source) + if err != nil { + return err + } + + if strings.HasSuffix(u.Scheme, "+oauthbearer") { + w.oauth = true + w.query = u.Query() + } else { + if u.User == nil { + return fmt.Errorf("user:password not specified") + } else if u.User.Username() == "" { + return fmt.Errorf("username not specified") + } else if _, ok := u.User.Password(); !ok { + return fmt.Errorf("password not specified") + } + } + + u.RawQuery = "" + u.Fragment = "" + w.user = u.User + u.User = nil + u.Scheme = "https" + + w.endpoint = u.String() + w.account = msg.Config + + return nil +} diff --git a/worker/jmap/connect.go b/worker/jmap/connect.go new file mode 100644 index 00000000..740dd251 --- /dev/null +++ b/worker/jmap/connect.go @@ -0,0 +1,42 @@ +package jmap + +import ( + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail" +) + +func (w *JMAPWorker) handleConnect(msg *types.Connect) error { + w.w.Debugf("connecting") + client := &jmap.Client{SessionEndpoint: w.endpoint} + + if w.oauth { + pass, _ := w.user.Password() + client.WithAccessToken(pass) + } else { + user := w.user.Username() + pass, _ := w.user.Password() + client.WithBasicAuth(user, pass) + } + + if err := client.Authenticate(); err != nil { + return err + } + + w.client = client + + return nil +} + +func (w *JMAPWorker) accountId() jmap.ID { + switch { + case w.client == nil: + fallthrough + case w.client.Session == nil: + fallthrough + case w.client.Session.PrimaryAccounts == nil: + return jmap.ID("") + default: + return w.client.Session.PrimaryAccounts[mail.URI] + } +} diff --git a/worker/jmap/directories.go b/worker/jmap/directories.go new file mode 100644 index 00000000..dbbb521a --- /dev/null +++ b/worker/jmap/directories.go @@ -0,0 +1,220 @@ +package jmap + +import ( + "fmt" + "path" + + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func (w *JMAPWorker) handleOpenDirectory(msg *types.OpenDirectory) error { + id, ok := w.dir2mbox[msg.Directory] + if !ok { + return fmt.Errorf("unknown directory: %s", msg.Directory) + } + w.selectedMbox = id + return nil +} + +func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryContents) error { + var req jmap.Request + + selected, ok := w.mboxes[w.selectedMbox] + if !ok { + return fmt.Errorf("no selected mailbox") + } + + filter, err := parseSearch(msg.FilterCriteria) + if err != nil { + return err + } + filter.InMailbox = w.selectedMbox + + selected.filter = filter + selected.sort = translateSort(msg.SortCriteria) + + req.Invoke(&email.Query{ + Account: w.accountId(), + Filter: filter, + Sort: selected.sort, + }) + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.QueryResponse: + var uids []uint32 + for _, id := range r.IDs { + uids = append(uids, w.uidStore.GetOrInsert(string(id))) + } + w.w.PostMessage(&types.DirectoryContents{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + selected.uids = uids + selected.queryState = r.QueryState + } + } + + return nil +} + +func (w *JMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) error { + var req jmap.Request + + filter, err := parseSearch(msg.Argv) + if err != nil { + return err + } + filter.InMailbox = w.selectedMbox + + req.Invoke(&email.Query{ + Account: w.accountId(), + Filter: filter, + }) + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.QueryResponse: + var uids []uint32 + for _, id := range r.IDs { + uids = append(uids, w.uidStore.GetOrInsert(string(id))) + } + w.w.PostMessage(&types.SearchResults{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + } + } + + return nil +} + +func (w *JMAPWorker) handleCreateDirectory(msg *types.CreateDirectory) error { + var req jmap.Request + var parentId, id jmap.ID + + if parent := path.Dir(msg.Directory); parent != "" && parent != "." { + var ok bool + if parentId, ok = w.dir2mbox[parent]; !ok { + return fmt.Errorf( + "parent mailbox %q does not exist", parent) + } + } + name := path.Base(msg.Directory) + + req.Invoke(&mailbox.Set{ + Account: w.accountId(), + Create: map[jmap.ID]*mailbox.Mailbox{ + id: { + ID: id, + ParentID: parentId, + Name: name, + }, + }, + }) + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.SetResponse: + if err, _ := r.NotCreated[id]; err != nil { + var s string + if err.Description != nil { + s = *err.Description + } + return fmt.Errorf( + "mailbox creation failed: %s", s) + } + } + } + + return nil +} + +func (w *JMAPWorker) handleRemoveDirectory(msg *types.RemoveDirectory) error { + var req jmap.Request + + id, ok := w.dir2mbox[msg.Directory] + if !ok { + return fmt.Errorf("unknown mailbox: %s", msg.Directory) + } + + req.Invoke(&mailbox.Set{ + Account: w.accountId(), + Destroy: []jmap.ID{id}, + OnDestroyRemoveEmails: msg.Quiet, + }) + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.SetResponse: + if err, _ := r.NotDestroyed[id]; err != nil { + var s string + if err.Description != nil { + s = *err.Description + } + return fmt.Errorf( + "mailbox destruction failed: %s", s) + } + } + } + + return nil +} + +func translateSort(criteria []*types.SortCriterion) []*email.SortComparator { + sort := make([]*email.SortComparator, 0, len(criteria)) + if len(criteria) == 0 { + criteria = []*types.SortCriterion{ + {Field: types.SortArrival, Reverse: true}, + {Field: types.SortSubject, Reverse: false}, + } + } + for _, s := range criteria { + var cmp email.SortComparator + switch s.Field { + case types.SortArrival: + cmp.Property = "receivedAt" + case types.SortCc: + cmp.Property = "cc" + case types.SortDate: + cmp.Property = "receivedAt" + case types.SortFrom: + cmp.Property = "from" + case types.SortRead: + cmp.Keyword = "$seen" + case types.SortSize: + cmp.Property = "size" + case types.SortSubject: + cmp.Property = "subject" + case types.SortTo: + cmp.Property = "to" + default: + continue + } + cmp.IsAscending = !s.Reverse + sort = append(sort, &cmp) + } + + return sort +} diff --git a/worker/jmap/fetch.go b/worker/jmap/fetch.go new file mode 100644 index 00000000..64593e0b --- /dev/null +++ b/worker/jmap/fetch.go @@ -0,0 +1,158 @@ +package jmap + +import ( + "bytes" + "fmt" + "io" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" +) + +var headersProperties = []string{ + "id", + "blobId", + "mailboxIds", + "keywords", + "size", + "receivedAt", + "headers", + "messageId", + "inReplyTo", + "references", + "from", + "to", + "cc", + "bcc", + "replyTo", + "subject", + "bodyStructure", +} + +func (w *JMAPWorker) handleFetchMessageHeaders(msg *types.FetchMessageHeaders) error { + var req jmap.Request + + ids := make([]jmap.ID, 0, len(msg.Uids)) + for _, uid := range msg.Uids { + id, ok := w.uidStore.GetKey(uid) + if !ok { + return fmt.Errorf("bug: no jmap id for message uid: %v", uid) + } + ids = append(ids, jmap.ID(id)) + } + + req.Invoke(&email.Get{ + Account: w.accountId(), + IDs: ids, + Properties: headersProperties, + }) + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.GetResponse: + for _, m := range r.List { + w.w.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: w.translateMsgInfo(m), + }, nil) + w.emails[m.ID] = m + } + w.emailState = r.State + } + } + + return nil +} + +func (w *JMAPWorker) handleFetchMessageBodyPart(msg *types.FetchMessageBodyPart) error { + id, ok := w.uidStore.GetKey(msg.Uid) + if !ok { + return fmt.Errorf("bug: unknown message uid %d", msg.Uid) + } + mail, ok := w.emails[jmap.ID(id)] + if !ok { + return fmt.Errorf("bug: unknown message id %s", id) + } + + part := mail.BodyStructure + for i, index := range msg.Part { + index -= 1 // convert to zero based offset + if index < len(part.SubParts) { + part = part.SubParts[index] + } else { + return fmt.Errorf( + "bug: invalid part index[%d]: %v", i, msg.Part) + } + } + + var buf []byte + + rd, err := w.client.Download(w.accountId(), part.BlobID) + if err != nil { + return w.wrapDownloadError("part", mail.BlobID, err) + } + buf, err = io.ReadAll(rd) + rd.Close() + if err != nil { + return err + } + w.w.PostMessage(&types.MessageBodyPart{ + Message: types.RespondTo(msg), + Part: &models.MessageBodyPart{ + Reader: bytes.NewReader(buf), + Uid: msg.Uid, + }, + }, nil) + + return nil +} + +func (w *JMAPWorker) handleFetchFullMessages(msg *types.FetchFullMessages) error { + for _, uid := range msg.Uids { + id, ok := w.uidStore.GetKey(uid) + if !ok { + return fmt.Errorf("bug: unknown message uid %d", uid) + } + mail, ok := w.emails[jmap.ID(id)] + if !ok { + return fmt.Errorf("bug: unknown message id %s", id) + } + rd, err := w.client.Download(w.accountId(), mail.BlobID) + if err != nil { + return w.wrapDownloadError("full", mail.BlobID, err) + } + buf, err := io.ReadAll(rd) + rd.Close() + if err != nil { + return err + } + w.w.PostMessage(&types.FullMessage{ + Message: types.RespondTo(msg), + Content: &models.FullMessage{ + Reader: bytes.NewReader(buf), + Uid: uid, + }, + }, nil) + } + + return nil +} + +func (w *JMAPWorker) wrapDownloadError(prefix string, blobId jmap.ID, err error) error { + urlRepl := strings.NewReplacer( + "{accountId}", string(w.accountId()), + "{blobId}", string(blobId), + "{type}", "application/octet-stream", + "{name}", "filename", + ) + url := urlRepl.Replace(w.client.Session.DownloadURL) + return fmt.Errorf("%s: blobId=%q: %s: %s", prefix, blobId, url, err) +} diff --git a/worker/jmap/jmap.go b/worker/jmap/jmap.go new file mode 100644 index 00000000..96e41952 --- /dev/null +++ b/worker/jmap/jmap.go @@ -0,0 +1,117 @@ +package jmap + +import ( + "fmt" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rockorager/go-jmap/mail" + "git.sr.ht/~rockorager/go-jmap/mail/email" + msgmail "github.com/emersion/go-message/mail" +) + +func (w *JMAPWorker) translateMsgInfo(m *email.Email) *models.MessageInfo { + env := &models.Envelope{ + Date: *m.ReceivedAt, + Subject: m.Subject, + From: translateAddrList(m.From), + ReplyTo: translateAddrList(m.ReplyTo), + To: translateAddrList(m.To), + Cc: translateAddrList(m.CC), + Bcc: translateAddrList(m.BCC), + MessageId: firstString(m.MessageID), + InReplyTo: firstString(m.InReplyTo), + } + labels := make([]string, 0, len(m.MailboxIDs)) + for id := range m.MailboxIDs { + if m, ok := w.mboxes[id]; ok { + labels = append(labels, m.dir) + } + } + return &models.MessageInfo{ + Envelope: env, + Flags: keywordsToFlags(m.Keywords), + Uid: w.uidStore.GetOrInsert(string(m.ID)), + BodyStructure: translateBodyStructure(m.BodyStructure), + RFC822Headers: translateJMAPHeader(m.Headers), + Refs: m.References, + Labels: labels, + Size: uint32(m.Size), + InternalDate: *m.ReceivedAt, + } +} + +func translateJMAPHeader(headers []*email.Header) *msgmail.Header { + hdr := new(msgmail.Header) + for _, h := range headers { + raw := fmt.Sprintf("%s:%s\r\n", h.Name, h.Value) + hdr.AddRaw([]byte(raw)) + } + return hdr +} + +func flagsToKeywords(flags models.Flags) map[string]bool { + kw := make(map[string]bool) + if flags.Has(models.SeenFlag) { + kw["$seen"] = true + } + if flags.Has(models.AnsweredFlag) { + kw["$answered"] = true + } + if flags.Has(models.FlaggedFlag) { + kw["$flagged"] = true + } + return kw +} + +func keywordsToFlags(kw map[string]bool) models.Flags { + var f models.Flags + for k, v := range kw { + if v { + switch k { + case "$seen": + f |= models.SeenFlag + case "$answered": + f |= models.AnsweredFlag + case "$flagged": + f |= models.FlaggedFlag + } + } + } + return f +} + +func firstString(s []string) string { + if len(s) == 0 { + return "" + } + return s[0] +} + +func translateAddrList(addrs []*mail.Address) []*msgmail.Address { + res := make([]*msgmail.Address, 0, len(addrs)) + for _, a := range addrs { + res = append(res, &msgmail.Address{Name: a.Name, Address: a.Email}) + } + return res +} + +func translateBodyStructure(part *email.BodyPart) *models.BodyStructure { + bs := &models.BodyStructure{ + Description: part.Name, + Encoding: part.Charset, + Params: map[string]string{ + "name": part.Name, + "charset": part.Charset, + }, + Disposition: part.Disposition, + DispositionParams: map[string]string{ + "filename": part.Name, + }, + } + bs.MIMEType, bs.MIMESubType, _ = strings.Cut(part.Type, "/") + for _, sub := range part.SubParts { + bs.Parts = append(bs.Parts, translateBodyStructure(sub)) + } + return bs +} diff --git a/worker/jmap/list.go b/worker/jmap/list.go new file mode 100644 index 00000000..cba3db91 --- /dev/null +++ b/worker/jmap/list.go @@ -0,0 +1,53 @@ +package jmap + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func (w *JMAPWorker) handleListDirectories(msg *types.ListDirectories) error { + var req jmap.Request + + w.w.Debugf("listing directories") + + req.Invoke(&mailbox.Get{Account: w.accountId()}) + resp, err := w.client.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.GetResponse: + for _, mbox := range r.List { + w.mboxes[mbox.ID] = &MailboxState{mbox: mbox} + } + w.mboxState = r.State + } + } + if w.mboxes == nil || len(w.mboxes) == 0 { + return errors.New("no mailboxes") + } + + for _, m := range w.mboxes { + m.dir = m.FullPath(w.mboxes) + w.dir2mbox[m.dir] = m.mbox.ID + w.w.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: m.dir, + Exists: int(m.mbox.TotalEmails), + Unseen: int(m.mbox.UnreadEmails), + Role: jmapRole2aerc[m.mbox.Role], + }, + }, nil) + } + + go w.monitorChanges() + + return nil +} diff --git a/worker/jmap/mboxstate.go b/worker/jmap/mboxstate.go new file mode 100644 index 00000000..0ee8bde3 --- /dev/null +++ b/worker/jmap/mboxstate.go @@ -0,0 +1,38 @@ +package jmap + +import ( + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +type MailboxState struct { + mbox *mailbox.Mailbox + dir string + queryState string + filter *email.FilterCondition + sort []*email.SortComparator + uids []uint32 +} + +func (s *MailboxState) FullPath(all map[jmap.ID]*MailboxState) string { + if s.mbox.ParentID == "" { + return s.mbox.Name + } + parent, ok := all[s.mbox.ParentID] + if !ok { + return s.mbox.Name + } + return parent.FullPath(all) + "/" + s.mbox.Name +} + +var jmapRole2aerc = map[mailbox.Role]models.Role{ + mailbox.RoleAll: models.AllRole, + mailbox.RoleArchive: models.ArchiveRole, + mailbox.RoleDrafts: models.DraftsRole, + mailbox.RoleInbox: models.InboxRole, + mailbox.RoleJunk: models.JunkRole, + mailbox.RoleSent: models.SentRole, + mailbox.RoleTrash: models.TrashRole, +} diff --git a/worker/jmap/push.go b/worker/jmap/push.go new file mode 100644 index 00000000..8b7c4ec5 --- /dev/null +++ b/worker/jmap/push.go @@ -0,0 +1,213 @@ +package jmap + +import ( + "time" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/core/push" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func (w *JMAPWorker) monitorChanges() { + events := push.EventSource{ + Client: w.client, + Handler: w.handleChange, + } + + w.stop = make(chan struct{}) + go func() { + <-w.stop + w.w.Errorf("listen stopping") + w.stop = nil + events.Close() + }() + + for w.stop != nil { + w.w.Debugf("listening for changes") + err := events.Listen() + if err != nil { + w.w.Errorf("listen error: %s (retry in 1 second)", err) + time.Sleep(1 * time.Second) + } + } +} + +func (w *JMAPWorker) handleChange(s *jmap.StateChange) { + changed, ok := s.Changed[w.accountId()] + if !ok { + return + } + w.w.Debugf("state change %#v", changed) + w.changes <- changed +} + +func (w *JMAPWorker) refresh(newState jmap.TypeState) error { + var req jmap.Request + + if w.mboxState != "" && newState["Mailbox"] != w.mboxState { + callID := req.Invoke(&mailbox.Changes{ + Account: w.accountId(), + SinceState: w.mboxState, + }) + req.Invoke(&mailbox.Get{ + Account: w.accountId(), + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Mailbox/changes", + Path: "/created", + }, + }) + req.Invoke(&mailbox.Get{ + Account: w.accountId(), + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Mailbox/changes", + Path: "/updated", + }, + }) + } + + selected := w.mboxes[w.selectedMbox] + + if w.emailState != "" && newState["Email"] != w.emailState && selected != nil { + req.Invoke(&email.QueryChanges{ + Account: w.accountId(), + Filter: selected.filter, + Sort: selected.sort, + SinceQueryState: selected.queryState, + }) + callID := req.Invoke(&email.Changes{ + Account: w.accountId(), + SinceState: w.emailState, + }) + req.Invoke(&email.Get{ + Account: w.accountId(), + Properties: headersProperties, + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Email/changes", + Path: "/updated", + }, + }) + } + + if len(req.Calls) == 0 { + return nil + } + + w.w.Debugf("fetching changes") + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + + var mboxIds []jmap.ID + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.ChangesResponse: + for _, d := range r.Destroyed { + m, ok := w.mboxes[d] + if ok { + w.w.PostMessage(&types.RemoveDirectory{ + Directory: m.dir, + }, nil) + } + delete(w.mboxes, d) + delete(w.dir2mbox, m.dir) + } + w.mboxState = r.NewState + + case *mailbox.GetResponse: + for _, mbox := range r.List { + mboxIds = append(mboxIds, mbox.ID) + if m, ok := w.mboxes[mbox.ID]; ok { + m.mbox = mbox + } else { + w.mboxes[mbox.ID] = &MailboxState{mbox: mbox} + } + } + + case *email.QueryChangesResponse: + removed := make(map[uint32]bool) + for _, id := range r.Removed { + uid := w.uidStore.GetOrInsert(string(id)) + removed[uid] = true + } + added := make(map[int]uint32) + for _, add := range r.Added { + uid := w.uidStore.GetOrInsert(string(add.ID)) + added[int(add.Index)] = uid + } + uids := make([]uint32, 0, len(selected.uids)-len(removed)+len(added)) + i := 0 + for _, uid := range selected.uids { + if removed[uid] { + continue + } + if addedUid, ok := added[i]; ok { + uids = append(uids, addedUid) + delete(added, i) + i += 1 + } + uids = append(uids, uid) + i += 1 + } + for _, uid := range added { + uids = append(uids, uid) + } + selected.uids = uids + w.w.PostMessage(&types.DirectoryContents{ + Uids: uids, + }, nil) + + case *email.GetResponse: + selectedUids := make(map[uint32]bool) + for _, uid := range selected.uids { + selectedUids[uid] = true + } + for _, m := range r.List { + w.emails[m.ID] = m + info := w.translateMsgInfo(m) + if selectedUids[info.Uid] { + w.w.PostMessage(&types.MessageInfo{ + Info: info, + }, nil) + } + } + w.emailState = r.State + } + } + + for _, id := range mboxIds { + m := w.mboxes[id] + if m.dir == "" { + // new mailbox + m.dir = m.FullPath(w.mboxes) + w.dir2mbox[m.dir] = m.mbox.ID + w.w.PostMessage(&types.Directory{ + Dir: &models.Directory{ + Name: m.dir, + Exists: int(m.mbox.TotalEmails), + Unseen: int(m.mbox.UnreadEmails), + Role: jmapRole2aerc[m.mbox.Role], + }, + }, nil) + } else { + // updated + w.w.PostMessage(&types.DirectoryInfo{ + Info: &models.DirectoryInfo{ + Name: m.dir, + Exists: int(m.mbox.TotalEmails), + Unseen: int(m.mbox.UnreadEmails), + }, + }, nil) + } + } + + return nil +} diff --git a/worker/jmap/search.go b/worker/jmap/search.go new file mode 100644 index 00000000..a751b700 --- /dev/null +++ b/worker/jmap/search.go @@ -0,0 +1,63 @@ +package jmap + +import ( + "strings" + + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/worker/lib" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~sircmpwn/getopt" +) + +func parseSearch(args []string) (*email.FilterCondition, error) { + f := new(email.FilterCondition) + if len(args) == 0 { + return f, nil + } + + opts, optind, err := getopt.Getopts(args, "rubax:X:t:H:f:c:d:") + if err != nil { + return nil, err + } + body := false + text := false + for _, opt := range opts { + switch opt.Option { + case 'r': + f.HasKeyword = "$seen" + case 'u': + f.NotKeyword = "$seen" + case 'f': + f.From = opt.Value + case 't': + f.To = opt.Value + case 'c': + f.Cc = opt.Value + case 'b': + body = true + case 'a': + text = true + case 'd': + start, end, err := lib.ParseDateRange(opt.Value) + if err != nil { + log.Errorf("failed to parse start date: %v", err) + continue + } + if !start.IsZero() { + f.After = &start + } + if !end.IsZero() { + f.Before = &end + } + } + } + switch { + case text: + f.Text = strings.Join(args[optind:], " ") + case body: + f.Body = strings.Join(args[optind:], " ") + default: + f.Subject = strings.Join(args[optind:], " ") + } + return f, nil +} diff --git a/worker/jmap/set.go b/worker/jmap/set.go new file mode 100644 index 00000000..d5d4990e --- /dev/null +++ b/worker/jmap/set.go @@ -0,0 +1,118 @@ +package jmap + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" +) + +func (w *JMAPWorker) updateFlags(uids []uint32, flags models.Flags, enable bool) error { + var req jmap.Request + update := make(map[jmap.ID]jmap.Patch) + + for _, uid := range uids { + id, ok := w.uidStore.GetKey(uid) + if !ok { + return fmt.Errorf("bug: unknown uid %d", uid) + } + update[jmap.ID(id)] = jmap.Patch{} + for kw := range flagsToKeywords(flags) { + path := fmt.Sprintf("keywords/%s", kw) + if enable { + update[jmap.ID(id)][path] = true + } else { + update[jmap.ID(id)][path] = nil + } + } + } + + req.Invoke(&email.Set{ + Account: w.accountId(), + Update: update, + }) + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.SetResponse: + for _, err := range r.NotUpdated { + var s string + if err.Description != nil { + s = *err.Description + } else { + s = err.Type + if err.Properties != nil { + s += fmt.Sprintf(" %v", *err.Properties) + } + } + return fmt.Errorf("flag update failed: %s", s) + } + } + } + + return nil +} + +func (w *JMAPWorker) moveCopy(uids []uint32, destDir string, move bool) error { + var req jmap.Request + var dest jmap.ID + var ok bool + + update := make(map[jmap.ID]jmap.Patch) + + dest, ok = w.dir2mbox[destDir] + if !ok && destDir != "" { + return fmt.Errorf("unknown destination mailbox") + } + + for _, uid := range uids { + id, ok := w.uidStore.GetKey(uid) + if !ok { + return fmt.Errorf("bug: unknown uid %d", uid) + } + patch := jmap.Patch{} + if dest != "" { + patch[fmt.Sprintf("mailboxIds/%s", dest)] = true + } + if move { + patch[fmt.Sprintf("mailboxIds/%s", w.selectedMbox)] = nil + } + update[jmap.ID(id)] = patch + } + + req.Invoke(&email.Set{ + Account: w.accountId(), + Update: update, + }) + + resp, err := w.client.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.SetResponse: + for _, err := range r.NotUpdated { + var s string + if err.Description != nil { + s = *err.Description + } else { + s = err.Type + if err.Properties != nil { + s += fmt.Sprintf(" %v", *err.Properties) + } + } + return fmt.Errorf("mailbox change failed: %s", s) + } + } + } + + return nil +} diff --git a/worker/jmap/worker.go b/worker/jmap/worker.go new file mode 100644 index 00000000..f0b3de81 --- /dev/null +++ b/worker/jmap/worker.go @@ -0,0 +1,150 @@ +package jmap + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/uidstore" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/handlers" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/core/push" + "git.sr.ht/~rockorager/go-jmap/mail/email" +) + +func init() { + handlers.RegisterWorkerFactory("jmap", NewJMAPWorker) +} + +type JMAPWorker struct { + // config + account *config.AccountConfig + endpoint string + oauth bool + user *url.Userinfo + query url.Values + + w *types.Worker + client *jmap.Client + + selectedMbox jmap.ID + mboxes map[jmap.ID]*MailboxState + dir2mbox map[string]jmap.ID + uidStore *uidstore.Store + emails map[jmap.ID]*email.Email + + events *push.EventSource + mboxState string + emailState string + changes chan jmap.TypeState + stop chan struct{} +} + +func NewJMAPWorker(worker *types.Worker) (types.Backend, error) { + return &JMAPWorker{ + w: worker, + uidStore: uidstore.NewStore(), + mboxes: make(map[jmap.ID]*MailboxState), + emails: make(map[jmap.ID]*email.Email), + dir2mbox: make(map[string]jmap.ID), + changes: make(chan jmap.TypeState), + }, nil +} + +var capas = models.Capabilities{Sort: true, Thread: false} + +func (w *JMAPWorker) Capabilities() *models.Capabilities { + return &capas +} + +func (w *JMAPWorker) PathSeparator() string { + return "/" +} + +func (w *JMAPWorker) handleMessage(msg types.WorkerMessage) error { + switch msg := msg.(type) { + case *types.Unsupported: + // No-op + case *types.Configure: + return w.handleConfigure(msg) + case *types.Connect: + if w.stop != nil { + return errors.New("already connected") + } + return w.handleConnect(msg) + case *types.Reconnect: + if w.stop == nil { + return errors.New("not connected") + } + close(w.stop) + return w.handleConnect(&types.Connect{Message: msg.Message}) + case *types.Disconnect: + if w.stop == nil { + return errors.New("not connected") + } + close(w.stop) + return nil + case *types.ListDirectories: + return w.handleListDirectories(msg) + case *types.OpenDirectory: + return w.handleOpenDirectory(msg) + case *types.FetchDirectoryContents: + return w.handleFetchDirectoryContents(msg) + case *types.SearchDirectory: + return w.handleSearchDirectory(msg) + case *types.CreateDirectory: + return w.handleCreateDirectory(msg) + case *types.RemoveDirectory: + return w.handleRemoveDirectory(msg) + case *types.FetchMessageHeaders: + return w.handleFetchMessageHeaders(msg) + case *types.FetchMessageBodyPart: + return w.handleFetchMessageBodyPart(msg) + case *types.FetchFullMessages: + return w.handleFetchFullMessages(msg) + case *types.FetchMessageFlags: + return nil + case *types.DeleteMessages: + return w.moveCopy(msg.Uids, "", true) + case *types.FlagMessages: + return w.updateFlags(msg.Uids, msg.Flags, msg.Enable) + case *types.AnsweredMessages: + return w.updateFlags(msg.Uids, models.AnsweredFlag, msg.Answered) + case *types.CopyMessages: + return w.moveCopy(msg.Uids, msg.Destination, false) + case *types.MoveMessages: + return w.moveCopy(msg.Uids, msg.Destination, true) + // case *types.AppendMessage: + // return w.handleAppendMessage(msg) + } + _, cmd, _ := strings.Cut(fmt.Sprintf("%T", msg), ".") + return fmt.Errorf("unsupported command for jmap: %s", cmd) +} + +func (w *JMAPWorker) Run() { + for { + select { + case change := <-w.changes: + err := w.refresh(change) + if err != nil { + w.w.Errorf("refresh: %s", err) + } + case msg := <-w.w.Actions: + msg = w.w.ProcessAction(msg) + if err := w.handleMessage(msg); err != nil { + w.w.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } else { + w.w.PostMessage(&types.Done{ + Message: types.RespondTo(msg), + }, nil) + } + } + } +} diff --git a/worker/worker_enabled.go b/worker/worker_enabled.go index 697ca402..cb937745 100644 --- a/worker/worker_enabled.go +++ b/worker/worker_enabled.go @@ -6,4 +6,5 @@ import ( _ "git.sr.ht/~rjarry/aerc/worker/lib/watchers" _ "git.sr.ht/~rjarry/aerc/worker/maildir" _ "git.sr.ht/~rjarry/aerc/worker/mbox" + _ "git.sr.ht/~rjarry/aerc/worker/jmap" ) |