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 } 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) 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) 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) { 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 } 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 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") }