1use crate::config::{MailConfig, ResolvedIdentity, 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 identity = config
154 .resolve_identity(root, fm.identity.as_deref())
155 .map_err(identity_draft_error)?;
156 let rfc822_message_id = format!("<{message_id}@afmail.local>");
157 build_message(
158 root,
159 case_path,
160 case_uid,
161 &fm,
162 &body,
163 &identity,
164 &rfc822_message_id,
165 )
166}
167
168fn build_message(
169 root: &Path,
170 case_path: &Path,
171 case_uid: &str,
172 fm: &DraftFrontmatter,
173 body: &str,
174 identity: &ResolvedIdentity,
175 rfc822_message_id: &str,
176) -> Result<Message> {
177 if fm.case_uid != case_uid {
178 return Err(AppError::new(
179 "draft_invalid",
180 "draft case_uid does not match case",
181 ));
182 }
183 let mut builder = Message::builder()
184 .from(identity_mailbox(identity)?)
185 .message_id(Some(rfc822_message_id.to_string()))
186 .subject(
187 fm.subject
188 .clone()
189 .ok_or_else(|| AppError::new("draft_invalid", "draft subject is required"))?,
190 );
191 for to in &fm.to {
192 builder = builder.to(parse_mailbox(to, "to")?);
193 }
194 for cc in &fm.cc {
195 builder = builder.cc(parse_mailbox(cc, "cc")?);
196 }
197 if let Some(reply_id) = fm.reply_to_message_id.as_ref() {
198 let headers = reply_headers(root, reply_id)?;
199 builder = builder
200 .in_reply_to(headers.in_reply_to)
201 .references(headers.references);
202 }
203 let attachments = &fm.attachments;
204 if attachments.is_empty() {
205 return builder
206 .header(header::ContentType::TEXT_PLAIN)
207 .body(body.to_string())
208 .map_err(|e| AppError::new("draft_invalid", e.to_string()));
209 }
210 let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(body.to_string()));
211 for attachment in attachments {
212 let path = draft_attachment_path(case_path, attachment)?;
213 let data = fs::read(&path).map_err(|e| AppError::io("read draft attachment", &e))?;
214 let raw_filename = path
215 .file_name()
216 .and_then(|s| s.to_str())
217 .map(ToString::to_string)
218 .ok_or_else(|| AppError::new("draft_invalid", "invalid attachment file name"))?;
219 let filename = safe_outbound_attachment_filename(&raw_filename);
220 let content_type_str = mime_guess::from_path(&filename)
221 .first_raw()
222 .unwrap_or("application/octet-stream");
223 let content_type = header::ContentType::parse(content_type_str)
224 .map_err(|e| AppError::new("draft_invalid", e.to_string()))?;
225 multipart = multipart.singlepart(Attachment::new(filename).body(data, content_type));
226 }
227 builder
228 .multipart(multipart)
229 .map_err(|e| AppError::new("draft_invalid", e.to_string()))
230}
231
232fn identity_mailbox(identity: &ResolvedIdentity) -> Result<Mailbox> {
233 let address = identity
234 .email
235 .parse::<Address>()
236 .map_err(|e| AppError::new("draft_invalid", format!("invalid identity email: {e}")))?;
237 Ok(Mailbox::new(Some(identity.name.clone()), address))
238}
239
240fn identity_draft_error(err: AppError) -> AppError {
241 match err.error_code {
242 "unknown_identity" | "config_invalid" => AppError::new("draft_invalid", err.message),
243 _ => err,
244 }
245}
246
247fn draft_attachment_path(case_path: &Path, attachment: &str) -> Result<PathBuf> {
248 let path = Path::new(attachment);
249 if attachment.trim().is_empty() || path.is_absolute() {
250 return Err(AppError::new(
251 "draft_invalid",
252 format!("invalid draft attachment path: {attachment}"),
253 ));
254 }
255 let mut safe = PathBuf::new();
256 for component in path.components() {
257 match component {
258 std::path::Component::Normal(part) => safe.push(part),
259 _ => {
260 return Err(AppError::new(
261 "draft_invalid",
262 format!("invalid draft attachment path: {attachment}"),
263 ))
264 }
265 }
266 }
267 if safe.as_os_str().is_empty() {
268 return Err(AppError::new(
269 "draft_invalid",
270 format!("invalid draft attachment path: {attachment}"),
271 ));
272 }
273 Ok(case_path.join(safe))
274}
275
276fn safe_outbound_attachment_filename(filename: &str) -> String {
277 let sanitized = sanitize_with_options(
278 filename.trim(),
279 SanitizeFilenameOptions {
280 windows: true,
281 truncate: true,
282 replacement: "_",
283 },
284 );
285 if sanitized.trim().is_empty() {
286 "attachment".to_string()
287 } else {
288 sanitized.trim().to_string()
289 }
290}
291
292fn build_transport(config: &SmtpConfig) -> Result<SmtpTransport> {
293 let mut builder = if config.tls_wrapper {
294 SmtpTransport::relay(&config.host)
295 .map_err(|e| AppError::new("smtp_connect_failed", e.to_string()))?
296 .port(config.port)
297 } else if config.starttls {
298 SmtpTransport::starttls_relay(&config.host)
299 .map_err(|e| AppError::new("smtp_connect_failed", e.to_string()))?
300 .port(config.port)
301 } else {
302 SmtpTransport::builder_dangerous(&config.host).port(config.port)
303 };
304 if let (Some(username), Some(password)) = (&config.username, &config.password_secret) {
305 builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
306 }
307 Ok(builder.build())
308}
309
310fn parse_mailbox(value: &str, field: &str) -> Result<Mailbox> {
311 value
312 .parse::<Mailbox>()
313 .map_err(|e| AppError::new("draft_invalid", format!("invalid {field} address: {e}")))
314}
315
316struct ReplyHeaders {
317 in_reply_to: String,
318 references: String,
319}
320
321fn reply_headers(root: &Path, message_id: &str) -> Result<ReplyHeaders> {
325 let message = crate::store::Workspace::at(root).read_message_by_id(message_id)?;
326 let parent_id = message.rfc822_message_id.ok_or_else(|| {
327 AppError::new(
328 "draft_invalid",
329 format!("reply message has no rfc822_message_id: {message_id}"),
330 )
331 })?;
332 let mut refs = message.references.clone();
333 if !refs.contains(&parent_id) {
334 refs.push(parent_id.clone());
335 }
336 let references = refs
337 .iter()
338 .map(|id| ensure_brackets(id))
339 .collect::<Vec<_>>()
340 .join(" ");
341 Ok(ReplyHeaders {
342 in_reply_to: ensure_brackets(&parent_id),
343 references,
344 })
345}
346
347fn ensure_brackets(id: &str) -> String {
349 let trimmed = id.trim();
350 if trimmed.starts_with('<') && trimmed.ends_with('>') {
351 trimmed.to_string()
352 } else {
353 format!("<{trimmed}>")
354 }
355}
356
357fn unique_outbound_id(root: &Path) -> String {
358 let base = format!(
359 "message_sent_{}",
360 crate::store::now_rfc3339().replace([':', '-'], "")
361 );
362 let dir = root.join(".afmail/messages");
363 if !dir.join(format!("{base}.json")).exists() {
364 return base;
365 }
366 for i in 1..1000 {
367 let candidate = format!("{base}_{i}");
368 if !dir.join(format!("{candidate}.json")).exists() {
369 return candidate;
370 }
371 }
372 base
373}
374
375fn read_case_messages(case_path: &Path, case_uid: &str) -> Result<CaseMessages> {
376 let path = case_path.join("data").join("case.json");
377 let data = fs::read_to_string(&path).map_err(|e| AppError::io("read case metadata", &e))?;
378 let messages: CaseMessages =
379 serde_json::from_str(&data).map_err(|e| AppError::json("parse case metadata", &e))?;
380 if messages.schema_name != crate::types::CASE_SCHEMA_NAME
381 || messages.schema_version != crate::types::MESSAGE_COLLECTION_SCHEMA_VERSION
382 || messages.collection_uid != case_uid
383 {
384 return Err(AppError::new(
385 "case_metadata_invalid",
386 "invalid case metadata schema",
387 ));
388 }
389 Ok(messages)
390}
391
392fn update_case_metadata_after_append(case_path: &Path, case: &CaseMessages) -> Result<()> {
393 let path = case_path.join("data").join("case.json");
394 let mut updated: CaseFrontmatter = case.clone();
395 updated.updated_rfc3339 = Some(crate::store::now_rfc3339());
396 updated.normalize(
397 crate::types::CASE_SCHEMA_NAME,
398 &case.collection_uid,
399 &case.collection_name,
400 );
401 write_json_pretty(&path, &updated)
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use std::path::PathBuf;
408 use std::time::{SystemTime, UNIX_EPOCH};
409
410 fn temp_root(name: &str) -> PathBuf {
411 let stamp = SystemTime::now()
412 .duration_since(UNIX_EPOCH)
413 .map(|d| d.as_nanos())
414 .unwrap_or(0);
415 std::env::temp_dir().join(format!("afmail-smtp-{name}-{}-{stamp}", std::process::id()))
416 }
417
418 fn draft(text: &str) -> DraftFrontmatter {
419 crate::markdown::parse_frontmatter(text).unwrap_or_default()
420 }
421
422 fn test_identity() -> ResolvedIdentity {
423 ResolvedIdentity {
424 identity: "me".to_string(),
425 name: "Me".to_string(),
426 email: "me@example.com".to_string(),
427 default: true,
428 footer: None,
429 notes: None,
430 }
431 }
432
433 #[test]
434 fn builds_plain_draft_message() {
435 let root = temp_root("build");
436 let case_path = root.join("cases/open/c20260521001");
437 let _ = fs::create_dir_all(case_path.join("drafts"));
438 let fm = draft(
439 "kind: draft\ncase_uid: c20260521001\nto:\n - alice@example.com\nsubject: Hello",
440 );
441 let msg = build_message(
442 &root,
443 &case_path,
444 "c20260521001",
445 &fm,
446 "Hi",
447 &test_identity(),
448 "<msg@example.com>",
449 );
450 assert!(msg.is_ok());
451 let raw = msg
452 .map(|m| String::from_utf8(m.formatted()).unwrap_or_default())
453 .unwrap_or_default();
454 assert!(raw.contains("Subject: Hello"));
455 assert!(raw.contains("Hi"));
456 assert!(raw.contains("Message-ID: <msg@example.com>"));
457 let _ = fs::remove_dir_all(root);
458 }
459
460 fn write_parent(root: &Path, message_id: &str, rfc822_id: &str, references: &[&str]) {
461 let msg = MessageFile {
462 schema_name: "message".to_string(),
463 schema_version: 1,
464 message_id: message_id.to_string(),
465 rfc822_message_id: Some(rfc822_id.to_string()),
466 in_reply_to: None,
467 references: references.iter().map(|s| s.to_string()).collect(),
468 remote: None,
469 direction: Some("inbound".to_string()),
470 subject: Some("Hi".to_string()),
471 from: Some("a@example.com".to_string()),
472 to: Vec::new(),
473 cc: Vec::new(),
474 bcc: Vec::new(),
475 reply_to: Vec::new(),
476 sender: None,
477 delivered_to: Vec::new(),
478 x_original_to: Vec::new(),
479 envelope_to: Vec::new(),
480 list_id: None,
481 mailing_list_headers: Vec::new(),
482 authentication: MessageAuthentication::default(),
483 received_rfc3339: None,
484 sent_rfc3339: None,
485 body_text: String::new(),
486 eml_path: None,
487 attachments: Vec::new(),
488 contact: None,
489 identity: None,
490 identity_email: None,
491 identity_match: None,
492 identity_candidates: Vec::new(),
493 observed_recipient_emails: Vec::new(),
494 workspace: crate::types::WorkspaceState {
495 status: "triage".to_string(),
496 archive_uid: None,
497 archived_rfc3339: None,
498 origin: None,
499 remote_sync: None,
500 push: None,
501 },
502 };
503 let _ = crate::store::Workspace::at(root).write_message_materialized_cache(&msg);
504 }
505
506 #[test]
507 fn reply_builds_full_references_chain() {
508 let root = temp_root("reply-chain");
509 let case_path = root.join("cases/open/c20260521001");
510 let _ = fs::create_dir_all(case_path.join("drafts"));
511 write_parent(
513 &root,
514 "message_p",
515 "parent@example.com",
516 &["root@example.com"],
517 );
518 let identity = test_identity();
519 let fm = draft(
520 "kind: draft\ncase_uid: c20260521001\nto:\n - a@example.com\nsubject: \"Re: Hi\"\nreply_to_message_id: message_p",
521 );
522 let raw = build_message(
523 &root,
524 &case_path,
525 "c20260521001",
526 &fm,
527 "reply body",
528 &identity,
529 "<reply@afmail.local>",
530 )
531 .map(|m| String::from_utf8(m.formatted()).unwrap_or_default())
532 .unwrap_or_default();
533 assert!(raw.contains("In-Reply-To: <parent@example.com>"));
534 assert!(raw.contains("References: <root@example.com> <parent@example.com>"));
535 let _ = fs::remove_dir_all(root);
536 }
537
538 #[test]
539 fn reply_without_parent_references_falls_back_to_parent_id() {
540 let root = temp_root("reply-fallback");
541 let case_path = root.join("cases/open/c20260521001");
542 let _ = fs::create_dir_all(case_path.join("drafts"));
543 write_parent(&root, "message_p", "parent@example.com", &[]);
544 let identity = test_identity();
545 let fm = draft(
546 "kind: draft\ncase_uid: c20260521001\nto:\n - a@example.com\nsubject: \"Re: Hi\"\nreply_to_message_id: message_p",
547 );
548 let raw = build_message(
549 &root,
550 &case_path,
551 "c20260521001",
552 &fm,
553 "reply body",
554 &identity,
555 "<reply@afmail.local>",
556 )
557 .map(|m| String::from_utf8(m.formatted()).unwrap_or_default())
558 .unwrap_or_default();
559 assert!(raw.contains("In-Reply-To: <parent@example.com>"));
560 assert!(raw.contains("References: <parent@example.com>"));
561 let _ = fs::remove_dir_all(root);
562 }
563}