mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-11-19 10:16:33 +01:00
Just as prior to this commit, the GMail extension for labels is used
towards servers where the X-GM-EXT-1 capability is available. For other
servers, this commit adds storing labels using keyword flags, if
PERMANENTFLAGS is either empty or allows `\*`, in accordance with
section 6.3.2 of rfc9051.
No feature validation has been done with either GMail nor Proton, as I
don't have accounts setup for either of those providers.
Labels are not visible by default. Taking inspiration from something
like these lines and adding to the appropriate sections of the
configuration can make them show up in the UI:
column-subject = {{if .Labels}}{{.Labels | join " "}}|{{ end }}{{.Subject}}
header-layout = From|To,Cc|Bcc,Date,Subject,Labels
Existing unit test coverage for imap is sparse. This commit does not
change that fact, as it is hard do see test cases which might be
meaningful to add.
Implements: https://todo.sr.ht/~rjarry/aerc/320
Changelog-added: Add support for Labels when using imap.
Signed-off-by: cos <aerc-mail.org@gunaha.netizen.se>
Acked-by: Robin Jarry <robin@jarry.cc>
357 lines
9.8 KiB
Go
357 lines
9.8 KiB
Go
package imap
|
|
|
|
import (
|
|
"bufio"
|
|
"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"].([]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}}
|
|
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"),
|
|
Size: _msg.Size,
|
|
Uid: models.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,
|
|
) {
|
|
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)
|
|
systemFlags, keywordFlags := translateImapFlags(_msg.Flags)
|
|
info := &models.MessageInfo{
|
|
Flags: systemFlags,
|
|
Uid: models.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,
|
|
) {
|
|
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)
|
|
systemFlags, keywordFlags := translateImapFlags(_msg.Flags)
|
|
info := &models.MessageInfo{
|
|
Flags: systemFlags,
|
|
Uid: models.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) {
|
|
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 {
|
|
systemFlags, keywordFlags := translateImapFlags(_msg.Flags)
|
|
info := &models.MessageInfo{
|
|
Flags: systemFlags,
|
|
Uid: models.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(
|
|
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)
|
|
}
|