1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2025-07-14 14:30:22 +02:00
aerc/worker/jmap/push.go
Robin Jarry 985ce7a92b jmap: explicitly fetch body structure subparts
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>
2025-02-11 21:02:24 +01:00

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
}