init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+45
View File
@@ -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 == "" || id == "" {
return ""
}
name := string(id)
sub := name[len(name)-2:]
return path.Join(c.blobsDir, sub, name)
}
+109
View File
@@ -0,0 +1,109 @@
package cache
import (
"errors"
"os"
"path"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"github.com/syndtr/goleveldb/leveldb"
"github.com/syndtr/goleveldb/leveldb/util"
)
type JMAPCache struct {
mem map[string][]byte
file *leveldb.DB
blobsDir string
}
func NewJMAPCache(state, blobs bool, accountName string) *JMAPCache {
c := new(JMAPCache)
cacheDir := xdg.CachePath()
if state && cacheDir != "" {
var err error
dir := path.Join(cacheDir, "aerc", accountName, "state")
_ = os.MkdirAll(dir, 0o700)
c.file, err = leveldb.OpenFile(dir, nil)
if err != nil {
log.Errorf("failed to open goleveldb: %s", err)
c.mem = make(map[string][]byte)
}
} else {
c.mem = make(map[string][]byte)
}
if blobs && cacheDir != "" {
c.blobsDir = path.Join(cacheDir, "aerc", accountName, "blobs")
}
return c
}
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")
}
func (c *JMAPCache) purge(prefix string) error {
switch {
case c.file != nil:
txn, err := c.file.OpenTransaction()
if err != nil {
return err
}
iter := txn.NewIterator(util.BytesPrefix([]byte(prefix)), nil)
for iter.Next() {
err = txn.Delete(iter.Key(), nil)
if err != nil {
break
}
}
iter.Release()
if err != nil {
txn.Discard()
return err
}
return txn.Commit()
case c.mem != nil:
for key := range c.mem {
if strings.HasPrefix(key, prefix) {
delete(c.mem, key)
}
}
return nil
}
panic("jmap cache with no backend")
}
+40
View File
@@ -0,0 +1,40 @@
package cache
import (
"git.sr.ht/~rockorager/go-jmap"
"git.sr.ht/~rockorager/go-jmap/mail/email"
)
func (c *JMAPCache) HasEmail(id jmap.ID) bool {
_, err := c.get(emailKey(id))
return err == nil
}
func (c *JMAPCache) GetEmail(id jmap.ID) (*email.Email, error) {
buf, err := c.get(emailKey(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(emailKey(id), buf)
}
func (c *JMAPCache) DeleteEmail(id jmap.ID) error {
return c.delete(emailKey(id))
}
func emailKey(id jmap.ID) string {
return "email/" + string(id)
}
+59
View File
@@ -0,0 +1,59 @@
package cache
import (
"reflect"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rockorager/go-jmap"
)
type FolderContents struct {
MailboxID jmap.ID
QueryState string
Filter *types.SearchCriteria
Sort []*types.SortCriterion
MessageIDs []jmap.ID
}
func (c *JMAPCache) GetFolderContents(mailboxId jmap.ID) (*FolderContents, error) {
key := folderContentsKey(mailboxId)
buf, err := c.get(key)
if err != nil {
return nil, err
}
m := new(FolderContents)
err = unmarshal(buf, m)
if err != nil {
log.Debugf("cache format has changed, purging foldercontents")
if e := c.purge("foldercontents/"); e != nil {
log.Errorf("foldercontents cache purge: %s", e)
}
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 *types.SearchCriteria, sort []*types.SortCriterion,
) bool {
return f.QueryState == "" ||
!reflect.DeepEqual(f.Sort, sort) ||
!reflect.DeepEqual(f.Filter, filter)
}
+33
View File
@@ -0,0 +1,33 @@
package cache
import (
"bytes"
"encoding/gob"
"git.sr.ht/~rockorager/go-jmap/mail/email"
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
)
type jmapObject interface {
*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)
}
+35
View File
@@ -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)
}
+32
View File
@@ -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"
+34
View File
@@ -0,0 +1,34 @@
package cache
import (
"encoding/json"
"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 = json.Unmarshal(buf, s)
if err != nil {
return nil, err
}
return s, nil
}
func (c *JMAPCache) PutSession(s *jmap.Session) error {
buf, err := json.Marshal(s)
if err != nil {
return err
}
return c.put(sessionKey, buf)
}
func (c *JMAPCache) DeleteSession() error {
return c.delete(sessionKey)
}
const sessionKey = "session"
+43
View File
@@ -0,0 +1,43 @@
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))
}
func (c *JMAPCache) GetThreadState() (string, error) {
buf, err := c.get(threadStateKey)
if err != nil {
return "", err
}
return string(buf), nil
}
func (c *JMAPCache) PutThreadState(state string) error {
return c.put(threadStateKey, []byte(state))
}
const (
mailboxStateKey = "state/mailbox"
emailStateKey = "state/email"
threadStateKey = "state/thread"
)
+34
View File
@@ -0,0 +1,34 @@
package cache
import (
"git.sr.ht/~rockorager/go-jmap"
)
func (c *JMAPCache) GetThread(id jmap.ID) ([]jmap.ID, error) {
buf, err := c.get(threadKey(id))
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) PutThread(id jmap.ID, list []jmap.ID) error {
buf, err := marshal(&IDList{IDs: list})
if err != nil {
return err
}
return c.put(threadKey(id), buf)
}
func (c *JMAPCache) DeleteThread(id jmap.ID) error {
return c.delete(mailboxKey(id))
}
func threadKey(id jmap.ID) string {
return "thread/" + string(id)
}
+66
View File
@@ -0,0 +1,66 @@
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 {
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.cache = cache.NewJMAPCache(
w.config.cacheState, w.config.cacheBlobs, msg.Config.Name)
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.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
}
return nil
}
func parseBool(val string) bool {
switch strings.ToLower(val) {
case "1", "t", "true", "yes", "y", "on":
return true
}
return false
}
+146
View File
@@ -0,0 +1,146 @@
package jmap
import (
"encoding/json"
"io"
"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 {
w.client = &jmap.Client{SessionEndpoint: w.config.endpoint}
if w.config.oauth {
pass, _ := w.config.user.Password()
w.client.WithAccessToken(pass)
} else {
user := w.config.user.Username()
pass, _ := w.config.user.Password()
w.client.WithBasicAuth(user, pass)
}
if session, err := w.cache.GetSession(); err == nil {
w.client.Session = session
}
if w.client.Session == nil {
if err := w.UpdateSession(); err != nil {
return err
}
}
go w.monitorChanges()
return nil
}
func (w *JMAPWorker) AccountId() jmap.ID {
switch {
case w.client == nil:
fallthrough
case w.client.Session == nil:
fallthrough
case w.client.Session.PrimaryAccounts == nil:
return ""
default:
return w.client.Session.PrimaryAccounts[mail.URI]
}
}
func (w *JMAPWorker) UpdateSession() error {
if err := w.client.Authenticate(); err != nil {
return err
}
if err := w.cache.PutSession(w.client.Session); err != nil {
w.w.Warnf("PutSession: %s", err)
}
return nil
}
func (w *JMAPWorker) GetIdentities() error {
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< %s", seq, err)
// Try to update session in case an endpoint changed
err := w.UpdateSession()
if err != nil {
return nil, err
}
// And try again if we succeeded
resp, err = w.client.Do(req)
if err != nil {
return nil, err
}
}
if resp.SessionState != w.client.Session.State {
if err := w.UpdateSession(); err != nil {
return nil, err
}
}
w.w.Debugf("<%d< done", seq)
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
}
+345
View File
@@ -0,0 +1,345 @@
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)
// If we can't get the cached mailbox state, at worst, we will just
// query information we might already know
cachedMailboxState, err := w.cache.GetMailboxState()
if err != nil {
w.w.Warnf("GetMailboxState: %s", err)
}
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 cachedMailboxState == "" || 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)
}
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,
}
}
if contents.NeedsRefresh(msg.Filter, msg.SortCriteria) {
var req jmap.Request
req.Invoke(&email.Query{
Account: w.AccountId(),
Filter: w.translateSearch(w.selectedMbox, msg.Filter),
Sort: translateSort(msg.SortCriteria),
})
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 = msg.SortCriteria
contents.Filter = msg.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([]models.UID, 0, len(contents.MessageIDs))
for _, id := range contents.MessageIDs {
uids = append(uids, models.UID(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
req.Invoke(&email.Query{
Account: w.AccountId(),
Filter: w.translateSearch(w.selectedMbox, msg.Criteria),
})
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 []models.UID
for _, id := range r.IDs {
uids = append(uids, models.UID(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 id, ok := w.dir2mbox[msg.Directory]; ok {
// directory already exists
mbox, err := w.cache.GetMailbox(id)
if err != nil {
return err
}
if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
return errNoop
}
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
}
+220
View File
@@ -0,0 +1,220 @@
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",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"headers",
"messageId",
"inReplyTo",
"references",
"from",
"to",
"cc",
"bcc",
"replyTo",
"subject",
"bodyStructure",
}
func (w *JMAPWorker) handleFetchMessageHeaders(msg *types.FetchMessageHeaders) error {
emailIdsToFetch := make([]jmap.ID, 0, len(msg.Uids))
currentEmails := make([]*email.Email, 0, len(msg.Uids))
for _, uid := range msg.Uids {
jid := jmap.ID(uid)
m, err := w.cache.GetEmail(jid)
if err != nil {
// Message wasn't in cache; fetch it
emailIdsToFetch = append(emailIdsToFetch, jid)
continue
}
currentEmails = append(currentEmails, m)
// Get the UI updated immediately
w.w.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: w.translateMsgInfo(m),
}, nil)
}
if len(emailIdsToFetch) > 0 {
var req jmap.Request
req.Invoke(&email.Get{
Account: w.AccountId(),
IDs: emailIdsToFetch,
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:
if err = w.cache.PutEmailState(r.State); err != nil {
w.w.Warnf("PutEmailState: %s", err)
}
currentEmails = append(currentEmails, r.List...)
case *jmap.MethodError:
return wrapMethodError(r)
}
}
}
var threadsToFetch []jmap.ID
for _, eml := range currentEmails {
thread, err := w.cache.GetThread(eml.ThreadID)
if err != nil {
threadsToFetch = append(threadsToFetch, eml.ThreadID)
continue
}
for _, id := range thread {
m, err := w.cache.GetEmail(id)
if err != nil {
// This should never happen. If we have the
// thread in cache, we will have fetched it
// already or updated it from the update loop
w.w.Warnf("Email ID %s from Thread %s not in cache", id, eml.ThreadID)
continue
}
currentEmails = append(currentEmails, m)
// Get the UI updated immediately
w.w.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: w.translateMsgInfo(m),
}, nil)
}
}
threadEmails, err := w.fetchEntireThreads(threadsToFetch)
if err != nil {
return err
}
for _, m := range threadEmails {
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)
}
}
return nil
}
func (w *JMAPWorker) handleFetchMessageBodyPart(msg *types.FetchMessageBodyPart) error {
mail, err := w.cache.GetEmail(jmap.ID(msg.Uid))
if err != nil {
return fmt.Errorf("bug: unknown message id %s: %w", msg.Uid, 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 {
w.w.Warnf("charset.Reader: %v", err)
} else {
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 {
mail, err := w.cache.GetEmail(jmap.ID(uid))
if err != nil {
return fmt.Errorf("bug: unknown message id %s: %w", uid, 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)
}
+179
View File
@@ -0,0 +1,179 @@
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: models.UID(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
}
if flags.Has(models.DraftFlag) {
kw["$draft"] = 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
case "$draft":
f |= models.DraftFlag
}
}
}
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)
}
+484
View File
@@ -0,0 +1,484 @@
package jmap
import (
"fmt"
"sort"
"time"
"git.sr.ht/~rjarry/aerc/lib/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"
"git.sr.ht/~rockorager/go-jmap/mail/thread"
)
func (w *JMAPWorker) monitorChanges() {
defer log.PanicHandler()
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)
}
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
}
}
emailUpdated := ""
emailCreated := ""
if emailState != "" && newState["Email"] != emailState {
callID := req.Invoke(&email.Changes{
Account: w.AccountId(),
SinceState: emailState,
})
emailUpdated = req.Invoke(&email.Get{
Account: w.AccountId(),
Properties: headersProperties,
ReferenceIDs: &jmap.ResultReference{
ResultOf: callID,
Name: "Email/changes",
Path: "/updated",
},
})
emailCreated = req.Invoke(&email.Get{
Account: w.AccountId(),
Properties: headersProperties,
ReferenceIDs: &jmap.ResultReference{
ResultOf: callID,
Name: "Email/changes",
Path: "/created",
},
})
}
threadState, err := w.cache.GetThreadState()
if err != nil {
w.w.Debugf("GetThreadState: %s", err)
}
if threadState != "" && newState["Thread"] != threadState {
callID := req.Invoke(&thread.Changes{
Account: w.AccountId(),
SinceState: threadState,
})
req.Invoke(&thread.Get{
Account: w.AccountId(),
ReferenceIDs: &jmap.ResultReference{
ResultOf: callID,
Name: "Thread/changes",
Path: "/created",
},
})
req.Invoke(&thread.Get{
Account: w.AccountId(),
ReferenceIDs: &jmap.ResultReference{
ResultOf: callID,
Name: "Thread/changes",
Path: "/updated",
},
})
}
if len(req.Calls) == 0 {
return nil
}
resp, err := w.Do(&req)
if err != nil {
return err
}
var changedMboxIds []jmap.ID
var labelsChanged bool
// threadEmails are email IDs from threads which changed or were
// created
var threadEmails []jmap.ID
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 *thread.ChangesResponse:
for _, id := range r.Destroyed {
err = w.cache.DeleteThread(id)
if err != nil {
w.w.Warnf("DeleteThread: %s", err)
}
}
err = w.cache.PutThreadState(r.NewState)
if err != nil {
w.w.Warnf("PutThreadState: %s", err)
}
case *thread.GetResponse:
for _, thread := range r.List {
err = w.cache.PutThread(thread.ID, thread.EmailIDs)
if err != nil {
w.w.Warnf("PutThread: %s", err)
}
// We keep the list of all emails and check in a
// subsequent request which ones we need to
// fetch
threadEmails = append(threadEmails, thread.EmailIDs...)
}
err = w.cache.PutThreadState(r.State)
if err != nil {
w.w.Warnf("PutThreadState: %s", err)
}
case *email.GetResponse:
switch inv.CallID {
case emailUpdated:
for _, m := range r.List {
err = w.cache.PutEmail(m.ID, m)
if err != nil {
w.w.Warnf("PutEmail: %s", err)
}
// Send an updated message info if this
// is part of our selected mailbox
if m.MailboxIDs[w.selectedMbox] {
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 emailCreated:
for _, m := range r.List {
err = w.cache.PutEmail(m.ID, m)
if err != nil {
w.w.Warnf("PutEmail: %s", err)
}
info := w.translateMsgInfo(m)
// Set recent on created messages so we
// get a notification
info.Flags |= models.RecentFlag
w.w.PostMessage(&types.MessageInfo{
Info: info,
}, 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))
}
}
var updatedMboxes []jmap.ID
for _, id := range changedMboxIds {
mbox := mboxes[id]
if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
continue
}
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)
updatedMboxes = append(updatedMboxes, id)
} 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 w.refreshQueriesAndThreads(updatedMboxes, threadEmails)
}
// refreshQueriesAndThreads updates the cached query for any mailbox which was updated
func (w *JMAPWorker) refreshQueriesAndThreads(
updatedMboxes []jmap.ID,
threadEmails []jmap.ID,
) error {
if len(updatedMboxes) == 0 && len(threadEmails) == 0 {
return nil
}
var req jmap.Request
queryChangesCalls := make(map[string]jmap.ID)
folderContents := make(map[jmap.ID]*cache.FolderContents)
for _, id := range updatedMboxes {
contents, err := w.cache.GetFolderContents(id)
if err != nil {
continue
}
callID := req.Invoke(&email.QueryChanges{
Account: w.AccountId(),
Filter: w.translateSearch(id, contents.Filter),
Sort: translateSort(contents.Sort),
SinceQueryState: contents.QueryState,
})
queryChangesCalls[callID] = id
folderContents[id] = contents
}
emailsToFetch := []jmap.ID{}
for _, id := range threadEmails {
if w.cache.HasEmail(id) {
continue
}
emailsToFetch = append(emailsToFetch, id)
}
req.Invoke(&email.Get{
Account: w.AccountId(),
Properties: headersProperties,
IDs: emailsToFetch,
})
resp, err := w.Do(&req)
if err != nil {
return err
}
for _, inv := range resp.Responses {
switch r := inv.Args.(type) {
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([]models.UID, 0, len(ids))
for _, id := range ids {
uids = append(uids, models.UID(id))
}
w.w.PostMessage(&types.DirectoryContents{
Uids: uids,
}, nil)
}
case *email.GetResponse:
for _, m := range r.List {
err = w.cache.PutEmail(m.ID, m)
if err != nil {
w.w.Warnf("PutEmail: %s", err)
}
// Send an updated message info if this
// is part of our selected mailbox
if m.MailboxIDs[w.selectedMbox] {
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)
}
}
}
}
return nil
}
+101
View File
@@ -0,0 +1,101 @@
package jmap
import (
"strings"
"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) translateSearch(
mbox jmap.ID, criteria *types.SearchCriteria,
) email.Filter {
cond := new(email.FilterCondition)
if mbox == "" {
// 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)
}
cond.InMailboxOtherThan = mboxes
} else {
cond.InMailbox = mbox
}
if criteria == nil {
return cond
}
// dates
if !criteria.StartDate.IsZero() {
cond.After = &criteria.StartDate
}
if !criteria.EndDate.IsZero() {
cond.Before = &criteria.EndDate
}
// general search terms
terms := strings.Join(criteria.Terms, " ")
switch {
case criteria.SearchAll:
cond.Text = terms
case criteria.SearchBody:
cond.Body = terms
default:
cond.Subject = terms
}
filter := &email.FilterOperator{Operator: jmap.OperatorAND}
filter.Conditions = append(filter.Conditions, cond)
// keywords/flags
for kw := range flagsToKeywords(criteria.WithFlags) {
filter.Conditions = append(filter.Conditions,
&email.FilterCondition{HasKeyword: kw})
}
for kw := range flagsToKeywords(criteria.WithoutFlags) {
filter.Conditions = append(filter.Conditions,
&email.FilterCondition{NotKeyword: kw})
}
// recipients
addrs := &email.FilterOperator{
Operator: jmap.OperatorOR,
}
for _, from := range criteria.From {
addrs.Conditions = append(addrs.Conditions,
&email.FilterCondition{From: from})
}
for _, to := range criteria.To {
addrs.Conditions = append(addrs.Conditions,
&email.FilterCondition{To: to})
}
for _, cc := range criteria.Cc {
addrs.Conditions = append(addrs.Conditions,
&email.FilterCondition{Cc: cc})
}
if len(addrs.Conditions) > 0 {
filter.Conditions = append(filter.Conditions, addrs)
}
// specific headers
headers := &email.FilterOperator{
Operator: jmap.OperatorAND,
}
for h, values := range criteria.Headers {
for _, v := range values {
headers.Conditions = append(headers.Conditions,
&email.FilterCondition{Header: []string{h, v}})
}
}
if len(headers.Conditions) > 0 {
filter.Conditions = append(filter.Conditions, headers)
}
return filter
}
+158
View File
@@ -0,0 +1,158 @@
package jmap
import (
"fmt"
"io"
"strings"
"git.sr.ht/~rjarry/aerc/lib/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.From)
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,
},
},
},
})
from := &emailsubmission.Address{Email: msg.From.Address}
var rcpts []*emailsubmission.Address
for _, address := range msg.Rcpts {
rcpts = append(rcpts, &emailsubmission.Address{
Email: address.Address,
})
}
envelope := &emailsubmission.Envelope{MailFrom: from, RcptTo: rcpts}
onSuccess := jmap.Patch{
"keywords/$draft": nil,
w.rolePatch(mailbox.RoleSent): true,
w.rolePatch(mailbox.RoleDrafts): nil,
}
for _, dir := range msg.CopyTo {
mbox, ok := w.dir2mbox[dir]
if ok && mbox != w.roles[mailbox.RoleSent] {
onSuccess[w.mboxPatch(mbox)] = true
}
}
// Create the submission
req.Invoke(&emailsubmission.Set{
Account: w.AccountId(),
Create: map[jmap.ID]*emailsubmission.EmailSubmission{
"sub": {
IdentityID: identity,
EmailID: "#aerc",
Envelope: envelope,
},
},
OnSuccessUpdateEmail: map[jmap.ID]jmap.Patch{
"#sub": onSuccess,
},
})
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(from *mail.Address) (jmap.ID, error) {
if len(w.identities) == 0 {
if err := w.GetIdentities(); err != nil {
return "", err
}
}
name, domain, _ := strings.Cut(from.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)
}
+263
View File
@@ -0,0 +1,263 @@
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 []models.UID, flags models.Flags, enable bool) error {
var req jmap.Request
patches := make(map[jmap.ID]jmap.Patch)
for _, uid := range uids {
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(uid)] = patch
}
req.Invoke(&email.Set{
Account: w.AccountId(),
Update: patches,
})
resp, err := w.Do(&req)
if err != nil {
return err
}
err = checkNotUpdated(resp)
if err != nil {
return err
}
// If we didn't get an update error, all methods succeeded. We can
// update the cache and UI now. We don't update the email state so that
// we still grab an updated set from the update channel
for _, uid := range uids {
jid := jmap.ID(uid)
m, err := w.cache.GetEmail(jid)
if err != nil {
// We'll get this from the update channel
continue
}
if enable {
for kw := range flagsToKeywords(flags) {
m.Keywords[kw] = true
}
} else {
for kw := range flagsToKeywords(flags) {
delete(m.Keywords, kw)
}
}
err = w.cache.PutEmail(jid, m)
if err != nil {
w.w.Warnf("PutEmail: %s", err)
}
// Get the UI updated immediately
w.w.PostMessage(&types.MessageInfo{
Info: w.translateMsgInfo(m),
}, nil)
}
return nil
}
func (w *JMAPWorker) moveCopy(uids []models.UID, 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
mail, err := w.cache.GetEmail(jmap.ID(uid))
if err != nil {
return fmt.Errorf("bug: unknown message id %s: %w", uid, 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(uid)] = 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("unknown label: %q", a)
}
patch[w.mboxPatch(mboxId)] = true
}
for _, r := range msg.Remove {
mboxId, ok := w.dir2mbox[r]
if !ok {
return fmt.Errorf("unknown label: %q", r)
}
patch[w.mboxPatch(mboxId)] = nil
}
patches := make(map[jmap.ID]jmap.Patch)
for _, uid := range msg.Uids {
patches[jmap.ID(uid)] = 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
}
+63
View File
@@ -0,0 +1,63 @@
package jmap
import (
"git.sr.ht/~rockorager/go-jmap"
"git.sr.ht/~rockorager/go-jmap/mail/email"
"git.sr.ht/~rockorager/go-jmap/mail/thread"
)
func (w *JMAPWorker) fetchEntireThreads(threads []jmap.ID) ([]*email.Email, error) {
var req jmap.Request
if len(threads) == 0 {
return []*email.Email{}, nil
}
threadGetId := req.Invoke(&thread.Get{
Account: w.AccountId(),
IDs: threads,
})
// Opportunistically fetch all emails in this thread. We could wait for
// the result, check which ones we don't have, then fetch only those.
// However we can do this all in a single request which ends up being
// faster than two requests for most contexts
req.Invoke(&email.Get{
Account: w.AccountId(),
ReferenceIDs: &jmap.ResultReference{
ResultOf: threadGetId,
Name: "Thread/get",
Path: "/list/*/emailIds",
},
Properties: headersProperties,
})
resp, err := w.Do(&req)
if err != nil {
return nil, err
}
emailsToReturn := make([]*email.Email, 0)
for _, inv := range resp.Responses {
switch r := inv.Args.(type) {
case *thread.GetResponse:
if err = w.cache.PutThreadState(r.State); err != nil {
w.w.Warnf("PutThreadState: %s", err)
}
for _, thread := range r.List {
if err = w.cache.PutThread(thread.ID, thread.EmailIDs); err != nil {
w.w.Warnf("PutThread: %s", err)
}
}
case *email.GetResponse:
emailsToReturn = append(emailsToReturn, r.List...)
if err = w.cache.PutEmailState(r.State); err != nil {
w.w.Warnf("PutEmailState: %s", err)
}
case *jmap.MethodError:
return nil, wrapMethodError(r)
}
}
return emailsToReturn, nil
}
+197
View File
@@ -0,0 +1,197 @@
package jmap
import (
"errors"
"net/url"
"time"
"git.sr.ht/~rjarry/aerc/config"
"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 (
errNoop error = errors.New("noop")
errUnsupported error = 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
selectedMbox jmap.ID
dir2mbox map[string]jmap.ID
mbox2dir map[jmap.ID]string
roles map[mailbox.Role]jmap.ID
identities map[string]*identity.Identity
changes chan jmap.TypeState
stop chan struct{}
}
func NewJMAPWorker(worker *types.Worker) (types.Backend, error) {
return &JMAPWorker{
w: worker,
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.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, errNoop):
// Operation did not have any effect.
// Do *NOT* send a Done message.
break
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: // err == nil
// Operation is finished.
// Send a Done message.
w.w.PostMessage(&types.Done{
Message: types.RespondTo(msg),
}, nil)
}
}
}
}