1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2025-10-20 05:04:23 +02:00
aerc/worker/imap/fetch.go
Simon Martin d7b097f317 imap: always consider recently received unread messages as recent
We rely on IMAP's \Recent flag to trigger the new email hook, making it
unreliable since not all providers support that flag (it's been
deprecated in RFC 9051).

This patch implements the following heuristic to force the \Recent flag
on messages, regardless of what the server says, and "fixes" this
inconsistency across providers: a message is always considered having
the \Recent flag if:
 - The IMAP header cache is enabled, and
 - The message is not in the header cache, and
 - It's not marked as read, and
 - It's been received less than `check-mail` in the past

Signed-off-by: Simon Martin <simon@nasilyan.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2025-08-28 10:16:02 +02:00

344 lines
9 KiB
Go

package imap
import (
"bufio"
"fmt"
"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"].([]interface{})) == 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,
) {
if msg.Context.Err() != nil {
imapw.worker.PostMessage(&types.Cancelled{
Message: types.RespondTo(msg),
}, nil)
return
}
toFetch := msg.Uids
cacheEnabled := imapw.config.cacheEnabled && imapw.cache != nil
if cacheEnabled {
toFetch = imapw.getCachedHeaders(msg)
}
if len(toFetch) == 0 {
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)},
nil)
return
}
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(),
}
imapw.handleFetchMessages(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}}
info := &models.MessageInfo{
BodyStructure: translateBodyStructure(_msg.BodyStructure),
Envelope: translateEnvelope(_msg.Envelope),
Flags: translateImapFlags(_msg.Flags),
InternalDate: _msg.InternalDate,
RFC822Headers: header,
Refs: parse.MsgIDList(header, "references"),
Size: _msg.Size,
Uid: models.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
}
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,
) {
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(),
}
imapw.handleFetchMessages(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: models.Uint32ToUid(_msg.Uid),
},
}, nil)
// Update flags (to mark message as read)
info := &models.MessageInfo{
Flags: translateImapFlags(_msg.Flags),
Uid: models.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
}
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
return nil
})
}
func (imapw *IMAPWorker) handleFetchFullMessages(
msg *types.FetchFullMessages,
) {
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(),
}
imapw.handleFetchMessages(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: models.Uint32ToUid(_msg.Uid),
},
}, nil)
// Update flags (to mark message as read)
info := &models.MessageInfo{
Flags: translateImapFlags(_msg.Flags),
Uid: models.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
}
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
return nil
})
}
func (imapw *IMAPWorker) handleFetchMessageFlags(msg *types.FetchMessageFlags) {
items := []imap.FetchItem{
imap.FetchFlags,
imap.FetchUid,
}
if msg.Context.Err() != nil {
imapw.worker.PostMessage(&types.Cancelled{
Message: types.RespondTo(msg),
}, nil)
return
}
imapw.handleFetchMessages(msg, msg.Uids, items,
func(_msg *imap.Message) error {
info := &models.MessageInfo{
Flags: translateImapFlags(_msg.Flags),
Uid: models.Uint32ToUid(_msg.Uid),
}
if imapw.caps.Has("X-GM-EXT-1") {
imapw.attachGMLabels(_msg, info)
}
imapw.worker.PostMessage(&types.MessageInfo{
Message: types.RespondTo(msg),
Info: info,
}, nil)
return nil
})
}
func (imapw *IMAPWorker) handleFetchMessages(
msg types.WorkerMessage, uids []models.UID, items []imap.FetchItem,
procFunc func(*imap.Message) error,
) {
messages := make(chan *imap.Message)
done := make(chan struct{})
missingUids := make(map[models.UID]bool)
for _, uid := range uids {
missingUids[uid] = true
}
go func() {
defer log.PanicHandler()
for _msg := range messages {
delete(missingUids, models.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: models.Uint32ToUid(_msg.Uid),
Error: err,
},
}, nil)
}
}
close(done)
}()
if imapw.caps.Has("X-GM-EXT-1") {
items = append(items, "X-GM-LABELS")
}
set := toSeqSet(uids)
if err := imapw.client.UidFetch(set, items, messages); err != nil {
imapw.worker.PostMessage(&types.Error{
Message: types.RespondTo(msg),
Error: err,
}, nil)
return
}
<-done
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)
}
imapw.worker.PostMessage(
&types.Done{Message: types.RespondTo(msg)}, nil)
}