use anyhow::{Result, anyhow};
use mail_parser::{HeaderValue, MessageParser};
use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug)]
#[allow(dead_code)]
pub struct PatchsetMetadata {
pub message_id: String,
pub subject: String,
pub author: String,
pub date: i64,
pub received_date: Option<i64>,
pub in_reply_to: Option<String>,
pub references: Vec<String>,
pub index: u32,
pub total: u32,
pub to: String,
pub cc: String,
pub is_patch_or_cover: bool,
pub version: Option<u32>,
pub body: String,
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct Patch {
pub message_id: String,
pub body: String,
pub diff: String,
pub part_index: u32,
}
pub fn extract_received_date(raw_email: &[u8]) -> Option<i64> {
let header_end = raw_email
.windows(4)
.position(|w| w == b"\r\n\r\n")
.unwrap_or(raw_email.len());
let headers_bytes = &raw_email[..header_end];
let headers_str = String::from_utf8_lossy(headers_bytes);
let mut current_header = String::new();
let mut in_received = false;
for line in headers_str.lines() {
if line.starts_with("Received:") {
if in_received {
break;
}
in_received = true;
current_header.push_str(line);
} else if in_received && (line.starts_with(' ') || line.starts_with('\t')) {
current_header.push_str(line);
} else if in_received {
break;
}
}
if current_header.is_empty() {
return None;
}
if let Some(semi_idx) = current_header.rfind(';') {
let date_str = current_header[semi_idx + 1..].trim();
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(date_str) {
return Some(dt.timestamp());
}
}
None
}
pub fn parse_email(raw_email: &[u8]) -> Result<(PatchsetMetadata, Option<Patch>)> {
let received_date = extract_received_date(raw_email);
let message = MessageParser::default()
.parse(raw_email)
.ok_or_else(|| anyhow!("Failed to parse email"))?;
let message_id = message
.message_id()
.ok_or_else(|| anyhow!("No Message-ID header"))?
.to_string();
let subject = message.subject().unwrap_or("(no subject)").to_string();
let author = message
.from()
.and_then(|addr| addr.first())
.map(|a| {
let name = a.name().unwrap_or_default().trim();
let address = a.address().unwrap_or("unknown@localhost").trim();
let addr = if address.is_empty() || !address.contains('@') {
"unknown@localhost"
} else {
address
};
if name.is_empty() || name.to_lowercase() == "unknown" {
addr.to_string()
} else {
format!("{} <{}>", name, addr)
}
})
.unwrap_or_else(|| "unknown@localhost".to_string());
let date = message.date().map(|d| d.to_timestamp()).unwrap_or(0);
let to = message
.to()
.map(|addr| {
addr.iter()
.filter_map(|a| {
let address = a.address().unwrap_or("").trim();
if address.is_empty() || !address.contains('@') {
None
} else {
let name = a.name().unwrap_or_default().trim();
if name.is_empty() {
Some(address.to_string())
} else {
Some(format!("\"{}\" <{}>", name.replace("\"", "\\\""), address))
}
}
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let cc = message
.cc()
.map(|addr| {
addr.iter()
.filter_map(|a| {
let address = a.address().unwrap_or("").trim();
if address.is_empty() || !address.contains('@') {
None
} else {
let name = a.name().unwrap_or_default().trim();
if name.is_empty() {
Some(address.to_string())
} else {
Some(format!("\"{}\" <{}>", name.replace("\"", "\\\""), address))
}
}
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let in_reply_to = match message.in_reply_to() {
HeaderValue::Text(t) => Some(t.to_string()),
HeaderValue::TextList(l) => l.first().map(|s| s.to_string()),
_ => None,
};
let references = match message.references() {
HeaderValue::Text(t) => vec![t.to_string()],
HeaderValue::TextList(l) => l.iter().map(|s| s.to_string()).collect(),
_ => vec![],
};
let (index, total) = parse_subject_index(&subject);
let version = parse_subject_version(&subject);
let mut body = String::new();
for i in 0..message.text_body_count() {
if let Some(text) = message.body_text(i) {
if !body.is_empty() {
body.push('\n');
}
body.push_str(&text);
}
}
let diff = if body.contains("diff --git")
|| (body.contains("--- ") && body.contains("+++ ") && body.contains("@@ -"))
{
body.clone()
} else {
String::new()
};
let subject_lower = subject.to_lowercase();
let subject_clean = subject_lower.trim();
let is_reply = subject_clean.starts_with("re:")
|| subject_clean.starts_with("fwd:")
|| subject_clean.starts_with("forwarded:")
|| subject_clean.starts_with("aw:") || subject_clean.starts_with("wg:") || subject_clean.starts_with("回复:") || subject_clean.starts_with("回复:") || subject_clean.starts_with("答复:") || subject_clean.starts_with("答复:") || subject_clean.starts_with("[reproducer]") || subject_lower.contains("(was ")
|| subject_lower.contains("(was:");
let has_patch_tag = subject_clean.contains("patch") || subject_clean.contains("rfc");
let has_diff = !diff.is_empty();
let is_series_metadata = total > 1 || index == 0;
let is_patch_or_cover = !is_reply && (has_patch_tag || has_diff || is_series_metadata);
let metadata = PatchsetMetadata {
message_id: message_id.clone(),
subject,
author,
date,
received_date,
in_reply_to,
references,
index,
total,
to,
cc,
is_patch_or_cover,
version,
body: body.clone(),
};
let patch = if has_diff && index != 0 {
Some(Patch {
message_id,
body,
diff,
part_index: index,
})
} else {
None
};
Ok((metadata, patch))
}
fn parse_subject_index(subject: &str) -> (u32, u32) {
static RE_BRACKETS: OnceLock<Regex> = OnceLock::new();
let re_brackets = RE_BRACKETS.get_or_init(|| {
Regex::new(r"(?i)\[.*?\b(?:PATCH|RFC|RESEND|v\d+)\b.*?(\d+)/(\d+).*?\]").unwrap()
});
if let Some(caps) = re_brackets.captures(subject)
&& let (Some(i), Some(t)) = (caps.get(1), caps.get(2))
{
let index = i.as_str().parse().unwrap_or(1);
let total = t.as_str().parse().unwrap_or(1);
return (index, total);
}
static RE_LOOSE: OnceLock<Regex> = OnceLock::new();
let re_loose =
RE_LOOSE.get_or_init(|| Regex::new(r"(?i)\b(?:PATCH|RFC|RESEND)\s+(\d+)/(\d+)\b").unwrap());
if let Some(caps) = re_loose.captures(subject)
&& let (Some(i), Some(t)) = (caps.get(1), caps.get(2))
{
let index = i.as_str().parse().unwrap_or(1);
let total = t.as_str().parse().unwrap_or(1);
return (index, total);
}
let cleaned = clean_subject(subject);
static RE_START: OnceLock<Regex> = OnceLock::new();
let re_start = RE_START.get_or_init(|| Regex::new(r"^\s*(\d+)/(\d+)\b").unwrap());
if let Some(caps) = re_start.captures(&cleaned)
&& let (Some(i), Some(t)) = (caps.get(1), caps.get(2))
{
let index = i.as_str().parse().unwrap_or(1);
let total = t.as_str().parse().unwrap_or(1);
return (index, total);
}
(1, 1)
}
pub fn parse_subject_version(subject: &str) -> Option<u32> {
static RE_VER: OnceLock<Regex> = OnceLock::new();
let re = RE_VER.get_or_init(|| {
Regex::new(r"(?i)(?:\[[^\]]*?\bv(\d+)\b[^\]]*?\]|^\s*v(\d+)\b|PATCH\W*v(\d+)\b)").unwrap()
});
if let Some(caps) = re.captures(subject) {
if let Some(m) = caps.get(1) {
return m.as_str().parse().ok();
}
if let Some(m) = caps.get(2) {
return m.as_str().parse().ok();
}
if let Some(m) = caps.get(3) {
return m.as_str().parse().ok();
}
}
None
}
pub fn get_subject_prefixes(subject: &str) -> Vec<String> {
static RE_BRACKETS: OnceLock<Regex> = OnceLock::new();
let re = RE_BRACKETS.get_or_init(|| Regex::new(r"\[(.*?)\]").unwrap());
let mut prefixes = Vec::new();
for cap in re.captures_iter(subject) {
if let Some(content) = cap.get(1) {
let tokens: Vec<&str> = content
.as_str()
.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '.' && c != '_')
.filter(|s| !s.is_empty())
.collect();
for token in tokens {
let lower = token.to_lowercase();
if lower == "patch" || lower == "rfc" || lower == "resend" {
continue;
}
if lower.starts_with('v') && lower[1..].chars().all(|c| c.is_ascii_digit()) {
continue;
}
if token.chars().all(|c| c.is_ascii_digit()) {
continue;
}
prefixes.push(token.to_string());
}
}
}
prefixes.sort();
prefixes.dedup();
prefixes
}
pub fn clean_subject(subject: &str) -> String {
static RE_BRACKETS: OnceLock<Regex> = OnceLock::new();
let re = RE_BRACKETS.get_or_init(|| Regex::new(r"\[.*?\]").unwrap());
let no_brackets = re.replace_all(subject, "");
let mut cleaned = no_brackets.trim().to_string();
let prefixes = ["re:", "fwd:", "aw:", "forwarded:", "回复:", "回复:"];
let mut changed = true;
while changed {
changed = false;
let lower = cleaned.to_lowercase();
for prefix in &prefixes {
if lower.starts_with(prefix)
&& let Some(rest) = cleaned.get(prefix.len()..)
{
cleaned = rest.trim().to_string();
changed = true;
break;
}
}
}
cleaned
}
pub fn extract_email(author: &str) -> String {
if let Some(start) = author.find('<')
&& let Some(end) = author.find('>')
&& end > start
{
return author[start + 1..end].trim().to_string();
}
author.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_email() {
assert_eq!(
extract_email("Name <email@example.com>"),
"email@example.com"
);
assert_eq!(extract_email("email@example.com"), "email@example.com");
assert_eq!(extract_email(" <email@example.com> "), "email@example.com");
assert_eq!(
extract_email("Name < email@example.com >"),
"email@example.com"
);
assert_eq!(extract_email("Invalid < Format"), "Invalid < Format");
}
#[test]
fn test_extract_received_date() {
let email = b"Received: from mail.example.com ([192.0.2.1])\r\n \
by mail.example.org with ESMTPS ;\r\n \
Tue, 12 May 2026 00:12:30 +0000\r\n\
\r\n\
Body";
let expected = chrono::DateTime::parse_from_rfc2822("Tue, 12 May 2026 00:12:30 +0000")
.unwrap()
.timestamp();
assert_eq!(extract_received_date(email), Some(expected));
let email_no_received = b"Subject: Test\r\n\
\r\n\
Body";
assert_eq!(extract_received_date(email_no_received), None);
let email_malformed_date = b"Received: from ... ; Invalid Date\r\n\
\r\n\
Body";
assert_eq!(extract_received_date(email_malformed_date), None);
}
#[test]
fn test_clean_subject() {
assert_eq!(clean_subject("[PATCH] Fix bug"), "Fix bug");
assert_eq!(clean_subject("[PATCH v2] Fix bug"), "Fix bug");
assert_eq!(clean_subject("[PATCH 1/2] Fix bug"), "Fix bug");
assert_eq!(clean_subject("Re: [PATCH] Fix bug"), "Fix bug");
assert_eq!(clean_subject("[PATCH] Re: Fix bug"), "Fix bug"); assert_eq!(clean_subject("Subject only"), "Subject only");
assert_eq!(
clean_subject("[RFC] [PATCH v3] Complex subject"),
"Complex subject"
);
}
#[test]
fn test_clean_subject_chinese() {
assert_eq!(clean_subject("回复: [PATCH] Fix bug"), "Fix bug");
assert_eq!(clean_subject("回复:[PATCH] Fix bug"), "Fix bug");
assert_eq!(clean_subject("回复:回复:[PATCH] Fix bug"), "Fix bug");
}
#[test]
fn test_chinese_reply() {
let raw =
b"Message-ID: <reply>\r\nSubject: \xE5\x9B\x9E\xE5\xA4\x8D: [PATCH] fix\r\n\r\nBody";
let (meta, _) = parse_email(raw).unwrap();
assert!(
!meta.is_patch_or_cover,
"Chinese reply should not be a patchset"
);
}
#[test]
fn test_author_parsing() {
let raw =
b"Message-ID: <123>\r\nFrom: Test User <test@example.com>\r\nSubject: Test\r\n\r\nBody";
let (meta, _) = parse_email(raw).unwrap();
assert_eq!(meta.author, "Test User <test@example.com>");
let raw_no_name =
b"Message-ID: <456>\r\nFrom: test2@example.com\r\nSubject: Test\r\n\r\nBody";
let (meta2, _) = parse_email(raw_no_name).unwrap();
assert_eq!(meta2.author, "test2@example.com");
let raw_malformed =
b"Message-ID: <789>\r\nFrom: \"fqr\" <user.email>\r\nSubject: Test\r\n\r\nBody";
let (meta3, _) = parse_email(raw_malformed).unwrap();
assert_eq!(meta3.author, "fqr <unknown@localhost>");
}
#[test]
fn test_recipient_parsing() {
let raw = b"Message-ID: <1>\r\nFrom: a@b.com\r\nTo: \"Valid User\" <valid@example.com>, invalid_no_at, <another@test.com>\r\nCc: Bad <bad>, \"Good\" <good@example.com>\r\nSubject: Test\r\n\r\nBody";
let (meta, _) = parse_email(raw).unwrap();
assert_eq!(
meta.to,
"\"Valid User\" <valid@example.com>, another@test.com"
);
assert_eq!(meta.cc, "\"Good\" <good@example.com>");
}
#[test]
fn test_reply_with_diff_is_not_patchset() {
let raw = b"Message-ID: <123>\r\nSubject: Re: [PATCH] fix bug\r\n\r\n> diff --git a/file b/file\n> index...";
let (meta, _) = parse_email(raw).unwrap();
assert!(
!meta.is_patch_or_cover,
"Reply with diff should NOT be a patchset"
);
}
#[test]
fn test_diff_without_patch_tag_ignored() {
let raw = b"Message-ID: <diffnopatch>\r\nSubject: Random fix\r\n\r\ndiff --git a/file b/file\nindex...";
let (meta, _) = parse_email(raw).unwrap();
assert!(
meta.is_patch_or_cover,
"Diff without [PATCH] tag should still be parsed as patch"
);
}
#[test]
fn test_normal_patch() {
let raw = b"Message-ID: <456>\r\nSubject: [PATCH] fix bug\r\n\r\ndiff --git a/file b/file\nindex...";
let (meta, _) = parse_email(raw).unwrap();
assert!(meta.is_patch_or_cover);
}
#[test]
fn test_single_patch_no_diff_ignored() {
let raw =
b"Message-ID: <nonpatch>\r\nSubject: [PATCH] discussion\r\n\r\nThis is not a patch";
let (meta, _) = parse_email(raw).unwrap();
assert!(
meta.is_patch_or_cover,
"Single patch without diff should still be parsed due to [PATCH] tag"
);
}
#[test]
fn test_cover_letter() {
let raw = b"Message-ID: <789>\r\nSubject: [PATCH 0/5] fix bug\r\n\r\nCover letter body";
let (meta, patch) = parse_email(raw).unwrap();
assert!(meta.is_patch_or_cover);
assert!(patch.is_none());
}
#[test]
fn test_cover_letter_with_diff() {
let raw = b"Message-ID: <cover_with_diff>\r\nSubject: [PATCH 0/5] fix bug\r\n\r\nExplanation:\ndiff --git a/file b/file\nindex...";
let (meta, patch) = parse_email(raw).unwrap();
assert!(meta.is_patch_or_cover);
assert!(
patch.is_none(),
"Cover letter (index 0) with diff should NOT be a patch"
);
}
#[test]
fn test_pure_reply() {
let raw = b"Message-ID: <abc>\r\nSubject: Re: [PATCH] fix bug\r\n\r\nLGTM";
let (meta, _) = parse_email(raw).unwrap();
assert!(!meta.is_patch_or_cover);
}
#[test]
fn test_rfc_patch_parsing() {
let subject = "[RFC PATCH 1/3] My RFC";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 1);
assert_eq!(total, 3);
}
#[test]
fn test_version_parsing() {
assert_eq!(parse_subject_version("[PATCH v2] subject"), Some(2));
assert_eq!(parse_subject_version("[PATCH v3 1/2] subject"), Some(3));
assert_eq!(parse_subject_version("[PATCH] subject"), None); assert_eq!(parse_subject_version("[RFC v4] subject"), Some(4));
assert_eq!(parse_subject_version("[PATCH -v2] subject"), Some(2));
assert_eq!(parse_subject_version("Subject with v2 inside"), None); assert_eq!(parse_subject_version("Subject with devicetree"), None); assert_eq!(parse_subject_version("[PATCH 0/10]"), None); assert_eq!(parse_subject_version("[PATCH v12]"), Some(12));
assert_eq!(parse_subject_version("[PATCH V2 13/13]"), Some(2)); assert_eq!(parse_subject_version("[PATCH bpf-next v5 10/10]"), Some(5)); assert_eq!(parse_subject_version("[PATCH RFC v2 8/8]"), Some(2)); assert_eq!(parse_subject_version("[PATCHv5 2/2]"), Some(5)); assert_eq!(parse_subject_version("[PATCH 00/33 v6]"), Some(6)); assert_eq!(parse_subject_version("[v3 PATCH 1/1]"), Some(3));
assert_eq!(parse_subject_version("[PATCH] v3: subject"), Some(3));
}
#[test]
fn test_complex_prefix_parsing() {
let subject = "[PATCH v2 net-next 02/14] Something";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 2);
assert_eq!(total, 14);
}
#[test]
fn test_no_patch_prefix_parsing() {
let subject = "[RFC 1/2] Just RFC";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 1);
assert_eq!(total, 2);
}
#[test]
fn test_missed_cover_letter_parsing() {
let subject = "[PATCH 6.18 000/430] 6.18.3-rc1 review";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 0);
assert_eq!(total, 430);
let raw = format!("Message-ID: <123>\r\nSubject: {}\r\n\r\nBody", subject);
let (meta, _) = parse_email(raw.as_bytes()).unwrap();
assert!(meta.is_patch_or_cover, "Should be detected as patch/cover");
}
#[test]
fn test_forwarded_reply_is_not_patch() {
let subject = "Forwarded: Re: [syzbot] WARNING in cm109_urb_irq_callback";
let raw = format!(
"Message-ID: <456>\r\nSubject: {}\r\n\r\nDiff:\n--- a\n+++ b\n@@ -1 +1 @@",
subject
);
let (meta, _) = parse_email(raw.as_bytes()).unwrap();
assert!(
!meta.is_patch_or_cover,
"Forwarded Re: should not be a patchset"
);
}
#[test]
fn test_loose_patch_parsing() {
let subject = "PATCH 1/2: Subject";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 1);
assert_eq!(total, 2);
let subject = "1/2: Subject";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 1);
assert_eq!(total, 2);
let subject = "[PATCH 01/02] Subject";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 1);
assert_eq!(total, 2);
let subject = "[PATCH v3] serial: 8250_pci: Fix broken RS485 for F81504/508/512";
let (index, total) = parse_subject_index(subject);
assert_eq!(index, 1);
assert_eq!(total, 1); }
#[test]
fn test_get_subject_prefixes() {
assert_eq!(
get_subject_prefixes("[PATCH net-next 1/2]"),
vec!["net-next"]
);
assert_eq!(
get_subject_prefixes("[PATCH v2 bpf-next]"),
vec!["bpf-next"]
);
assert_eq!(get_subject_prefixes("[PATCH RFC]"), Vec::<String>::new());
assert_eq!(get_subject_prefixes("[PATCH 00/10]"), Vec::<String>::new()); assert_eq!(get_subject_prefixes("[PATCH 6.18]"), vec!["6.18"]);
assert_eq!(
get_subject_prefixes("[PATCH net-next v3 0/1]"),
vec!["net-next"]
);
assert_eq!(
get_subject_prefixes("[PATCH net-next,bpf 1/2]"),
vec!["bpf", "net-next"]
); assert_eq!(get_subject_prefixes("[PATCH]"), Vec::<String>::new());
}
}