mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-07-14 14:30:22 +02:00

When requesting the bodyStructure email property, the spec says that the default returned body part properties are: ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location"]. Specifically, the "subParts" property is *NOT* expected by default. Fastmail servers seem to use a different default "bodyProperties" list and implicitly return "subParts" even if not requested. Other JMAP server implementations (e.g. Apache James) do use the RFC default "bodyProperties" and therefore omit returning "subParts" unless explicitly asked to. Change the requested "bodyProperties" to include "subParts" as well. Aerc needs them to display messages. NB: for later, we should probably change our message abstraction not to include any body structure. This makes very little sense to expose that to users. In fact, aerc has code to explicitly prevent users from selecting multipart/* parts. Not requesting that information to the server would make it easier. Link: https://datatracker.ietf.org/doc/html/rfc8621#section-4.2 Reported-by: Benoit Tellier <btellier@linagora.com> Signed-off-by: Robin Jarry <robin@jarry.cc> Reviewed-by: Tim Culverhouse <tim@timculverhouse.com>
487 lines
11 KiB
Go
487 lines
11 KiB
Go
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: emailProperties,
|
|
BodyProperties: bodyProperties,
|
|
ReferenceIDs: &jmap.ResultReference{
|
|
ResultOf: callID,
|
|
Name: "Email/changes",
|
|
Path: "/updated",
|
|
},
|
|
})
|
|
|
|
emailCreated = req.Invoke(&email.Get{
|
|
Account: w.AccountId(),
|
|
Properties: emailProperties,
|
|
BodyProperties: bodyProperties,
|
|
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: emailProperties,
|
|
BodyProperties: bodyProperties,
|
|
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
|
|
}
|