fix(PROJ-86): Date-Fallback auf Received-Header-Kette wenn Date-Header fehlt

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPFC6Jk2ke1Pq9XcuVGP1R
This commit is contained in:
sysops
2026-09-01 14:45:51 +02:00
co-authored by Claude Sonnet 5
parent e1f39ce56f
commit 160b28099e
+85 -59
View File
@@ -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