Skip to main content

agent_first_mail/
smtp_send.rs

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
304/// Build RFC 5322 threading headers for a reply to `message_id`.
305/// `In-Reply-To` is the parent's own Message-ID; `References` is the parent's
306/// own References chain plus the parent's Message-ID appended.
307fn 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
330/// Wrap a bare message-id in angle brackets unless it already has them.
331fn 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        // Parent already carries a References chain (bracket-less, as stored).
478        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}