ljos_test_boot::harness!();
use chrono::{Duration, TimeZone as _, Utc};
use ljos_cli::mailbox::config::MailConfig;
use ljos_cli::mailbox::export::{Approval, Draft};
use sha2::{Digest as _, Sha256};
const MESSAGE: &str = "From: Owner <owner@example.test>\n\
To: Friend <friend@example.test>\n\
Subject: Friday\n\
Date: Thu, 22 Oct 2026 12:00:00 +0000\n\
Message-ID: <draft-7@example.test>\n\
Content-Type: text/plain; charset=utf-8\n\
\n\
See you at noon.\n\
\tIndented by a tab.\n";
fn now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 10, 22, 12, 0, 0).unwrap()
}
fn config() -> &'static MailConfig {
static CONFIG: std::sync::OnceLock<(tempfile::TempDir, MailConfig)> =
std::sync::OnceLock::new();
&CONFIG
.get_or_init(|| {
let dir = tempfile::tempdir().unwrap();
let deny = dir.path().join("deny.txt");
std::fs::write(&deny, "domain: denied.example.test\nterm: project-x\n").unwrap();
let config = MailConfig {
identity: Some("owner@example.test".into()),
deny_file: Some(deny),
..MailConfig::default()
};
(dir, config)
})
.1
}
fn export(
draft: &Draft<'_>,
expires: chrono::DateTime<Utc>,
now: chrono::DateTime<Utc>,
) -> anyhow::Result<Approval> {
ljos_cli::mailbox::export::export(draft, config(), expires, now)
}
fn sha(message: &str) -> &'static str {
Box::leak(hex::encode(Sha256::digest(message.as_bytes())).into_boxed_str())
}
fn draft(message: &str) -> Draft<'_> {
Draft {
id: "draft-7",
reachbook_hash: sha(message),
message: message.as_bytes(),
}
}
#[test]
fn the_file_is_the_statement_block_then_the_exact_bytes() {
let expires = now() + Duration::hours(2);
let got = export(&draft(MESSAGE), expires, now()).unwrap();
let text = String::from_utf8(got.file.clone()).unwrap();
let (block, message) = text.split_once("\n\n").unwrap();
let lines: Vec<&str> = block.lines().collect();
assert_eq!(
lines[..4],
[
"ljos-outbox v1",
"draft draft-7",
&format!("reachbook-hash {}", sha(MESSAGE)),
"expires 2026-10-22T14:00:00Z",
]
);
let nonce = lines[4].strip_prefix("nonce ").expect("a nonce line");
assert_eq!(nonce.len(), 32);
assert!(nonce
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()));
assert_eq!(nonce, got.nonce);
assert_eq!(lines.len(), 5);
assert_eq!(message, MESSAGE);
assert_eq!(got.hash, hex::encode(Sha256::digest(MESSAGE.as_bytes())));
assert_eq!(got.expires, expires);
let again = export(&draft(MESSAGE), expires, now()).unwrap();
assert_ne!(again.nonce, got.nonce);
assert_eq!(again.hash, got.hash, "H is the message, not the nonce");
}
#[test]
fn the_hash_covers_the_bytes_sendmail_is_given_including_a_lone_dot() {
let dotted = MESSAGE.replace("See you at noon.\n", "See you at noon.\n.\nStill here.\n");
let got = export(&draft(&dotted), now() + Duration::hours(1), now()).unwrap();
assert!(got.file.ends_with(dotted.as_bytes()));
assert_eq!(got.hash, hex::encode(Sha256::digest(dotted.as_bytes())));
}
#[test]
fn crlf_is_refused_because_the_stored_form_is_lf() {
let crlf = MESSAGE.replace('\n', "\r\n");
let err = export(&draft(&crlf), now() + Duration::hours(1), now()).unwrap_err();
assert!(err.to_string().contains("U+000D"), "{err}");
}
#[test]
fn characters_that_hide_reorder_or_break_text_are_refused_anywhere() {
for (bad, what) in [
(MESSAGE.replace("noon", "no\u{1b}[8mon"), "escape"),
(MESSAGE.replace('\n', "\r\n"), "carriage return"),
(MESSAGE.replace("Friday", "Fri\0day"), "nul"),
(MESSAGE.replace("noon", "no\u{7f}on"), "delete"),
(MESSAGE.replace("noon", "no\u{85}on"), "next line"),
(
MESSAGE.replace("noon", "\u{202e}noon"),
"right-to-left override",
),
(
MESSAGE.replace("Friday", "\u{2066}Friday\u{2069}"),
"isolate",
),
(MESSAGE.replace("noon", "no\u{0c}on"), "form feed"),
(MESSAGE.replace("Friday", "Fri\u{ad}day"), "soft hyphen"),
(
MESSAGE.replace("Friday", "Fri\u{180e}day"),
"mongolian vowel separator",
),
(
MESSAGE.replace("Friday", "Fri\u{200b}day"),
"zero width space",
),
(
MESSAGE.replace("Friday", "Fri\u{200d}day"),
"zero width joiner",
),
(
MESSAGE.replace("Friday", "\u{200f}Friday"),
"right-to-left mark",
),
(MESSAGE.replace("Friday", "Fri\u{2060}day"), "word joiner"),
(
MESSAGE.replace("Friday", "Fri\u{2064}day"),
"invisible plus",
),
(
MESSAGE.replace("Friday", "\u{feff}Friday"),
"byte order mark",
),
(
MESSAGE.replace("friend@", "x@example.test\u{2028} friend@"),
"line separator",
),
(
MESSAGE.replace("noon", "no\u{2029}on"),
"paragraph separator",
),
] {
let err = export(&draft(&bad), now() + Duration::hours(1), now());
assert!(err.is_err(), "{what} was exported");
assert!(err.unwrap_err().to_string().contains("U+"), "{what}");
}
}
#[test]
fn a_denied_term_split_by_any_default_ignorable_character_is_refused() {
for (split, code, what) in [
('\u{200b}', "U+200B", "zero width space"),
('\u{fe0f}', "U+FE0F", "variation selector"),
('\u{e0100}', "U+E0100", "supplementary variation selector"),
('\u{e0041}', "U+E0041", "tag character"),
] {
let bad = MESSAGE.replace("noon", &format!("noon on proj{split}ect-x"));
let err = export(&draft(&bad), now() + Duration::hours(1), now()).unwrap_err();
assert!(err.to_string().contains(code), "{what}: {err}");
}
}
#[test]
fn the_expiry_is_after_now_and_at_most_a_day_out() {
let ok = |hours: i64| export(&draft(MESSAGE), now() + Duration::hours(hours), now()).is_ok();
assert!(ok(24));
assert!(ok(1));
assert!(!ok(0));
assert!(!ok(-1));
assert!(!ok(25));
assert!(export(
&draft(MESSAGE),
now() + Duration::hours(24) + Duration::seconds(1),
now()
)
.is_err());
}
#[test]
fn a_message_without_its_fixed_headers_or_a_bad_field_is_refused() {
let no_id = MESSAGE.replace("Message-ID: <draft-7@example.test>\n", "");
let no_date = MESSAGE.replace("Date: Thu, 22 Oct 2026 12:00:00 +0000\n", "");
let no_body = MESSAGE.split("\n\n").next().unwrap().to_string();
let in_body = MESSAGE.replace("See you", "Message-ID: <x@example.test>\nSee you");
let later = now() + Duration::hours(1);
for message in [&no_id, &no_date, &no_body] {
assert!(export(&draft(message), later, now()).is_err(), "{message}");
}
let only_in_body = in_body.replace("Message-ID: <draft-7@example.test>\n", "");
assert!(export(&draft(&only_in_body), later, now()).is_err());
for (id, hash) in [
("", "ab"),
("draft 7", "ab"),
("draft-7", "a\nb"),
("draft-7", ""),
] {
let bad = Draft {
id,
reachbook_hash: hash,
message: MESSAGE.as_bytes(),
};
assert!(export(&bad, later, now()).is_err(), "{id:?} {hash:?}");
}
let latin1 = Draft {
message: b"Date: x\nMessage-ID: <a@example.test>\n\ncaf\xe9\n",
..draft(MESSAGE)
};
assert!(export(&latin1, later, now()).is_err());
}
#[test]
fn date_and_message_id_must_be_valid_not_only_present() {
let later = now() + Duration::hours(1);
let date = "Date: Thu, 22 Oct 2026 12:00:00 +0000\n";
let id = "Message-ID: <draft-7@example.test>\n";
for bad in [
MESSAGE.replace(date, "Date: \n"),
MESSAGE.replace(date, "Date: tomorrow\n"),
MESSAGE.replace(date, "Date: 2026-10-22T12:00:00Z\n"),
MESSAGE.replace(date, &format!("{date}{date}")),
MESSAGE.replace(id, "Message-ID: \n"),
MESSAGE.replace(id, "Message-ID: <>\n"),
MESSAGE.replace(id, "Message-ID: draft-7@example.test\n"),
MESSAGE.replace(id, "Message-ID: <draft 7@example.test>\n"),
MESSAGE.replace(id, "Message-ID: <\"d\"@example.test>\n"),
MESSAGE.replace(id, "Message-ID: <a..b@example.test>\n"),
MESSAGE.replace(id, "Message-ID: <a@b@example.test>\n"),
MESSAGE.replace(id, &format!("{id}{id}")),
] {
assert!(export(&draft(&bad), later, now()).is_err(), "{bad}");
}
for good in [
MESSAGE.replace(date, "Date: Thu, 22 Oct 2026\n 12:00:00 +0000\n"),
MESSAGE.replace(id, "Message-ID: <a.b+c@[192.0.2.1]>\n"),
] {
assert!(export(&draft(&good), later, now()).is_ok(), "{good}");
}
}
#[test]
fn a_bcc_header_is_refused_because_sendmail_t_strips_it() {
let later = now() + Duration::hours(1);
for bcc in ["Bcc: hidden@example.test\n", "bcc:\n hidden@example.test\n"] {
let message = MESSAGE.replace("Subject:", &format!("{bcc}Subject:"));
let err = export(&draft(&message), later, now()).unwrap_err();
assert!(err.to_string().contains("Bcc"), "{bcc}: {err}");
}
}
#[test]
fn every_resent_header_is_refused_because_sendmail_t_follows_it() {
let later = now() + Duration::hours(1);
for resent in [
"Resent-To: other@example.test\n",
"Resent-Cc: other@example.test\n",
"Resent-Bcc: x@example.test\n",
"RESENT-FROM: owner@example.test\n",
"Resent-Date: Thu, 22 Oct 2026 12:00:00 +0000\n",
"Resent-Message-ID: <r@example.test>\n",
"resent-sender:\n owner@example.test\n",
] {
let message = MESSAGE.replace("Subject:", &format!("{resent}Subject:"));
let err = export(&draft(&message), later, now()).unwrap_err();
assert!(err.to_string().contains("Resent-"), "{resent}: {err}");
}
}
#[test]
fn reachbook_hash_must_be_the_sha256_of_the_message() {
let later = now() + Duration::hours(1);
let other = MESSAGE.replace("noon", "one");
let upper = sha(MESSAGE).to_ascii_uppercase();
for hash in [sha(&other), &upper, "ab12cd34"] {
let bad = Draft {
reachbook_hash: hash,
..draft(MESSAGE)
};
let err = export(&bad, later, now()).unwrap_err();
assert!(err.to_string().contains("reachbook-hash"), "{hash}: {err}");
}
let got = export(&draft(MESSAGE), later, now()).unwrap();
assert_eq!(got.hash, sha(MESSAGE));
}
#[test]
fn no_approval_file_is_minted_for_a_draft_the_checks_refuse() {
let later = now() + Duration::hours(1);
for bad in [
MESSAGE.replace("Owner <owner@", "Owner <other@"),
MESSAGE.replace("friend@example.test", "pal@denied.example.test"),
MESSAGE.replace("See you at noon.", "About project-x."),
MESSAGE.replace("See you at noon.", "Generated by a tool."),
MESSAGE.replace("text/plain", "text/html"),
] {
let err = export(&draft(&bad), later, now()).unwrap_err();
assert!(err.to_string().contains("checks"), "{bad}: {err}");
}
let unset = MailConfig::default();
let err = ljos_cli::mailbox::export::export(&draft(MESSAGE), &unset, later, now()).unwrap_err();
assert!(err.to_string().contains("identity"), "{err}");
}