lean_ctx/core/extractors/
eml.rs1#[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#[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 _ => ("", 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#[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#[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
85fn 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
100fn 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
123fn 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}