Skip to main content

agent_first_mail/
smtp_send.rs

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