aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--Makefile4
-rw-r--r--README.md1
-rw-r--r--commands/account/rmdir.go1
-rw-r--r--commands/compose/send.go36
-rw-r--r--commands/help.go1
-rw-r--r--doc/aerc-accounts.5.scd5
-rw-r--r--doc/aerc-config.5.scd6
-rw-r--r--doc/aerc-jmap.5.scd148
-rw-r--r--doc/aerc.1.scd6
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--worker/jmap/cache/blob.go45
-rw-r--r--worker/jmap/cache/cache.go79
-rw-r--r--worker/jmap/cache/email.go35
-rw-r--r--worker/jmap/cache/folder_contents.go61
-rw-r--r--worker/jmap/cache/gob.go35
-rw-r--r--worker/jmap/cache/mailbox.go35
-rw-r--r--worker/jmap/cache/mailbox_list.go32
-rw-r--r--worker/jmap/cache/session.go32
-rw-r--r--worker/jmap/cache/state.go30
-rw-r--r--worker/jmap/configure.go70
-rw-r--r--worker/jmap/connect.go133
-rw-r--r--worker/jmap/directories.go360
-rw-r--r--worker/jmap/fetch.go196
-rw-r--r--worker/jmap/jmap.go174
-rw-r--r--worker/jmap/push.go333
-rw-r--r--worker/jmap/search.go63
-rw-r--r--worker/jmap/send.go144
-rw-r--r--worker/jmap/set.go241
-rw-r--r--worker/jmap/worker.go195
-rw-r--r--worker/types/messages.go11
-rw-r--r--worker/worker_enabled.go1
33 files changed, 2507 insertions, 10 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0aecd62d..7c43e89b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,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 11b7c42c..55a0b93c 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 \
@@ -127,6 +128,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
@@ -164,6 +166,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
@@ -181,6 +184,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 52eaab7a..242fb99d 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/commands/compose/send.go b/commands/compose/send.go
index eeea96cd..7eeb124c 100644
--- a/commands/compose/send.go
+++ b/commands/compose/send.go
@@ -174,6 +174,8 @@ func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx,
fallthrough
case "smtps":
sender, err = newSmtpSender(ctx)
+ case "jmap":
+ sender, err = newJmapSender(composer, header, ctx)
case "":
sender, err = newSendmailSender(ctx)
default:
@@ -186,7 +188,7 @@ func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx,
var writer io.Writer = sender
- if config.CopyTo != "" {
+ if config.CopyTo != "" && ctx.scheme != "jmap" {
writer = io.MultiWriter(writer, &copyBuf)
}
err = composer.WriteMessage(header, writer)
@@ -210,7 +212,7 @@ func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx,
aerc.NewTab(composer, tabName)
return
}
- if config.CopyTo != "" {
+ if config.CopyTo != "" && ctx.scheme != "jmap" {
aerc.PushStatus("Copying to "+config.CopyTo, 10*time.Second)
errch := copyToSent(composer.Worker(), config.CopyTo,
copyBuf.Len(), &copyBuf)
@@ -512,6 +514,36 @@ func connectSmtps(host string) (*smtp.Client, error) {
return conn, nil
}
+func newJmapSender(
+ composer *widgets.Composer, header *mail.Header, ctx sendCtx,
+) (io.WriteCloser, error) {
+ var writer io.WriteCloser
+ done := make(chan error)
+
+ composer.Worker().PostAction(
+ &types.StartSendingMessage{Header: header},
+ func(msg types.WorkerMessage) {
+ switch msg := msg.(type) {
+ case *types.Done:
+ return
+ case *types.Unsupported:
+ done <- fmt.Errorf("unsupported by worker")
+ case *types.Error:
+ done <- msg.Error
+ case *types.MessageWriter:
+ writer = msg.Writer
+ default:
+ done <- fmt.Errorf("unexpected worker message: %#v", msg)
+ }
+ close(done)
+ },
+ )
+
+ err := <-done
+
+ return writer, err
+}
+
func copyToSent(worker *types.Worker, dest string,
n int, msg io.Reader,
) <-chan error {
diff --git a/commands/help.go b/commands/help.go
index b09e1143..b2654ab5 100644
--- a/commands/help.go
+++ b/commands/help.go
@@ -14,6 +14,7 @@ var pages = []string{
"binds",
"config",
"imap",
+ "jmap",
"notmuch",
"search",
"sendmail",
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..9fc482c9
--- /dev/null
+++ b/doc/aerc-jmap.5.scd
@@ -0,0 +1,148 @@
+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
+ ```
+
+ Your source credentials must have the _urn:ietf:params:jmap:mail_
+ capability.
+
+*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
+
+*outgoing* = _jmap://_
+ The JMAP connection can also be used to send emails. No need to repeat
+ the URL nor any credentials. Just the URL scheme will be enough.
+
+ Your source credentials must have the _urn:ietf:params:jmap:submission_
+ capability.
+
+*cache-state* = _true_|_false_
+ Cache all email state (mailboxes, email headers, mailbox contents, email
+ flags, etc.) on disk in a levelDB database located in folder
+ _~/.cache/aerc/<account>/state_.
+
+ The cached data should remain small, in the order of a few megabytes,
+ even for very large email stores. Aerc will make its best to purge
+ deleted/outdated information. It is safe to delete that folder when aerc
+ is not running and it will be recreated from scratch on next startup.
+
+ Default: _false_
+
+*cache-blobs* = _true_|_false_
+ Cache all downloaded email bodies and attachments on disk as individual
+ files in _~/.cache/aerc/<account>/blobs/<xx>/<blob_id>_ (where _<xx>_ is
+ a subfolder named after the last two characters of _<blob_id>_).
+
+ Aerc will not purge the cached blobs automatically. Even when their
+ related emails are destroyed permanently from the server. If required,
+ you may want to run some periodic cleanup based on file creation date in
+ a crontab, e.g.:
+
+ @daily find ~/.cache/aerc/foo/blobs -type f -mtime +30 -delete
+
+ Default: _false_
+
+*use-labels* = _true_|_false_
+ If set to _true_, mailboxes with the _archive_ role (usually _Archive_)
+ will be hidden from the directory list and replaced by an *all-mail*
+ virtual folder. The name of that folder can be configured via the
+ *all-mail* setting.
+
+ *:archive flat* may still be used to effectively "tag" messages with the
+ hidden _Archive_ mailbox so that they appear in the *all-mail* virtual
+ folder. When the *all-mail* virtual folder is selected, *:archive flat*
+ should not be used and will have no effect. The messages will be grayed
+ out but will never be refreshed until aerc is restarted.
+
+ Also, this enables support for the *:modify-labels* (alias *:tag*)
+ command.
+
+ Default: _false_
+
+*all-mail* = _<name>_
+ Name of the virtual folder that replaces the role=_archive_ mailbox when
+ *use-labels* = _true_.
+
+ Default: _All mail_
+
+*server-ping* = _<duration>_
+ Interval the server should ping the client at when monitoring for email
+ changes. The server may choose to ignore this value. By default, no ping
+ will be requested from the server.
+
+ See https://pkg.go.dev/time#ParseDuration.
+
+# NOTES
+
+JMAP messages can be seen as "labels" or "tags". Every message must belong to
+one or more mailboxes (folders in aerc). Each mailbox has a "role" as described
+in _https://www.iana.org/assignments/imap-mailbox-name-attributes/_.
+
+When deleting messages that belong only to the selected mailbox, aerc will
+attempt to "move" these messages to a mailbox with the _trash_ role. If it
+cannot find such mailbox or if the selected mailbox is the _trash_ mailbox, it
+will effectively destroy the messages from the server.
+
+*:delete* removes messages from the selected mailbox and effectively does the
+same thing than *:tag -<selected_folder>*.
+
+*:cp <foo>* is an alias for *:tag <foo>* or *:tag +<foo>*.
+
+*:mv <foo>* is a compound of *:delete* and *:mv* and can be seen as an alias of
+*:tag -<selected_folder> +<foo>*.
+
+*:archive flat* is an alias for *:tag -<selected_folder> +<archive>*.
+
+# 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 cb2a4bb1..4002e339 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -628,9 +628,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..f1eaa288 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.3.0
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..4daac760 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+git.sr.ht/~rockorager/go-jmap v0.3.0 h1:h2WuPcNyXRYFg9+W2HGf/mzIqC6ISy9EaS/BGa7Z5RY=
+git.sr.ht/~rockorager/go-jmap v0.3.0/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/cache/blob.go b/worker/jmap/cache/blob.go
new file mode 100644
index 00000000..2a239835
--- /dev/null
+++ b/worker/jmap/cache/blob.go
@@ -0,0 +1,45 @@
+package cache
+
+import (
+ "os"
+ "path"
+
+ "git.sr.ht/~rockorager/go-jmap"
+)
+
+func (c *JMAPCache) GetBlob(id jmap.ID) ([]byte, error) {
+ fpath := c.blobPath(id)
+ if fpath == "" {
+ return nil, notfound
+ }
+ return os.ReadFile(fpath)
+}
+
+func (c *JMAPCache) PutBlob(id jmap.ID, buf []byte) error {
+ fpath := c.blobPath(id)
+ if fpath == "" {
+ return nil
+ }
+ _ = os.MkdirAll(path.Dir(fpath), 0o700)
+ return os.WriteFile(fpath, buf, 0o600)
+}
+
+func (c *JMAPCache) DeleteBlob(id jmap.ID) error {
+ fpath := c.blobPath(id)
+ if fpath == "" {
+ return nil
+ }
+ defer func() {
+ _ = os.Remove(path.Dir(fpath))
+ }()
+ return os.Remove(fpath)
+}
+
+func (c *JMAPCache) blobPath(id jmap.ID) string {
+ if c.blobsDir == "" {
+ return ""
+ }
+ name := string(id)
+ sub := name[len(name)-2:]
+ return path.Join(c.blobsDir, sub, name)
+}
diff --git a/worker/jmap/cache/cache.go b/worker/jmap/cache/cache.go
new file mode 100644
index 00000000..ab264744
--- /dev/null
+++ b/worker/jmap/cache/cache.go
@@ -0,0 +1,79 @@
+package cache
+
+import (
+ "errors"
+ "os"
+ "path"
+
+ "github.com/mitchellh/go-homedir"
+ "github.com/syndtr/goleveldb/leveldb"
+)
+
+type JMAPCache struct {
+ mem map[string][]byte
+ file *leveldb.DB
+ blobsDir string
+}
+
+func NewJMAPCache(state, blobs bool, accountName string) (*JMAPCache, error) {
+ c := new(JMAPCache)
+ cacheDir, err := os.UserCacheDir()
+ if err != nil {
+ cacheDir, err = homedir.Expand("~/.cache")
+ if err != nil {
+ return nil, err
+ }
+ }
+ if state {
+ dir := path.Join(cacheDir, "aerc", accountName, "state")
+ _ = os.MkdirAll(dir, 0o700)
+ c.file, err = leveldb.OpenFile(dir, nil)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ c.mem = make(map[string][]byte)
+ }
+ if blobs {
+ c.blobsDir = path.Join(cacheDir, "aerc", accountName, "blobs")
+ }
+ return c, nil
+}
+
+var notfound = errors.New("key not found")
+
+func (c *JMAPCache) get(key string) ([]byte, error) {
+ switch {
+ case c.file != nil:
+ return c.file.Get([]byte(key), nil)
+ case c.mem != nil:
+ value, ok := c.mem[key]
+ if !ok {
+ return nil, notfound
+ }
+ return value, nil
+ }
+ panic("jmap cache with no backend")
+}
+
+func (c *JMAPCache) put(key string, value []byte) error {
+ switch {
+ case c.file != nil:
+ return c.file.Put([]byte(key), value, nil)
+ case c.mem != nil:
+ c.mem[key] = value
+ return nil
+ }
+ panic("jmap cache with no backend")
+}
+
+func (c *JMAPCache) delete(key string) error {
+ switch {
+ case c.file != nil:
+ return c.file.Delete([]byte(key), nil)
+ case c.mem != nil:
+ delete(c.mem, key)
+ return nil
+ }
+ panic("jmap cache with no backend")
+}
diff --git a/worker/jmap/cache/email.go b/worker/jmap/cache/email.go
new file mode 100644
index 00000000..52044281
--- /dev/null
+++ b/worker/jmap/cache/email.go
@@ -0,0 +1,35 @@
+package cache
+
+import (
+ "git.sr.ht/~rockorager/go-jmap"
+ "git.sr.ht/~rockorager/go-jmap/mail/email"
+)
+
+func (c *JMAPCache) GetEmail(id jmap.ID) (*email.Email, error) {
+ buf, err := c.get(emailey(id))
+ if err != nil {
+ return nil, err
+ }
+ e := new(email.Email)
+ err = unmarshal(buf, e)
+ if err != nil {
+ return nil, err
+ }
+ return e, nil
+}
+
+func (c *JMAPCache) PutEmail(id jmap.ID, e *email.Email) error {
+ buf, err := marshal(e)
+ if err != nil {
+ return err
+ }
+ return c.put(emailey(id), buf)
+}
+
+func (c *JMAPCache) DeleteEmail(id jmap.ID) error {
+ return c.delete(emailey(id))
+}
+
+func emailey(id jmap.ID) string {
+ return "email/" + string(id)
+}
diff --git a/worker/jmap/cache/folder_contents.go b/worker/jmap/cache/folder_contents.go
new file mode 100644
index 00000000..6c6a7d80
--- /dev/null
+++ b/worker/jmap/cache/folder_contents.go
@@ -0,0 +1,61 @@
+package cache
+
+import (
+ "reflect"
+
+ "git.sr.ht/~rockorager/go-jmap"
+ "git.sr.ht/~rockorager/go-jmap/mail/email"
+)
+
+type FolderContents struct {
+ MailboxID jmap.ID
+ QueryState string
+ Filter *email.FilterCondition
+ Sort []*email.SortComparator
+ MessageIDs []jmap.ID
+}
+
+func (c *JMAPCache) GetFolderContents(mailboxId jmap.ID) (*FolderContents, error) {
+ buf, err := c.get(folderContentsKey(mailboxId))
+ if err != nil {
+ return nil, err
+ }
+ m := new(FolderContents)
+ err = unmarshal(buf, m)
+ if err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func (c *JMAPCache) PutFolderContents(mailboxId jmap.ID, m *FolderContents) error {
+ buf, err := marshal(m)
+ if err != nil {
+ return err
+ }
+ return c.put(folderContentsKey(mailboxId), buf)
+}
+
+func (c *JMAPCache) DeleteFolderContents(mailboxId jmap.ID) error {
+ return c.delete(folderContentsKey(mailboxId))
+}
+
+func folderContentsKey(mailboxId jmap.ID) string {
+ return "foldercontents/" + string(mailboxId)
+}
+
+func (f *FolderContents) NeedsRefresh(
+ filter *email.FilterCondition, sort []*email.SortComparator,
+) bool {
+ if f.QueryState == "" || f.Filter == nil || len(f.Sort) != len(sort) {
+ return true
+ }
+
+ for i := 0; i < len(sort) && i < len(f.Sort); i++ {
+ if !reflect.DeepEqual(sort[i], f.Sort[i]) {
+ return true
+ }
+ }
+
+ return !reflect.DeepEqual(filter, f.Filter)
+}
diff --git a/worker/jmap/cache/gob.go b/worker/jmap/cache/gob.go
new file mode 100644
index 00000000..589bd954
--- /dev/null
+++ b/worker/jmap/cache/gob.go
@@ -0,0 +1,35 @@
+package cache
+
+import (
+ "bytes"
+ "encoding/gob"
+
+ "git.sr.ht/~rockorager/go-jmap"
+ "git.sr.ht/~rockorager/go-jmap/mail/email"
+ "git.sr.ht/~rockorager/go-jmap/mail/mailbox"
+)
+
+type jmapObject interface {
+ *jmap.Session |
+ *email.Email |
+ *email.QueryResponse |
+ *mailbox.Mailbox |
+ *FolderContents |
+ *IDList
+}
+
+func marshal[T jmapObject](obj T) ([]byte, error) {
+ buf := bytes.NewBuffer(nil)
+ encoder := gob.NewEncoder(buf)
+ err := encoder.Encode(obj)
+ if err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func unmarshal[T jmapObject](data []byte, obj T) error {
+ buf := bytes.NewBuffer(data)
+ decoder := gob.NewDecoder(buf)
+ return decoder.Decode(obj)
+}
diff --git a/worker/jmap/cache/mailbox.go b/worker/jmap/cache/mailbox.go
new file mode 100644
index 00000000..44388778
--- /dev/null
+++ b/worker/jmap/cache/mailbox.go
@@ -0,0 +1,35 @@
+package cache
+
+import (
+ "git.sr.ht/~rockorager/go-jmap"
+ "git.sr.ht/~rockorager/go-jmap/mail/mailbox"
+)
+
+func (c *JMAPCache) GetMailbox(id jmap.ID) (*mailbox.Mailbox, error) {
+ buf, err := c.get(mailboxKey(id))
+ if err != nil {
+ return nil, err
+ }
+ m := new(mailbox.Mailbox)
+ err = unmarshal(buf, m)
+ if err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func (c *JMAPCache) PutMailbox(id jmap.ID, m *mailbox.Mailbox) error {
+ buf, err := marshal(m)
+ if err != nil {
+ return err
+ }
+ return c.put(mailboxKey(id), buf)
+}
+
+func (c *JMAPCache) DeleteMailbox(id jmap.ID) error {
+ return c.delete(mailboxKey(id))
+}
+
+func mailboxKey(id jmap.ID) string {
+ return "mailbox/" + string(id)
+}
diff --git a/worker/jmap/cache/mailbox_list.go b/worker/jmap/cache/mailbox_list.go
new file mode 100644
index 00000000..fb9bd3ea
--- /dev/null
+++ b/worker/jmap/cache/mailbox_list.go
@@ -0,0 +1,32 @@
+package cache
+
+import (
+ "git.sr.ht/~rockorager/go-jmap"
+)
+
+type IDList struct {
+ IDs []jmap.ID
+}
+
+func (c *JMAPCache) GetMailboxList() ([]jmap.ID, error) {
+ buf, err := c.get(mailboxListKey)
+ if err != nil {
+ return nil, err
+ }
+ var list IDList
+ err = unmarshal(buf, &list)
+ if err != nil {
+ return nil, err
+ }
+ return list.IDs, nil
+}
+
+func (c *JMAPCache) PutMailboxList(list []jmap.ID) error {
+ buf, err := marshal(&IDList{IDs: list})
+ if err != nil {
+ return err
+ }
+ return c.put(mailboxListKey, buf)
+}
+
+const mailboxListKey = "mailbox/list"
diff --git a/worker/jmap/cache/session.go b/worker/jmap/cache/session.go
new file mode 100644
index 00000000..7126041f
--- /dev/null
+++ b/worker/jmap/cache/session.go
@@ -0,0 +1,32 @@
+package cache
+
+import (
+ "git.sr.ht/~rockorager/go-jmap"
+)
+
+func (c *JMAPCache) GetSession() (*jmap.Session, error) {
+ buf, err := c.get(sessionKey)
+ if err != nil {
+ return nil, err
+ }
+ s := new(jmap.Session)
+ err = unmarshal(buf, s)
+ if err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func (c *JMAPCache) PutSession(s *jmap.Session) error {
+ buf, err := marshal(s)
+ if err != nil {
+ return err
+ }
+ return c.put(sessionKey, buf)
+}
+
+func (c *JMAPCache) DeleteSession() error {
+ return c.delete(sessionKey)
+}
+
+const sessionKey = "session"
diff --git a/worker/jmap/cache/state.go b/worker/jmap/cache/state.go
new file mode 100644
index 00000000..5fec5034
--- /dev/null
+++ b/worker/jmap/cache/state.go
@@ -0,0 +1,30 @@
+package cache
+
+func (c *JMAPCache) GetMailboxState() (string, error) {
+ buf, err := c.get(mailboxStateKey)
+ if err != nil {
+ return "", err
+ }
+ return string(buf), nil
+}
+
+func (c *JMAPCache) PutMailboxState(state string) error {
+ return c.put(mailboxStateKey, []byte(state))
+}
+
+func (c *JMAPCache) GetEmailState() (string, error) {
+ buf, err := c.get(emailStateKey)
+ if err != nil {
+ return "", err
+ }
+ return string(buf), nil
+}
+
+func (c *JMAPCache) PutEmailState(state string) error {
+ return c.put(emailStateKey, []byte(state))
+}
+
+const (
+ mailboxStateKey = "state/mailbox"
+ emailStateKey = "state/email"
+)
diff --git a/worker/jmap/configure.go b/worker/jmap/configure.go
new file mode 100644
index 00000000..f57dbd73
--- /dev/null
+++ b/worker/jmap/configure.go
@@ -0,0 +1,70 @@
+package jmap
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "git.sr.ht/~rjarry/aerc/worker/jmap/cache"
+ "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.config.oauth = true
+ } 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.config.user = u.User
+ u.User = nil
+ u.Scheme = "https"
+
+ w.config.endpoint = u.String()
+ w.config.account = msg.Config
+ w.config.cacheState = parseBool(msg.Config.Params["cache-state"])
+ w.config.cacheBlobs = parseBool(msg.Config.Params["cache-blobs"])
+ w.config.useLabels = parseBool(msg.Config.Params["use-labels"])
+ w.config.allMail = msg.Config.Params["all-mail"]
+ if w.config.allMail == "" {
+ w.config.allMail = "All mail"
+ }
+ if ping, ok := msg.Config.Params["server-ping"]; ok {
+ dur, err := time.ParseDuration(ping)
+ if err != nil {
+ return fmt.Errorf("server-ping: %w", err)
+ }
+ w.config.serverPing = dur
+ }
+
+ c, err := cache.NewJMAPCache(
+ w.config.cacheState, w.config.cacheBlobs, msg.Config.Name)
+ if err != nil {
+ return err
+ }
+ w.cache = c
+
+ return nil
+}
+
+func parseBool(val string) bool {
+ switch strings.ToLower(val) {
+ case "1", "t", "true", "yes", "y", "on":
+ return true
+ }
+ return false
+}
diff --git a/worker/jmap/connect.go b/worker/jmap/connect.go
new file mode 100644
index 00000000..affa9474
--- /dev/null
+++ b/worker/jmap/connect.go
@@ -0,0 +1,133 @@
+package jmap
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/url"
+ "strings"
+ "sync/atomic"
+
+ "git.sr.ht/~rjarry/aerc/worker/types"
+ "git.sr.ht/~rockorager/go-jmap"
+ "git.sr.ht/~rockorager/go-jmap/mail"
+ "git.sr.ht/~rockorager/go-jmap/mail/identity"
+)
+
+func (w *JMAPWorker) handleConnect(msg *types.Connect) error {
+ client := &jmap.Client{SessionEndpoint: w.config.endpoint}
+
+ if w.config.oauth {
+ pass, _ := w.config.user.Password()
+ client.WithAccessToken(pass)
+ } else {
+ user := w.config.user.Username()
+ pass, _ := w.config.user.Password()
+ client.WithBasicAuth(user, pass)
+ }
+
+ if session, err := w.cache.GetSession(); err != nil {
+ if err := client.Authenticate(); err != nil {
+ return err
+ }
+ if err := w.cache.PutSession(client.Session); err != nil {
+ w.w.Warnf("PutSession: %s", err)
+ }
+ } else {
+ client.Session = session
+ }
+
+ switch {
+ case client == nil:
+ fallthrough
+ case client.Session == nil:
+ fallthrough
+ case client.Session.PrimaryAccounts == nil:
+ break
+ default:
+ w.accountId = client.Session.PrimaryAccounts[mail.URI]
+ }
+
+ w.client = client
+
+ return w.GetIdentities()
+}
+
+func (w *JMAPWorker) GetIdentities() error {
+ u, err := url.Parse(w.config.account.Outgoing.Value)
+ if err != nil {
+ return fmt.Errorf("GetIdentities: %w", err)
+ }
+ if !strings.HasPrefix(u.Scheme, "jmap") {
+ // no need for identities
+ return nil
+ }
+
+ var req jmap.Request
+
+ req.Invoke(&identity.Get{Account: w.accountId})
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+ for _, inv := range resp.Responses {
+ switch r := inv.Args.(type) {
+ case *identity.GetResponse:
+ for _, ident := range r.List {
+ w.identities[ident.Email] = ident
+ }
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+
+ return nil
+}
+
+var seqnum uint64
+
+func (w *JMAPWorker) Do(req *jmap.Request) (*jmap.Response, error) {
+ seq := atomic.AddUint64(&seqnum, 1)
+ body, _ := json.Marshal(req.Calls)
+ w.w.Debugf(">%d> POST %s", seq, body)
+ resp, err := w.client.Do(req)
+ if err == nil {
+ w.w.Debugf("<%d< done", seq)
+ } else {
+ w.w.Debugf("<%d< %s", seq, err)
+ }
+ return resp, err
+}
+
+func (w *JMAPWorker) Download(blobID jmap.ID) (io.ReadCloser, error) {
+ seq := atomic.AddUint64(&seqnum, 1)
+ replacer := strings.NewReplacer(
+ "{accountId}", string(w.accountId),
+ "{blobId}", string(blobID),
+ "{type}", "application/octet-stream",
+ "{name}", "filename",
+ )
+ url := replacer.Replace(w.client.Session.DownloadURL)
+ w.w.Debugf(">%d> GET %s", seq, url)
+ rd, err := w.client.Download(w.accountId, blobID)
+ if err == nil {
+ w.w.Debugf("<%d< 200 OK", seq)
+ } else {
+ w.w.Debugf("<%d< %s", seq, err)
+ }
+ return rd, err
+}
+
+func (w *JMAPWorker) Upload(reader io.Reader) (*jmap.UploadResponse, error) {
+ seq := atomic.AddUint64(&seqnum, 1)
+ url := strings.ReplaceAll(w.client.Session.UploadURL,
+ "{accountId}", string(w.accountId))
+ w.w.Debugf(">%d> POST %s", seq, url)
+ resp, err := w.client.Upload(w.accountId, reader)
+ if err == nil {
+ w.w.Debugf("<%d< 200 OK", seq)
+ } else {
+ w.w.Debugf("<%d< %s", seq, err)
+ }
+ return resp, err
+}
diff --git a/worker/jmap/directories.go b/worker/jmap/directories.go
new file mode 100644
index 00000000..b3297169
--- /dev/null
+++ b/worker/jmap/directories.go
@@ -0,0 +1,360 @@
+package jmap
+
+import (
+ "errors"
+ "fmt"
+ "path"
+ "sort"
+
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker/jmap/cache"
+ "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) handleListDirectories(msg *types.ListDirectories) error {
+ var ids, missing []jmap.ID
+ var labels []string
+ var mboxes map[jmap.ID]*mailbox.Mailbox
+
+ mboxes = make(map[jmap.ID]*mailbox.Mailbox)
+
+ mboxIds, err := w.cache.GetMailboxList()
+ if err == nil {
+ for _, id := range mboxIds {
+ mbox, err := w.cache.GetMailbox(id)
+ if err != nil {
+ w.w.Warnf("GetMailbox: %s", err)
+ missing = append(missing, id)
+ continue
+ }
+ mboxes[id] = mbox
+ ids = append(ids, id)
+ }
+ }
+
+ if err != nil || len(missing) > 0 {
+ var req jmap.Request
+
+ req.Invoke(&mailbox.Get{Account: w.accountId})
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+
+ mboxes = make(map[jmap.ID]*mailbox.Mailbox)
+ ids = make([]jmap.ID, 0)
+
+ for _, inv := range resp.Responses {
+ switch r := inv.Args.(type) {
+ case *mailbox.GetResponse:
+ for _, mbox := range r.List {
+ mboxes[mbox.ID] = mbox
+ ids = append(ids, mbox.ID)
+ err = w.cache.PutMailbox(mbox.ID, mbox)
+ if err != nil {
+ w.w.Warnf("PutMailbox: %s", err)
+ }
+ }
+ err = w.cache.PutMailboxList(ids)
+ if err != nil {
+ w.w.Warnf("PutMailboxList: %s", err)
+ }
+ err = w.cache.PutMailboxState(r.State)
+ if err != nil {
+ w.w.Warnf("PutMailboxState: %s", err)
+ }
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+ }
+
+ if len(mboxes) == 0 {
+ return errors.New("no mailboxes")
+ }
+
+ for _, mbox := range mboxes {
+ dir := w.MailboxPath(mbox)
+ w.addMbox(mbox, dir)
+ labels = append(labels, dir)
+ }
+ if w.config.useLabels {
+ sort.Strings(labels)
+ w.w.PostMessage(&types.LabelList{Labels: labels}, nil)
+ }
+
+ for _, id := range ids {
+ mbox := mboxes[id]
+ if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
+ // replace archive with virtual all-mail folder
+ mbox = &mailbox.Mailbox{
+ Name: w.config.allMail,
+ Role: mailbox.RoleAll,
+ }
+ w.addMbox(mbox, mbox.Name)
+ }
+ w.w.PostMessage(&types.Directory{
+ Message: types.RespondTo(msg),
+ Dir: &models.Directory{
+ Name: w.mbox2dir[mbox.ID],
+ Exists: int(mbox.TotalEmails),
+ Unseen: int(mbox.UnreadEmails),
+ Role: jmapRole2aerc[mbox.Role],
+ },
+ }, nil)
+ }
+
+ go w.monitorChanges()
+
+ return nil
+}
+
+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 {
+ contents, err := w.cache.GetFolderContents(w.selectedMbox)
+ if err != nil {
+ contents = &cache.FolderContents{
+ MailboxID: w.selectedMbox,
+ Filter: &email.FilterCondition{},
+ }
+ }
+
+ filter, err := parseSearch(msg.FilterCriteria)
+ if err != nil {
+ return err
+ }
+ filter.InMailbox = w.selectedMbox
+
+ sort := translateSort(msg.SortCriteria)
+
+ if contents.NeedsRefresh(filter, sort) {
+ var req jmap.Request
+
+ req.Invoke(&email.Query{
+ Account: w.accountId,
+ Filter: filter,
+ Sort: sort,
+ })
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+ var canCalculateChanges bool
+ for _, inv := range resp.Responses {
+ switch r := inv.Args.(type) {
+ case *email.QueryResponse:
+ contents.Sort = sort
+ contents.Filter = filter
+ contents.QueryState = r.QueryState
+ contents.MessageIDs = r.IDs
+ canCalculateChanges = r.CanCalculateChanges
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+ if canCalculateChanges {
+ err = w.cache.PutFolderContents(w.selectedMbox, contents)
+ if err != nil {
+ w.w.Warnf("PutFolderContents: %s", err)
+ }
+ } else {
+ w.w.Debugf("%q: server cannot calculate changes, flushing cache",
+ w.mbox2dir[w.selectedMbox])
+ err = w.cache.DeleteFolderContents(w.selectedMbox)
+ if err != nil {
+ w.w.Warnf("DeleteFolderContents: %s", err)
+ }
+ }
+ }
+
+ uids := make([]uint32, 0, len(contents.MessageIDs))
+ for _, id := range contents.MessageIDs {
+ uids = append(uids, w.uidStore.GetOrInsert(string(id)))
+ }
+ w.w.PostMessage(&types.DirectoryContents{
+ Message: types.RespondTo(msg),
+ Uids: uids,
+ }, nil)
+
+ return nil
+}
+
+func (w *JMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) error {
+ var req jmap.Request
+
+ filter, err := parseSearch(msg.Argv)
+ if err != nil {
+ return err
+ }
+ if w.selectedMbox == "" {
+ // all mail virtual folder: display all but trash and spam
+ var mboxes []jmap.ID
+ if id, ok := w.roles[mailbox.RoleJunk]; ok {
+ mboxes = append(mboxes, id)
+ }
+ if id, ok := w.roles[mailbox.RoleTrash]; ok {
+ mboxes = append(mboxes, id)
+ }
+ filter.InMailboxOtherThan = mboxes
+ } else {
+ filter.InMailbox = w.selectedMbox
+ }
+
+ req.Invoke(&email.Query{
+ Account: w.accountId,
+ Filter: filter,
+ })
+
+ resp, err := w.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)
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+
+ return nil
+}
+
+func (w *JMAPWorker) handleCreateDirectory(msg *types.CreateDirectory) error {
+ var req jmap.Request
+ var parentId, id jmap.ID
+
+ if _, ok := w.dir2mbox[msg.Directory]; ok {
+ // directory already exists
+ return nil
+ }
+ 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)
+ id = jmap.ID(msg.Directory)
+
+ req.Invoke(&mailbox.Set{
+ Account: w.accountId,
+ Create: map[jmap.ID]*mailbox.Mailbox{
+ id: {
+ ParentID: parentId,
+ Name: name,
+ },
+ },
+ })
+
+ resp, err := w.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 {
+ e := wrapSetError(err)
+ if msg.Quiet {
+ w.w.Warnf("mailbox creation failed: %s", e)
+ } else {
+ return e
+ }
+ }
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+
+ 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.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 {
+ return wrapSetError(err)
+ }
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+
+ 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},
+ }
+ }
+ 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..17b3fb2f
--- /dev/null
+++ b/worker/jmap/fetch.go
@@ -0,0 +1,196 @@
+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"
+ "github.com/emersion/go-message/charset"
+)
+
+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)
+ }
+ jid := jmap.ID(id)
+ m, err := w.cache.GetEmail(jid)
+ if err == nil {
+ w.w.PostMessage(&types.MessageInfo{
+ Message: types.RespondTo(msg),
+ Info: w.translateMsgInfo(m),
+ }, nil)
+ continue
+ }
+ ids = append(ids, jid)
+ }
+
+ if len(ids) == 0 {
+ return nil
+ }
+
+ req.Invoke(&email.Get{
+ Account: w.accountId,
+ IDs: ids,
+ Properties: headersProperties,
+ })
+
+ resp, err := w.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)
+ if err := w.cache.PutEmail(m.ID, m); err != nil {
+ w.w.Warnf("PutEmail: %s", err)
+ }
+ }
+ if err = w.cache.PutEmailState(r.State); err != nil {
+ w.w.Warnf("PutEmailState: %s", err)
+ }
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+
+ 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, err := w.cache.GetEmail(jmap.ID(id))
+ if err != nil {
+ return fmt.Errorf("bug: unknown message id %s: %w", id, err)
+ }
+
+ 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)
+ }
+ }
+
+ buf, err := w.cache.GetBlob(part.BlobID)
+ if err != nil {
+ rd, err := w.Download(part.BlobID)
+ if err != nil {
+ return w.wrapDownloadError("part", part.BlobID, err)
+ }
+ buf, err = io.ReadAll(rd)
+ rd.Close()
+ if err != nil {
+ return err
+ }
+ if err = w.cache.PutBlob(part.BlobID, buf); err != nil {
+ w.w.Warnf("PutBlob: %s", err)
+ }
+ }
+ var reader io.Reader = bytes.NewReader(buf)
+ if strings.HasPrefix(part.Type, "text/") && part.Charset != "" {
+ r, err := charset.Reader(part.Charset, reader)
+ if err != nil {
+ return fmt.Errorf("charset.Reader: %w", err)
+ }
+ reader = r
+ }
+ w.w.PostMessage(&types.MessageBodyPart{
+ Message: types.RespondTo(msg),
+ Part: &models.MessageBodyPart{
+ Reader: reader,
+ 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, err := w.cache.GetEmail(jmap.ID(id))
+ if err != nil {
+ return fmt.Errorf("bug: unknown message id %s: %w", id, err)
+ }
+ buf, err := w.cache.GetBlob(mail.BlobID)
+ if err != nil {
+ rd, err := w.Download(mail.BlobID)
+ if err != nil {
+ return w.wrapDownloadError("full", mail.BlobID, err)
+ }
+ buf, err = io.ReadAll(rd)
+ rd.Close()
+ if err != nil {
+ return err
+ }
+ if err = w.cache.PutBlob(mail.BlobID, buf); err != nil {
+ w.w.Warnf("PutBlob: %s", 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: %q %w", prefix, url, err)
+}
diff --git a/worker/jmap/jmap.go b/worker/jmap/jmap.go
new file mode 100644
index 00000000..8012b6e4
--- /dev/null
+++ b/worker/jmap/jmap.go
@@ -0,0 +1,174 @@
+package jmap
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rockorager/go-jmap"
+ "git.sr.ht/~rockorager/go-jmap/mail"
+ "git.sr.ht/~rockorager/go-jmap/mail/email"
+ "git.sr.ht/~rockorager/go-jmap/mail/mailbox"
+ 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 dir, ok := w.mbox2dir[id]; ok {
+ labels = append(labels, dir)
+ }
+ }
+ sort.Strings(labels)
+
+ 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 (w *JMAPWorker) MailboxPath(mbox *mailbox.Mailbox) string {
+ if mbox == nil {
+ return ""
+ }
+ if mbox.ParentID == "" {
+ return mbox.Name
+ }
+ parent, err := w.cache.GetMailbox(mbox.ParentID)
+ if err != nil {
+ w.w.Warnf("MailboxPath/GetMailbox: %s", err)
+ return mbox.Name
+ }
+ return w.MailboxPath(parent) + "/" + 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,
+}
+
+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
+}
+
+func wrapSetError(err *jmap.SetError) error {
+ var s string
+ if err.Description != nil {
+ s = *err.Description
+ } else {
+ s = err.Type
+ if err.Properties != nil {
+ s += fmt.Sprintf(" %v", *err.Properties)
+ }
+ if s == "invalidProperties: [mailboxIds]" {
+ s = "a message must belong to one or more mailboxes"
+ }
+ }
+ return errors.New(s)
+}
+
+func wrapMethodError(err *jmap.MethodError) error {
+ var s string
+ if err.Description != nil {
+ s = *err.Description
+ } else {
+ s = err.Type
+ }
+ return errors.New(s)
+}
diff --git a/worker/jmap/push.go b/worker/jmap/push.go
new file mode 100644
index 00000000..2582b17a
--- /dev/null
+++ b/worker/jmap/push.go
@@ -0,0 +1,333 @@
+package jmap
+
+import (
+ "fmt"
+ "sort"
+ "time"
+
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker/jmap/cache"
+ "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,
+ Ping: uint(w.config.serverPing.Seconds()),
+ }
+
+ w.stop = make(chan struct{})
+ go func() {
+ defer log.PanicHandler()
+ <-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.PostMessage(&types.Error{
+ Error: fmt.Errorf("jmap listen: %w", err),
+ }, nil)
+ time.Sleep(5 * 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
+
+ mboxState, err := w.cache.GetMailboxState()
+ if err != nil {
+ w.w.Debugf("GetMailboxState: %s", err)
+ }
+ if mboxState != "" && newState["Mailbox"] != mboxState {
+ callID := req.Invoke(&mailbox.Changes{
+ Account: w.accountId,
+ SinceState: 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",
+ },
+ })
+ }
+
+ emailState, err := w.cache.GetEmailState()
+ if err != nil {
+ w.w.Debugf("GetEmailState: %s", err)
+ }
+ queryChangesCalls := make(map[string]jmap.ID)
+ folderContents := make(map[jmap.ID]*cache.FolderContents)
+ ids, _ := w.cache.GetMailboxList()
+ mboxes := make(map[jmap.ID]*mailbox.Mailbox)
+ for _, id := range ids {
+ mbox, err := w.cache.GetMailbox(id)
+ if err != nil {
+ w.w.Warnf("GetMailbox: %s", err)
+ continue
+ }
+ if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
+ mboxes[""] = &mailbox.Mailbox{
+ Name: w.config.allMail,
+ Role: mailbox.RoleAll,
+ }
+ } else {
+ mboxes[id] = mbox
+ }
+ }
+ if emailState != "" && newState["Email"] != emailState {
+ callID := req.Invoke(&email.Changes{
+ Account: w.accountId,
+ SinceState: emailState,
+ })
+ req.Invoke(&email.Get{
+ Account: w.accountId,
+ Properties: headersProperties,
+ ReferenceIDs: &jmap.ResultReference{
+ ResultOf: callID,
+ Name: "Email/changes",
+ Path: "/updated",
+ },
+ })
+
+ for id := range mboxes {
+ contents, err := w.cache.GetFolderContents(id)
+ if err != nil {
+ continue
+ }
+ callID = req.Invoke(&email.QueryChanges{
+ Account: w.accountId,
+ Filter: contents.Filter,
+ Sort: contents.Sort,
+ SinceQueryState: contents.QueryState,
+ })
+ queryChangesCalls[callID] = id
+ folderContents[id] = contents
+ }
+ }
+
+ if len(req.Calls) == 0 {
+ return nil
+ }
+
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+
+ var changedMboxIds []jmap.ID
+ var labelsChanged bool
+
+ for _, inv := range resp.Responses {
+ switch r := inv.Args.(type) {
+ case *mailbox.ChangesResponse:
+ for _, id := range r.Destroyed {
+ dir, ok := w.mbox2dir[id]
+ if ok {
+ w.w.PostMessage(&types.RemoveDirectory{
+ Directory: dir,
+ }, nil)
+ }
+ w.deleteMbox(id)
+ err = w.cache.DeleteMailbox(id)
+ if err != nil {
+ w.w.Warnf("DeleteMailbox: %s", err)
+ }
+ labelsChanged = true
+ }
+ err = w.cache.PutMailboxState(r.NewState)
+ if err != nil {
+ w.w.Warnf("PutMailboxState: %s", err)
+ }
+
+ case *mailbox.GetResponse:
+ for _, mbox := range r.List {
+ changedMboxIds = append(changedMboxIds, mbox.ID)
+ mboxes[mbox.ID] = mbox
+ err = w.cache.PutMailbox(mbox.ID, mbox)
+ if err != nil {
+ w.w.Warnf("PutMailbox: %s", err)
+ }
+ }
+ err = w.cache.PutMailboxState(r.State)
+ if err != nil {
+ w.w.Warnf("PutMailboxState: %s", err)
+ }
+
+ case *email.QueryChangesResponse:
+ mboxId := queryChangesCalls[inv.CallID]
+ contents := folderContents[mboxId]
+
+ removed := make(map[jmap.ID]bool)
+ for _, id := range r.Removed {
+ removed[id] = true
+ }
+ added := make(map[int]jmap.ID)
+ for _, add := range r.Added {
+ added[int(add.Index)] = add.ID
+ }
+ w.w.Debugf("%q: %d added, %d removed",
+ w.mbox2dir[mboxId], len(added), len(removed))
+ n := len(contents.MessageIDs) - len(removed) + len(added)
+ if n < 0 {
+ w.w.Errorf("bug: invalid folder contents state")
+ err = w.cache.DeleteFolderContents(mboxId)
+ if err != nil {
+ w.w.Warnf("DeleteFolderContents: %s", err)
+ }
+ continue
+ }
+ ids = make([]jmap.ID, 0, n)
+ i := 0
+ for _, id := range contents.MessageIDs {
+ if removed[id] {
+ continue
+ }
+ if addedId, ok := added[i]; ok {
+ ids = append(ids, addedId)
+ delete(added, i)
+ i += 1
+ }
+ ids = append(ids, id)
+ i += 1
+ }
+ for _, id := range added {
+ ids = append(ids, id)
+ }
+ contents.MessageIDs = ids
+ contents.QueryState = r.NewQueryState
+
+ err = w.cache.PutFolderContents(mboxId, contents)
+ if err != nil {
+ w.w.Warnf("PutFolderContents: %s", err)
+ }
+
+ if w.selectedMbox == mboxId {
+ uids := make([]uint32, 0, len(ids))
+ for _, id := range ids {
+ uid := w.uidStore.GetOrInsert(string(id))
+ uids = append(uids, uid)
+ }
+ w.w.PostMessage(&types.DirectoryContents{
+ Uids: uids,
+ }, nil)
+ }
+
+ case *email.GetResponse:
+ selectedIds := make(map[jmap.ID]bool)
+ contents, ok := folderContents[w.selectedMbox]
+ if ok {
+ for _, id := range contents.MessageIDs {
+ selectedIds[id] = true
+ }
+ }
+ for _, m := range r.List {
+ err = w.cache.PutEmail(m.ID, m)
+ if err != nil {
+ w.w.Warnf("PutEmail: %s", err)
+ }
+ if selectedIds[m.ID] {
+ w.w.PostMessage(&types.MessageInfo{
+ Info: w.translateMsgInfo(m),
+ }, nil)
+ }
+ }
+ err = w.cache.PutEmailState(r.State)
+ if err != nil {
+ w.w.Warnf("PutEmailState: %s", err)
+ }
+
+ case *jmap.MethodError:
+ w.w.Errorf("%s: %s", wrapMethodError(r))
+ if inv.Name == "Email/queryChanges" {
+ id := queryChangesCalls[inv.CallID]
+ w.w.Infof("flushing %q contents from cache",
+ w.mbox2dir[id])
+ err := w.cache.DeleteFolderContents(id)
+ if err != nil {
+ w.w.Warnf("DeleteFolderContents: %s", err)
+ }
+ }
+ }
+ }
+
+ for _, id := range changedMboxIds {
+ mbox := mboxes[id]
+ newDir := w.MailboxPath(mbox)
+ dir, ok := w.mbox2dir[id]
+ if ok {
+ // updated
+ if newDir == dir {
+ w.deleteMbox(id)
+ w.addMbox(mbox, dir)
+ w.w.PostMessage(&types.DirectoryInfo{
+ Info: &models.DirectoryInfo{
+ Name: dir,
+ Exists: int(mbox.TotalEmails),
+ Unseen: int(mbox.UnreadEmails),
+ },
+ }, nil)
+ continue
+ } else {
+ // renamed mailbox
+ w.deleteMbox(id)
+ w.w.PostMessage(&types.RemoveDirectory{
+ Directory: dir,
+ }, nil)
+ dir = newDir
+ }
+ }
+ // new mailbox
+ w.addMbox(mbox, dir)
+ w.w.PostMessage(&types.Directory{
+ Dir: &models.Directory{
+ Name: dir,
+ Exists: int(mbox.TotalEmails),
+ Unseen: int(mbox.UnreadEmails),
+ Role: jmapRole2aerc[mbox.Role],
+ },
+ }, nil)
+ labelsChanged = true
+ }
+
+ if w.config.useLabels && labelsChanged {
+ labels := make([]string, 0, len(w.dir2mbox))
+ for dir := range w.dir2mbox {
+ labels = append(labels, dir)
+ }
+ sort.Strings(labels)
+ w.w.PostMessage(&types.LabelList{Labels: labels}, 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/send.go b/worker/jmap/send.go
new file mode 100644
index 00000000..4b033d2e
--- /dev/null
+++ b/worker/jmap/send.go
@@ -0,0 +1,144 @@
+package jmap
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "git.sr.ht/~rjarry/aerc/log"
+ "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/emailsubmission"
+ "git.sr.ht/~rockorager/go-jmap/mail/mailbox"
+ "github.com/emersion/go-message/mail"
+)
+
+func (w *JMAPWorker) handleStartSend(msg *types.StartSendingMessage) error {
+ reader, writer := io.Pipe()
+ send := &jmapSendWriter{writer: writer, done: make(chan error)}
+
+ w.w.PostMessage(&types.MessageWriter{
+ Message: types.RespondTo(msg),
+ Writer: send,
+ }, nil)
+
+ go func() {
+ defer log.PanicHandler()
+ defer close(send.done)
+
+ identity, err := w.getSenderIdentity(msg.Header)
+ if err != nil {
+ send.done <- err
+ return
+ }
+
+ blob, err := w.Upload(reader)
+ if err != nil {
+ send.done <- err
+ return
+ }
+
+ var req jmap.Request
+
+ // Import the blob into drafts
+ req.Invoke(&email.Import{
+ Account: w.accountId,
+ Emails: map[string]*email.EmailImport{
+ "aerc": {
+ BlobID: blob.ID,
+ MailboxIDs: map[jmap.ID]bool{
+ w.roles[mailbox.RoleDrafts]: true,
+ },
+ Keywords: map[string]bool{
+ "$draft": true,
+ "$seen": true,
+ },
+ },
+ },
+ })
+
+ // Create the submission
+ req.Invoke(&emailsubmission.Set{
+ Account: w.accountId,
+ Create: map[jmap.ID]*emailsubmission.EmailSubmission{
+ "sub": {
+ IdentityID: identity,
+ EmailID: "#aerc",
+ },
+ },
+ OnSuccessUpdateEmail: map[jmap.ID]jmap.Patch{
+ "#sub": {
+ "keywords/$draft": nil,
+ w.rolePatch(mailbox.RoleSent): true,
+ w.rolePatch(mailbox.RoleDrafts): nil,
+ },
+ },
+ })
+
+ resp, err := w.Do(&req)
+ if err != nil {
+ send.done <- err
+ return
+ }
+
+ for _, inv := range resp.Responses {
+ switch r := inv.Args.(type) {
+ case *email.ImportResponse:
+ if err, ok := r.NotCreated["aerc"]; ok {
+ send.done <- wrapSetError(err)
+ return
+ }
+ case *emailsubmission.SetResponse:
+ if err, ok := r.NotCreated["sub"]; ok {
+ send.done <- wrapSetError(err)
+ return
+ }
+ case *jmap.MethodError:
+ send.done <- wrapMethodError(r)
+ return
+ }
+ }
+ }()
+
+ return nil
+}
+
+type jmapSendWriter struct {
+ writer *io.PipeWriter
+ done chan error
+}
+
+func (w *jmapSendWriter) Write(data []byte) (int, error) {
+ return w.writer.Write(data)
+}
+
+func (w *jmapSendWriter) Close() error {
+ writeErr := w.writer.Close()
+ sendErr := <-w.done
+ if writeErr != nil {
+ return writeErr
+ }
+ return sendErr
+}
+
+func (w *JMAPWorker) getSenderIdentity(header *mail.Header) (jmap.ID, error) {
+ from, err := header.AddressList("from")
+ if err != nil {
+ return "", fmt.Errorf("msg.Header.AddressList: %w", err)
+ }
+ if len(from) != 1 {
+ return "", fmt.Errorf("no from header in message")
+ }
+ name, domain, _ := strings.Cut(from[0].Address, "@")
+ for _, ident := range w.identities {
+ n, d, _ := strings.Cut(ident.Email, "@")
+ switch {
+ case n == name && d == domain:
+ fallthrough
+ case n == "*" && d == domain:
+ return ident.ID, nil
+ }
+ }
+ return "", fmt.Errorf("no identity found for address: %s@%s", name, domain)
+}
diff --git a/worker/jmap/set.go b/worker/jmap/set.go
new file mode 100644
index 00000000..4495e428
--- /dev/null
+++ b/worker/jmap/set.go
@@ -0,0 +1,241 @@
+package jmap
+
+import (
+ "fmt"
+
+ "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"
+ "git.sr.ht/~rockorager/go-jmap/mail/mailbox"
+)
+
+func (w *JMAPWorker) updateFlags(uids []uint32, flags models.Flags, enable bool) error {
+ var req jmap.Request
+ patches := 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)
+ }
+ patch := jmap.Patch{}
+ for kw := range flagsToKeywords(flags) {
+ path := fmt.Sprintf("keywords/%s", kw)
+ if enable {
+ patch[path] = true
+ } else {
+ patch[path] = nil
+ }
+ }
+ patches[jmap.ID(id)] = patch
+ }
+
+ req.Invoke(&email.Set{
+ Account: w.accountId,
+ Update: patches,
+ })
+
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+
+ return checkNotUpdated(resp)
+}
+
+func (w *JMAPWorker) moveCopy(uids []uint32, destDir string, deleteSrc bool) error {
+ var req jmap.Request
+ var destMbox jmap.ID
+ var destroy []jmap.ID
+ var ok bool
+
+ patches := make(map[jmap.ID]jmap.Patch)
+
+ destMbox, ok = w.dir2mbox[destDir]
+ if !ok && destDir != "" {
+ return fmt.Errorf("unknown destination mailbox")
+ }
+ if destMbox != "" && destMbox == w.selectedMbox {
+ return fmt.Errorf("cannot move to current mailbox")
+ }
+
+ for _, uid := range uids {
+ dest := destMbox
+ id, ok := w.uidStore.GetKey(uid)
+ if !ok {
+ return fmt.Errorf("bug: unknown uid %d", uid)
+ }
+ mail, err := w.cache.GetEmail(jmap.ID(id))
+ if err != nil {
+ return fmt.Errorf("bug: unknown message id %s: %w", id, err)
+ }
+
+ patch := w.moveCopyPatch(mail, dest, deleteSrc)
+ if len(patch) == 0 {
+ destroy = append(destroy, mail.ID)
+ w.w.Debugf("destroying <%s>", mail.MessageID[0])
+ } else {
+ patches[jmap.ID(id)] = patch
+ }
+ }
+
+ req.Invoke(&email.Set{
+ Account: w.accountId,
+ Update: patches,
+ Destroy: destroy,
+ })
+
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+
+ return checkNotUpdated(resp)
+}
+
+func (w *JMAPWorker) moveCopyPatch(
+ mail *email.Email, dest jmap.ID, deleteSrc bool,
+) jmap.Patch {
+ patch := jmap.Patch{}
+
+ if dest == "" && deleteSrc && len(mail.MailboxIDs) == 1 {
+ dest = w.roles[mailbox.RoleTrash]
+ }
+ if dest != "" && dest != w.selectedMbox {
+ d := w.mbox2dir[dest]
+ if deleteSrc {
+ w.w.Debugf("moving <%s> to %q", mail.MessageID[0], d)
+ } else {
+ w.w.Debugf("copying <%s> to %q", mail.MessageID[0], d)
+ }
+ patch[w.mboxPatch(dest)] = true
+ }
+ if deleteSrc && len(patch) > 0 {
+ switch {
+ case w.selectedMbox != "":
+ patch[w.mboxPatch(w.selectedMbox)] = nil
+ case len(mail.MailboxIDs) == 1:
+ // In "all mail" virtual mailbox and email is in
+ // a single mailbox, "Move" it to the specified
+ // destination
+ patch = jmap.Patch{"mailboxIds": []jmap.ID{dest}}
+ default:
+ // In "all mail" virtual mailbox and email is in
+ // multiple mailboxes. Since we cannot know what mailbox
+ // to remove, try at least to remove role=inbox.
+ patch[w.rolePatch(mailbox.RoleInbox)] = nil
+ }
+ }
+
+ return patch
+}
+
+func (w *JMAPWorker) mboxPatch(mbox jmap.ID) string {
+ return fmt.Sprintf("mailboxIds/%s", mbox)
+}
+
+func (w *JMAPWorker) rolePatch(role mailbox.Role) string {
+ return fmt.Sprintf("mailboxIds/%s", w.roles[role])
+}
+
+func (w *JMAPWorker) handleModifyLabels(msg *types.ModifyLabels) error {
+ var req jmap.Request
+ patch := jmap.Patch{}
+
+ for _, a := range msg.Add {
+ mboxId, ok := w.dir2mbox[a]
+ if !ok {
+ return fmt.Errorf("unkown label: %q", a)
+ }
+ patch[w.mboxPatch(mboxId)] = true
+ }
+ for _, r := range msg.Remove {
+ mboxId, ok := w.dir2mbox[r]
+ if !ok {
+ return fmt.Errorf("unkown label: %q", r)
+ }
+ patch[w.mboxPatch(mboxId)] = nil
+ }
+
+ patches := make(map[jmap.ID]jmap.Patch)
+
+ for _, uid := range msg.Uids {
+ id, ok := w.uidStore.GetKey(uid)
+ if !ok {
+ return fmt.Errorf("bug: unknown uid %d", uid)
+ }
+ patches[jmap.ID(id)] = patch
+ }
+
+ req.Invoke(&email.Set{
+ Account: w.accountId,
+ Update: patches,
+ })
+
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+
+ return checkNotUpdated(resp)
+}
+
+func checkNotUpdated(resp *jmap.Response) error {
+ for _, inv := range resp.Responses {
+ switch r := inv.Args.(type) {
+ case *email.SetResponse:
+ for _, err := range r.NotUpdated {
+ return wrapSetError(err)
+ }
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+ return nil
+}
+
+func (w *JMAPWorker) handleAppendMessage(msg *types.AppendMessage) error {
+ dest, ok := w.dir2mbox[msg.Destination]
+ if !ok {
+ return fmt.Errorf("unknown destination mailbox")
+ }
+
+ // Upload the message
+ blob, err := w.Upload(msg.Reader)
+ if err != nil {
+ return err
+ }
+
+ var req jmap.Request
+
+ // Import the blob into specified directory
+ req.Invoke(&email.Import{
+ Account: w.accountId,
+ Emails: map[string]*email.EmailImport{
+ "aerc": {
+ BlobID: blob.ID,
+ MailboxIDs: map[jmap.ID]bool{dest: true},
+ Keywords: flagsToKeywords(msg.Flags),
+ },
+ },
+ })
+
+ resp, err := w.Do(&req)
+ if err != nil {
+ return err
+ }
+
+ for _, inv := range resp.Responses {
+ switch r := inv.Args.(type) {
+ case *email.ImportResponse:
+ if err, ok := r.NotCreated["aerc"]; ok {
+ return wrapSetError(err)
+ }
+ case *jmap.MethodError:
+ return wrapMethodError(r)
+ }
+ }
+
+ return nil
+}
diff --git a/worker/jmap/worker.go b/worker/jmap/worker.go
new file mode 100644
index 00000000..a538f60c
--- /dev/null
+++ b/worker/jmap/worker.go
@@ -0,0 +1,195 @@
+package jmap
+
+import (
+ "errors"
+ "net/url"
+ "time"
+
+ "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/jmap/cache"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+ "git.sr.ht/~rockorager/go-jmap"
+ "git.sr.ht/~rockorager/go-jmap/mail/identity"
+ "git.sr.ht/~rockorager/go-jmap/mail/mailbox"
+)
+
+func init() {
+ handlers.RegisterWorkerFactory("jmap", NewJMAPWorker)
+}
+
+var errUnsupported = errors.New("unsupported")
+
+type JMAPWorker struct {
+ config struct {
+ account *config.AccountConfig
+ endpoint string
+ oauth bool
+ user *url.Userinfo
+ cacheState bool
+ cacheBlobs bool
+ serverPing time.Duration
+ useLabels bool
+ allMail string
+ }
+
+ w *types.Worker
+ client *jmap.Client
+ cache *cache.JMAPCache
+ accountId jmap.ID
+
+ selectedMbox jmap.ID
+ dir2mbox map[string]jmap.ID
+ mbox2dir map[jmap.ID]string
+ roles map[mailbox.Role]jmap.ID
+ identities map[string]*identity.Identity
+ uidStore *uidstore.Store
+
+ changes chan jmap.TypeState
+ stop chan struct{}
+}
+
+func NewJMAPWorker(worker *types.Worker) (types.Backend, error) {
+ return &JMAPWorker{
+ w: worker,
+ uidStore: uidstore.NewStore(),
+ roles: make(map[mailbox.Role]jmap.ID),
+ dir2mbox: make(map[string]jmap.ID),
+ mbox2dir: make(map[jmap.ID]string),
+ identities: make(map[string]*identity.Identity),
+ changes: make(chan jmap.TypeState),
+ }, nil
+}
+
+func (w *JMAPWorker) addMbox(mbox *mailbox.Mailbox, dir string) {
+ w.mbox2dir[mbox.ID] = dir
+ w.dir2mbox[dir] = mbox.ID
+ w.roles[mbox.Role] = mbox.ID
+}
+
+func (w *JMAPWorker) deleteMbox(id jmap.ID) {
+ var dir string
+ var role mailbox.Role
+
+ delete(w.mbox2dir, id)
+ for d, i := range w.dir2mbox {
+ if i == id {
+ dir = d
+ break
+ }
+ }
+ delete(w.dir2mbox, dir)
+ for r, i := range w.roles {
+ if i == id {
+ role = r
+ break
+ }
+ }
+ delete(w.roles, role)
+}
+
+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
+ break
+ 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.FlagMessages:
+ return w.updateFlags(msg.Uids, msg.Flags, msg.Enable)
+ case *types.AnsweredMessages:
+ return w.updateFlags(msg.Uids, models.AnsweredFlag, msg.Answered)
+ case *types.DeleteMessages:
+ return w.moveCopy(msg.Uids, "", true)
+ case *types.CopyMessages:
+ return w.moveCopy(msg.Uids, msg.Destination, false)
+ case *types.MoveMessages:
+ return w.moveCopy(msg.Uids, msg.Destination, true)
+ case *types.ModifyLabels:
+ if w.config.useLabels {
+ return w.handleModifyLabels(msg)
+ }
+ case *types.AppendMessage:
+ return w.handleAppendMessage(msg)
+ case *types.StartSendingMessage:
+ return w.handleStartSend(msg)
+ }
+ return errUnsupported
+}
+
+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)
+ err := w.handleMessage(msg)
+ switch {
+ case errors.Is(err, errUnsupported):
+ w.w.PostMessage(&types.Unsupported{
+ Message: types.RespondTo(msg),
+ }, nil)
+ case err != nil:
+ w.w.PostMessage(&types.Error{
+ Message: types.RespondTo(msg),
+ Error: err,
+ }, nil)
+ default:
+ w.w.PostMessage(&types.Done{
+ Message: types.RespondTo(msg),
+ }, nil)
+ }
+ }
+ }
+}
diff --git a/worker/types/messages.go b/worker/types/messages.go
index 7bad31f9..26408684 100644
--- a/worker/types/messages.go
+++ b/worker/types/messages.go
@@ -7,6 +7,7 @@ import (
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/models"
+ "github.com/emersion/go-message/mail"
)
type WorkerMessage interface {
@@ -209,6 +210,11 @@ type CheckMail struct {
Timeout time.Duration
}
+type StartSendingMessage struct {
+ Message
+ Header *mail.Header
+}
+
// Messages
type Directory struct {
@@ -281,3 +287,8 @@ type CheckMailDirectories struct {
Message
Directories []string
}
+
+type MessageWriter struct {
+ Message
+ Writer io.WriteCloser
+}
diff --git a/worker/worker_enabled.go b/worker/worker_enabled.go
index 697ca402..e53c06ea 100644
--- a/worker/worker_enabled.go
+++ b/worker/worker_enabled.go
@@ -3,6 +3,7 @@ package worker
// the following workers are always enabled
import (
_ "git.sr.ht/~rjarry/aerc/worker/imap"
+ _ "git.sr.ht/~rjarry/aerc/worker/jmap"
_ "git.sr.ht/~rjarry/aerc/worker/lib/watchers"
_ "git.sr.ht/~rjarry/aerc/worker/maildir"
_ "git.sr.ht/~rjarry/aerc/worker/mbox"