mail-parser 1.0.0

Fast and robust e-mail parsing library for Rust
Documentation
/*
 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
 *
 * SPDX-License-Identifier: Apache-2.0 OR MIT
 */

use super::{FieldCtx, HeaderForm, first_field};
use crate::{DateTime, HeaderValue, store::MessageData, view::Resolver};
use serde_json::{Value as Json, json};
use std::path::PathBuf;

pub(crate) fn load_cases(name: &str) -> Vec<Json> {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("resources")
        .join(name);
    let bytes = std::fs::read(&path).expect("fixture file");
    serde_json::from_slice(&bytes).expect("valid JSON")
}

pub(crate) fn case_text<'x>(case: &'x Json, key: &str) -> &'x str {
    case.get(key)
        .and_then(Json::as_str)
        .unwrap_or_else(|| panic!("{key:?} is a string in {case}"))
}

pub(crate) fn load_text_pairs(name: &str) -> Vec<(String, String)> {
    load_cases(name)
        .iter()
        .map(|case| {
            (
                case_text(case, "input").to_string(),
                case_text(case, "expected").to_string(),
            )
        })
        .collect()
}

pub(crate) fn load_tests(name: &str) -> Vec<(String, Json)> {
    load_cases(name)
        .into_iter()
        .filter_map(|test| {
            Some((
                test.get("header")?.as_str()?.to_string(),
                test.get("expected")?.clone(),
            ))
        })
        .collect()
}

fn content_type_json(value: HeaderValue<'_>) -> Json {
    match value {
        HeaderValue::ContentType(content_type) => {
            let attributes: Vec<Json> = content_type
                .attributes()
                .map(|(name, value)| json!([name, value]))
                .collect();
            json!({
                "c_type": content_type.ctype(),
                "c_subtype": content_type.subtype(),
                "attributes": if attributes.is_empty() { Json::Null } else { Json::Array(attributes) },
            })
        }
        _ => Json::Null,
    }
}

fn normalize(expected: Json) -> Json {
    match expected {
        Json::Object(fields) => {
            let field = |name: &str| fields.get(name).cloned().unwrap_or(Json::Null);
            json!({
                "c_type": field("c_type"),
                "c_subtype": field("c_subtype"),
                "attributes": field("attributes"),
            })
        }
        other => other,
    }
}

#[test]
fn content_type_fixtures() {
    let tests = load_tests("content_type.json");
    assert_eq!(tests.len(), 120);
    for (header, expected) in tests {
        let parsed = HeaderForm::ContentType.parse(header.as_bytes());
        assert_eq!(
            content_type_json(parsed.value()),
            normalize(expected),
            "{header:?}"
        );
    }
}

#[test]
fn raw_values() {
    let inputs = [
        ("Saying Hello\nMessage-Id", Some("Saying Hello")),
        ("Re: Saying Hello\r\n \r\nFrom:", Some("Re: Saying Hello")),
        (
            concat!(
                " from x.y.test\n      by example.net\n      via TCP\n",
                "      with ESMTP\n      id ABC12345\n      ",
                "for <mary@example.net>;  21 Nov 1997 10:05:43 -0600\n"
            ),
            Some(concat!(
                "from x.y.test\n      by example.net\n      via TCP\n",
                "      with ESMTP\n      id ABC12345\n      ",
                "for <mary@example.net>;  21 Nov 1997 10:05:43 -0600"
            )),
        ),
        ("Re: Saying Hello", Some("Re: Saying Hello")),
        ("  \r\n", Some("")),
        ("caf\u{e9}\n", Some("café")),
    ];
    for (input, expected) in inputs {
        let parsed = HeaderForm::Raw.parse(input.as_bytes());
        assert_eq!(parsed.value().as_text(), expected, "{input:?}");
    }
    let parsed = HeaderForm::Raw.parse(b" caf\xe9 \n");
    assert_eq!(parsed.value().as_text(), Some("caf\u{fffd}"));
}

#[test]
fn ignored_values() {
    assert!(HeaderForm::Ignore.parse(b" anything\n").value().is_empty());
}

fn stored(form: HeaderForm, input: &[u8]) -> (bool, Option<String>, Option<DateTime>) {
    let range = first_field(input, 0..input.len());
    let mut data = MessageData::default();
    let value = form.parse_field(&mut FieldCtx::new(input, &mut data), range);
    let value = HeaderValue::new(Resolver::new(input, &data.strings), &data, value);
    (
        value.is_empty(),
        value.as_text().map(str::to_string),
        value.as_datetime(),
    )
}

#[test]
fn standalone_values_match_the_store_path() {
    let mut inputs: Vec<Vec<u8>> = [
        "date.json",
        "unstructured.json",
        "address.json",
        "received.json",
    ]
    .iter()
    .flat_map(|name| load_tests(name))
    .map(|(header, _)| header.into_bytes())
    .collect();
    for sample in [
        &b""[..],
        b" ",
        b"\r\n",
        b" plain value\r\n",
        b"plain\r\n next: x\r\n",
        b" folded\r\n value\r\n",
        b" =?utf-8?q?caf=C3=A9?=\n",
        b" caf\xc3\xa9\n",
        b" caf\xe9\n",
        b" \xff\xfe\r\n",
        b"a\rb\n",
        b"Tue, 1 Jul 2003 10:52:37 +0200\r\n",
        b"Tue, 1 Jul 2003 10:52:37 +0200\nX-y: -0500",
        b" 1 Jul 2003 10:52:37 -0700 (PDT)",
        b"junk\n",
    ] {
        inputs.push(sample.to_vec());
    }
    for input in &inputs {
        for form in [
            HeaderForm::Raw,
            HeaderForm::Text,
            HeaderForm::Date,
            HeaderForm::Ignore,
        ] {
            let parsed = form.parse(input);
            let value = parsed.value();
            assert_eq!(
                (
                    value.is_empty(),
                    value.as_text().map(str::to_string),
                    value.as_datetime()
                ),
                stored(form, input),
                "{form:?} {:?}",
                String::from_utf8_lossy(input)
            );
        }
        let text = String::from_utf8_lossy(input);
        assert_eq!(
            DateTime::parse_rfc822(&text),
            stored(HeaderForm::Date, text.as_bytes()).2,
            "{text:?}"
        );
    }
}