diff options
author | Robin Jarry <robin@jarry.cc> | 2023-06-04 14:05:22 +0200 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2023-06-21 17:29:21 +0200 |
commit | be0bfc1ae28b49be6546626ff9eaadce5464a6c8 (patch) | |
tree | 60b9d8b812408db281c00d5a71197e118a736337 /worker/jmap/set.go | |
parent | b4ae11b4ec20b61a18e1ce08ea65014bcebc2f16 (diff) | |
download | aerc-be0bfc1ae28b49be6546626ff9eaadce5464a6c8.tar.gz |
worker: add jmap support
Add support for JMAP backends. This is on par with IMAP features with
some additions specific to JMAP:
* tagging
* sending emails
This makes use of git.sr.ht/~rockorager/go-jmap for the low level
interaction with the JMAP server. The transport is JSON over HTTPS.
For now, only oauthbearer with token is supported. If this proves
useful, we may need to file for an official three-legged oauth support
at JMAP providers.
I have tested most features and this seems to be reliable. There are
some quirks with the use-labels option. Especially when moving and
deleting messages from the "All mail" virtual folder (see aerc-jmap(5)).
Overall, the user experience is nice and there are a lot less background
updates issues than with IMAP (damn IDLE mode hanging after restoring
from sleep).
I know that not everyone has access to a JMAP provider. For those
interested, there are at least these two commercial offerings:
https://www.fastmail.com/
https://www.topicbox.com/
And, if you host your own mail, you can use a JMAP capable server:
https://stalw.art/jmap/
https://www.cyrusimap.org/imap/download/installation/http/jmap.html
Link: https://www.rfc-editor.org/rfc/rfc8620.html
Link: https://www.rfc-editor.org/rfc/rfc8621.html
Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Tim Culverhouse <tim@timculverhouse.com>
Diffstat (limited to 'worker/jmap/set.go')
-rw-r--r-- | worker/jmap/set.go | 241 |
1 files changed, 241 insertions, 0 deletions
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 +} |