1use crate::compose::{address_of, split_addresses};
13use mail_parser::MessageParser;
14
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
17pub struct Mailto {
18 pub to: Vec<String>,
19 pub cc: Vec<String>,
20 pub bcc: Vec<String>,
21 pub subject: String,
22 pub body: String,
24 pub in_reply_to: Option<String>,
26}
27
28pub fn is_mailto(text: &str) -> bool {
30 text.trim_start().get(..7).is_some_and(|scheme| scheme.eq_ignore_ascii_case("mailto:"))
31}
32
33pub fn parse(uri: &str) -> Option<Mailto> {
35 let uri = uri.trim();
36 if !is_mailto(uri) {
37 return None;
38 }
39 let rest = uri[7..].split('#').next().unwrap_or_default();
41 let (to, fields) = rest.split_once('?').unwrap_or((rest, ""));
42 let mut mailto = Mailto::default();
43 mailto.to.extend(split_addresses(&one_line(&decode(to))));
44 for field in fields.split('&').filter(|f| !f.is_empty()) {
45 let (name, value) = field.split_once('=').unwrap_or((field, ""));
46 let value = decode(value);
47 match decode(name).trim().to_ascii_lowercase().as_str() {
48 "to" => mailto.to.extend(split_addresses(&header(&value))),
49 "cc" => mailto.cc.extend(split_addresses(&header(&value))),
50 "bcc" => mailto.bcc.extend(split_addresses(&header(&value))),
51 "subject" if mailto.subject.is_empty() => mailto.subject = header(&value).trim().to_string(),
52 "body" if mailto.body.is_empty() => mailto.body = value.replace("\r\n", "\n").replace('\r', "\n"),
54 "in-reply-to" if mailto.in_reply_to.is_none() => {
55 let id = one_line(&value).trim().to_string();
56 if id.starts_with('<') && id.ends_with('>') && id.len() > 2 {
57 mailto.in_reply_to = Some(id);
58 }
59 }
60 _ => {}
61 }
62 }
63 for list in [&mut mailto.to, &mut mailto.cc, &mut mailto.bcc] {
64 once_each(list);
65 }
66 Some(mailto)
67}
68
69pub fn once_each(list: &mut Vec<String>) {
72 let mut seen = std::collections::BTreeSet::new();
73 list.retain(|entry| seen.insert(address_of(entry).unwrap_or_else(|| entry.trim().to_string()).to_lowercase()));
74}
75
76pub fn decode(text: &str) -> String {
80 let bytes = text.as_bytes();
81 let mut out = Vec::with_capacity(bytes.len());
82 let mut i = 0;
83 while i < bytes.len() {
84 let hex = |b: u8| (b as char).to_digit(16);
85 if bytes[i] == b'%'
86 && let (Some(high), Some(low)) = (bytes.get(i + 1).copied().and_then(hex), bytes.get(i + 2).copied().and_then(hex))
87 {
88 out.push((high * 16 + low) as u8);
89 i += 3;
90 continue;
91 }
92 out.push(bytes[i]);
93 i += 1;
94 }
95 String::from_utf8_lossy(&out).into_owned()
96}
97
98fn one_line(text: &str) -> String {
100 text.replace(['\r', '\n'], " ")
101}
102
103fn header(value: &str) -> String {
107 let value = one_line(value);
108 if !value.contains("=?") {
109 return value;
110 }
111 let raw = format!("Subject: {value}\r\n\r\n");
112 MessageParser::default().parse(raw.as_bytes()).and_then(|m| m.subject().map(str::to_string)).unwrap_or(value)
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn to(uri: &str) -> Vec<String> {
120 parse(uri).expect("a mailto: address").to
121 }
122
123 #[test]
125 fn basic_examples() {
126 assert_eq!(to("mailto:chris@example.com"), ["chris@example.com"]);
127 assert_eq!(parse("mailto:infobot@example.com?subject=current-issue").unwrap().subject, "current-issue");
128 assert_eq!(parse("mailto:infobot@example.com?body=send%20current-issue").unwrap().body, "send current-issue");
129 assert_eq!(parse("mailto:infobot@example.com?body=send%20current-issue%0D%0Asend%20index").unwrap().body, "send current-issue\nsend index");
130 let answer = parse("mailto:list@example.org?In-Reply-To=%3C3469A91.D10AF4C@example.com%3E").unwrap();
131 assert_eq!((answer.to.as_slice(), answer.in_reply_to.as_deref()), (&["list@example.org".to_string()][..], Some("<3469A91.D10AF4C@example.com>")));
132 assert_eq!(parse("mailto:majordomo@example.com?body=subscribe%20bamboo-l").unwrap().body, "subscribe bamboo-l");
133 let copy = parse("mailto:joe@example.com?cc=bob@example.com&body=hello").unwrap();
134 assert_eq!((copy.to, copy.cc, copy.body.as_str()), (vec!["joe@example.com".to_string()], vec!["bob@example.com".to_string()], "hello"));
135 assert_eq!(parse("mailto:joe@example.com?cc=bob@example.com?body=hello").unwrap().cc, ["bob@example.com?body=hello"]);
137 assert_eq!(to("mailto:gorby%25kremvax@example.com"), ["gorby%kremvax@example.com"]);
138 let unlikely = parse("mailto:unlikely%3Faddress@example.com?blat=foop").unwrap();
139 assert_eq!((unlikely.to.as_slice(), unlikely.subject.as_str(), unlikely.body.as_str()), (&["unlikely?address@example.com".to_string()][..], "", ""));
140 assert_eq!(to("mailto:Mike%26family@example.org"), ["Mike&family@example.org"]);
141 }
142
143 #[test]
145 fn quoted_local_parts() {
146 assert_eq!(to("mailto:%22not%40me%22@example.org"), ["\"not@me\"@example.org"]);
147 assert_eq!(to("mailto:%22oh%5C%5Cno%22@example.org"), ["\"oh\\\\no\"@example.org"]);
148 assert_eq!(to("mailto:%22%5C%5C%5C%22it's%5C%20ugly%5C%5C%5C%22%22@example.org"), ["\"\\\\\\\"it's\\ ugly\\\\\\\"\"@example.org"]);
149 }
150
151 #[test]
153 fn beyond_ascii() {
154 assert_eq!(parse("mailto:user@example.org?subject=caf%C3%A9").unwrap().subject, "café");
155 assert_eq!(parse("mailto:user@example.org?subject=%3D%3Futf-8%3FQ%3Fcaf%3DC3%3DA9%3F%3D").unwrap().subject, "café");
156 assert_eq!(parse("mailto:user@example.org?subject=%3D%3Fiso-8859-1%3FQ%3Fcaf%3DE9%3F%3D").unwrap().subject, "café");
157 let both = parse("mailto:user@example.org?subject=caf%C3%A9&body=caf%C3%A9").unwrap();
158 assert_eq!((both.subject.as_str(), both.body.as_str()), ("café", "café"));
159 let natto = parse("mailto:user@%E7%B4%8D%E8%B1%86.example.org?subject=Test&body=NATTO").unwrap();
160 assert_eq!((natto.to.as_slice(), natto.subject.as_str(), natto.body.as_str()), (&["user@納豆.example.org".to_string()][..], "Test", "NATTO"));
161 assert_eq!(parse("mailto:x@example.org?body=%3D%3Futf-8%3FQ%3Fcaf%3DC3%3DA9%3F%3D").unwrap().body, "=?utf-8?Q?caf=C3=A9?=");
163 assert_eq!(parse("mailto:x@example.org?subject=caf%E9").unwrap().subject, "caf\u{FFFD}");
165 }
166
167 #[test]
169 fn recipients_in_the_path_and_the_fields() {
170 let two = ["addr1@an.example".to_string(), "addr2@an.example".to_string()];
171 assert_eq!(to("mailto:addr1@an.example,addr2@an.example"), two);
172 assert_eq!(to("mailto:?to=addr1@an.example,addr2@an.example"), two);
173 assert_eq!(to("mailto:addr1@an.example?to=addr2@an.example"), two);
174 assert_eq!(to("mailto:a@example.org?to=A@EXAMPLE.ORG,Ann%20%3Ca@example.org%3E,b@example.org"), ["a@example.org", "b@example.org"]);
176 assert_eq!(parse("mailto:?cc=%22Doe%2C%20Jane%22%20%3Cjane@example.org%3E").unwrap().cc, ["\"Doe, Jane\" <jane@example.org>"]);
178 assert_eq!(parse("mailto:?bcc=%3D%3Futf-8%3FQ%3FJos%3DC3%3DA9%3F%3D%20%3Cjose@example.org%3E").unwrap().bcc, ["José <jose@example.org>"]);
179 }
180
181 #[test]
182 fn what_is_left_out() {
183 let plus = parse("MAILTO:a+tag@example.org?SUBJECT=1+1&Body=a+b").unwrap();
185 assert_eq!((plus.to.as_slice(), plus.subject.as_str(), plus.body.as_str()), (&["a+tag@example.org".to_string()][..], "1+1", "a+b"));
186 let sly = parse("mailto:x@example.org?attach=/home/me/.ssh/id_rsa&attachment=file:///etc/passwd&from=boss@example.org&reply-to=y@example.org").unwrap();
188 assert_eq!(sly, Mailto { to: vec!["x@example.org".to_string()], ..Mailto::default() });
189 assert_eq!(parse("mailto:x@example.org?subject=a%0D%0ABcc:%20y@example.org#top").unwrap().subject, "a Bcc: y@example.org");
191 assert_eq!(to("mailto:x@example.org#top"), ["x@example.org"]);
192 let twice = parse("mailto:?subject=one&subject=two&body=first&body=second&in-reply-to=nobrackets@example.org").unwrap();
194 assert_eq!((twice.subject.as_str(), twice.body.as_str(), twice.in_reply_to), ("one", "first", None));
195 assert_eq!(parse("mailto:"), Some(Mailto::default()));
197 assert_eq!(parse("https://example.org/?to=x@example.org"), None);
198 assert_eq!(parse("mail"), None);
199 assert!(is_mailto(" MailTo:x@example.org") && !is_mailto("mailto"));
200 assert_eq!(decode("100%"), "100%");
202 assert_eq!(decode("%zz%4"), "%zz%4");
203 }
204}