1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2026-02-17 11:28:16 +01:00
aerc/worker/imap/fetch.go
Robin Jarry e324fa248f worker: add directory and filter metadata to response messages
Add Directory and Filter fields to DirectoryContents, DirectoryThreaded,
SearchResults, MessageInfo, and FullMessage response types. Workers now
populate these fields so the UI knows which directory and filter context
each response belongs to.

This metadata allows the message store to correctly associate incoming
messages with their source context, which is essential for the offline
worker to cache messages by directory and for proper handling of
concurrent operations across multiple folders.

Signed-off-by: Robin Jarry <robin@jarry.cc>
Reviewed-by: Simon Martin <simon@nasilyan.com>
2026-02-09 14:46:27 +01:00

382 lines
10 KiB
Go

package imap
import (
"bufio"
"context"
"fmt"
"slices"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-message"
_ "github.com/emersion/go-message/charset"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-message/textproto"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/parse"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"github.com/emersion/go-imap/utf7"
)
func (imapw *IMAPWorker) attachGMLabels(_msg *imap.Message, info *models.MessageInfo) {
if len(_msg.Items["X-GM-LABELS"].([]any)) == 0 {
return
}
imapw.worker.Debugf("Attaching labels %v to message %v\n", _msg.Items["X-GM-LABELS"], _msg.Uid)
enc := utf7.Encoding.NewDecoder()
for _, label := range _msg.Items["X-GM-LABELS"].([]any) {
decodedLabel, err := enc.String(label.(string))
if err != nil {
imapw.worker.Errorf("Failed to decode label %v from UTF-7\n", label.(string))
info.Labels = append(info.Labels, label.(string))
} else {
info.Labels = append(info.Labels, decodedLabel)
}
}
}
func (imapw *IMAPWorker) handleFetchMessageHeaders(
msg *types.FetchMessageHeaders,
) error {
if err := imapw.ensureSelected(msg.Directory); err != nil {
return err
}
if msg.Context().Err() != nil {
return msg.Context().Err()
}
toFetch := msg.Uids
// Expand UIDs to include entire threads when Gmail X-GM-EXT-1 is available
if imapw.caps.Has("X-GM-EXT-1") {
uids, err := imapw.fetchEntireThreads(toFetch)
if err != nil {
imapw.worker.Warnf("failed to fetch entire threads: %v", err)
} else if len(uids) > 0 {
toFetch = uids
}
}
cacheEnabled := imapw.config.cacheEnabled && imapw.cache != nil
if cacheEnabled {
msg.Uids = toFetch
toFetch = imapw.getCachedHeaders(msg)
}
if len(toFetch) == 0 {
return nil
}
imapw.worker.Tracef("Fetching message headers: %v", toFetch)
hdrBodyPart := imap.BodyPartName{
Specifier: imap.HeaderSpecifier,
}
switch {
case len(imapw.config.headersExclude) > 0:
hdrBodyPart.NotFields = true
hdrBodyPart.Fields = imapw.config.headersExclude
case len(imapw.config.headers) > 0:
hdrBodyPart.Fields = imapw.config.headers
}
section := &imap.BodySectionName{
BodyPartName: hdrBodyPart,
Peek: true,
}
items := []imap.FetchItem{
imap.FetchBodyStructure,
imap.FetchEnvelope,
imap.FetchInternalDate,
imap.FetchFlags,
imap.FetchUid,
imap.FetchRFC822Size,
section.FetchItem(),
}
return imapw.handleFetchMessages(msg.Context(), msg, toFetch, items,
func(_msg *imap.Message) error {
if len(_msg.Body) == 0 {
// ignore duplicate messages with only flag updates
return nil
}
reader := _msg.GetBody(section)
if reader == nil {
return fmt.Errorf("failed to find part: %v", section)
}
textprotoHeader, err := textproto.ReadHeader(bufio.NewReader(reader))
if err != nil {
return fmt.Errorf("failed to read part header: %w", err)
}
header := &mail.Header{Header: message.Header{Header: textprotoHeader}}
systemFlags, keywordFlags := translateImapFlags(_msg.Flags)
info := &models.MessageInfo{
BodyStructure: translateBodyStructure(_msg.BodyStructure),
Envelope: translateEnvelope(_msg.Envelope),
Flags: systemFlags,
InternalDate: _msg.InternalDate,
RFC822Headers: header,
Refs: parse.MsgIDList(header, "references"),
Directory: msg.Directory,
Size: _msg.Size,
Uid: imapw.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
} else if len(imapw.selected.PermanentFlags) == 0 || slices.Contains(imapw.selected.PermanentFlags, "\\*") {
info.Labels = keywordFlags
}
if cacheEnabled && !info.Flags.Has(models.SeenFlag) &&
time.Since(info.InternalDate) < imapw.config.checkMail {
// Consider unread messages received within the last CheckMail
// period as Recent, regardless of what the IMAP server says.
info.Flags |= models.RecentFlag
}
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
if imapw.config.cacheEnabled && imapw.cache != nil {
imapw.cacheHeader(info)
}
return nil
})
}
func (imapw *IMAPWorker) handleFetchMessageBodyPart(
msg *types.FetchMessageBodyPart,
) error {
if err := imapw.ensureSelected(msg.Directory); err != nil {
return err
}
imapw.worker.Tracef("Fetching message %d part: %v", msg.Uid, msg.Part)
var partHeaderSection imap.BodySectionName
partHeaderSection.Peek = true
if len(msg.Part) > 0 {
partHeaderSection.Specifier = imap.MIMESpecifier
} else {
partHeaderSection.Specifier = imap.HeaderSpecifier
}
partHeaderSection.Path = msg.Part
var partBodySection imap.BodySectionName
if len(msg.Part) > 0 {
partBodySection.Specifier = imap.EntireSpecifier
} else {
partBodySection.Specifier = imap.TextSpecifier
}
partBodySection.Path = msg.Part
partBodySection.Peek = true
items := []imap.FetchItem{
imap.FetchEnvelope,
imap.FetchUid,
imap.FetchBodyStructure,
imap.FetchFlags,
partHeaderSection.FetchItem(),
partBodySection.FetchItem(),
}
return imapw.handleFetchMessages(msg.Context(), msg, []models.UID{msg.Uid}, items,
func(_msg *imap.Message) error {
if len(_msg.Body) == 0 {
// ignore duplicate messages with only flag updates
return nil
}
body := _msg.GetBody(&partHeaderSection)
if body == nil {
return fmt.Errorf("failed to find part: %v", partHeaderSection)
}
h, err := textproto.ReadHeader(bufio.NewReader(body))
if err != nil {
return fmt.Errorf("failed to read part header: %w", err)
}
part, err := message.New(message.Header{Header: h},
_msg.GetBody(&partBodySection))
if message.IsUnknownCharset(err) {
imapw.worker.Warnf("unknown charset encountered "+
"for uid %d", _msg.Uid)
} else if err != nil {
return fmt.Errorf("failed to create message reader: %w", err)
}
imapw.worker.PostMessage(&types.MessageBodyPart{
Message: types.RespondTo(msg),
Part: &models.MessageBodyPart{
Reader: part.Body,
Uid: imapw.Uint32ToUid(_msg.Uid),
},
}, nil)
// Update flags (to mark message as read)
systemFlags, keywordFlags := translateImapFlags(_msg.Flags)
info := &models.MessageInfo{
Flags: systemFlags,
Directory: msg.Directory,
Uid: imapw.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
} else if len(imapw.selected.PermanentFlags) == 0 || slices.Contains(imapw.selected.PermanentFlags, "\\*") {
info.Labels = keywordFlags
}
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
return nil
})
}
func (imapw *IMAPWorker) handleFetchFullMessages(
msg *types.FetchFullMessages,
) error {
if err := imapw.ensureSelected(msg.Directory); err != nil {
return err
}
imapw.worker.Tracef("Fetching full messages: %v", msg.Uids)
section := &imap.BodySectionName{
Peek: true,
}
items := []imap.FetchItem{
imap.FetchEnvelope,
imap.FetchFlags,
imap.FetchUid,
section.FetchItem(),
}
return imapw.handleFetchMessages(msg.Context(), msg, msg.Uids, items,
func(_msg *imap.Message) error {
if len(_msg.Body) == 0 {
// ignore duplicate messages with only flag updates
return nil
}
r := _msg.GetBody(section)
if r == nil {
return fmt.Errorf("could not get section %#v", section)
}
imapw.worker.PostMessage(&types.FullMessage{
Message: types.RespondTo(msg),
Content: &models.FullMessage{
Reader: bufio.NewReader(r),
Uid: imapw.Uint32ToUid(_msg.Uid),
},
}, nil)
// Update flags (to mark message as read)
systemFlags, keywordFlags := translateImapFlags(_msg.Flags)
info := &models.MessageInfo{
Flags: systemFlags,
Directory: msg.Directory,
Uid: imapw.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
} else if len(imapw.selected.PermanentFlags) == 0 || slices.Contains(imapw.selected.PermanentFlags, "\\*") {
info.Labels = keywordFlags
}
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
return nil
})
}
func (imapw *IMAPWorker) handleFetchMessageFlags(msg *types.FetchMessageFlags) error {
if err := imapw.ensureSelected(msg.Directory); err != nil {
return err
}
items := []imap.FetchItem{
imap.FetchFlags,
imap.FetchUid,
}
return imapw.handleFetchMessages(msg.Context(), msg, msg.Uids, items,
func(_msg *imap.Message) error {
systemFlags, keywordFlags := translateImapFlags(_msg.Flags)
info := &models.MessageInfo{
Flags: systemFlags,
Directory: msg.Directory,
Uid: imapw.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
} else if len(imapw.selected.PermanentFlags) == 0 || slices.Contains(imapw.selected.PermanentFlags, "\\*") {
info.Labels = keywordFlags
}
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
return nil
})
}
func (imapw *IMAPWorker) handleFetchMessages(
ctx context.Context, msg types.WorkerMessage, uids []models.UID,
items []imap.FetchItem, procFunc func(*imap.Message) error,
) error {
messages := make(chan *imap.Message)
done := make(chan error)
missingUids := make(map[models.UID]bool)
for _, uid := range uids {
missingUids[uid] = true
}
go func() {
defer log.PanicHandler()
for {
select {
case <-ctx.Done():
done <- ctx.Err()
goto out
case _msg := <-messages:
if _msg == nil {
goto out
}
delete(missingUids, imapw.Uint32ToUid(_msg.Uid))
err := procFunc(_msg)
if err != nil {
imapw.worker.Errorf("failed to process message <%d>: %v", _msg.Uid, err)
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: &models.MessageInfo{
Uid: imapw.Uint32ToUid(_msg.Uid),
Error: err,
},
}, nil)
}
}
}
out:
close(done)
}()
if imapw.caps.Has("X-GM-EXT-1") {
items = append(items, "X-GM-LABELS")
}
set := imapw.UidListToSeqSet(uids)
if err := imapw.client.UidFetch(set, items, messages); err != nil {
return err
}
err := <-done
if err != nil {
return err
}
for uid := range missingUids {
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: &models.MessageInfo{
Uid: uid,
Error: fmt.Errorf("invalid response from server (detailed error in log)"),
},
}, nil)
}
return nil
}