Skip to main content

sioul_core/
mailto.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! `mailto:` addresses (RFC 6068): the message a link, or another
5//! application, asks to write. Recipients (the part before "?", and `to`,
6//! `cc`, `bcc`), `subject`, `body`, and `In-Reply-To` for the message it
7//! answers. Nothing else is read: above all no `attach` or `attachment`,
8//! which some programs take and which would let a web page name one of your
9//! files, nor `from`. Nothing here sends anything: the address only fills a
10//! draft (docs/client.md, "Writing from other applications").
11
12use crate::compose::{address_of, split_addresses};
13use mail_parser::MessageParser;
14
15/// What a `mailto:` address asks.
16#[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    /// The text, line breaks as "\n".
23    pub body: String,
24    /// The message answered, its Message-ID with its angle brackets.
25    pub in_reply_to: Option<String>,
26}
27
28/// Whether `text` is a `mailto:` address, in any case.
29pub fn is_mailto(text: &str) -> bool {
30    text.trim_start().get(..7).is_some_and(|scheme| scheme.eq_ignore_ascii_case("mailto:"))
31}
32
33/// Reads a `mailto:` address; none when `uri` is not one.
34pub fn parse(uri: &str) -> Option<Mailto> {
35    let uri = uri.trim();
36    if !is_mailto(uri) {
37        return None;
38    }
39    // A fragment is no part of a mailto: address (RFC 6068 §2).
40    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            // Encoded words mean nothing in the body (RFC 6068 §2): kept as written.
53            "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
69/// Each address once in a list, the first spelling kept: "Jane <jane@x.org>"
70/// and "JANE@x.org" are one.
71pub 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
76/// Percent-decoding (RFC 3986 §2.1): the bytes read as UTF-8, a wrong one
77/// replaced; "+" stays "+" (a space is "%20" in mailto:, RFC 6068 §5), and a
78/// "%" not followed by two hexadecimal digits stays as it is.
79pub 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
98/// A header's value on one line: a line break would be a header of its own.
99fn one_line(text: &str) -> String {
100    text.replace(['\r', '\n'], " ")
101}
102
103/// A header's value as written, its MIME encoded words decoded (RFC 2047,
104/// allowed in header values by RFC 6068 §2): read as a header by the same
105/// parser as received mail.
106fn 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    // RFC 6068 §6.1, basic examples.
124    #[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        // The RFC's own wrong example: the second "?" belongs to the cc's value.
136        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    // RFC 6068 §6.2, complicated addresses.
144    #[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    // RFC 6068 §6.3, beyond ASCII.
152    #[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        // Encoded words mean nothing in the body.
162        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        // A wrong byte is replaced, never a failure.
164        assert_eq!(parse("mailto:x@example.org?subject=caf%E9").unwrap().subject, "caf\u{FFFD}");
165    }
166
167    // RFC 6068 §2: three ways to the same two recipients.
168    #[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        // Once each, the first spelling kept.
175        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        // Names in the fields' values, encoded words among them.
177        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        // "+" is no space; names of fields in any case; the scheme too.
184        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        // Files, the sender, and anything else are never taken from an address.
187        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        // A line break cannot make a header of its own; a fragment is no part of it.
190        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        // The first subject and body count; an In-Reply-To without its brackets does not.
193        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        // Empty, and not one.
196        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        // A "%" that encodes nothing stays.
201        assert_eq!(decode("100%"), "100%");
202        assert_eq!(decode("%zz%4"), "%zz%4");
203    }
204}