Skip to main content

mxr_compose/
lib.rs

1pub mod attachments;
2pub mod email;
3pub mod editor;
4pub mod frontmatter;
5pub mod parse;
6pub mod render;
7
8use crate::frontmatter::{ComposeError, ComposeFrontmatter};
9use std::path::PathBuf;
10use uuid::Uuid;
11
12/// The kind of compose action.
13pub enum ComposeKind {
14    New,
15    NewWithTo {
16        to: String,
17    },
18    Reply {
19        in_reply_to: String,
20        references: Vec<String>,
21        to: String,
22        cc: String,
23        subject: String,
24        /// Reader-mode-cleaned thread content for context block.
25        thread_context: String,
26    },
27    Forward {
28        subject: String,
29        /// Reader-mode-cleaned original message for context block.
30        original_context: String,
31    },
32}
33
34/// Create a draft file on disk and return its path + the cursor line.
35pub fn create_draft_file(kind: ComposeKind, from: &str) -> Result<(PathBuf, usize), ComposeError> {
36    let draft_id = Uuid::now_v7();
37    let path = std::env::temp_dir().join(format!("mxr-draft-{draft_id}.md"));
38
39    let (fm, body, context) = match kind {
40        ComposeKind::New => {
41            let fm = ComposeFrontmatter {
42                to: String::new(),
43                cc: String::new(),
44                bcc: String::new(),
45                subject: String::new(),
46                from: from.to_string(),
47                in_reply_to: None,
48                references: Vec::new(),
49                attach: Vec::new(),
50            };
51            (fm, String::new(), None)
52        }
53        ComposeKind::NewWithTo { to } => {
54            let fm = ComposeFrontmatter {
55                to,
56                cc: String::new(),
57                bcc: String::new(),
58                subject: String::new(),
59                from: from.to_string(),
60                in_reply_to: None,
61                references: Vec::new(),
62                attach: Vec::new(),
63            };
64            (fm, String::new(), None)
65        }
66        ComposeKind::Reply {
67            in_reply_to,
68            references,
69            to,
70            cc,
71            subject,
72            thread_context,
73        } => {
74            let fm = ComposeFrontmatter {
75                to,
76                cc,
77                bcc: String::new(),
78                subject: format!("Re: {subject}"),
79                from: from.to_string(),
80                in_reply_to: Some(in_reply_to),
81                references,
82                attach: Vec::new(),
83            };
84            (fm, String::new(), Some(thread_context))
85        }
86        ComposeKind::Forward {
87            subject,
88            original_context,
89        } => {
90            let fm = ComposeFrontmatter {
91                to: String::new(),
92                cc: String::new(),
93                bcc: String::new(),
94                subject: format!("Fwd: {subject}"),
95                from: from.to_string(),
96                in_reply_to: None,
97                references: Vec::new(),
98                attach: Vec::new(),
99            };
100            let body = "---------- Forwarded message ----------".to_string();
101            (fm, body, Some(original_context))
102        }
103    };
104
105    let content = frontmatter::render_compose_file(&fm, &body, context.as_deref())?;
106
107    // Calculate cursor line: first empty line after frontmatter closing ---
108    let cursor_line = content
109        .lines()
110        .enumerate()
111        .skip(1)
112        .find_map(|(i, line)| {
113            if line == "---" {
114                Some(i + 2) // line after ---, 1-indexed, +1 for blank line
115            } else {
116                None
117            }
118        })
119        .unwrap_or(1);
120
121    std::fs::write(&path, &content)?;
122
123    Ok((path, cursor_line))
124}
125
126/// Validate a parsed draft before sending.
127pub fn validate_draft(frontmatter: &ComposeFrontmatter, body: &str) -> Vec<ComposeValidation> {
128    let mut issues = Vec::new();
129
130    if frontmatter.to.trim().is_empty() {
131        issues.push(ComposeValidation::Error(
132            "No recipients (to: field is empty)".into(),
133        ));
134    }
135
136    if frontmatter.subject.trim().is_empty() {
137        issues.push(ComposeValidation::Warning("Subject is empty".into()));
138    }
139
140    if body.trim().is_empty() {
141        issues.push(ComposeValidation::Warning("Message body is empty".into()));
142    }
143
144    // Validate email addresses
145    for addr in frontmatter
146        .to
147        .split(',')
148        .chain(frontmatter.cc.split(','))
149        .chain(frontmatter.bcc.split(','))
150    {
151        let addr = addr.trim();
152        if !addr.is_empty() && !addr.contains('@') {
153            issues.push(ComposeValidation::Error(format!(
154                "Invalid email address: {addr}"
155            )));
156        }
157    }
158
159    issues
160}
161
162#[derive(Debug)]
163pub enum ComposeValidation {
164    Error(String),
165    Warning(String),
166}
167
168impl ComposeValidation {
169    pub fn is_error(&self) -> bool {
170        matches!(self, ComposeValidation::Error(_))
171    }
172}
173
174impl std::fmt::Display for ComposeValidation {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            ComposeValidation::Error(msg) => write!(f, "Error: {msg}"),
178            ComposeValidation::Warning(msg) => write!(f, "Warning: {msg}"),
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use frontmatter::parse_compose_file;
187
188    #[test]
189    fn roundtrip_new_message() {
190        let (path, _cursor) = create_draft_file(ComposeKind::New, "me@example.com").unwrap();
191        let content = std::fs::read_to_string(&path).unwrap();
192        let (fm, body) = parse_compose_file(&content).unwrap();
193        assert_eq!(fm.from, "me@example.com");
194        assert!(fm.to.is_empty());
195        assert!(body.is_empty());
196        std::fs::remove_file(path).ok();
197    }
198
199    #[test]
200    fn roundtrip_reply() {
201        let (path, _) = create_draft_file(
202            ComposeKind::Reply {
203                in_reply_to: "<msg-123@example.com>".into(),
204                references: vec!["<root@example.com>".into(), "<msg-123@example.com>".into()],
205                to: "alice@example.com".into(),
206                cc: "bob@example.com".into(),
207                subject: "Deployment plan".into(),
208                thread_context: "From: alice\nDate: 2026-03-15\n\nHey team?".into(),
209            },
210            "me@example.com",
211        )
212        .unwrap();
213        let content = std::fs::read_to_string(&path).unwrap();
214        let (fm, body) = parse_compose_file(&content).unwrap();
215        assert_eq!(fm.subject, "Re: Deployment plan");
216        assert_eq!(fm.to, "alice@example.com");
217        assert!(fm.in_reply_to.is_some());
218        assert_eq!(fm.references.len(), 2);
219        assert!(!body.contains("Hey team?"));
220        std::fs::remove_file(path).ok();
221    }
222
223    #[test]
224    fn roundtrip_forward() {
225        let (path, _) = create_draft_file(
226            ComposeKind::Forward {
227                subject: "Important doc".into(),
228                original_context: "The original message content.".into(),
229            },
230            "me@example.com",
231        )
232        .unwrap();
233        let content = std::fs::read_to_string(&path).unwrap();
234        let (fm, body) = parse_compose_file(&content).unwrap();
235        assert_eq!(fm.subject, "Fwd: Important doc");
236        assert!(body.contains("Forwarded message"));
237        assert!(!body.contains("original message content"));
238        std::fs::remove_file(path).ok();
239    }
240
241    #[test]
242    fn validates_missing_recipient() {
243        let fm = ComposeFrontmatter {
244            to: String::new(),
245            cc: String::new(),
246            bcc: String::new(),
247            subject: "Test".into(),
248            from: "me@example.com".into(),
249            in_reply_to: None,
250            references: Vec::new(),
251            attach: Vec::new(),
252        };
253        let issues = validate_draft(&fm, "body");
254        assert!(issues.iter().any(|i| i.is_error()));
255    }
256
257    #[test]
258    fn validates_invalid_email() {
259        let fm = ComposeFrontmatter {
260            to: "not-an-email".into(),
261            cc: String::new(),
262            bcc: String::new(),
263            subject: "Test".into(),
264            from: "me@example.com".into(),
265            in_reply_to: None,
266            references: Vec::new(),
267            attach: Vec::new(),
268        };
269        let issues = validate_draft(&fm, "body");
270        assert!(issues.iter().any(|i| i.is_error()));
271    }
272
273    #[test]
274    fn validates_empty_subject_warning() {
275        let fm = ComposeFrontmatter {
276            to: "alice@example.com".into(),
277            cc: String::new(),
278            bcc: String::new(),
279            subject: String::new(),
280            from: "me@example.com".into(),
281            in_reply_to: None,
282            references: Vec::new(),
283            attach: Vec::new(),
284        };
285        let issues = validate_draft(&fm, "body");
286        assert!(!issues.iter().any(|i| i.is_error()));
287        assert!(issues.iter().any(|i| !i.is_error()));
288    }
289
290    #[test]
291    fn roundtrip_new_with_to() {
292        let (path, _cursor) = create_draft_file(
293            ComposeKind::NewWithTo {
294                to: "alice@example.com".into(),
295            },
296            "me@example.com",
297        )
298        .unwrap();
299        let content = std::fs::read_to_string(&path).unwrap();
300        let (fm, body) = parse_compose_file(&content).unwrap();
301        assert_eq!(fm.from, "me@example.com");
302        assert_eq!(fm.to, "alice@example.com");
303        assert!(fm.subject.is_empty());
304        assert!(body.is_empty());
305        std::fs::remove_file(path).ok();
306    }
307
308    #[test]
309    fn valid_draft_no_errors() {
310        let fm = ComposeFrontmatter {
311            to: "alice@example.com".into(),
312            cc: String::new(),
313            bcc: String::new(),
314            subject: "Hello".into(),
315            from: "me@example.com".into(),
316            in_reply_to: None,
317            references: Vec::new(),
318            attach: Vec::new(),
319        };
320        let issues = validate_draft(&fm, "Hello there!");
321        assert!(!issues.iter().any(|i| i.is_error()));
322    }
323}