diff options
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 @@ -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 @@ -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, ©Buf) } 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(), ©Buf) @@ -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 @@ -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 @@ -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" |