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:
co-authored by
Claude Sonnet 5
parent
e1f39ce56f
commit
160b28099e
+85
-59
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user