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 return Err(crate::policy_label::labelled(
68 "draft-warning",
69 format!("export: {name} must be 1 to 200 printable characters with no space"),
70 ));
71 }
72 }
73 if expires <= now || expires - now > MAX_LIFETIME {
74 return Err(crate::policy_label::labelled(
75 "draft-warning",
76 "export: expires must be after now and at most 24 h out",
77 ));
78 }
79 let message = std::str::from_utf8(draft.message).context("export: the message is not UTF-8")?;
80 refuse_hiding(message)?;
81 let Some((headers, _)) = message.split_once("\n\n") else {
82 return Err(crate::policy_label::labelled(
83 "draft-warning",
84 "export: the message has no blank line between headers and body",
85 ));
86 };
87 let fields = super::checks::unfold(headers)
88 .context("export: a header line is neither a field nor a continuation")?;
89 let one = |name: &str| -> Result<&str> {
90 let mut values = fields.iter().filter(|(n, _)| n == name);
91 match (values.next(), values.next()) {
92 (Some((_, value)), None) => Ok(value),
93 _ => Err(crate::policy_label::labelled(
94 "draft-warning",
95 format!(
96 "export: the message needs exactly one {name} header; the send path would add one"
97 ),
98 )),
99 }
100 };
101 if DateTime::parse_from_rfc2822(one("date")?).is_err() {
102 return Err(crate::policy_label::labelled(
103 "draft-warning",
104 "export: Date is not an RFC 5322 date-time",
105 ));
106 }
107 if !is_msg_id(one("message-id")?) {
108 return Err(crate::policy_label::labelled(
109 "draft-warning",
110 "export: Message-ID is not an RFC 5322 msg-id such as <id@example.test>",
111 ));
112 }
113 if fields.iter().any(|(n, _)| n == "bcc") {
114 return Err(crate::policy_label::labelled(
115 "draft-warning",
116 "export: sendmail -t strips Bcc, so the sent bytes would not be H; use To or Cc",
117 ));
118 }
119 if let Some((name, _)) = fields.iter().find(|(n, _)| n.starts_with("resent-")) {
120 return Err(crate::policy_label::labelled(
121 "draft-warning",
122 format!(
123 "export: {name} is refused: with a Resent- header sendmail -t sends to the Resent- recipients, not To and Cc"
124 ),
125 ));
126 }
127 let refused = super::checks::refusals(draft.message, config);
128 if !refused.is_empty() {
129 return Err(crate::policy_label::labelled(
130 "draft-warning",
131 format!(
132 "export: the checks refuse this draft: {}",
133 refused.join("; ")
134 ),
135 ));
136 }
137 let hash = hex::encode(Sha256::digest(draft.message));
138 if draft.reachbook_hash != hash {
139 return Err(crate::policy_label::labelled(
140 "draft-warning",
141 "export: reachbook-hash is not the sha256 of the message, so the outbox holds other bytes",
142 ));
143 }
144 let mut seed = [0u8; 16];
145 std::fs::File::open("/dev/urandom")
146 .and_then(|mut f| f.read_exact(&mut seed))
147 .context("export: no randomness for the nonce")?;
148 let nonce = hex::encode(seed);
149 let mut file = format!(
150 "ljos-outbox v1\ndraft {}\nreachbook-hash {}\nexpires {}\nnonce {nonce}\n\n",
151 draft.id,
152 draft.reachbook_hash,
153 expires.to_rfc3339_opts(SecondsFormat::Secs, true),
154 )
155 .into_bytes();
156 file.extend_from_slice(draft.message);
157 Ok(Approval {
158 file,
159 hash,
160 nonce,
161 expires,
162 })
163}
164
165fn is_msg_id(value: &str) -> bool {
168 let dot_atom = |s: &str| {
169 s.split('.').all(|atom| {
170 !atom.is_empty()
171 && atom
172 .bytes()
173 .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-/=?^_`{|}~".contains(&b))
174 })
175 };
176 let Some((left, right)) = value
177 .strip_prefix('<')
178 .and_then(|v| v.strip_suffix('>'))
179 .and_then(|v| v.split_once('@'))
180 else {
181 return false;
182 };
183 let literal = right
184 .strip_prefix('[')
185 .and_then(|r| r.strip_suffix(']'))
186 .is_some_and(|d| {
187 d.bytes()
188 .all(|b| b.is_ascii_graphic() && !b"[]\\".contains(&b))
189 });
190 dot_atom(left) && (dot_atom(right) || literal)
191}
192
193fn refuse_hiding(message: &str) -> Result<()> {
194 for (n, line) in message.split('\n').enumerate() {
195 if let Some((col, c)) = line
196 .chars()
197 .enumerate()
198 .find(|(_, c)| super::notmuch::hiding(*c))
199 {
200 bail!(
201 "export: line {} column {} carries U+{:04X}; only tab and newline may appear",
202 n + 1,
203 col + 1,
204 c as u32
205 );
206 }
207 }
208 Ok(())
209}