From 160b28099e5da447742e956cc829adae59822970 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 1 Sep 2026 14:45:51 +0200 Subject: [PATCH] fix(PROJ-86): Date-Fallback auf Received-Header-Kette wenn Date-Header fehlt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alte Bulk-importierte Archive (kein Date:-Header, nur Received:-Zeilen) bekamen bislang das Import-Datum (received_at) als date_ts zugewiesen - storage.Save() ignoriert den pm.Date-Parameter komplett und setzt received_at immer auf NOW(). Jetzt wird bei fehlendem/unparsbarem Date:-Header die Received:-Kette geparst (origin-nächster Hop zuerst, via net/mail.Header["Received"] rückwärts iteriert) - liefert das tatsächliche Mail-Datum statt des Import-Zeitpunkts. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UPFC6Jk2ke1Pq9XcuVGP1R --- pkg/mailparser/parser.go | 144 +++++++++++++++++++++++---------------- 1 file changed, 85 insertions(+), 59 deletions(-) diff --git a/pkg/mailparser/parser.go b/pkg/mailparser/parser.go index d64ff90..f3237e3 100644 --- a/pkg/mailparser/parser.go +++ b/pkg/mailparser/parser.go @@ -51,6 +51,69 @@ type ParsedMail struct { // fatal error for the whole process. const maxMultipartDepth = 20 +// parseDateFlexible tries the RFC 2822 date format plus the non-standard +// variants seen in real-world Date: and Received: header tails (missing +// leading zero, colon in timezone offset, no seconds, MTA timezone name, +// localised weekday prefix, trailing "(TZ)" comment). Returns ok=false if +// none of them match. +func parseDateFlexible(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + // Strip parenthesised timezone comment: "... +0100 (CET)" → "... +0100" + if idx := strings.LastIndex(raw, "("); idx > 0 { + raw = strings.TrimSpace(raw[:idx]) + } + for _, layout := range []string{ + "Mon, 2 Jan 2006 15:04:05 -0700", + "Mon, 02 Jan 2006 15:04:05 -0700", + "2 Jan 2006 15:04:05 -0700", + "02 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + "Mon, 02 Jan 2006 15:04:05 MST", + // Colon in timezone offset (e.g. "+02:00") used by some MTA versions + "Mon, 2 Jan 2006 15:04:05 -07:00", + "Mon, 02 Jan 2006 15:04:05 -07:00", + "2 Jan 2006 15:04:05 -07:00", + "02 Jan 2006 15:04:05 -07:00", + // Without seconds + "Mon, 2 Jan 2006 15:04 -0700", + "Mon, 02 Jan 2006 15:04 -0700", + "2 Jan 2006 15:04 -0700", + // Go stdlib aliases + time.RFC1123Z, + time.RFC1123, + } { + if t, err := time.Parse(layout, raw); err == nil { + return t, true + } + } + // Some MTAs (e.g. PMG with German locale) use localised weekday names: + // "So, 24 Aug 2025 00:05:17 +0200" instead of "Sun, 24 Aug 2025...". + // Strip the "Weekday, " prefix (≤3 chars before the first comma) and retry. + commaIdx := strings.Index(raw, ",") + if commaIdx <= 0 || commaIdx > 3 { + return time.Time{}, false + } + noWeekday := strings.TrimSpace(raw[commaIdx+1:]) + for _, layout := range []string{ + "2 Jan 2006 15:04:05 -0700", + "02 Jan 2006 15:04:05 -0700", + "2 Jan 2006 15:04:05 -07:00", + "02 Jan 2006 15:04:05 -07:00", + "2 Jan 2006 15:04:05 MST", + "02 Jan 2006 15:04:05 MST", + "2 Jan 2006 15:04 -0700", + "02 Jan 2006 15:04 -0700", + } { + if t, err := time.Parse(layout, noWeekday); err == nil { + return t, true + } + } + return time.Time{}, false +} + // Parse parses a raw RFC 2822 / MIME email and returns a ParsedMail. // // Input is untrusted (IMAP/POP3/SMTP/upload). A panic inside the MIME/charset @@ -123,72 +186,35 @@ func Parse(raw []byte) (pmOut *ParsedMail, errOut error) { } } - // Date — try go-message parser first, then fallback formats, then zero + // Date — try go-message parser first, then fallback formats, then Received: + // trace headers, then zero. if d, err := msg.Header.Date(); err == nil { pm.Date = d + } else if t, ok := parseDateFlexible(msg.Header.Get("Date")); ok { + pm.Date = t } else { - // Some MUAs emit non-standard variants (e.g. "+0100 (CET)" suffix). - // Try common RFC 2822 / non-standard formats before giving up. - raw := strings.TrimSpace(msg.Header.Get("Date")) - // Strip parenthesised timezone comment: "... +0100 (CET)" → "... +0100" - if idx := strings.LastIndex(raw, "("); idx > 0 { - raw = strings.TrimSpace(raw[:idx]) - } - parsed := false - for _, layout := range []string{ - "Mon, 2 Jan 2006 15:04:05 -0700", - "Mon, 02 Jan 2006 15:04:05 -0700", - "2 Jan 2006 15:04:05 -0700", - "02 Jan 2006 15:04:05 -0700", - "Mon, 2 Jan 2006 15:04:05 MST", - "Mon, 02 Jan 2006 15:04:05 MST", - // Colon in timezone offset (e.g. "+02:00") used by some MTA versions - "Mon, 2 Jan 2006 15:04:05 -07:00", - "Mon, 02 Jan 2006 15:04:05 -07:00", - "2 Jan 2006 15:04:05 -07:00", - "02 Jan 2006 15:04:05 -07:00", - // Without seconds - "Mon, 2 Jan 2006 15:04 -0700", - "Mon, 02 Jan 2006 15:04 -0700", - "2 Jan 2006 15:04 -0700", - // Go stdlib aliases - time.RFC1123Z, - time.RFC1123, - } { - if t, err := time.Parse(layout, raw); err == nil { + // No usable Date: header at all (missing, or unparseable in every + // format we know) — some very old/legacy MTAs never set one. Fall + // back to the Received: trace, which every relay hop adds a + // timestamp to. net/mail.Header["Received"] preserves header order + // (topmost/latest hop first, origin-closest hop last) — iterate + // back-to-front so we try the origin-closest hop first, which is + // our best proxy for when the mail actually originated. Far better + // than defaulting to "now"/import time for archived mail. + pm.Date = time.Time{} + received := msg.Header["Received"] + for i := len(received) - 1; i >= 0; i-- { + semi := strings.LastIndex(received[i], ";") + if semi < 0 { + continue + } + if t, ok := parseDateFlexible(received[i][semi+1:]); ok { pm.Date = t - parsed = true break } } - if !parsed { - // Some MTAs (e.g. PMG with German locale) use localised weekday names: - // "So, 24 Aug 2025 00:05:17 +0200" instead of "Sun, 24 Aug 2025...". - // Strip the "Weekday, " prefix (≤3 chars before the first comma) and retry. - if commaIdx := strings.Index(raw, ","); commaIdx > 0 && commaIdx <= 3 { - noWeekday := strings.TrimSpace(raw[commaIdx+1:]) - for _, layout := range []string{ - "2 Jan 2006 15:04:05 -0700", - "02 Jan 2006 15:04:05 -0700", - "2 Jan 2006 15:04:05 -07:00", - "02 Jan 2006 15:04:05 -07:00", - "2 Jan 2006 15:04:05 MST", - "02 Jan 2006 15:04:05 MST", - "2 Jan 2006 15:04 -0700", - "02 Jan 2006 15:04 -0700", - } { - if t, err := time.Parse(layout, noWeekday); err == nil { - pm.Date = t - parsed = true - break - } - } - } - } - if !parsed { - // Leave pm.Date as zero — storage will use DB DEFAULT NOW() - pm.Date = time.Time{} - } + // Leave pm.Date as zero if nothing parsed — storage will use DB + // DEFAULT NOW() / EffectiveDate's caller-supplied fallback. } // Parse body / MIME parts