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
12pub 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 thread_context: String,
26 },
27 Forward {
28 subject: String,
29 original_context: String,
31 },
32}
33
34pub 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 let cursor_line = content
109 .lines()
110 .enumerate()
111 .skip(1)
112 .find_map(|(i, line)| {
113 if line == "---" {
114 Some(i + 2) } else {
116 None
117 }
118 })
119 .unwrap_or(1);
120
121 std::fs::write(&path, &content)?;
122
123 Ok((path, cursor_line))
124}
125
126pub 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 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}