aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobin Jarry <robin@jarry.cc>2023-06-04 14:05:22 +0200
committerRobin Jarry <robin@jarry.cc>2023-06-06 16:12:52 +0200
commit9e27c777d4c818dd4a9f265d174686ea988f9955 (patch)
tree56724afe0b4d9c28d277ffa274b2894ce9ffb95d
parent856b3cc29b659a76ba9edf555ed162c0dd84b781 (diff)
downloadaerc-9e27c777d4c818dd4a9f265d174686ea988f9955.tar.gz
aerc-9e27c777d4c818dd4a9f265d174686ea988f9955.zip
jmap
Signed-off-by: Robin Jarry <robin@jarry.cc>
-rw-r--r--.env1
-rw-r--r--CHANGELOG.md1
-rw-r--r--Makefile4
-rw-r--r--README.md1
-rw-r--r--commands/account/rmdir.go1
-rw-r--r--doc/aerc-accounts.5.scd5
-rw-r--r--doc/aerc-config.5.scd6
-rw-r--r--doc/aerc-jmap.5.scd61
-rw-r--r--doc/aerc.1.scd6
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--worker/jmap/configure.go40
-rw-r--r--worker/jmap/connect.go42
-rw-r--r--worker/jmap/directories.go220
-rw-r--r--worker/jmap/fetch.go158
-rw-r--r--worker/jmap/jmap.go117
-rw-r--r--worker/jmap/list.go53
-rw-r--r--worker/jmap/mboxstate.go38
-rw-r--r--worker/jmap/push.go213
-rw-r--r--worker/jmap/search.go63
-rw-r--r--worker/jmap/set.go118
-rw-r--r--worker/jmap/worker.go150
-rw-r--r--worker/worker_enabled.go1
23 files changed, 1294 insertions, 8 deletions
diff --git a/.env b/.env
new file mode 100644
index 00000000..7b06cc5e
--- /dev/null
+++ b/.env
@@ -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
diff --git a/Makefile b/Makefile
index 73d078ca..e1860f0a 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/README.md b/README.md
index bf3f1e11..cc6c4dd6 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/go.mod b/go.mod
index cec977e6..20b3aaad 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 38d49446..3fb21206 100644
--- a/go.sum
+++ b/go.sum
@@ -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"
)