craft-mime: strict base64, mailto links and code page labels from MailCraft (#934)

What MailCraft's mime and charset crates still had that craft-mime did not:
strict RFC 4648 base64 (crates/charset), mailto: links (RFC 6068,
crates/mime) and the Windows code page to charset label table (crates/pst).
With these, MailCraft's charset crate has nothing craft-mime lacks.

Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
Dylan M. TaylorandMartin Ball authored and GitHub committed 2026-11-11 18:45:10 -05:00
1 parent 2e0bbc3a9c
commit 090f121943
6 files changed
+551 -7

No files matched your search

+11 -6
View File
@@ -16,6 +16,10 @@ MHTML web archives and mbox files.
qp_decode}`, `charset::{decode, guess}`, `parse_address_list` / `format_address_list`,
`parse_date` / `format_date`, `split_multipart`, `mbox_messages` / `write_mbox_message`, and
`Resources` / `start_part` / `resolve_location` for `multipart/related`.
- **Beside MIME**, what the same callers need: `parse_mailto` reads a `mailto:` link (RFC 6068:
recipients, subject and body only, sizes capped, no line break into a header), `base64` is
strict RFC 4648 (both alphabets, padded or not, one spelling per value) for keys, tokens and
credentials, and `charset::code_page_label` names the character set of a Windows code page.
Written from the public RFCs: 5322 (messages), 2045–2049 (MIME), 2047 (encoded-words), 2231
(parameter values), 2387 and 2557 (`multipart/related`, MHTML), 4155 (mbox, `mboxrd` quoting).
@@ -89,10 +93,7 @@ always there as `headers.get("date")`.
- S/MIME and PGP: detecting protected messages, canonical CRLF form, keeping the original
bytes. `split_headers` and `split_multipart` give the raw slices those need.
- File-name-to-media-type tables and default attachment names.
- `mailto:` links (RFC 6068), which are not MIME.
- For MHT: picking the page, reading its HTML, and the budget for pictures loaded.
- Strict base64 (RFC 4648 with refusals, URL-safe alphabet) for keys and tokens. This crate has
the mail kind only: an encoder, and a decoder that skips whatever is not base64.
## Migration
@@ -113,7 +114,8 @@ No app uses this crate yet. These tables say what replaces what.
| `parse::Walk` (the recursion over parts, depth and part caps) | `parse` / `parse_entity`, then a walk over `Part`s |
| `parse::is_media_type`, content id and file name extraction | `Part::declared_media_type`, `content_id`, `file_name`, `disposition` |
| `charset::decode`, `guess`, `unix_lines` (`mailcraft-charset`) | `charset::` the same names |
| `mailcraft_charset::base64` (strict) | stays |
| `mailcraft_charset::base64` (strict) | `base64::` the same names (`STANDARD`, `STANDARD_NO_PAD`, `URL_SAFE`, `URL_SAFE_NO_PAD`, `Config`, `DecodeError`) |
| `mailcraft_pst::code_page_label` | `charset::code_page_label` |
| `address::parse_list`, `format_address`, `format_list` | `parse_address_list`, `format_address`, `format_address_list`, on `Mailbox { name, email }` |
| `mailcraft_time::parse_rfc5322`, `format_rfc5322` | `parse_date`, `format_date`, on `DateTime` |
| `mbox::mbox_messages`, `parse_mbox` | `mbox_messages` gives each message's bytes; MailCraft parses them |
@@ -126,7 +128,8 @@ No app uses this crate yet. These tables say what replaces what.
| `media::safe_media_type` | `safe_media_type` |
| `media::media_type_for`, `default_name` | stay |
| `parse::read_headers`, `write::write_header`, `calendar_form`, `body` | stay (model mapping) |
| `protected`, `signed`, `rich`, `mailto` | stay |
| `mailto::{Mailto, is_mailto, parse_mailto}` | the same names; `mailto::cut`, which `rich` borrows, stays |
| `protected`, `signed`, `rich` | stay |
### WordCraft (`crates/formats/src/mht.rs`)
@@ -153,7 +156,9 @@ settable).
| Part | Source |
|---|---|
| Headers, parameters, encoded-words, transfer encodings, address lists, multipart splitting, mbox, the writer's parts and boundaries, their tests | MailCraft `crates/mime` [a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5) |
| Character sets | MailCraft `crates/charset`, same commit |
| Character sets, strict base64 | MailCraft `crates/charset`, same commit |
| `mailto:` links | MailCraft `crates/mime` (`mailto.rs`), same commit |
| Code page labels | MailCraft `crates/pst` (`codepage.rs`), same commit |
| Date parsing and formatting | MailCraft `crates/time` (`text.rs`), same commit; the calendar arithmetic replaced by `chrono` |
| `multipart/related` lookups, location resolving, comment-aware parameters, the MHT tests | WordCraft `crates/formats/src/mht.rs` [639b813](https://github.com/storytold/wordcraft/commit/639b8133ea31efb63aac381a7ced8b8aed32aa30) |
+147
View File
@@ -0,0 +1,147 @@
//! Base64 (RFC 4648 sections 4 and 5), written from the RFC.
//!
//! Four ready-made [`Config`]s: [`STANDARD`] (the `+/` alphabet, padded with `=`),
//! [`STANDARD_NO_PAD`], [`URL_SAFE`] (the `-_` alphabet, padded) and [`URL_SAFE_NO_PAD`].
//!
//! Encoding is the RFC's. **Decoding is strict**: nothing but the config's alphabet (no white
//! space, no line breaks), padding exactly as the config says (a padded config requires it and
//! counts it; an unpadded one refuses `=`), a length that can be, and the unused bits of the last
//! symbol zero (so every value has one spelling). A caller that reads mail, where line breaks and
//! missing padding are everyday, cleans its input first, as it always has; the strictness is what
//! the signature, key and credential checks want. No input panics, and the output is never longer
//! than three quarters of the input.
use std::fmt;
/// Which alphabet and whether padded.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Config {
url_safe: bool,
padded: bool,
}
/// RFC 4648 section 4: `A-Za-z0-9+/`, padded with `=`.
pub const STANDARD: Config = Config { url_safe: false, padded: true };
/// Section 4 without padding.
pub const STANDARD_NO_PAD: Config = Config { url_safe: false, padded: false };
/// Section 5: `A-Za-z0-9-_`, padded with `=`.
pub const URL_SAFE: Config = Config { url_safe: true, padded: true };
/// Section 5 without padding (OAuth's PKCE and JSON Web Tokens).
pub const URL_SAFE_NO_PAD: Config = Config { url_safe: true, padded: false };
/// Why a text is not base64 of the config's kind. Says nothing of the text itself.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DecodeError;
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("not valid base64")
}
}
impl std::error::Error for DecodeError {}
impl Config {
/// The symbol for the six bits `v` (only the low six are looked at).
fn symbol(self, v: u32) -> char {
let v = (v & 63) as u8;
let c = match v {
0..=25 => b'A'.saturating_add(v),
26..=51 => b'a'.saturating_add(v.saturating_sub(26)),
52..=61 => b'0'.saturating_add(v.saturating_sub(52)),
62 if self.url_safe => b'-',
62 => b'+',
_ if self.url_safe => b'_',
_ => b'/',
};
char::from(c)
}
/// The six bits a symbol stands for, if it is one.
fn value(self, c: u8) -> Option<u32> {
let v = match c {
b'A'..=b'Z' => c.saturating_sub(b'A'),
b'a'..=b'z' => c.saturating_sub(b'a').saturating_add(26),
b'0'..=b'9' => c.saturating_sub(b'0').saturating_add(52),
b'+' if !self.url_safe => 62,
b'/' if !self.url_safe => 63,
b'-' if self.url_safe => 62,
b'_' if self.url_safe => 63,
_ => return None,
};
Some(u32::from(v))
}
/// The text for `data`.
pub fn encode(self, data: impl AsRef<[u8]>) -> String {
let data = data.as_ref();
let mut out = String::with_capacity(data.len().div_ceil(3).saturating_mul(4));
let (groups, remainder) = data.as_chunks::<3>();
for [a, b, c] in groups {
let n = u32::from(*a) << 16 | u32::from(*b) << 8 | u32::from(*c);
for shift in [18, 12, 6, 0] {
out.push(self.symbol(n >> shift));
}
}
match *remainder {
[a] => {
let n = u32::from(a) << 16;
out.push(self.symbol(n >> 18));
out.push(self.symbol(n >> 12));
if self.padded {
out.push_str("==");
}
}
[a, b] => {
let n = u32::from(a) << 16 | u32::from(b) << 8;
out.push(self.symbol(n >> 18));
out.push(self.symbol(n >> 12));
out.push(self.symbol(n >> 6));
if self.padded {
out.push('=');
}
}
_ => {}
}
out
}
/// The bytes of `text`, which must be exactly base64 of this config's kind (see the module).
pub fn decode(self, text: impl AsRef<[u8]>) -> Result<Vec<u8>, DecodeError> {
let text = text.as_ref();
let (symbols, pad) = if self.padded {
if text.len() % 4 != 0 {
return Err(DecodeError);
}
let pad = text.iter().rev().take(2).take_while(|c| **c == b'=').count();
(text.get(..text.len().saturating_sub(pad)).unwrap_or_default(), pad)
} else {
(text, 0)
};
// A last group of one symbol holds under eight bits; with padding, `=` fills it to four.
let rest = symbols.len() % 4;
if rest == 1 || (self.padded && pad != (4 - rest) % 4) {
return Err(DecodeError);
}
let mut out = Vec::with_capacity(symbols.len().saturating_mul(3) / 4);
let (mut acc, mut bits) = (0u32, 0u32);
for c in symbols {
acc = acc << 6 | self.value(*c).ok_or(DecodeError)?;
bits = bits.saturating_add(6);
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
// What is left over must be zero: one spelling for one value.
if acc != 0 {
return Err(DecodeError);
}
Ok(out)
}
}
#[cfg(test)]
#[path = "base64_tests.rs"]
mod base64_tests;
+81
View File
@@ -0,0 +1,81 @@
//! Base64: the RFC 4648 test vectors, the strict refusals, and that only the one spelling of a
//! value decodes, on every config.
use super::*;
use proptest::prelude::*;
#[test]
fn the_rfc_vectors_round_trip_in_every_config() {
let vectors = [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")];
for (plain, coded) in vectors {
assert_eq!(STANDARD.encode(plain), coded);
assert_eq!(URL_SAFE.encode(plain), coded);
assert_eq!(STANDARD_NO_PAD.encode(plain), coded.trim_end_matches('='));
assert_eq!(URL_SAFE_NO_PAD.encode(plain), coded.trim_end_matches('='));
assert_eq!(STANDARD.decode(coded).unwrap(), plain.as_bytes());
assert_eq!(URL_SAFE_NO_PAD.decode(coded.trim_end_matches('=')).unwrap(), plain.as_bytes());
}
}
#[test]
fn the_two_alphabets_differ_in_the_last_two_symbols() {
assert_eq!(STANDARD.encode([0xfb, 0xff]), "+/8=");
assert_eq!(URL_SAFE.encode([0xfb, 0xff]), "-_8=");
assert!(URL_SAFE.decode("+/8=").is_err());
assert!(STANDARD.decode("-_8=").is_err());
}
#[test]
fn what_is_not_exactly_base64_is_refused() {
for bad in ["Zg", "Zg=", "Zg===", "Z", "Z===", "====", "Zm9v\n", " Zm9v", "Zm 9v", "Zh==", "Zm9=", "Zm=v", "Zm9v=", "é", "Zm9\u{0}"] {
assert!(STANDARD.decode(bad).is_err(), "{bad:?}");
}
for bad in ["Zg==", "Z", "Zh", "Zm9v\n", "Zm 9v", "Zg=", "Zm9="] {
assert!(STANDARD_NO_PAD.decode(bad).is_err(), "{bad:?}");
}
assert!(STANDARD_NO_PAD.decode("Zm8").is_ok());
}
#[test]
fn hostile_inputs_do_not_panic() {
let big = vec![b'A'; 1 << 20];
for cfg in [STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD] {
assert_eq!(cfg.decode(&big).map(|b| b.len()).unwrap_or(0), 786_432);
for bytes in [&b""[..], b"=", b"==", b"===", b"\xff\xfe", b"=A=A", b"AA=A"] {
let _ = cfg.decode(bytes);
}
}
}
const CONFIGS: [Config; 4] = [STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD];
proptest! {
#[test]
fn what_is_encoded_decodes_to_itself(data in proptest::collection::vec(any::<u8>(), 0..400)) {
for config in CONFIGS {
prop_assert_eq!(config.decode(config.encode(&data)).ok(), Some(data.clone()));
}
}
/// Strict: a text is accepted only when it is the one spelling of its bytes.
#[test]
fn what_decodes_is_the_one_spelling_of_its_bytes(
data in proptest::collection::vec(any::<u8>(), 0..400),
junk in proptest::collection::vec(any::<u8>(), 0..64),
text in "[A-Za-z0-9+/=_ -]{0,40}",
) {
for config in CONFIGS {
let mut cut = config.encode(&data).into_bytes();
cut.pop();
let mut pad = cut.clone();
if let Some(first) = pad.first_mut() {
*first = b'=';
}
for candidate in [junk.as_slice(), text.as_bytes(), cut.as_slice(), pad.as_slice()] {
if let Ok(bytes) = config.decode(candidate) {
prop_assert_eq!(config.encode(&bytes).into_bytes(), candidate.to_vec());
}
}
}
}
}
+62
View File
@@ -51,6 +51,52 @@ pub fn guess(bytes: &[u8]) -> String {
}
}
/// The character set a Windows code page stands for (as Outlook items, RTF and
/// Windows APIs number them), by the label [`decode`] reads it under. `None` for a page that has no
/// character set here.
pub fn code_page_label(cp: i64) -> Option<&'static str> {
Some(match cp {
65001 => "utf-8",
1200 => "utf-16le",
1201 => "utf-16be",
874 => "windows-874",
1250 => "windows-1250",
1251 => "windows-1251",
1252 | 28591 => "windows-1252",
1253 => "windows-1253",
1254 => "windows-1254",
1255 => "windows-1255",
1256 => "windows-1256",
1257 => "windows-1257",
1258 => "windows-1258",
932 => "shift_jis",
936 => "gbk",
54936 => "gb18030",
949 | 51949 => "euc-kr",
950 => "big5",
51932 => "euc-jp",
50220..=50222 => "iso-2022-jp",
866 => "ibm866",
20866 => "koi8-r",
21866 => "koi8-u",
10000 => "macintosh",
10007 => "x-mac-cyrillic",
28592 => "iso-8859-2",
28593 => "iso-8859-3",
28594 => "iso-8859-4",
28595 => "iso-8859-5",
28596 => "iso-8859-6",
28597 => "iso-8859-7",
28598 => "iso-8859-8",
38598 => "iso-8859-8-i",
28599 => "iso-8859-9",
28603 => "iso-8859-13",
28605 => "iso-8859-15",
20127 => "us-ascii",
_ => return None,
})
}
/// Line endings as `\n`: CRLF and a lone CR both become LF.
pub fn unix_lines(text: &str) -> String {
if !text.contains('\r') {
@@ -98,6 +144,22 @@ mod tests {
assert_eq!(guess(b"\xE2\x82"), "\u{e2}\u{201a}");
}
#[test]
fn code_pages_name_character_sets_the_decoder_knows() {
assert_eq!(code_page_label(1252), Some("windows-1252"));
assert_eq!(code_page_label(28591), Some("windows-1252"));
assert_eq!(code_page_label(65001), Some("utf-8"));
assert_eq!(code_page_label(0), None);
assert_eq!(code_page_label(-1), None);
// Every label is one the Encoding Standard knows (US-ASCII reads as windows-1252 there).
for cp in 0..66_000 {
if let Some(label) = code_page_label(cp) {
assert!(encoding_rs::Encoding::for_label(label.as_bytes()).is_some(), "{cp} {label}");
}
}
assert_eq!(decode(&[0xC0, 0xC1], code_page_label(1251).unwrap_or_default()), "АБ");
}
#[test]
fn line_endings() {
assert_eq!(unix_lines("a\r\nb\rc\nd\r\n"), "a\nb\nc\nd\n");
+5 -1
View File
@@ -12,7 +12,8 @@
//! ([`transfer`]), character sets ([`charset`]), address lists ([`parse_address_list`],
//! [`format_address_list`]), dates ([`parse_date`], [`format_date`]), mbox files
//! ([`mbox_messages`], [`write_mbox_message`]) and the lookups of `multipart/related`
//! ([`Resources`]).
//! ([`Resources`]). Beside MIME: `mailto:` links ([`parse_mailto`], RFC 6068) and strict
//! base64 ([`base64`], RFC 4648).
//!
//! Written from the public RFCs: 5322 (messages), 2045–2049 (MIME), 2047 (encoded-words), 2231
//! (parameter values), 2387 and 2557 (`multipart/related`, MHTML) and 4155 (mbox).
@@ -25,12 +26,14 @@
//! What a message means (which part is the body, which parts are files, what a flag header
//! says) is the caller's: this crate has no notion of a mailbox or a document.
pub mod base64;
pub mod charset;
pub mod transfer;
mod address;
mod date;
mod header;
mod mailto;
mod mbox;
mod part;
mod related;
@@ -40,6 +43,7 @@ mod write;
pub use address::{MAX_ADDRESSES, Mailbox, format_address, format_address_list, parse_address_list};
pub use date::{DateTime, format_date, parse_date};
pub use header::{Field, Headers, MAX_PARAMS, Params, clean, is_field_name, parse_params, split_headers, write_field};
pub use mailto::{Mailto, is_mailto, parse_mailto};
pub use mbox::{MboxMessages, mbox_messages, write_mbox_message};
pub use part::{Body, Message, Part, parse, parse_entity, split_multipart};
pub use related::{Resources, resolve_location, start_part};
+245
View File
@@ -0,0 +1,245 @@
//! `mailto:` links (RFC 6068): who a new message is for and how it starts.
//!
//! A link is hostile input like any other: it comes from a web page, a document or a message.
//! Only the recipients, the subject and the body are taken from it. Every other header field a
//! link names is ignored (RFC 6068 section 7: a link must not get to set headers of its choice),
//! nothing taken from it can carry a line break into a header, and sizes are capped.
/// What a `mailto:` link asks a new message to start with.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Mailto {
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub subject: String,
pub body: String,
}
/// The most of a link that is read.
const MAX_LINK: usize = 64 * 1024;
/// The most recipients taken for one field.
const MAX_ADDRESSES: usize = 200;
/// The longest subject taken (a header line's limit in RFC 5322).
const MAX_SUBJECT: usize = 998;
/// Whether `text` is a `mailto:` link (by its scheme, whatever its case).
pub fn is_mailto(text: &str) -> bool {
rest_of(text).is_some()
}
/// What follows the scheme, when `text` is a `mailto:` link.
fn rest_of(text: &str) -> Option<&str> {
let text = text.trim();
let scheme = text.get(..7)?;
if scheme.eq_ignore_ascii_case("mailto:") { text.get(7..) } else { None }
}
/// Read a `mailto:` link. `None` when `link` isn't one; a link with nothing after the scheme is
/// an empty message.
pub fn parse_mailto(link: &str) -> Option<Mailto> {
let rest = cut(rest_of(link)?, MAX_LINK);
// A fragment is no part of the message.
let rest = rest.split('#').next().unwrap_or("");
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
let mut m = Mailto::default();
// Commas separate the addresses before they are decoded: an encoded comma belongs to its address.
for address in path.split(',') {
add(&mut m.to, &decode(address));
}
let (mut subject, mut body) = (None, None);
for field in query.split('&') {
let (name, value) = field.split_once('=').unwrap_or((field, ""));
let value = decode(value);
match decode(name).to_ascii_lowercase().as_str() {
"to" => add_list(&mut m.to, &value),
"cc" => add_list(&mut m.cc, &value),
"bcc" => add_list(&mut m.bcc, &value),
// The first of each counts; a link that repeats one is not to be trusted with the rest.
"subject" if subject.is_none() => subject = Some(one_line(&value, MAX_SUBJECT)),
"body" if body.is_none() => body = Some(lines(&value)),
_ => {}
}
}
m.subject = subject.unwrap_or_default();
m.body = body.unwrap_or_default();
Some(m)
}
/// `text` up to `max` bytes, cut where a character ends.
pub(crate) fn cut(text: &str, max: usize) -> &str {
if text.len() <= max {
return text;
}
let mut end = max;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text.get(..end).unwrap_or("")
}
/// Percent-decoding. An escape that isn't one stays as it is written, a plus sign is a plus sign
/// (RFC 6068 section 5), and bytes that aren't UTF-8 become the replacement character.
fn decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while let Some(&b) = bytes.get(i) {
let escaped = if b == b'%' { hex(bytes.get(i + 1)).zip(hex(bytes.get(i + 2))) } else { None };
match escaped {
Some((high, low)) => {
out.push((high << 4) | low);
i += 3;
}
None => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex(b: Option<&u8>) -> Option<u8> {
char::from(*b?).to_digit(16).and_then(|d| u8::try_from(d).ok())
}
/// One header line's worth of text: line breaks and other control characters become spaces.
fn one_line(text: &str, max: usize) -> String {
let line: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
cut(line.trim(), max).trim_end().to_string()
}
/// Body text: every kind of line break becomes `\n`, and no other control character but the tab
/// is kept.
fn lines(text: &str) -> String {
text.replace("\r\n", "\n").chars().map(|c| if c == '\r' { '\n' } else { c }).filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect()
}
/// One address, if anything is left of it once it is a single clean line.
fn add(list: &mut Vec<String>, address: &str) {
let address = one_line(address, MAX_SUBJECT);
if !address.is_empty() && list.len() < MAX_ADDRESSES {
list.push(address);
}
}
/// The addresses of a decoded header value: separated by commas, except inside a quoted name or
/// an angle-bracketed address.
fn add_list(list: &mut Vec<String>, value: &str) {
let (mut start, mut quoted, mut angle, mut escaped) = (0, false, false, false);
for (i, c) in value.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if quoted => escaped = true,
'"' => quoted = !quoted,
'<' if !quoted => angle = true,
'>' if !quoted => angle = false,
',' if !quoted && !angle => {
add(list, value.get(start..i).unwrap_or(""));
start = i + 1;
}
_ => {}
}
}
add(list, value.get(start..).unwrap_or(""));
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
fn s(list: &[&str]) -> Vec<String> {
list.iter().map(|a| a.to_string()).collect()
}
#[test]
fn reads_the_examples_of_rfc_6068() {
assert_eq!(parse_mailto("mailto:chris@example.org"), Some(Mailto { to: s(&["chris@example.org"]), ..Default::default() }));
let m = parse_mailto("mailto:infobot@example.org?subject=current-issue").unwrap();
assert_eq!((m.to, m.subject.as_str()), (s(&["infobot@example.org"]), "current-issue"));
let m = parse_mailto("mailto:infobot@example.org?body=send%20current-issue%0D%0Asend%20index").unwrap();
assert_eq!(m.body, "send current-issue\nsend index");
let m = parse_mailto("mailto:joe@example.org?cc=bob@example.org&body=hello").unwrap();
assert_eq!((m.to, m.cc, m.body.as_str()), (s(&["joe@example.org"]), s(&["bob@example.org"]), "hello"));
// Addresses in the path, in `to` fields, or both.
let m = parse_mailto("mailto:addr1@an.example,addr2@an.example").unwrap();
assert_eq!(m.to, s(&["addr1@an.example", "addr2@an.example"]));
let m = parse_mailto("mailto:?to=addr1@an.example,addr2@an.example").unwrap();
assert_eq!(m.to, s(&["addr1@an.example", "addr2@an.example"]));
let m = parse_mailto("mailto:addr1@an.example?to=addr2@an.example").unwrap();
assert_eq!(m.to, s(&["addr1@an.example", "addr2@an.example"]));
// Encoded characters in an address, and UTF-8 in a subject.
assert_eq!(parse_mailto("mailto:gorby%25kremvax@example.org").unwrap().to, s(&["gorby%kremvax@example.org"]));
assert_eq!(parse_mailto("mailto:user@example.org?subject=caf%C3%A9").unwrap().subject, "café");
assert_eq!(parse_mailto("mailto:%22not%40me%22@example.org").unwrap().to, s(&["\"not@me\"@example.org"]));
}
#[test]
fn knows_a_link_from_anything_else() {
assert!(is_mailto(" MailTo:someone@example.org "));
assert!(!is_mailto("mailbox.example"));
assert!(!is_mailto("mail"));
assert!(!is_mailto("https://example.org/?mailto:x"));
assert_eq!(parse_mailto("C:\\mail\\mailto.eml"), None);
assert_eq!(parse_mailto("mailto:"), Some(Mailto::default()));
assert_eq!(parse_mailto("MAILTO:a@example.org#top").unwrap().to, s(&["a@example.org"]));
}
#[test]
fn a_link_sets_nothing_but_recipients_subject_and_body() {
let m =
parse_mailto("mailto:a@example.org?from=boss@example.org&reply-to=x@evil.example&attach=C:%5Csecrets.txt&X-Mailer=m&subject=Hi").unwrap();
assert_eq!(m, Mailto { to: s(&["a@example.org"]), subject: "Hi".into(), ..Default::default() });
// No line break reaches a header, whatever the link encodes.
let m = parse_mailto(
"mailto:a@example.org%0D%0ABcc:%20x@evil.example?subject=Hi%0D%0ABcc:%20y@evil.example&cc=c@example.org%0Abcc:z@evil.example",
)
.unwrap();
assert!(m.bcc.is_empty());
for text in m.to.iter().chain(&m.cc).chain([&m.subject]) {
assert!(!text.contains(['\r', '\n', '\0']), "{text:?}");
}
// The first subject and body count.
let m = parse_mailto("mailto:?subject=one&subject=two&body=first&body=second").unwrap();
assert_eq!((m.subject.as_str(), m.body.as_str()), ("one", "first"));
// A plus sign is not a space, a broken escape is what it looks like.
let m = parse_mailto("mailto:a+b@example.org?subject=1+1%3D2%&body=100%25%zz").unwrap();
assert_eq!((m.to, m.subject.as_str(), m.body.as_str()), (s(&["a+b@example.org"]), "1+1=2%", "100%%zz"));
}
#[test]
fn splits_recipient_lists_the_way_addresses_are_written() {
let m = parse_mailto("mailto:?cc=%22Rivera%2C%20Tom%C3%A1s%22%20%3Ctomas@example.org%3E,%20lin@example.org&bcc=a@example.org,,b@example.org")
.unwrap();
assert_eq!(m.cc, s(&["\"Rivera, Tomás\" <tomas@example.org>", "lin@example.org"]));
assert_eq!(m.bcc, s(&["a@example.org", "b@example.org"]));
}
#[test]
fn sizes_are_capped() {
let many = format!("mailto:{}", "a@example.org,".repeat(1000));
assert_eq!(parse_mailto(&many).unwrap().to.len(), MAX_ADDRESSES);
let long = format!("mailto:a@example.org?subject={}&body={}", "é".repeat(5000), "x".repeat(200_000));
let m = parse_mailto(&long).unwrap();
assert!(m.subject.len() <= MAX_SUBJECT && m.subject.chars().all(|c| c == 'é'));
assert!(m.body.len() <= MAX_LINK);
}
proptest! {
#[test]
fn any_text_is_read_without_trouble(text in ".{0,300}", tail in proptest::collection::vec(any::<u8>(), 0..300)) {
let _ = parse_mailto(&text);
let link = format!("mailto:{text}?{}", String::from_utf8_lossy(&tail));
let m = parse_mailto(&link).unwrap();
for line in m.to.iter().chain(&m.cc).chain(&m.bcc).chain([&m.subject]) {
prop_assert!(!line.chars().any(char::is_control));
}
prop_assert!(!m.body.contains('\r'));
}
}
}