1use crate::config::{MailConfig, SmtpConfig};
2use crate::error::{AppError, Result};
3use crate::frontmatter::{CaseFrontmatter, DraftFrontmatter};
4use crate::mail::parse_outbound_message_with_status;
5use crate::markdown::read_doc;
6use crate::types::CaseMessages;
7#[cfg(test)]
8use crate::types::{MessageAuthentication, MessageFile};
9use crate::util::{write_bytes_atomic, write_json_pretty};
10use lettre::address::{Address, Envelope};
11use lettre::message::{header, Attachment, Mailbox, Message, MultiPart, SinglePart};
12use lettre::transport::smtp::authentication::Credentials;
13use lettre::{SmtpTransport, Transport};
14use sanitize_filename::{sanitize_with_options, Options as SanitizeFilenameOptions};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct PreparedOutbound {
20 pub message_id: String,
21 pub raw: Vec<u8>,
22 pub envelope_from: String,
23 pub envelope_to: Vec<String>,
24}
25
26pub fn prepare_outbound(
27 root: &Path,
28 case_path: &Path,
29 case_uid: &str,
30 draft_name: &str,
31 config: &MailConfig,
32 existing_message_id: Option<&str>,
33) -> Result<PreparedOutbound> {
34 let message_id = existing_message_id
35 .map(ToString::to_string)
36 .unwrap_or_else(|| unique_outbound_id(root));
37 let message = build_draft_message(root, case_path, case_uid, draft_name, config, &message_id)?;
38 let raw = message.formatted();
39 let envelope = message.envelope();
40 let envelope_from = envelope
41 .from()
42 .map(ToString::to_string)
43 .ok_or_else(|| AppError::new("draft_invalid", "draft envelope from is required"))?;
44 let envelope_to = envelope
45 .to()
46 .iter()
47 .map(ToString::to_string)
48 .collect::<Vec<_>>();
49 Ok(PreparedOutbound {
50 message_id,
51 raw,
52 envelope_from,
53 envelope_to,
54 })
55}
56
57pub fn message_id_for_push(push_id: &str) -> String {
58 let suffix = push_id
59 .strip_prefix("push_")
60 .unwrap_or(push_id)
61 .chars()
62 .map(|ch| {
63 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
64 ch
65 } else {
66 '_'
67 }
68 })
69 .collect::<String>();
70 format!("message_sent_{suffix}")
71}
72
73pub fn mark_sent_and_append_case(
74 root: &Path,
75 case_path: &Path,
76 case_uid: &str,
77 message_id: &str,
78 raw: &[u8],
79 _config: &MailConfig,
80) -> Result<()> {
81 let sent = crate::store::now_rfc3339();
82 let parsed = parse_outbound_message_with_status(
83 message_id.to_string(),
84 raw,
85 case_uid.to_string(),
86 "case".to_string(),
87 Some(sent),
88 )?;
89 let messages_dir = root.join(".afmail/messages");
90 fs::create_dir_all(&messages_dir).map_err(|e| AppError::io("create messages dir", &e))?;
91 write_bytes_atomic(
92 &messages_dir.join(format!("{message_id}.eml")),
93 raw,
94 "write sent eml",
95 )?;
96 crate::store::Workspace::at(root).write_message_artifacts(&parsed.message)?;
97 let mut case_data = read_case_messages(case_path, case_uid)?;
98 let added_rfc3339 = parsed
99 .message
100 .sent_rfc3339
101 .clone()
102 .unwrap_or_else(crate::store::now_rfc3339);
103 case_data.upsert_item(
104 message_id,
105 parsed.message.subject.as_deref(),
106 &added_rfc3339,
107 );
108 update_case_metadata_after_append(case_path, &case_data)?;
109 crate::store::Workspace::at(root).render_refresh()?;
110 Ok(())
111}
112
113pub fn send_raw_message(
114 config: &MailConfig,
115 envelope_from: &str,
116 envelope_to: &[String],
117 raw: &[u8],
118) -> Result<()> {
119 let smtp = config.require_smtp()?;
120 let sender = build_transport(&smtp)?;
121 let from = envelope_from
122 .parse::<Address>()
123 .map_err(|e| AppError::new("smtp_send_failed", format!("invalid envelope from: {e}")))?;
124 let mut to = Vec::new();
125 for address in envelope_to {
126 to.push(address.parse::<Address>().map_err(|e| {
127 AppError::new(
128 "smtp_send_failed",
129 format!("invalid envelope recipient: {e}"),
130 )
131 })?);
132 }
133 let envelope = Envelope::new(Some(from), to)
134 .map_err(|e| AppError::new("smtp_send_failed", e.to_string()))?;
135 sender
136 .send_raw(&envelope, raw)
137 .map_err(|e| AppError::new("smtp_send_failed", e.to_string()))?;
138 Ok(())
139}
140
141fn build_draft_message(
142 root: &Path,
143 case_path: &Path,
144 case_uid: &str,
145 draft_name: &str,
146 config: &MailConfig,
147 message_id: &str,
148) -> Result<Message> {
149 let draft_path = case_path.join("drafts").join(draft_name);
150 let draft_text = fs::read_to_string(&draft_path).map_err(|e| AppError::io("read draft", &e))?;
151 let (fm, raw_body) = read_doc::<DraftFrontmatter>(&draft_text)?;
152 let body = raw_body.trim_start().to_string();
153 let from = config.require_from()?;
154 let rfc822_message_id = format!("<{message_id}@afmail.local>");
155 build_message(
156 root,
157 case_path,
158 case_uid,
159 &fm,
160 &body,
161 &from,
162 &rfc822_message_id,
163 )
164}
165
166fn build_message(
167 root: &Path,
168 case_path: &Path,
169 case_uid: &str,
170 fm: &DraftFrontmatter,
171 body: &str,
172 from: &str,
173 rfc822_message_id: &str,
174) -> Result<Message> {
175 if fm.case_uid != case_uid {
176 return Err(AppError::new(
177 "draft_invalid",
178 "draft case_uid does not match case",
179 ));
180 }
181 let mut builder = Message::builder()
182 .from(parse_mailbox(from, "from")?)
183 .message_id(Some(rfc822_message_id.to_string()))
184 .subject(
185 fm.subject
186 .clone()
187 .ok_or_else(|| AppError::new("draft_invalid", "draft subject is required"))?,
188 );
189 for to in &fm.to {
190 builder = builder.to(parse_mailbox(to, "to")?);
191 }
192 for cc in &fm.cc {
193 builder = builder.cc(parse_mailbox(cc, "cc")?);
194 }
195 if let Some(reply_id) = fm.reply_to_message_id.as_ref() {
196 let headers = reply_headers(root, reply_id)?;
197 builder = builder
198 .in_reply_to(headers.in_reply_to)
199 .references(headers.references);
200 }
201 let attachments = &fm.attachments;
202 if attachments.is_empty() {
203 return builder
204 .header(header::ContentType::TEXT_PLAIN)
205 .body(body.to_string())
206 .map_err(|e| AppError::new("draft_invalid", e.to_string()));
207 }
208 let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(body.to_string()));
209 for attachment in attachments {
210 let path = draft_attachment_path(case_path, attachment)?;
211 let data = fs::read(&path).map_err(|e| AppError::io("read draft attachment", &e))?;
212 let raw_filename = path
213 .file_name()
214 .and_then(|s| s.to_str())
215 .map(ToString::to_string)
216 .ok_or_else(|| AppError::new("draft_invalid", "invalid attachment file name"))?;
217 let filename = safe_outbound_attachment_filename(&raw_filename);
218 let content_type_str = mime_guess::from_path(&filename)
219 .first_raw()
220 .unwrap_or("application/octet-stream");
221 let content_type = header::ContentType::parse(content_type_str)
222 .map_err(|e| AppError::new("draft_invalid", e.to_string()))?;
223 multipart = multipart.singlepart(Attachment::new(filename).body(data, content_type));
224 }
225 builder
226 .multipart(multipart)
227 .map_err(|e| AppError::new("draft_invalid", e.to_string()))
228}
229
230fn draft_attachment_path(case_path: &Path, attachment: &str) -> Result<PathBuf> {
231 let path = Path::new(attachment);
232 if attachment.trim().is_empty() || path.is_absolute() {
233 return Err(AppError::new(
234 "draft_invalid",
235 format!("invalid draft attachment path: {attachment}"),
236 ));
237 }
238 let mut safe = PathBuf::new();
239 for component in path.components() {
240 match component {
241 std::path::Component::Normal(part) => safe.push(part),
242 _ => {
243 return Err(AppError::new(
244 "draft_invalid",
245 format!("invalid draft attachment path: {attachment}"),
246 ))
247 }
248 }
249 }
250 if safe.as_os_str().is_empty() {
251 return Err(AppError::new(
252 "draft_invalid",
253 format!("invalid draft attachment path: {attachment}"),
254 ));
255 }
256 Ok(case_path.join(safe))
257}
258
259fn safe_outbound_attachment_filename(filename: &str) -> String {
260 let sanitized = sanitize_with_options(
261 filename.trim(),
262 SanitizeFilenameOptions {
263 windows: true,
264 truncate: true,
265 replacement: "_",
266 },
267 );
268 if sanitized.trim().is_empty() {
269 "attachment".to_string()
270 } else {
271 sanitized.trim().to_string()
272 }
273}
274
275fn build_transport(config: &SmtpConfig) -> Result<SmtpTransport> {
276 let mut builder = if config.tls_wrapper {
277 SmtpTransport::relay(&config.host)
278 .map_err(|e| AppError::new("smtp_connect_failed", e.to_string()))?
279 .port(config.port)
280 } else if config.starttls {
281 SmtpTransport::starttls_relay(&config.host)
282 .map_err(|e| AppError::new("smtp_connect_failed", e.to_string()))?
283 .port(config.port)
284 } else {
285 SmtpTransport::builder_dangerous(&config.host).port(config.port)
286 };
287 if let (Some(username), Some(password)) = (&config.username, &config.password_secret) {
288 builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
289 }
290 Ok(builder.build())
291}
292
293fn parse_mailbox(value: &str, field: &str) -> Result<Mailbox> {
294 value
295 .parse::<Mailbox>()
296 .map_err(|e| AppError::new("draft_invalid", format!("invalid {field} address: {e}")))
297}
298
299struct ReplyHeaders {
300 in_reply_to: String,
301 references: String,
302}
303
304fn reply_headers(root: &Path, message_id: &str) -> Result<ReplyHeaders> {
308 let message = crate::store::Workspace::at(root).read_message_by_id(message_id)?;
309 let parent_id = message.rfc822_message_id.ok_or_else(|| {
310 AppError::new(
311 "draft_invalid",
312 format!("reply message has no rfc822_message_id: {message_id}"),
313 )
314 })?;
315 let mut refs = message.references.clone();
316 if !refs.contains(&parent_id) {
317 refs.push(parent_id.clone());
318 }
319 let references = refs
320 .iter()
321 .map(|id| ensure_brackets(id))
322 .collect::<Vec<_>>()
323 .join(" ");
324 Ok(ReplyHeaders {
325 in_reply_to: ensure_brackets(&parent_id),
326 references,
327 })
328}
329
330fn ensure_brackets(id: &str) -> String {
332 let trimmed = id.trim();
333 if trimmed.starts_with('<') && trimmed.ends_with('>') {
334 trimmed.to_string()
335 } else {
336 format!("<{trimmed}>")
337 }
338}
339
340fn unique_outbound_id(root: &Path) -> String {
341 let base = format!(
342 "message_sent_{}",
343 crate::store::now_rfc3339().replace([':', '-'], "")
344 );
345 let dir = root.join(".afmail/messages");
346 if !dir.join(format!("{base}.json")).exists() {
347 return base;
348 }
349 for i in 1..1000 {
350 let candidate = format!("{base}_{i}");
351 if !dir.join(format!("{candidate}.json")).exists() {
352 return candidate;
353 }
354 }
355 base
356}
357
358fn read_case_messages(case_path: &Path, case_uid: &str) -> Result<CaseMessages> {
359 let path = case_path.join("data").join("case.json");
360 let data = fs::read_to_string(&path).map_err(|e| AppError::io("read case metadata", &e))?;
361 let messages: CaseMessages =
362 serde_json::from_str(&data).map_err(|e| AppError::json("parse case metadata", &e))?;
363 if messages.schema_name != crate::types::CASE_SCHEMA_NAME
364 || messages.schema_version != crate::types::MESSAGE_COLLECTION_SCHEMA_VERSION
365 || messages.collection_uid != case_uid
366 {
367 return Err(AppError::new(
368 "case_metadata_invalid",
369 "invalid case metadata schema",
370 ));
371 }
372 Ok(messages)
373}
374
375fn update_case_metadata_after_append(case_path: &Path, case: &CaseMessages) -> Result<()> {
376 let path = case_path.join("data").join("case.json");
377 let mut updated: CaseFrontmatter = case.clone();
378 updated.updated_rfc3339 = Some(crate::store::now_rfc3339());
379 updated.normalize(
380 crate::types::CASE_SCHEMA_NAME,
381 &case.collection_uid,
382 &case.collection_name,
383 );
384 write_json_pretty(&path, &updated)
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use std::path::PathBuf;
391 use std::time::{SystemTime, UNIX_EPOCH};
392
393 fn temp_root(name: &str) -> PathBuf {
394 let stamp = SystemTime::now()
395 .duration_since(UNIX_EPOCH)
396 .map(|d| d.as_nanos())
397 .unwrap_or(0);
398 std::env::temp_dir().join(format!("afmail-smtp-{name}-{}-{stamp}", std::process::id()))
399 }
400
401 fn draft(text: &str) -> DraftFrontmatter {
402 crate::markdown::parse_frontmatter(text).unwrap_or_default()
403 }
404
405 #[test]
406 fn builds_plain_draft_message() {
407 let root = temp_root("build");
408 let case_path = root.join("cases/open/c20260521001");
409 let _ = fs::create_dir_all(case_path.join("drafts"));
410 let fm = draft(
411 "kind: draft\ncase_uid: c20260521001\nto:\n - alice@example.com\nsubject: Hello",
412 );
413 let msg = build_message(
414 &root,
415 &case_path,
416 "c20260521001",
417 &fm,
418 "Hi",
419 "Me <me@example.com>",
420 "<msg@example.com>",
421 );
422 assert!(msg.is_ok());
423 let raw = msg
424 .map(|m| String::from_utf8(m.formatted()).unwrap_or_default())
425 .unwrap_or_default();
426 assert!(raw.contains("Subject: Hello"));
427 assert!(raw.contains("Hi"));
428 assert!(raw.contains("Message-ID: <msg@example.com>"));
429 let _ = fs::remove_dir_all(root);
430 }
431
432 fn write_parent(root: &Path, message_id: &str, rfc822_id: &str, references: &[&str]) {
433 let msg = MessageFile {
434 schema_name: "message".to_string(),
435 schema_version: 1,
436 message_id: message_id.to_string(),
437 rfc822_message_id: Some(rfc822_id.to_string()),
438 in_reply_to: None,
439 references: references.iter().map(|s| s.to_string()).collect(),
440 remote: None,
441 direction: Some("inbound".to_string()),
442 subject: Some("Hi".to_string()),
443 from: Some("a@example.com".to_string()),
444 to: Vec::new(),
445 cc: Vec::new(),
446 bcc: Vec::new(),
447 reply_to: Vec::new(),
448 sender: None,
449 delivered_to: Vec::new(),
450 x_original_to: Vec::new(),
451 envelope_to: Vec::new(),
452 list_id: None,
453 mailing_list_headers: Vec::new(),
454 authentication: MessageAuthentication::default(),
455 received_rfc3339: None,
456 sent_rfc3339: None,
457 body_text: String::new(),
458 eml_path: None,
459 attachments: Vec::new(),
460 workspace: crate::types::WorkspaceState {
461 status: "triage".to_string(),
462 archive_uid: None,
463 archived_rfc3339: None,
464 origin: None,
465 remote_sync: None,
466 push: None,
467 },
468 };
469 let _ = crate::store::Workspace::at(root).write_message_materialized_cache(&msg);
470 }
471
472 #[test]
473 fn reply_builds_full_references_chain() {
474 let root = temp_root("reply-chain");
475 let case_path = root.join("cases/open/c20260521001");
476 let _ = fs::create_dir_all(case_path.join("drafts"));
477 write_parent(
479 &root,
480 "message_p",
481 "parent@example.com",
482 &["root@example.com"],
483 );
484 let fm = draft(
485 "kind: draft\ncase_uid: c20260521001\nto:\n - a@example.com\nsubject: \"Re: Hi\"\nreply_to_message_id: message_p",
486 );
487 let raw = build_message(
488 &root,
489 &case_path,
490 "c20260521001",
491 &fm,
492 "reply body",
493 "Me <me@example.com>",
494 "<reply@afmail.local>",
495 )
496 .map(|m| String::from_utf8(m.formatted()).unwrap_or_default())
497 .unwrap_or_default();
498 assert!(raw.contains("In-Reply-To: <parent@example.com>"));
499 assert!(raw.contains("References: <root@example.com> <parent@example.com>"));
500 let _ = fs::remove_dir_all(root);
501 }
502
503 #[test]
504 fn reply_without_parent_references_falls_back_to_parent_id() {
505 let root = temp_root("reply-fallback");
506 let case_path = root.join("cases/open/c20260521001");
507 let _ = fs::create_dir_all(case_path.join("drafts"));
508 write_parent(&root, "message_p", "parent@example.com", &[]);
509 let fm = draft(
510 "kind: draft\ncase_uid: c20260521001\nto:\n - a@example.com\nsubject: \"Re: Hi\"\nreply_to_message_id: message_p",
511 );
512 let raw = build_message(
513 &root,
514 &case_path,
515 "c20260521001",
516 &fm,
517 "reply body",
518 "Me <me@example.com>",
519 "<reply@afmail.local>",
520 )
521 .map(|m| String::from_utf8(m.formatted()).unwrap_or_default())
522 .unwrap_or_default();
523 assert!(raw.contains("In-Reply-To: <parent@example.com>"));
524 assert!(raw.contains("References: <parent@example.com>"));
525 let _ = fs::remove_dir_all(root);
526 }
527}