package imapclient // HEADER PARSING. This is the only part of this package that touches text a stranger controls, because // anybody can send a recruiter an email and choose exactly what these headers say. // // EVERY FUNCTION HERE IS TOTAL. There is no input that panics, no input that loops forever, and no input that // allocates without bound. A malformed header yields an empty field and the message is skipped; it never // yields an error that stops the sync, because one hostile message must not be able to stop a recruiter's // entire mailbox from logging. import ( "strings" "time" ) // parseFetch turns raw FETCH response lines into headers. Untagged responses interleave, continuation lines // are folded, and literals are announced with a byte count that this deliberately does not trust: the parser // works on the lines it was given and stops at the next response boundary. func parseFetch(lines []string) []Header { var out []Header var cur []string inMsg := false flush := func() { if len(cur) > 0 { if h, ok := parseHeaderBlock(cur); ok { out = append(out, h) } cur = nil } } for _, l := range lines { // A new FETCH response begins a new message. if strings.HasPrefix(l, "* ") && strings.Contains(l, "FETCH") { flush() inMsg = true continue } if !inMsg { continue } // A bare closing paren ends the literal for this message. if strings.TrimSpace(l) == ")" { flush() inMsg = false continue } cur = append(cur, l) // A single message's header block is bounded. A server claiming a million header lines for one // message is either broken or hostile, and either way the answer is to stop reading it. if len(cur) > 400 { flush() inMsg = false } } flush() return out } // parseHeaderBlock reads one RFC 5322 header block. It returns ok=false when there is no Message-ID, because // a message with no identifier cannot be deduplicated and logging it would mean logging it again on every // subsequent sync. func parseHeaderBlock(lines []string) (Header, bool) { var h Header fields := map[string]string{} key := "" for _, raw := range lines { if strings.TrimSpace(raw) == "" { continue } // A continuation line begins with whitespace and belongs to the previous field. This is header // folding, and getting it wrong truncates long recipient lists at the first wrap. if raw[0] == ' ' || raw[0] == '\t' { if key != "" { fields[key] += " " + strings.TrimSpace(raw) } continue } i := strings.IndexByte(raw, ':') if i <= 0 { continue } key = strings.ToLower(strings.TrimSpace(raw[:i])) fields[key] = strings.TrimSpace(raw[i+1:]) } h.MessageID = strings.Trim(strings.TrimSpace(fields["message-id"]), "<>") if h.MessageID == "" || len(h.MessageID) > 400 { return h, false } h.Subject = clip(decodeIfEncoded(fields["subject"]), 300) h.From = firstAddress(fields["from"]) h.To = addresses(fields["to"]) h.Cc = addresses(fields["cc"]) h.Date = parseDate(fields["date"]) return h, true } // addresses extracts the bare addresses from a header value, discarding display names. Bounded, because a // header can legitimately list hundreds of recipients and a hostile one can list a great many more. func addresses(v string) []string { if v == "" { return nil } out := make([]string, 0, 8) seen := map[string]bool{} for _, part := range splitAddressList(v) { a := bareAddress(part) if a == "" || seen[a] { continue } seen[a] = true out = append(out, a) if len(out) == 64 { break } } return out } func firstAddress(v string) string { for _, part := range splitAddressList(v) { if a := bareAddress(part); a != "" { return a } } return "" } // splitAddressList splits on commas that are NOT inside a quoted display name. A recruiter's contact list is // full of names like "Smith, Jane" , and splitting naively on every comma turns one recipient // into two, one of which is garbage. func splitAddressList(v string) []string { var parts []string var b strings.Builder inQuote := false for i := 0; i < len(v); i++ { switch c := v[i]; { case c == '"': inQuote = !inQuote b.WriteByte(c) case c == ',' && !inQuote: parts = append(parts, b.String()) b.Reset() if len(parts) > 200 { return parts } default: b.WriteByte(c) } } if b.Len() > 0 { parts = append(parts, b.String()) } return parts } // bareAddress pulls the address out of one list entry, in either form: angle bracketed after a display name, // or bare. It lowercases, because address comparison is how a message is matched to a person and case is not // meaningful for the domain and is conventionally ignored for the local part. func bareAddress(s string) string { s = strings.TrimSpace(s) if i := strings.LastIndexByte(s, '<'); i >= 0 { if j := strings.IndexByte(s[i:], '>'); j > 0 { s = s[i+1 : i+j] } } s = strings.TrimSpace(strings.Trim(s, "<>")) s = strings.ToLower(s) at := strings.IndexByte(s, '@') if at <= 0 || at == len(s)-1 || len(s) > 320 || strings.ContainsAny(s, " \t\"") { return "" } return s } // parseDate accepts the RFC 5322 forms that appear in the wild. A date that will not parse yields the zero // time rather than an error, and the caller falls back to the time of the sync, because a message with an // unreadable Date is still a message that happened. func parseDate(v string) time.Time { v = strings.TrimSpace(v) if v == "" { return time.Time{} } // A trailing parenthesised zone comment, "(UTC)", is common and breaks strict parsing. if i := strings.IndexByte(v, '('); i > 0 { v = strings.TrimSpace(v[:i]) } for _, layout := range []string{ time.RFC1123Z, time.RFC1123, "2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 MST", "2 Jan 2006 15:04:05 MST", "Mon, 2 Jan 2006 15:04 -0700", } { if t, err := time.Parse(layout, v); err == nil { return t } } return time.Time{} } // decodeIfEncoded handles the one encoded word case common enough to matter, and otherwise returns the text // unchanged. It deliberately does NOT implement RFC 2047 fully: a subject line is a label on an activity row, // and a partially decoded subject is a cosmetic problem while a decoder with a bug in it is a parser bug in // the one place stranger controlled text arrives. func decodeIfEncoded(s string) string { s = strings.TrimSpace(s) if !strings.HasPrefix(s, "=?") { return s } // Strip the encoding wrapper and return the readable remainder rather than attempting a decode. if i := strings.LastIndex(s, "?="); i > 0 { inner := s[:i] if j := strings.LastIndexByte(inner, '?'); j > 0 { return strings.TrimSpace(strings.ReplaceAll(inner[j+1:], "_", " ")) } } return s }