Mail-Graveyard/backend/04-imap-source.go
2026-07-15 00:14:54 +02:00

490 lines
12 KiB
Go

package backend
import (
"crypto/sha256"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"sort"
"strconv"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
// RawMessage ist eine 1:1 aus der Quelle geholte Nachricht: der Rohkoerper
// plus die Metadaten, die den sauberen Umzug ausmachen.
type RawMessage struct {
MessageID string // aus Envelope/Header, Schluessel fuer Idempotenz
Body []byte // kompletter RFC822-Rohtext, wird NICHT umgeschrieben
Flags []string // \Seen \Answered \Flagged ... 1:1 uebernehmen
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.
type Folder struct {
Name string // dekodiert, z.B. "Geloeschte Objekte" oder "INBOX.Sent"
Attrs []string // z.B. ["\\Sent"] aus SPECIAL-USE
Delim string // "." oder "/" - servereigener Trenner
}
// SourceMailbox abstrahiert Quelle (IMAP primaer, POP3 Fallback), damit
// 07-migrate.go gegen ein Interface arbeitet.
type SourceMailbox interface {
Folders() ([]Folder, error) // rekursiver Ordnerbaum
Count(folder string) (int, error)
Headers(folder string, limit, offset int) ([]MessageHeader, error)
AllHeaders(folder string) ([]MessageHeader, error)
FetchOne(folder string, uid uint32) (RawMessage, error)
Fetch(folder string, fn func(RawMessage) error) error // BODY[] FLAGS INTERNALDATE
Close() error
}
func OpenIMAPSource(a Account) (SourceMailbox, error) {
c, err := dialIMAPClient(a.SrcHost, a.SrcPort, a.SrcSecurity, a.SrcInsecure)
if err != nil {
return nil, err
}
if err := c.Login(a.SrcUser, a.SrcPass).Wait(); err != nil {
_ = c.Close()
return nil, err
}
return &imapSource{mailbox: &imapClientMailbox{c: c}}, nil
}
type imapSource struct {
mailbox *imapClientMailbox
}
func (s *imapSource) Folders() ([]Folder, error) {
boxes, err := s.mailbox.listMailboxes()
if err != nil {
return nil, err
}
folders := make([]Folder, 0, len(boxes))
for _, box := range boxes {
if hasIMAPAttr(box.Attrs, `\Noselect`) {
continue
}
folders = append(folders, Folder{Name: box.Name, Attrs: box.Attrs, Delim: box.Delim})
}
if len(folders) == 0 {
folders = append(folders, Folder{Name: "INBOX", Delim: "/"})
}
return folders, nil
}
func (s *imapSource) Fetch(folder string, fn func(RawMessage) error) error {
return s.mailbox.fetchEach(folder, fn)
}
func (s *imapSource) Count(folder string) (int, error) {
return s.mailbox.count(folder)
}
func (s *imapSource) Headers(folder string, limit, offset int) ([]MessageHeader, error) {
return s.mailbox.fetchHeaders(folder, limit, offset)
}
func (s *imapSource) AllHeaders(folder string) ([]MessageHeader, error) {
return s.mailbox.fetchAllHeadersByUID(folder)
}
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()
}
type imapClientMailbox struct {
c *imapclient.Client
}
type imapListMailbox struct {
Name string
Attrs []string
Delim string
}
func dialIMAPClient(host string, port int, security string, insecure bool) (*imapclient.Client, error) {
addr := net.JoinHostPort(host, strconv.Itoa(port))
opts := &imapclient.Options{
TLSConfig: &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: insecure, //nolint:gosec // explicit legacy-provider option
},
}
switch strings.ToLower(strings.TrimSpace(security)) {
case "tls", "":
return imapclient.DialTLS(addr, opts)
case "starttls":
return imapclient.DialStartTLS(addr, opts)
case "none":
return imapclient.DialInsecure(addr, opts)
default:
return nil, fmt.Errorf("unbekannter IMAP-Security-Modus %q", security)
}
}
func (m *imapClientMailbox) listMailboxes() ([]imapListMailbox, error) {
cmd := m.c.List("", "*", &imap.ListOptions{ReturnSpecialUse: true})
defer cmd.Close()
var out []imapListMailbox
for {
data := cmd.Next()
if data == nil {
break
}
out = append(out, imapListMailbox{
Name: data.Mailbox,
Attrs: mailboxAttrsToStrings(data.Attrs),
Delim: delimString(data.Delim),
})
}
if err := cmd.Close(); err != nil {
return out, err
}
return out, nil
}
func (m *imapClientMailbox) fetchEach(folder string, fn func(RawMessage) error) error {
selected, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return err
}
if selected.NumMessages == 0 {
return nil
}
section := &imap.FetchItemBodySection{Peek: true}
seqSet := imap.SeqSet{}
seqSet.AddRange(1, 0)
cmd := m.c.Fetch(seqSet, &imap.FetchOptions{
Envelope: true,
Flags: true,
InternalDate: true,
BodySection: []*imap.FetchItemBodySection{section},
})
defer cmd.Close()
for {
data := cmd.Next()
if data == nil {
break
}
buf, err := data.Collect()
if err != nil {
return err
}
if err := fn(rawMessageFromFetch(buf, section)); err != nil {
return err
}
}
return cmd.Close()
}
func (m *imapClientMailbox) count(folder string) (int, error) {
selected, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return 0, err
}
return int(selected.NumMessages), nil
}
func (m *imapClientMailbox) fetchHeaders(folder string, limit, offset int) ([]MessageHeader, error) {
if limit <= 0 || limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
headers, err := m.fetchAllHeadersByUID(folder)
if err != nil {
return nil, err
}
sortMessageHeadersByDateDesc(headers)
if offset >= len(headers) {
return nil, nil
}
end := offset + limit
if end > len(headers) {
end = len(headers)
}
return headers[offset:end], nil
}
func (m *imapClientMailbox) fetchAllHeadersByUID(folder string) ([]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
}
uidSet := imap.UIDSet{}
uidSet.AddRange(1, 0)
cmd := m.c.Fetch(uidSet, &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
}
sortMessageHeadersByUID(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
}
if err := m.c.Logout().Wait(); err != nil {
return m.c.Close()
}
return m.c.Close()
}
func hasIMAPAttr(attrs []string, want string) bool {
for _, attr := range attrs {
if strings.EqualFold(attr, want) {
return true
}
}
return false
}
func mailboxAttrsToStrings(attrs []imap.MailboxAttr) []string {
out := make([]string, 0, len(attrs))
for _, attr := range attrs {
out = append(out, string(attr))
}
return out
}
func delimString(delim rune) string {
if delim == 0 {
return "/"
}
return string(delim)
}
func sanitizeIMAPFlags(in []imap.Flag) []imap.Flag {
out := make([]imap.Flag, 0, len(in))
for _, flag := range in {
switch strings.ToLower(string(flag)) {
case `\recent`, `\*`:
continue
default:
out = append(out, flag)
}
}
return out
}
func flagsToStrings(flags []imap.Flag) []string {
out := make([]string, 0, len(flags))
for _, flag := range flags {
out = append(out, string(flag))
}
return out
}
func stringsToFlags(flags []string) []imap.Flag {
return sanitizeIMAPFlags(func() []imap.Flag {
out := make([]imap.Flag, 0, len(flags))
for _, flag := range flags {
flag = strings.TrimSpace(flag)
if flag != "" {
out = append(out, imap.Flag(flag))
}
}
return out
}())
}
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 sortMessageHeadersByUID(headers []MessageHeader) {
sort.SliceStable(headers, func(i, j int) bool {
return headers[i].UID < headers[j].UID
})
}
func sortMessageHeadersByDateDesc(headers []MessageHeader) {
sort.SliceStable(headers, func(i, j int) bool {
di := headerSortDate(headers[i])
dj := headerSortDate(headers[j])
if !di.Equal(dj) {
return di.After(dj)
}
return headers[i].UID > headers[j].UID
})
}
func headerSortDate(h MessageHeader) time.Time {
if !h.Date.IsZero() {
return h.Date
}
return h.InternalDate
}
func messageIDFromFetch(buf *imapclient.FetchMessageBuffer, body []byte) string {
if buf != nil && buf.Envelope != nil && strings.TrimSpace(buf.Envelope.MessageID) != "" {
return normalizeMessageID(buf.Envelope.MessageID)
}
if len(body) == 0 {
return ""
}
return messageID(body)
}
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 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) {
text := strings.ToLower(imapErr.Text)
return strings.Contains(text, "exist") || strings.Contains(text, "already")
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "exist") || strings.Contains(text, "already")
}