ljos_cli/mailbox/
export.rs1use std::io::Read as _;
24
25use anyhow::{bail, Context as _, Result};
26use chrono::{DateTime, Duration, SecondsFormat, Utc};
27use sha2::{Digest as _, Sha256};
28
29use super::config::MailConfig;
30
31pub const MAX_LIFETIME: Duration = Duration::hours(24);
33
34pub struct Draft<'a> {
36 pub id: &'a str,
37 pub reachbook_hash: &'a str,
39 pub message: &'a [u8],
41}
42
43#[derive(Debug, Clone)]
45pub struct Approval {
46 pub file: Vec<u8>,
47 pub hash: String,
49 pub nonce: String,
50 pub expires: DateTime<Utc>,
51}
52
53pub fn export(
57 draft: &Draft<'_>,
58 config: &MailConfig,
59 expires: DateTime<Utc>,
60 now: DateTime<Utc>,
61) -> Result<Approval> {
62 for (name, value) in [
63 ("draft", draft.id),
64 ("reachbook-hash", draft.reachbook_hash),
65 ] {
66 if value.is_empty() || value.len() > 200 || !value.bytes().all(|b| b.is_ascii_graphic()) {
67 bail!("export: {name} must be 1 to 200 printable characters with no space");
68 }
69 }
70 if expires <= now || expires - now > MAX_LIFETIME {
71 bail!("export: expires must be after now and at most 24 h out");
72 }
73 let message = std::str::from_utf8(draft.message).context("export: the message is not UTF-8")?;
74 refuse_hiding(message)?;
75 let Some((headers, _)) = message.split_once("\n\n") else {
76 bail!("export: the message has no blank line between headers and body");
77 };
78 let fields = super::checks::unfold(headers)
79 .context("export: a header line is neither a field nor a continuation")?;
80 let one = |name: &str| -> Result<&str> {
81 let mut values = fields.iter().filter(|(n, _)| n == name);
82 match (values.next(), values.next()) {
83 (Some((_, value)), None) => Ok(value),
84 _ => bail!(
85 "export: the message needs exactly one {name} header; the send path would add one"
86 ),
87 }
88 };
89 if DateTime::parse_from_rfc2822(one("date")?).is_err() {
90 bail!("export: Date is not an RFC 5322 date-time");
91 }
92 if !is_msg_id(one("message-id")?) {
93 bail!("export: Message-ID is not an RFC 5322 msg-id such as <id@example.test>");
94 }
95 if fields.iter().any(|(n, _)| n == "bcc") {
96 bail!("export: sendmail -t strips Bcc, so the sent bytes would not be H; use To or Cc");
97 }
98 if let Some((name, _)) = fields.iter().find(|(n, _)| n.starts_with("resent-")) {
99 bail!(
100 "export: {name} is refused: with a Resent- header sendmail -t sends to the Resent- recipients, not To and Cc"
101 );
102 }
103 let refused = super::checks::refusals(draft.message, config);
104 if !refused.is_empty() {
105 bail!(
106 "export: the checks refuse this draft: {}",
107 refused.join("; ")
108 );
109 }
110 let hash = hex::encode(Sha256::digest(draft.message));
111 if draft.reachbook_hash != hash {
112 bail!("export: reachbook-hash is not the sha256 of the message, so the outbox holds other bytes");
113 }
114 let mut seed = [0u8; 16];
115 std::fs::File::open("/dev/urandom")
116 .and_then(|mut f| f.read_exact(&mut seed))
117 .context("export: no randomness for the nonce")?;
118 let nonce = hex::encode(seed);
119 let mut file = format!(
120 "ljos-outbox v1\ndraft {}\nreachbook-hash {}\nexpires {}\nnonce {nonce}\n\n",
121 draft.id,
122 draft.reachbook_hash,
123 expires.to_rfc3339_opts(SecondsFormat::Secs, true),
124 )
125 .into_bytes();
126 file.extend_from_slice(draft.message);
127 Ok(Approval {
128 file,
129 hash,
130 nonce,
131 expires,
132 })
133}
134
135fn is_msg_id(value: &str) -> bool {
138 let dot_atom = |s: &str| {
139 s.split('.').all(|atom| {
140 !atom.is_empty()
141 && atom
142 .bytes()
143 .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-/=?^_`{|}~".contains(&b))
144 })
145 };
146 let Some((left, right)) = value
147 .strip_prefix('<')
148 .and_then(|v| v.strip_suffix('>'))
149 .and_then(|v| v.split_once('@'))
150 else {
151 return false;
152 };
153 let literal = right
154 .strip_prefix('[')
155 .and_then(|r| r.strip_suffix(']'))
156 .is_some_and(|d| {
157 d.bytes()
158 .all(|b| b.is_ascii_graphic() && !b"[]\\".contains(&b))
159 });
160 dot_atom(left) && (dot_atom(right) || literal)
161}
162
163fn refuse_hiding(message: &str) -> Result<()> {
164 for (n, line) in message.split('\n').enumerate() {
165 if let Some((col, c)) = line
166 .chars()
167 .enumerate()
168 .find(|(_, c)| super::notmuch::hiding(*c))
169 {
170 bail!(
171 "export: line {} column {} carries U+{:04X}; only tab and newline may appear",
172 n + 1,
173 col + 1,
174 c as u32
175 );
176 }
177 }
178 Ok(())
179}