Skip to main content

rmut_core/
mailto.rs

1//! RFC 6068 `mailto:` URLs, for `rmut mailto:...` as a desktop mail
2//! handler. Only the headers a compose window can honor are read
3//! (to, cc, bcc, subject, body); anything else is ignored rather than
4//! smuggled into the draft, since a URL is untrusted input.
5
6/// The pieces of a `mailto:` URL that reach a draft.
7#[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
16/// Parse a `mailto:` URL. Not one is `None`; a malformed query simply
17/// contributes nothing, which is what a mail handler should do rather
18/// than refusing to open.
19pub 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        // Header names are case-insensitive in a mailto URL.
38        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
61/// A header value cannot carry newlines: folding them away keeps a
62/// crafted URL from injecting extra headers into the draft.
63fn one_line(value: &str) -> String {
64    value.replace(['\r', '\n'], " ").trim().to_string()
65}
66
67/// Percent-decoding, with `+` for a space as browsers write it.
68/// Invalid escapes stay literal instead of being dropped.
69fn 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/jarda/Maildir"), None);
153    }
154}