1#[derive(Debug, Default, Clone, PartialEq, Eq)]
8pub struct Mailto {
9 pub to: String,
10 pub cc: Option<String>,
11 pub bcc: Option<String>,
12 pub subject: String,
13 pub body: String,
14}
15
16pub fn parse(url: &str) -> Option<Mailto> {
20 let rest = url
21 .strip_prefix("mailto:")
22 .or_else(|| url.strip_prefix("MAILTO:"))?;
23 let (path, query) = match rest.split_once('?') {
24 Some((path, query)) => (path, Some(query)),
25 None => (rest, None),
26 };
27 let mut out = Mailto {
28 to: decode(path).trim().to_string(),
29 ..Default::default()
30 };
31 let mut extra_to: Vec<String> = Vec::new();
32 for pair in query.into_iter().flat_map(|q| q.split('&')) {
33 let Some((name, value)) = pair.split_once('=') else {
34 continue;
35 };
36 let value = decode(value);
37 match decode(name).to_ascii_lowercase().as_str() {
39 "to" if !value.trim().is_empty() => extra_to.push(value),
40 "cc" => out.cc = non_empty(value),
41 "bcc" => out.bcc = non_empty(value),
42 "subject" => out.subject = one_line(&value),
43 "body" => out.body = value,
44 _ => {}
45 }
46 }
47 for more in extra_to {
48 if out.to.is_empty() {
49 out.to = more;
50 } else {
51 out.to = format!("{}, {more}", out.to);
52 }
53 }
54 Some(out)
55}
56
57fn non_empty(value: String) -> Option<String> {
58 (!value.trim().is_empty()).then_some(value)
59}
60
61fn one_line(value: &str) -> String {
64 value.replace(['\r', '\n'], " ").trim().to_string()
65}
66
67fn decode(input: &str) -> String {
70 let bytes = input.as_bytes();
71 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
72 let mut i = 0;
73 while i < bytes.len() {
74 match bytes[i] {
75 b'+' => {
76 out.push(b' ');
77 i += 1;
78 }
79 b'%' if i + 2 < bytes.len() => {
80 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
81 match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
82 Some(byte) => {
83 out.push(byte);
84 i += 3;
85 }
86 None => {
87 out.push(bytes[i]);
88 i += 1;
89 }
90 }
91 }
92 byte => {
93 out.push(byte);
94 i += 1;
95 }
96 }
97 }
98 String::from_utf8_lossy(&out).into_owned()
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn plain_address() {
107 let m = parse("mailto:jane@example.com").unwrap();
108 assert_eq!(m.to, "jane@example.com");
109 assert_eq!(m.subject, "");
110 assert_eq!(m.body, "");
111 }
112
113 #[test]
114 fn query_headers_and_escapes() {
115 let m = parse(
116 "mailto:jane@example.com?subject=Lunch%20on%20Friday&cc=petr@example.com\
117 &body=Hi%20Jane%2C%0Aare%20you%20free%3F",
118 )
119 .unwrap();
120 assert_eq!(m.to, "jane@example.com");
121 assert_eq!(m.subject, "Lunch on Friday");
122 assert_eq!(m.cc.as_deref(), Some("petr@example.com"));
123 assert_eq!(m.body, "Hi Jane,\nare you free?");
124 assert_eq!(m.bcc, None);
125 }
126
127 #[test]
128 fn several_recipients_join() {
129 let m = parse("mailto:a@x,b@x?to=c@x").unwrap();
130 assert_eq!(m.to, "a@x,b@x, c@x");
131 let m = parse("mailto:?to=only@x").unwrap();
132 assert_eq!(m.to, "only@x");
133 }
134
135 #[test]
136 fn plus_is_a_space_and_bad_escapes_stay() {
137 let m = parse("mailto:a@x?subject=one+two%zz%2").unwrap();
138 assert_eq!(m.subject, "one two%zz%2");
139 }
140
141 #[test]
142 fn a_crafted_subject_cannot_add_headers() {
143 let m = parse("mailto:a@x?subject=hi%0ABcc:%20evil@x").unwrap();
144 assert!(!m.subject.contains('\n'), "{:?}", m.subject);
145 assert_eq!(m.subject, "hi Bcc: evil@x");
146 assert_eq!(m.bcc, None);
147 }
148
149 #[test]
150 fn not_a_mailto() {
151 assert_eq!(parse("https://example.com"), None);
152 assert_eq!(parse("/home/alex/Maildir"), None);
153 }
154}