Use header-only IMAP lists

This commit is contained in:
DonVoo 2026-07-14 18:23:31 +02:00
parent a52963c380
commit a17d2c8a2c
5 changed files with 456 additions and 184 deletions

View file

@ -24,6 +24,17 @@ type RawMessage struct {
InternalDate time.Time // Original-Zeit, per APPEND erhalten
}
type MessageHeader struct {
UID uint32
MessageID string
Subject string
From string
Date time.Time
InternalDate time.Time
Flags []string
Size int64
}
// Folder ist ein Ordner der Quelle MIT den Infos, die 11-folders.go fuer die
// Rollen-Zuordnung braucht: dekodierter Name, SPECIAL-USE-Attribute und der
// Hierarchie-Trenner.
@ -36,7 +47,9 @@ type Folder struct {
// SourceMailbox abstrahiert Quelle (IMAP primaer, POP3 Fallback), damit
// 07-migrate.go gegen ein Interface arbeitet.
type SourceMailbox interface {
Folders() ([]Folder, error) // rekursiver Ordnerbaum
Folders() ([]Folder, error) // rekursiver Ordnerbaum
Headers(folder string, limit, offset int) ([]MessageHeader, error)
FetchOne(folder string, uid uint32) (RawMessage, error)
Fetch(folder string, fn func(RawMessage) error) error // BODY[] FLAGS INTERNALDATE
Close() error
}
@ -79,6 +92,14 @@ func (s *imapSource) Fetch(folder string, fn func(RawMessage) error) error {
return s.mailbox.fetchEach(folder, fn)
}
func (s *imapSource) Headers(folder string, limit, offset int) ([]MessageHeader, error) {
return s.mailbox.fetchHeaders(folder, limit, offset)
}
func (s *imapSource) FetchOne(folder string, uid uint32) (RawMessage, error) {
return s.mailbox.fetchOne(folder, uid)
}
func (s *imapSource) Close() error {
return s.mailbox.Close()
}
@ -154,6 +175,7 @@ func (m *imapClientMailbox) fetchEach(folder string, fn func(RawMessage) error)
InternalDate: true,
BodySection: []*imap.FetchItemBodySection{section},
})
defer cmd.Close()
for {
data := cmd.Next()
@ -162,38 +184,103 @@ func (m *imapClientMailbox) fetchEach(folder string, fn func(RawMessage) error)
}
buf, err := data.Collect()
if err != nil {
_ = cmd.Close()
return err
}
body := buf.FindBodySection(section)
if body == nil {
body = []byte{}
}
if err := fn(RawMessage{
MessageID: messageIDFromFetch(buf, body),
Body: body,
Flags: flagsToStrings(sanitizeIMAPFlags(buf.Flags)),
InternalDate: buf.InternalDate,
}); err != nil {
_ = cmd.Close()
if err := fn(rawMessageFromFetch(buf, section)); err != nil {
return err
}
}
return cmd.Close()
}
func (m *imapClientMailbox) fetchAll(folder string) ([]RawMessage, error) {
var out []RawMessage
err := m.fetchEach(folder, func(m RawMessage) error {
out = append(out, m)
return nil
})
func (m *imapClientMailbox) fetchHeaders(folder string, limit, offset int) ([]MessageHeader, error) {
selected, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return nil, err
}
if selected.NumMessages == 0 {
return nil, nil
}
if limit <= 0 || limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
stop := int(selected.NumMessages) - offset
if stop <= 0 {
return nil, nil
}
start := stop - limit + 1
if start < 1 {
start = 1
}
seqSet := imap.SeqSet{}
seqSet.AddRange(uint32(start), uint32(stop))
cmd := m.c.Fetch(seqSet, &imap.FetchOptions{
Envelope: true,
Flags: true,
InternalDate: true,
RFC822Size: true,
UID: true,
})
defer cmd.Close()
var out []MessageHeader
for {
data := cmd.Next()
if data == nil {
break
}
buf, err := data.Collect()
if err != nil {
return out, err
}
out = append(out, messageHeaderFromFetch(buf))
}
if err := cmd.Close(); err != nil {
return out, err
}
reverseHeaders(out)
return out, nil
}
func (m *imapClientMailbox) fetchOne(folder string, uid uint32) (RawMessage, error) {
if uid == 0 {
return RawMessage{}, fmt.Errorf("Nachricht ohne UID")
}
if _, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return RawMessage{}, err
}
section := &imap.FetchItemBodySection{Peek: true}
cmd := m.c.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{
Envelope: true,
Flags: true,
InternalDate: true,
UID: true,
BodySection: []*imap.FetchItemBodySection{section},
})
defer cmd.Close()
data := cmd.Next()
if data == nil {
if err := cmd.Close(); err != nil {
return RawMessage{}, err
}
return RawMessage{}, fmt.Errorf("Nachricht nicht gefunden")
}
buf, err := data.Collect()
if err != nil {
return RawMessage{}, err
}
msg := rawMessageFromFetch(buf, section)
if err := cmd.Close(); err != nil {
return RawMessage{}, err
}
return msg, nil
}
func (m *imapClientMailbox) Close() error {
if m == nil || m.c == nil {
return nil
@ -262,9 +349,68 @@ func stringsToFlags(flags []string) []imap.Flag {
}())
}
func rawMessageFromFetch(buf *imapclient.FetchMessageBuffer, section *imap.FetchItemBodySection) RawMessage {
body := buf.FindBodySection(section)
if body == nil {
body = []byte{}
}
return RawMessage{
MessageID: messageIDFromFetch(buf, body),
Body: body,
Flags: flagsToStrings(sanitizeIMAPFlags(buf.Flags)),
InternalDate: buf.InternalDate,
}
}
func messageHeaderFromFetch(buf *imapclient.FetchMessageBuffer) MessageHeader {
h := MessageHeader{
UID: uint32(buf.UID),
MessageID: messageIDFromFetch(buf, nil),
InternalDate: buf.InternalDate,
Flags: flagsToStrings(sanitizeIMAPFlags(buf.Flags)),
Size: buf.RFC822Size,
}
if buf.Envelope != nil {
h.Subject = strings.TrimSpace(buf.Envelope.Subject)
h.From = envelopeFrom(buf.Envelope)
h.Date = buf.Envelope.Date
}
if h.Date.IsZero() {
h.Date = h.InternalDate
}
return h
}
func envelopeFrom(env *imap.Envelope) string {
if env == nil || len(env.From) == 0 {
return ""
}
for _, addr := range env.From {
email := strings.TrimSpace(addr.Addr())
if email == "" {
continue
}
name := strings.TrimSpace(addr.Name)
if name != "" {
return (&mail.Address{Name: name, Address: email}).String()
}
return email
}
return ""
}
func reverseHeaders(headers []MessageHeader) {
for i, j := 0, len(headers)-1; i < j; i, j = i+1, j-1 {
headers[i], headers[j] = headers[j], headers[i]
}
}
func messageIDFromFetch(buf *imapclient.FetchMessageBuffer, body []byte) string {
if buf != nil && buf.Envelope != nil && strings.TrimSpace(buf.Envelope.MessageID) != "" {
return strings.TrimSpace(buf.Envelope.MessageID)
return normalizeMessageID(buf.Envelope.MessageID)
}
if len(body) == 0 {
return ""
}
return messageID(body)
}
@ -273,12 +419,16 @@ func messageID(body []byte) string {
msg, err := mail.ReadMessage(strings.NewReader(string(body)))
if err == nil {
if id := strings.TrimSpace(msg.Header.Get("Message-ID")); id != "" {
return id
return normalizeMessageID(id)
}
}
return fmt.Sprintf("sha256:%x", sha256.Sum256(body))
}
func normalizeMessageID(id string) string {
return strings.Trim(strings.TrimSpace(id), "<>")
}
func isAlreadyExistsError(err error) bool {
var imapErr *imap.Error
if errors.As(err, &imapErr) {