diff options
Diffstat (limited to 'worker/jmap')
-rw-r--r-- | worker/jmap/cache/blob.go | 45 | ||||
-rw-r--r-- | worker/jmap/cache/cache.go | 79 | ||||
-rw-r--r-- | worker/jmap/cache/email.go | 35 | ||||
-rw-r--r-- | worker/jmap/cache/folder_contents.go | 61 | ||||
-rw-r--r-- | worker/jmap/cache/gob.go | 35 | ||||
-rw-r--r-- | worker/jmap/cache/mailbox.go | 35 | ||||
-rw-r--r-- | worker/jmap/cache/mailbox_list.go | 32 | ||||
-rw-r--r-- | worker/jmap/cache/session.go | 32 | ||||
-rw-r--r-- | worker/jmap/cache/state.go | 30 | ||||
-rw-r--r-- | worker/jmap/configure.go | 70 | ||||
-rw-r--r-- | worker/jmap/connect.go | 133 | ||||
-rw-r--r-- | worker/jmap/directories.go | 360 | ||||
-rw-r--r-- | worker/jmap/fetch.go | 196 | ||||
-rw-r--r-- | worker/jmap/jmap.go | 174 | ||||
-rw-r--r-- | worker/jmap/push.go | 333 | ||||
-rw-r--r-- | worker/jmap/search.go | 63 | ||||
-rw-r--r-- | worker/jmap/send.go | 144 | ||||
-rw-r--r-- | worker/jmap/set.go | 241 | ||||
-rw-r--r-- | worker/jmap/worker.go | 195 |
19 files changed, 2293 insertions, 0 deletions
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) + } + } + } +} |