Skip to main content

sioul_core/
headers.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The raw header block, unfolded (RFC 5322 §2.2.3).
5//!
6//! mail-parser decodes what people read: names, subjects, bodies. The headers
7//! servers write to record a message's journey (Authentication-Results,
8//! Received, the spam verdicts) are read here from the raw block instead,
9//! because they repeat and their order matters: each server adds its own on
10//! top (RFC 5321 §4.4).
11
12/// Header fields in the order they appear, names as written, values unfolded.
13#[derive(Debug, Default, Clone)]
14pub struct RawHeaders {
15    fields: Vec<(String, String)>,
16}
17
18impl RawHeaders {
19    /// Reads the header block of a raw message.
20    pub fn parse(raw: &[u8]) -> Self {
21        let text = String::from_utf8_lossy(header_block(raw));
22        let mut fields: Vec<(String, String)> = Vec::new();
23        for line in text.split('\n') {
24            push_line(&mut fields, line.strip_suffix('\r').unwrap_or(line));
25        }
26        RawHeaders { fields }
27    }
28
29    /// Every value of a header, top to bottom (newest server first).
30    pub fn all<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a str> + use<'a> {
31        let name = name.to_string();
32        self.fields
33            .iter()
34            .filter(move |(n, _)| n.eq_ignore_ascii_case(&name))
35            .map(|(_, v)| v.as_str())
36    }
37
38    /// The topmost value of a header.
39    pub fn first(&self, name: &str) -> Option<&str> {
40        self.all(name).next()
41    }
42
43    /// Whether the header is present at all.
44    pub fn has(&self, name: &str) -> bool {
45        self.first(name).is_some()
46    }
47
48    /// Every field, top to bottom: its name as written, its value unfolded.
49    pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
50        self.fields.iter().map(|(name, value)| (name.as_str(), value.as_str()))
51    }
52
53    /// The `n` topmost fields alone: what servers added above a given one.
54    pub fn top(&self, n: usize) -> RawHeaders {
55        RawHeaders { fields: self.fields.iter().take(n).cloned().collect() }
56    }
57}
58
59/// The header block ends at the first empty line (RFC 5322 §2.1), whatever
60/// ends the lines: a body's own blank line further down never extends it, so
61/// the body cannot pass for headers (an Authentication-Results among them).
62fn header_block(raw: &[u8]) -> &[u8] {
63    let mut start = 0;
64    for line in raw.split(|&b| b == b'\n') {
65        if line.is_empty() || line == b"\r" {
66            return &raw[..start];
67        }
68        start += line.len() + 1;
69    }
70    raw
71}
72
73/// A line starting with a space or a tab continues the previous field (folding).
74fn push_line(fields: &mut Vec<(String, String)>, line: &str) {
75    let continues = line.starts_with(' ') || line.starts_with('\t');
76    if let (true, Some(last)) = (continues, fields.last_mut()) {
77        last.1.push(' ');
78        last.1.push_str(line.trim());
79    } else if let Some((name, value)) = line.split_once(':') {
80        fields.push((name.trim().to_string(), value.trim().to_string()));
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn unfolds_and_keeps_order() {
90        let raw = b"Received: from a\r\n by b\r\nReceived: from c\r\nSubject: hi\r\n\r\nbody: not a header\r\n";
91        let h = RawHeaders::parse(raw);
92        let received: Vec<&str> = h.all("received").collect();
93        assert_eq!(received, vec!["from a by b", "from c"]);
94        assert_eq!(h.first("Subject"), Some("hi"));
95        assert!(!h.has("body"));
96        // Lines ended by LF alone, and a body holding a CRLF blank line: still the body.
97        let raw = b"Subject: hi\n\nAuthentication-Results: mx.example.net; dmarc=pass\r\n\r\nbye\r\n";
98        assert!(!RawHeaders::parse(raw).has("Authentication-Results"));
99    }
100}