Mail-Graveyard/backend/04-imap-source.go
2026-07-14 05:53:11 +02:00

279 lines
6.8 KiB
Go

package backend
import (
"crypto/sha256"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"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
}
// 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
Fetch(folder string) ([]RawMessage, 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) ([]RawMessage, error) {
return s.mailbox.fetchAll(folder)
}
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) fetchAll(folder string) ([]RawMessage, 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
}
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()
var out []RawMessage
for {
data := cmd.Next()
if data == nil {
break
}
buf, err := data.Collect()
if err != nil {
return out, err
}
body := buf.FindBodySection(section)
if body == nil {
body = []byte{}
}
out = append(out, RawMessage{
MessageID: messageIDFromFetch(buf, body),
Body: body,
Flags: flagsToStrings(sanitizeIMAPFlags(buf.Flags)),
InternalDate: buf.InternalDate,
})
}
if err := cmd.Close(); err != nil {
return out, err
}
return out, 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 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 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 id
}
}
return fmt.Sprintf("sha256:%x", sha256.Sum256(body))
}
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")
}