Skip to main content

lean_ctx/core/extractors/
eml.rs

1//! RFC-822/2822 email (`.eml`) → header summary + body text (EPIC 12.13).
2//!
3//! Splits the message into the header block and body at the first blank line,
4//! unfolds multi-line headers, surfaces the salient headers (From/To/Subject/
5//! Date), and — for `multipart/*` — extracts the `text/plain` parts so an LLM
6//! sees the message, not MIME boilerplate. Degrades gracefully: input without a
7//! header/body split is treated as a body so non-empty input is never dropped.
8
9/// A parsed email: salient headers (in order) and decoded body text.
10#[derive(Debug, Clone, Default)]
11pub struct Email {
12    pub headers: Vec<(String, String)>,
13    pub body: String,
14}
15
16const SALIENT: [&str; 5] = ["from", "to", "cc", "subject", "date"];
17
18/// Parse an `.eml` document into salient headers + a text body.
19#[must_use]
20pub fn parse(input: &str) -> Email {
21    let normalized = input.replace("\r\n", "\n");
22    let (header_block, body_block) = match normalized.split_once("\n\n") {
23        Some((h, b)) if looks_like_headers(h) => (h, b),
24        // No header/body split (or block isn't headers): treat all as body.
25        _ => ("", normalized.as_str()),
26    };
27
28    let all_headers = parse_headers(header_block);
29    let content_type = header_value(&all_headers, "content-type").unwrap_or_default();
30
31    let body = if content_type.contains("multipart/") {
32        extract_plain_parts(&content_type, body_block)
33    } else {
34        body_block.trim().to_string()
35    };
36
37    let headers = all_headers
38        .into_iter()
39        .filter(|(k, _)| SALIENT.contains(&k.to_ascii_lowercase().as_str()))
40        .collect();
41
42    Email { headers, body }
43}
44
45/// Render the email as a header summary followed by the body.
46#[must_use]
47pub fn to_text(input: &str) -> String {
48    let email = parse(input);
49    let mut out = String::new();
50    for (k, v) in &email.headers {
51        out.push_str(&format!("{k}: {v}\n"));
52    }
53    if !email.headers.is_empty() && !email.body.is_empty() {
54        out.push('\n');
55    }
56    out.push_str(&email.body);
57    out.trim().to_string()
58}
59
60/// Chunks: the header summary as one chunk, then body paragraphs.
61#[must_use]
62pub fn chunks(input: &str) -> Vec<String> {
63    let email = parse(input);
64    let mut out = Vec::new();
65    if !email.headers.is_empty() {
66        let header_block = email
67            .headers
68            .iter()
69            .map(|(k, v)| format!("{k}: {v}"))
70            .collect::<Vec<_>>()
71            .join("\n");
72        out.push(header_block);
73    }
74    out.extend(super::paragraph_chunks(&email.body));
75    out.retain(|c| !c.trim().is_empty());
76    if out.is_empty() {
77        let trimmed = input.trim();
78        if !trimmed.is_empty() {
79            out.push(trimmed.to_string());
80        }
81    }
82    out
83}
84
85/// A header block looks like headers if its first non-empty line is `Name: …`.
86fn looks_like_headers(block: &str) -> bool {
87    block
88        .lines()
89        .find(|l| !l.trim().is_empty())
90        .is_some_and(|l| {
91            l.split_once(':')
92                .is_some_and(|(name, _)| !name.is_empty() && name.chars().all(is_header_name_char))
93        })
94}
95
96fn is_header_name_char(c: char) -> bool {
97    c.is_ascii_alphanumeric() || c == '-'
98}
99
100/// Parse a header block, unfolding continuation lines (leading whitespace).
101fn parse_headers(block: &str) -> Vec<(String, String)> {
102    let mut headers: Vec<(String, String)> = Vec::new();
103    for line in block.lines() {
104        if line.starts_with([' ', '\t']) {
105            if let Some(last) = headers.last_mut() {
106                last.1.push(' ');
107                last.1.push_str(line.trim());
108            }
109        } else if let Some((name, value)) = line.split_once(':') {
110            headers.push((name.trim().to_string(), value.trim().to_string()));
111        }
112    }
113    headers
114}
115
116fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
117    headers
118        .iter()
119        .find(|(k, _)| k.eq_ignore_ascii_case(name))
120        .map(|(_, v)| v.clone())
121}
122
123/// Extract concatenated `text/plain` parts from a multipart body.
124fn extract_plain_parts(content_type: &str, body: &str) -> String {
125    let Some(boundary) = boundary_of(content_type) else {
126        return body.trim().to_string();
127    };
128    let sep = format!("--{boundary}");
129    let mut parts = Vec::new();
130    for raw in body.split(&sep) {
131        let part = raw.trim_start_matches('\n');
132        let Some((part_headers, part_body)) = part.split_once("\n\n") else {
133            continue;
134        };
135        let ct = part_headers.to_ascii_lowercase();
136        if ct.contains("text/plain")
137            || (!ct.contains("content-type") && !part_body.trim().is_empty())
138        {
139            let text = part_body.trim();
140            if !text.is_empty() {
141                parts.push(text.to_string());
142            }
143        }
144    }
145    if parts.is_empty() {
146        body.trim().to_string()
147    } else {
148        parts.join("\n\n")
149    }
150}
151
152fn boundary_of(content_type: &str) -> Option<String> {
153    let lower = content_type.to_ascii_lowercase();
154    let idx = lower.find("boundary=")?;
155    let rest = &content_type[idx + "boundary=".len()..];
156    let rest = rest.trim();
157    let b = rest
158        .strip_prefix('"')
159        .and_then(|r| r.split('"').next())
160        .unwrap_or_else(|| rest.split([';', ' ', '\n']).next().unwrap_or(rest));
161    (!b.is_empty()).then(|| b.to_string())
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn parses_headers_and_body() {
170        let eml = "From: a@x.com\nTo: b@y.com\nSubject: Hi\n\nHello there.\n";
171        let email = parse(eml);
172        assert_eq!(header_value(&email.headers, "subject").unwrap(), "Hi");
173        assert_eq!(email.body, "Hello there.");
174    }
175
176    #[test]
177    fn unfolds_continuation_headers() {
178        let eml = "Subject: a very\n long subject\n\nbody";
179        let email = parse(eml);
180        assert_eq!(
181            header_value(&email.headers, "subject").unwrap(),
182            "a very long subject"
183        );
184    }
185
186    #[test]
187    fn extracts_plain_from_multipart() {
188        let eml = "Content-Type: multipart/alternative; boundary=\"BB\"\n\n--BB\nContent-Type: text/plain\n\nplain body\n--BB\nContent-Type: text/html\n\n<p>html</p>\n--BB--";
189        let email = parse(eml);
190        assert!(email.body.contains("plain body"));
191        assert!(!email.body.contains("<p>"));
192    }
193
194    #[test]
195    fn plain_text_without_headers_is_body() {
196        let email = parse("just a single line");
197        assert!(email.headers.is_empty());
198        assert_eq!(email.body, "just a single line");
199        assert_eq!(chunks("just a single line"), vec!["just a single line"]);
200    }
201}