Skip to main content

rmut_core/
compose.rs

1//! Draft building and finalizing for outgoing mail.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, ensure};
6use chrono::{Local, TimeZone};
7
8use crate::pattern::Me;
9
10pub struct DraftHeaders {
11    /// From line when an identity override applies (reverse_name, a
12    /// folder/recipient rule, the account); the user can edit it, and
13    /// finalize falls back to the default identity when absent.
14    pub from: Option<String>,
15    pub to: String,
16    pub cc: Option<String>,
17    pub subject: String,
18    pub in_reply_to: Option<String>,
19    pub references: Option<String>,
20}
21
22/// The text the user edits in $EDITOR: header block, blank line, body.
23pub fn draft_text(h: &DraftHeaders, body: &str) -> String {
24    let mut out = String::new();
25    if let Some(from) = &h.from {
26        out += &format!("From: {from}\n");
27    }
28    out += &format!("To: {}\n", h.to);
29    if let Some(cc) = &h.cc
30        && !cc.trim().is_empty()
31    {
32        out += &format!("Cc: {cc}\n");
33    }
34    out += &format!("Subject: {}\n", h.subject);
35    if let Some(x) = &h.in_reply_to {
36        out += &format!("In-Reply-To: {x}\n");
37    }
38    if let Some(x) = &h.references {
39        out += &format!("References: {x}\n");
40    }
41    out.push('\n');
42    out.push_str(body);
43    if !out.ends_with('\n') {
44        out.push('\n');
45    }
46    out
47}
48
49/// mutt's $attribution: the line a quoted reply opens with.
50pub const DEFAULT_ATTRIBUTION: &str = "On %d, %n wrote:";
51
52/// mutt's $forward_format: the subject a forward carries.
53pub const DEFAULT_FORWARD_FORMAT: &str = "[%a: %s]";
54
55/// mutt's $indent_string: what a quoted line is prefixed with.
56pub const DEFAULT_INDENT: &str = "> ";
57
58/// The message an attribution or a forward subject is about, for the
59/// format strings that describe it.
60pub struct Quoted<'a> {
61    /// The From header as written, name and address together.
62    pub from: &'a str,
63    pub subject: &'a str,
64    pub message_id: Option<&'a str>,
65    /// When it was sent, seconds since the epoch.
66    pub date: i64,
67}
68
69impl Quoted<'_> {
70    /// The author's display name, or their address when the header
71    /// carries no name (as mutt's %n does).
72    fn name(&self) -> String {
73        let trimmed = self.from.trim();
74        let name = match trimmed.split_once('<') {
75            Some((name, _)) => name.trim().trim_matches('"').trim(),
76            None => "",
77        };
78        match name.is_empty() {
79            true => self.address(),
80            false => name.to_string(),
81        }
82    }
83
84    fn address(&self) -> String {
85        bare_address(self.from).unwrap_or_else(|| self.from.trim().to_string())
86    }
87}
88
89/// Expand one of mutt's message format strings: `%a` the author's
90/// address, `%n` their name, `%s` the subject, `%i` the message-id,
91/// `%d` the date, `%{...}` the date through strftime, `%%` a percent.
92/// Padding and conditionals work as they do in the index format.
93pub fn render_quoted(fmt: &str, m: &Quoted) -> String {
94    // `%{...}` first: the strftime span would confuse the specifier
95    // machinery, which reads one character.
96    let fmt = expand_strftime(fmt, m.date);
97    crate::format::render_with(&fmt, &|spec| match spec {
98        'a' => m.address(),
99        'n' => m.name(),
100        'f' => m.from.trim().to_string(),
101        's' => m.subject.trim().to_string(),
102        'i' => m
103            .message_id
104            .unwrap_or_default()
105            .trim_matches(['<', '>'])
106            .to_string(),
107        'd' => format_date(m.date),
108        '%' => "%".to_string(),
109        other => format!("%{other}"),
110    })
111}
112
113/// mutt's `%{strftime}`: the message's date, in the caller's words.
114fn expand_strftime(fmt: &str, date: i64) -> String {
115    let mut out = String::new();
116    let mut rest = fmt;
117    while let Some(at) = rest.find("%{") {
118        out.push_str(&rest[..at]);
119        let Some(end) = rest[at + 2..].find('}') else {
120            break;
121        };
122        let spec = &rest[at + 2..at + 2 + end];
123        out.push_str(&strftime(spec, date));
124        rest = &rest[at + 2 + end + 1..];
125    }
126    out.push_str(rest);
127    out
128}
129
130fn strftime(spec: &str, epoch: i64) -> String {
131    match Local.timestamp_opt(epoch, 0) {
132        chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => {
133            dt.format(spec).to_string()
134        }
135        chrono::LocalResult::None => String::new(),
136    }
137}
138
139/// mutt's $reply_regexp default: "Re:" with an optional bracketed
140/// count, as Re[2]: has it.
141pub const REPLY_REGEXP: &str = r"^(re)(\[[0-9]+\])*:[ \t]*";
142
143/// Compile a $reply_regexp the way mutt does: case-insensitively
144/// unless the pattern itself has an uppercase letter (mutt's
145/// mutt_which_case).
146pub fn reply_regexp(spec: &str) -> Result<regex_lite::Regex, regex_lite::Error> {
147    let smart = if spec.chars().any(char::is_uppercase) {
148        spec.to_string()
149    } else {
150        format!("(?i){spec}")
151    };
152    regex_lite::Regex::new(&smart)
153}
154
155pub fn default_reply_regexp() -> regex_lite::Regex {
156    reply_regexp(REPLY_REGEXP).expect("default reply_regexp compiles")
157}
158
159/// The subject of a reply, as mutt makes it: "Re: " over the original
160/// with whatever $reply_regexp matched at its start taken off, so an
161/// "RE: x" or "Re[2]: x" answers as "Re: x" rather than piling up.
162pub fn reply_subject(orig: &str, re: &regex_lite::Regex) -> String {
163    let t = orig.trim();
164    let rest = match re.find(t) {
165        Some(m) if m.start() == 0 => t[m.end()..].trim_start(),
166        _ => t,
167    };
168    format!("Re: {rest}")
169}
170
171/// mutt's $forward_format over the message being forwarded.
172pub fn forward_subject(fmt: &str, m: &Quoted) -> String {
173    render_quoted(fmt, m)
174}
175
176fn format_date(epoch: i64) -> String {
177    match Local.timestamp_opt(epoch, 0) {
178        chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => {
179            dt.format("%a, %d %b %Y %H:%M").to_string()
180        }
181        chrono::LocalResult::None => "an unknown date".into(),
182    }
183}
184
185/// mutt's $attribution over the message being replied to.
186pub fn attribution(fmt: &str, m: &Quoted) -> String {
187    render_quoted(fmt, m)
188}
189
190/// Quote a body mutt-style under an attribution line, each line
191/// prefixed with $indent_string.
192pub fn quote(attribution: &str, indent: &str, body: &str) -> String {
193    let mut out = format!("{attribution}\n");
194    for line in body.lines() {
195        out += &format!("{indent}{line}\n");
196    }
197    out
198}
199
200/// The forwarded original between mutt's markers. With mutt's
201/// $forward_quote the message itself is prefixed with $indent_string,
202/// the way a reply is quoted; the markers stay flush, since they are
203/// rmut's words and not the original's.
204pub fn forward_body(
205    from: &str,
206    date_epoch: i64,
207    subject: &str,
208    body: &str,
209    quote_with: Option<&str>,
210) -> String {
211    let body = body.trim_end();
212    let body = match quote_with {
213        Some(indent) => body
214            .lines()
215            .map(|l| format!("{indent}{l}"))
216            .collect::<Vec<_>>()
217            .join("\n"),
218        None => body.to_string(),
219    };
220    format!(
221        "----- Forwarded message from {from} -----\nDate: {}\nSubject: {subject}\n\n{body}\n----- End forwarded message -----\n",
222        format_date(date_epoch),
223    )
224}
225
226/// mutt's $signature: the text a draft ends with. A name ending in
227/// `|` is a command whose standard output is the signature (mutt's
228/// rule); anything else is a file. A signature that cannot be read is
229/// no signature: a draft is worth more than the ornament on it.
230pub fn signature_text(setting: &str) -> Option<String> {
231    let setting = setting.trim();
232    if setting.is_empty() {
233        return None;
234    }
235    let text = match setting.strip_suffix('|') {
236        Some(command) => {
237            let out = std::process::Command::new("sh")
238                .arg("-c")
239                .arg(command.trim())
240                .output()
241                .ok()?;
242            String::from_utf8_lossy(&out.stdout).into_owned()
243        }
244        None => std::fs::read_to_string(expand_home(setting)).ok()?,
245    };
246    let text = text.trim_end_matches('\n');
247    (!text.is_empty()).then(|| text.to_string())
248}
249
250/// The body with the signature under it, mutt's way: a blank line,
251/// then $sig_dashes' "-- " line when it is on, then the signature.
252pub fn with_signature(body: &str, signature: &str, dashes: bool) -> String {
253    with_signature_at(body, signature, dashes, false)
254}
255
256/// The signature added to a draft. mutt's $sig_on_top puts it above
257/// the quoted original instead of below (with a blank line between,
258/// so the reply is written between the signature and the quote).
259pub fn with_signature_at(body: &str, signature: &str, dashes: bool, on_top: bool) -> String {
260    let mut sig = String::new();
261    if dashes {
262        sig += "-- \n";
263    }
264    sig += signature;
265    if !sig.ends_with('\n') {
266        sig.push('\n');
267    }
268    if on_top {
269        return format!("{sig}\n{body}");
270    }
271    let mut out = body.to_string();
272    if !out.is_empty() && !out.ends_with('\n') {
273        out.push('\n');
274    }
275    out.push('\n');
276    out += &sig;
277    out
278}
279
280pub fn make_message_id(hostname: &str) -> String {
281    format!(
282        "<{}.{}.rmut@{hostname}>",
283        Local::now().timestamp_millis(),
284        std::process::id(),
285    )
286}
287
288pub fn rfc2822_now() -> String {
289    Local::now().to_rfc2822()
290}
291
292fn header_present(head: &str, name: &str) -> bool {
293    head.lines().any(|l| {
294        l.get(..name.len())
295            .is_some_and(|k| k.eq_ignore_ascii_case(name))
296            && l.as_bytes().get(name.len()) == Some(&b':')
297    })
298}
299
300/// Finalize an edited draft for sending: require a recipient, add
301/// mutt's $user_agent header, when the caller asks for it.
302pub fn user_agent_header() -> String {
303    format!("User-Agent: rmut/{}", env!("CARGO_PKG_VERSION"))
304}
305
306/// From/Date/Message-ID when the user didn't write them.
307pub fn finalize(draft: &str, from: &str, msg_id: &str, date: &str) -> Result<String> {
308    finalize_with(draft, from, msg_id, date, false)
309}
310
311/// Like `finalize`, adding a `User-Agent` header when `user_agent`
312/// and the draft has none of its own.
313pub fn finalize_with(
314    draft: &str,
315    from: &str,
316    msg_id: &str,
317    date: &str,
318    user_agent: bool,
319) -> Result<String> {
320    let (head, body) = draft.split_once("\n\n").unwrap_or((draft.trim_end(), ""));
321    let has_recipients = head.lines().any(|l| {
322        l.split_once(':').is_some_and(|(k, v)| {
323            ["to", "cc", "bcc"].contains(&k.trim().to_lowercase().as_str()) && !v.trim().is_empty()
324        })
325    });
326    ensure!(has_recipients, "no recipients (To/Cc/Bcc)");
327    let mut head = head.trim_end().to_string();
328    if !header_present(&head, "From") {
329        head += &format!("\nFrom: {from}");
330    }
331    if !header_present(&head, "Date") {
332        head += &format!("\nDate: {date}");
333    }
334    if !header_present(&head, "Message-ID") {
335        head += &format!("\nMessage-ID: {msg_id}");
336    }
337    if user_agent && !header_present(&head, "User-Agent") {
338        head += &format!("\n{}", user_agent_header());
339    }
340    Ok(format!("{head}\n\n{body}"))
341}
342
343/// A file named in an `Attach:` pseudo-header of the draft.
344pub struct Attachment {
345    pub path: PathBuf,
346    /// Content-type override (compose menu ctrl+t); guessed from the
347    /// extension otherwise.
348    pub mime: Option<String>,
349    pub description: Option<String>,
350    /// mutt's rename-attachment: the filename the part is sent under,
351    /// when not the file's own. `@name="..."` on the line.
352    pub name: Option<String>,
353    /// mutt's toggle-disposition: Content-Disposition inline rather
354    /// than attachment. `@inline` on the line.
355    pub inline: bool,
356    /// mutt's toggle-unlink: the file goes once the message has been
357    /// sent. `@unlink` on the line.
358    pub unlink: bool,
359}
360
361impl Attachment {
362    /// A plain attachment of this file: nothing overridden.
363    pub fn of(path: PathBuf) -> Attachment {
364        Attachment {
365            path,
366            mime: None,
367            description: None,
368            name: None,
369            inline: false,
370            unlink: false,
371        }
372    }
373
374    /// The filename the part goes out under.
375    pub fn send_name(&self) -> &str {
376        self.name
377            .as_deref()
378            .filter(|n| !n.is_empty())
379            .unwrap_or_else(|| {
380                self.path
381                    .file_name()
382                    .and_then(|n| n.to_str())
383                    .unwrap_or("attachment")
384            })
385    }
386}
387
388/// A `type/subtype` token (letters, digits, `.+-`), so a content-type
389/// between the path and the description is recognizable.
390fn looks_like_mime(token: &str) -> bool {
391    match token.split_once('/') {
392        Some((t, s)) if !t.is_empty() && !s.is_empty() => token
393            .chars()
394            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '+' | '-')),
395        _ => false,
396    }
397}
398
399/// One Attach: line back from its parts (quotes around a path with
400/// spaces, then the optional type and description).
401pub fn attach_line(a: &Attachment) -> String {
402    let p = a.path.display().to_string();
403    let mut line = if p.contains(' ') {
404        format!("Attach: \"{p}\"")
405    } else {
406        format!("Attach: {p}")
407    };
408    if let Some(m) = &a.mime {
409        line += &format!(" {m}");
410    }
411    // The @-options sit between the type and the description, where
412    // a description is not expected to start with an @.
413    if let Some(n) = a.name.as_deref().filter(|n| !n.is_empty()) {
414        line += &format!(" @name=\"{}\"", n.replace('"', ""));
415    }
416    if a.inline {
417        line += " @inline";
418    }
419    if a.unlink {
420        line += " @unlink";
421    }
422    if let Some(d) = &a.description {
423        line += &format!(" {d}");
424    }
425    line
426}
427
428/// Pull mutt-style `Attach: <path> [type/subtype] [description]`
429/// pseudo-headers out of a draft's header block; quotes allow a path
430/// with spaces, `~/` means $HOME. Returns the draft without those
431/// lines.
432pub fn extract_attachments(draft: &str) -> (String, Vec<Attachment>) {
433    let (head, body) = match draft.split_once("\n\n") {
434        Some((h, b)) => (h, Some(b)),
435        None => (draft, None),
436    };
437    let mut attachments = Vec::new();
438    let mut kept = Vec::new();
439    for line in head.lines() {
440        let value = match line.split_once(':') {
441            Some((k, v)) if k.trim().eq_ignore_ascii_case("attach") => v.trim(),
442            _ => {
443                kept.push(line);
444                continue;
445            }
446        };
447        if value.is_empty() {
448            continue;
449        }
450        let (path, desc) = match value.strip_prefix('"') {
451            Some(rest) => rest.split_once('"').unwrap_or((rest, "")),
452            None => value.split_once(char::is_whitespace).unwrap_or((value, "")),
453        };
454        let mut desc = desc.trim();
455        let mut mime = None;
456        match desc.split_once(char::is_whitespace) {
457            Some((first, rest)) if looks_like_mime(first) => {
458                mime = Some(first.to_string());
459                desc = rest.trim();
460            }
461            None if looks_like_mime(desc) => {
462                mime = Some(desc.to_string());
463                desc = "";
464            }
465            _ => {}
466        }
467        // The @-options, in any order, ahead of the description.
468        let mut a = Attachment::of(expand_home(path));
469        a.mime = mime;
470        while let Some(rest) = desc.strip_prefix('@') {
471            if let Some(rest) = rest.strip_prefix("inline") {
472                a.inline = true;
473                desc = rest.trim_start();
474            } else if let Some(rest) = rest.strip_prefix("unlink") {
475                a.unlink = true;
476                desc = rest.trim_start();
477            } else if let Some(rest) = rest.strip_prefix("name=") {
478                let (name, rest) = match rest.strip_prefix('"') {
479                    Some(q) => q.split_once('"').unwrap_or((q, "")),
480                    None => rest.split_once(char::is_whitespace).unwrap_or((rest, "")),
481                };
482                a.name = (!name.is_empty()).then(|| name.to_string());
483                desc = rest.trim_start();
484            } else {
485                break; // a description that happens to start with @
486            }
487        }
488        a.description = (!desc.is_empty()).then(|| desc.to_string());
489        attachments.push(a);
490    }
491    let mut out = kept.join("\n");
492    if let Some(body) = body {
493        out += "\n\n";
494        out += body;
495    }
496    (out, attachments)
497}
498
499fn expand_home(path: &str) -> PathBuf {
500    if let Some(rest) = path.strip_prefix("~/")
501        && let Ok(home) = std::env::var("HOME")
502    {
503        return Path::new(&home).join(rest);
504    }
505    PathBuf::from(path)
506}
507
508/// Content type guessed from the filename extension.
509pub fn content_type(path: &Path) -> &'static str {
510    let ext = path
511        .extension()
512        .and_then(|e| e.to_str())
513        .map(|e| e.to_ascii_lowercase());
514    match ext.as_deref() {
515        Some("txt" | "log" | "md" | "patch" | "diff") => "text/plain",
516        Some("html" | "htm") => "text/html",
517        Some("csv") => "text/csv",
518        Some("pdf") => "application/pdf",
519        Some("png") => "image/png",
520        Some("jpg" | "jpeg") => "image/jpeg",
521        Some("gif") => "image/gif",
522        Some("zip") => "application/zip",
523        Some("gz") => "application/gzip",
524        Some("tar") => "application/x-tar",
525        Some("json") => "application/json",
526        Some("xml") => "application/xml",
527        _ => "application/octet-stream",
528    }
529}
530
531/// Base64 in MIME shape: 76-character lines, CRLF line endings.
532fn b64_wrapped(bytes: &[u8]) -> String {
533    let s = crate::smtp::b64(bytes);
534    let mut out = String::with_capacity(s.len() + s.len() / 38 + 2);
535    for chunk in s.as_bytes().chunks(76) {
536        out.push_str(std::str::from_utf8(chunk).expect("base64 is ascii"));
537        out.push_str("\r\n");
538    }
539    out
540}
541
542/// The text/plain entity of an outgoing message: its Content-Type
543/// header and the body in canonical CRLF form. With mutt's
544/// $text_flowed the part is declared `format=flowed` and the body is
545/// space-stuffed, so a reader may rewrap it (RFC 3676). The
546/// paragraphs are the editor's doing: rmut adds no trailing spaces.
547pub fn text_entity(body: &str, flowed: bool) -> String {
548    let mut out = String::from("Content-Type: text/plain; charset=utf-8");
549    if flowed {
550        out += "; format=flowed";
551    }
552    out += "\r\nContent-Transfer-Encoding: 8bit\r\n\r\n";
553    let body = match flowed {
554        true => crate::flowed::space_stuff(body),
555        false => body.to_string(),
556    };
557    out += &String::from_utf8_lossy(&crate::pgp::crlf(body.as_bytes()));
558    out
559}
560
561/// The draft's text as a MIME entity: text/plain, or with markdown
562/// compose a multipart/alternative of that text/plain and the
563/// text/html rendered from it. The plain half comes first, so a
564/// reader that shows the last part it can (every graphical one) shows
565/// the html, and a plain one loses nothing.
566pub fn body_entity(body: &str, flowed: bool, markdown: bool) -> String {
567    let plain = text_entity(body, flowed);
568    if !markdown {
569        return plain;
570    }
571    let html = format!(
572        "Content-Type: text/html; charset=utf-8\r\n\
573         Content-Transfer-Encoding: quoted-printable\r\n\r\n{}",
574        quoted_printable(&crate::markdown::to_html(body))
575    );
576    let boundary = {
577        let mut n = 0usize;
578        loop {
579            let b = format!("=-rmut-alt-{}-{n}", std::process::id());
580            if !plain.contains(&b) && !html.contains(&b) {
581                break b;
582            }
583            n += 1;
584        }
585    };
586    let mut out = format!("Content-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\r\n");
587    for part in [plain, html] {
588        out += &format!("--{boundary}\r\n{part}");
589        if !out.ends_with("\r\n") {
590            out += "\r\n";
591        }
592    }
593    out += &format!("--{boundary}--\r\n");
594    out
595}
596
597/// Quoted-printable (RFC 2045), CRLF line ends: the html half's
598/// paragraphs are single lines, often past the 998 bytes SMTP allows.
599pub fn quoted_printable(text: &str) -> String {
600    let mut out = String::new();
601    for (i, line) in text.split('\n').enumerate() {
602        if i > 0 {
603            out += "\r\n";
604        }
605        let line = line.strip_suffix('\r').unwrap_or(line);
606        let bytes = line.as_bytes();
607        let mut width = 0;
608        for (j, &b) in bytes.iter().enumerate() {
609            let last = j + 1 == bytes.len();
610            let piece = match b {
611                b'=' => "=3D".to_string(),
612                b' ' | b'\t' if last => format!("={b:02X}"),
613                b' ' | b'\t' | 33..=126 => (b as char).to_string(),
614                _ => format!("={b:02X}"),
615            };
616            // A soft break keeps every line within 76, its "=" included.
617            if width + piece.len() > 75 {
618                out += "=\r\n";
619                width = 0;
620            }
621            width += piece.len();
622            out += &piece;
623        }
624    }
625    out
626}
627
628/// The draft header that says whether this draft is markdown: rmut's
629/// own, written by the compose menu's toggle, kept through postpone,
630/// and taken off before the message goes anywhere.
631pub const MARKDOWN_HEADER: &str = "X-Rmut-Markdown";
632
633/// The draft without its markdown header, and what the header said
634/// (None when it has none, and the config decides).
635pub fn take_markdown(draft: &str) -> (String, Option<bool>) {
636    let (head, body) = match draft.split_once("\n\n") {
637        Some((h, b)) => (h, Some(b)),
638        None => (draft, None),
639    };
640    let mut said = None;
641    let kept: Vec<&str> = head
642        .lines()
643        .filter(|line| match line.split_once(':') {
644            Some((k, v)) if k.trim().eq_ignore_ascii_case(MARKDOWN_HEADER) => {
645                said = Some(matches!(
646                    v.trim().to_lowercase().as_str(),
647                    "yes" | "true" | "on"
648                ));
649                false
650            }
651            _ => true,
652        })
653        .collect();
654    let head = kept.join("\n");
655    let text = match body {
656        Some(body) => format!("{head}\n\n{body}"),
657        None => head,
658    };
659    (text, said)
660}
661
662/// mutt's $text_flowed for a message that goes out with no MIME
663/// wrapper at all: declare the body and space-stuff it, in place, on
664/// a finalized draft. One that already carries a Content-Type (the
665/// user wrote their own) is left alone.
666pub fn flow_plain(text: &str) -> String {
667    let (head, body) = match text.split_once("\n\n") {
668        Some(pair) => pair,
669        None => return text.to_string(),
670    };
671    if header_present(head, "Content-Type") {
672        return text.to_string();
673    }
674    format!(
675        "{}\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8; format=flowed\n\
676         Content-Transfer-Encoding: 8bit\n\n{}",
677        head.trim_end(),
678        crate::flowed::space_stuff(body),
679    )
680}
681
682/// The Content-Disposition filename parameter: a plain quoted string
683/// when the name is ASCII without `"` or `\`, else RFC 2231's
684/// `filename*=utf-8''<percent-encoded>`, which carries any name and
685/// which readers decode alike (quoted-pair escapes they often do not).
686/// Control characters (a newline in a file name) become spaces.
687fn filename_param(name: &str) -> String {
688    let name = one_line_value(name);
689    if name.is_ascii() && !name.contains(['"', '\\']) {
690        return format!("filename=\"{name}\"");
691    }
692    let mut out = String::from("filename*=utf-8''");
693    for b in name.bytes() {
694        // RFC 2231's attribute-char: what may stand unencoded.
695        if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
696            out.push(b as char);
697        } else {
698            out += &format!("%{b:02X}");
699        }
700    }
701    out
702}
703
704/// A header value that stays one header: CR, LF and other controls
705/// become spaces.
706fn one_line_value(value: &str) -> String {
707    value
708        .chars()
709        .map(|c| if c.is_control() { ' ' } else { c })
710        .collect()
711}
712
713/// The MIME entity (Content-Type header + body, CRLF endings) for a
714/// draft body with attachments: multipart/mixed with the text first,
715/// files base64-encoded, and optionally the forwarded original as
716/// message/rfc822 (mutt's mime_forward). The caller puts it under the
717/// draft's top-level headers, or inside a PGP layer.
718pub fn mixed_entity(
719    body: &str,
720    files: &[Attachment],
721    original: Option<&[u8]>,
722    flowed: bool,
723    markdown: bool,
724) -> Result<String> {
725    let mut parts: Vec<String> = Vec::new();
726    parts.push(body_entity(body, flowed, markdown));
727    for a in files {
728        let bytes =
729            std::fs::read(&a.path).with_context(|| format!("reading {}", a.path.display()))?;
730        let mime = a.mime.as_deref().unwrap_or_else(|| content_type(&a.path));
731        let disposition = if a.inline { "inline" } else { "attachment" };
732        // An attached message (mutt's attach-message) goes in as it
733        // is, a message/rfc822 part with no encoding and no filename.
734        if mime.eq_ignore_ascii_case("message/rfc822") {
735            let mut p =
736                format!("Content-Type: message/rfc822\r\nContent-Disposition: {disposition}\r\n");
737            if let Some(d) = &a.description {
738                p += &format!("Content-Description: {}\r\n", one_line_value(d));
739            }
740            p += "\r\n";
741            p += &String::from_utf8_lossy(&crate::pgp::crlf(&bytes));
742            parts.push(p);
743            continue;
744        }
745        let mut p = format!(
746            "Content-Type: {mime}\r\nContent-Disposition: {disposition}; {}\r\n",
747            filename_param(a.send_name()),
748        );
749        if let Some(d) = &a.description {
750            p += &format!("Content-Description: {}\r\n", one_line_value(d));
751        }
752        p += "Content-Transfer-Encoding: base64\r\n\r\n";
753        p += &b64_wrapped(&bytes);
754        parts.push(p);
755    }
756    if let Some(orig) = original {
757        let mut p =
758            String::from("Content-Type: message/rfc822\r\nContent-Disposition: attachment\r\n\r\n");
759        p += &String::from_utf8_lossy(&crate::pgp::crlf(orig));
760        parts.push(p);
761    }
762    let boundary = {
763        let mut n = 0usize;
764        loop {
765            let b = format!("=-rmut-mixed-{}-{n}", std::process::id());
766            if !parts.iter().any(|p| p.contains(&b)) {
767                break b;
768            }
769            n += 1;
770        }
771    };
772    let mut out = format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n");
773    for p in &parts {
774        out += &format!("--{boundary}\r\n");
775        out += p;
776        if !out.ends_with("\r\n") {
777            out += "\r\n";
778        }
779    }
780    out += &format!("--{boundary}--\r\n");
781    Ok(out)
782}
783
784/// Message text for a bounce: the original with a fresh Resent-* block
785/// prepended, per RFC 5322 (the newest resend goes first).
786pub fn bounce_text(original: &[u8], from: &str, to: &str, date: &str, msg_id: &str) -> String {
787    format!(
788        "Resent-From: {from}\r\nResent-Date: {date}\r\nResent-Message-ID: {msg_id}\r\nResent-To: {to}\r\n{}",
789        String::from_utf8_lossy(original),
790    )
791}
792
793/// First address in an RFC 5322 address field, without display name,
794/// e.g. the SMTP envelope sender from a From line.
795/// The posting address from a List-Post header value:
796/// `<mailto:dev@example.com>`, with the RFC 2369 `NO` meaning the list
797/// takes no posts. Extra mailto parameters are dropped.
798pub fn list_post_address(value: &str) -> Option<String> {
799    let value = value.trim();
800    if value.eq_ignore_ascii_case("NO") {
801        return None;
802    }
803    let start = value.to_ascii_lowercase().find("mailto:")? + "mailto:".len();
804    let rest = &value[start..];
805    let addr = rest
806        .split(['>', '?', ',', ' '])
807        .next()
808        .unwrap_or(rest)
809        .trim();
810    (!addr.is_empty()).then(|| addr.to_string())
811}
812
813/// mutt's $followup_to: the Mail-Followup-To for a message going to a
814/// mailing list. Every recipient goes in; your own address is left out
815/// when you are subscribed (the list copy is the one you will get) and
816/// kept when you are not.
817pub fn followup_to(to: &str, cc: &str, me: Me, subscribed: bool, my_from: &str) -> String {
818    let mut out: Vec<String> = Vec::new();
819    let mut push = |single: &mailparse::SingleInfo| {
820        if subscribed && me.is_me(&single.addr) {
821            return;
822        }
823        let written = match &single.display_name {
824            Some(name) if !name.trim().is_empty() => format!("{name} <{}>", single.addr),
825            _ => single.addr.clone(),
826        };
827        if !out.iter().any(|a| a == &written) {
828            out.push(written);
829        }
830    };
831    for field in [to, cc] {
832        let Ok(list) = mailparse::addrparse(field) else {
833            continue;
834        };
835        for addr in list.iter() {
836            match addr {
837                mailparse::MailAddr::Single(single) => push(single),
838                mailparse::MailAddr::Group(group) => group.addrs.iter().for_each(&mut push),
839            }
840        }
841    }
842    if !subscribed
843        && let Some(from) = bare_address(my_from)
844        && !out
845            .iter()
846            .any(|a| bare_address(a).is_some_and(|b| b == from))
847    {
848        out.push(my_from.trim().to_string());
849    }
850    out.join(", ")
851}
852
853/// The Cc a group reply gets: everyone the original named in To and
854/// Cc, written the way they were written, minus anyone already in
855/// `to` (the sender, normally) and, unless mutt's $metoo is set, minus
856/// me. Duplicates collapse on the bare address, so a person listed in
857/// both To and Cc appears once.
858pub fn group_recipients(orig_to: &str, orig_cc: &str, to: &str, me: Me, metoo: bool) -> String {
859    let mut seen: Vec<String> = addresses(to).iter().map(|a| a.to_lowercase()).collect();
860    let mut out: Vec<String> = Vec::new();
861    let mut push = |single: &mailparse::SingleInfo| {
862        let bare = single.addr.to_lowercase();
863        if seen.contains(&bare) || (!metoo && me.is_me(&bare)) {
864            return;
865        }
866        seen.push(bare);
867        out.push(match &single.display_name {
868            Some(name) if !name.trim().is_empty() => format!("{name} <{}>", single.addr),
869            _ => single.addr.clone(),
870        });
871    };
872    for field in [orig_to, orig_cc] {
873        let Ok(list) = mailparse::addrparse(field) else {
874            continue;
875        };
876        for addr in list.iter() {
877            match addr {
878                mailparse::MailAddr::Single(single) => push(single),
879                mailparse::MailAddr::Group(group) => group.addrs.iter().for_each(&mut push),
880            }
881        }
882    }
883    out.join(", ")
884}
885
886/// A draft seen as a message, so hook patterns (`~t`, `~c`, `~s`,
887/// `~f`, ...) can be matched against outgoing mail the way mutt
888/// matches fcc-hook. `path` is where the body lives, so `~b` and `~h`
889/// still have something to read; the date is now, since the draft has
890/// no Date header yet. Bcc joins the Cc
891/// addresses, so a hook on `~c` sees a blind recipient too.
892pub fn draft_envelope(text: &str, path: &Path) -> crate::message::Envelope {
893    let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
894    let header = |name: &str| -> String {
895        head.lines()
896            .filter_map(|l| {
897                let (k, v) = l.split_once(':')?;
898                k.trim().eq_ignore_ascii_case(name).then(|| v.trim())
899            })
900            .collect::<Vec<_>>()
901            .join(", ")
902    };
903    let bare = |field: &str| -> Vec<String> {
904        addresses(field).iter().map(|a| a.to_lowercase()).collect()
905    };
906    let from_full = header("From");
907    crate::message::Envelope {
908        file: crate::maildir::MailFile {
909            path: path.to_path_buf(),
910            is_new: false,
911            flags: crate::maildir::Flags {
912                seen: true,
913                ..Default::default()
914            },
915            size: text.len() as u64,
916        },
917        from: crate::message::short_from(&from_full),
918        from_full,
919        subject: header("Subject"),
920        date: Local::now().timestamp(),
921        msg_id: None,
922        references: Vec::new(),
923        tagged: false,
924        to: bare(&header("To")),
925        cc: [header("Cc"), header("Bcc")]
926            .iter()
927            .flat_map(|f| bare(f))
928            .collect(),
929        lines: Some(body.lines().count()),
930        list: None,
931        label: None,
932        broken: false,
933    }
934}
935
936/// mutt's `my_hdr`: extra header lines that go on every draft. An
937/// entry naming a header the draft already carries replaces it, so
938/// `my_hdr From:` and `my_hdr Reply-To:` win over what rmut chose;
939/// To, Cc and Bcc instead gain the address, like mutt, so a standing
940/// `my_hdr Bcc: me@example.com` cannot erase a reply's recipients.
941/// Malformed entries (no colon, no name) are ignored.
942pub fn apply_my_hdr(text: &str, my_hdr: &[String]) -> String {
943    if my_hdr.is_empty() {
944        return text.to_string();
945    }
946    let (head, body) = match text.split_once("\n\n") {
947        Some((head, body)) => (head, body),
948        None => (text.trim_end(), ""),
949    };
950    let mut lines: Vec<String> = head.lines().map(String::from).collect();
951    for entry in my_hdr {
952        let Some((name, value)) = entry.split_once(':') else {
953            continue;
954        };
955        let (name, value) = (name.trim(), value.trim());
956        if name.is_empty() {
957            continue;
958        }
959        let at = lines.iter().position(|l| {
960            l.get(..name.len())
961                .is_some_and(|k| k.eq_ignore_ascii_case(name))
962                && l.as_bytes().get(name.len()) == Some(&b':')
963        });
964        let addressy = ["to", "cc", "bcc"].contains(&name.to_lowercase().as_str());
965        match at {
966            Some(i) if addressy => {
967                let old = lines[i]
968                    .split_once(':')
969                    .map(|(_, v)| v.trim().to_string())
970                    .unwrap_or_default();
971                lines[i] = if old.is_empty() {
972                    format!("{name}: {value}")
973                } else {
974                    format!("{name}: {old}, {value}")
975                };
976            }
977            Some(i) => lines[i] = format!("{name}: {value}"),
978            None => lines.push(format!("{name}: {value}")),
979        }
980    }
981    format!("{}\n\n{body}", lines.join("\n"))
982}
983
984pub fn bare_address(field: &str) -> Option<String> {
985    match mailparse::addrparse(field)
986        .ok()?
987        .into_inner()
988        .into_iter()
989        .next()?
990    {
991        mailparse::MailAddr::Single(single) => Some(single.addr),
992        mailparse::MailAddr::Group(group) => group.addrs.first().map(|a| a.addr.clone()),
993    }
994}
995
996/// The SMTP envelope sender: the bare address of the From header.
997pub fn from_address(text: &str) -> Option<String> {
998    let mail = mailparse::parse_mail(text.as_bytes()).ok()?;
999    bare_address(&crate::rfc2047::first(&mail.get_headers(), "From")?)
1000}
1001
1002fn field_addresses(value: &str, out: &mut Vec<String>) {
1003    let Ok(list) = mailparse::addrparse(value) else {
1004        return;
1005    };
1006    for addr in list.iter() {
1007        match addr {
1008            mailparse::MailAddr::Single(single) => out.push(single.addr.clone()),
1009            mailparse::MailAddr::Group(group) => {
1010                out.extend(group.addrs.iter().map(|a| a.addr.clone()));
1011            }
1012        }
1013    }
1014}
1015
1016/// Every bare address in an RFC 5322 address field.
1017pub fn addresses(field: &str) -> Vec<String> {
1018    let mut out = Vec::new();
1019    field_addresses(field, &mut out);
1020    out
1021}
1022
1023/// mutt's reverse_name: the first of my addresses (`alternates`
1024/// included) the original was addressed to, in the form it appeared:
1025/// the display name from the To/Cc header is kept.
1026pub fn reverse_from(orig_to: &str, orig_cc: &str, me: Me, realname: bool) -> Option<String> {
1027    let mine = |single: &mailparse::SingleInfo| -> Option<String> {
1028        if !me.is_me(&single.addr) {
1029            return None;
1030        }
1031        Some(match &single.display_name {
1032            // mutt's $reverse_realname off: the address is theirs,
1033            // the name stays whatever the identity says.
1034            Some(name) if realname && !name.trim().is_empty() => {
1035                format!("{name} <{}>", single.addr)
1036            }
1037            _ => single.addr.clone(),
1038        })
1039    };
1040    for field in [orig_to, orig_cc] {
1041        let Ok(list) = mailparse::addrparse(field) else {
1042            continue;
1043        };
1044        for addr in list.iter() {
1045            match addr {
1046                mailparse::MailAddr::Single(single) => {
1047                    if let Some(from) = mine(single) {
1048                        return Some(from);
1049                    }
1050                }
1051                mailparse::MailAddr::Group(group) => {
1052                    if let Some(from) = group.addrs.iter().find_map(&mine) {
1053                        return Some(from);
1054                    }
1055                }
1056            }
1057        }
1058    }
1059    None
1060}
1061
1062/// SMTP envelope for a finalized draft: every To/Cc/Bcc address, and
1063/// the text with Bcc headers removed (they must not go on the wire).
1064pub fn smtp_envelope(text: &str) -> Result<(Vec<String>, String)> {
1065    let mail = mailparse::parse_mail(text.as_bytes())?;
1066    let mut rcpts = Vec::new();
1067    for header in &mail.headers {
1068        let key = header.get_key();
1069        if ["to", "cc", "bcc"].contains(&key.to_lowercase().as_str()) {
1070            field_addresses(&crate::rfc2047::value(header), &mut rcpts);
1071        }
1072    }
1073    rcpts.dedup();
1074    let (head, body) = text.split_once("\n\n").unwrap_or((text.trim_end(), ""));
1075    let mut out = String::new();
1076    let mut skipping = false;
1077    for line in head.lines() {
1078        if line.starts_with(' ') || line.starts_with('\t') {
1079            // Folded continuation belongs to the previous header.
1080            if skipping {
1081                continue;
1082            }
1083        } else {
1084            skipping = line
1085                .split_once(':')
1086                .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case("bcc"));
1087        }
1088        if !skipping {
1089            out.push_str(line);
1090            out.push('\n');
1091        }
1092    }
1093    out.push('\n');
1094    out.push_str(body);
1095    Ok((rcpts, out))
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101
1102    #[test]
1103    fn a_markdown_body_is_text_and_html() {
1104        let entity = body_entity("Hi *Jane*\n", false, true);
1105        assert!(
1106            entity.starts_with("Content-Type: multipart/alternative; boundary="),
1107            "{entity}"
1108        );
1109        let plain = entity.find("Content-Type: text/plain").expect(&entity);
1110        let html = entity.find("Content-Type: text/html").expect(&entity);
1111        assert!(plain < html, "the plain half first: {entity}");
1112        assert!(
1113            entity.contains("\r\nHi *Jane*\r\n"),
1114            "the text as typed: {entity}"
1115        );
1116        assert!(entity.contains("<em>Jane</em>"), "{entity}");
1117        assert!(entity.contains("Content-Transfer-Encoding: quoted-printable"));
1118        // Off, it is the plain part alone.
1119        assert!(body_entity("Hi\n", false, false).starts_with("Content-Type: text/plain"));
1120    }
1121
1122    #[test]
1123    fn quoted_printable_keeps_lines_short_and_bytes_safe() {
1124        let long = "word ".repeat(40);
1125        let qp = quoted_printable(&format!("{long}\na=b \u{17e}luť"));
1126        assert!(
1127            qp.lines().all(|l| l.trim_end_matches('\r').len() <= 76),
1128            "{qp}"
1129        );
1130        assert!(qp.contains("=\r\n"), "a soft break: {qp}");
1131        // The trailing space of the long line is encoded, = is, and
1132        // the UTF-8 bytes are.
1133        assert!(qp.contains("=20\r\na=3Db =C5=BElu=C5=A5"), "{qp}");
1134        let back: String = qp.replace("=\r\n", "");
1135        assert!(back.starts_with("word word"), "{back}");
1136    }
1137
1138    #[test]
1139    fn the_markdown_header_is_read_and_taken_off() {
1140        let (text, said) = take_markdown("To: a@x\nX-Rmut-Markdown: yes\nSubject: s\n\nbody\n");
1141        assert_eq!(said, Some(true));
1142        assert_eq!(text, "To: a@x\nSubject: s\n\nbody\n");
1143        let (_, said) = take_markdown("To: a@x\nx-rmut-markdown: no\n\nbody\n");
1144        assert_eq!(said, Some(false));
1145        let (text, said) = take_markdown("To: a@x\n\nX-Rmut-Markdown: yes in the body\n");
1146        assert_eq!(said, None, "only the header block counts");
1147        assert!(text.contains("in the body"));
1148    }
1149
1150    #[test]
1151    fn a_forward_can_come_in_quoted() {
1152        let plain = forward_body("Ann <ann@x>", 0, "hi", "one\ntwo\n", None);
1153        assert!(plain.contains("\none\ntwo\n"), "{plain}");
1154        let quoted = forward_body("Ann <ann@x>", 0, "hi", "one\ntwo\n", Some("> "));
1155        assert!(quoted.contains("\n> one\n> two\n"), "{quoted}");
1156        // The markers are rmut's words, so they stay flush.
1157        assert!(quoted.starts_with("----- Forwarded message from Ann <ann@x> -----"));
1158        assert!(quoted.ends_with("----- End forwarded message -----\n"));
1159    }
1160
1161    #[test]
1162    fn the_signature_sits_under_a_dashes_line() {
1163        let body = with_signature("hello\n", "Ann\nx.example", true);
1164        assert_eq!(body, "hello\n\n-- \nAnn\nx.example\n");
1165        // nosig_dashes: the text alone, still a blank line down.
1166        assert_eq!(with_signature("hello\n", "Ann", false), "hello\n\nAnn\n");
1167        // An empty draft starts with the blank line all the same.
1168        assert_eq!(with_signature("", "Ann", true), "\n-- \nAnn\n");
1169    }
1170
1171    #[test]
1172    fn a_signature_comes_from_a_file_or_a_command() {
1173        let dir = std::env::temp_dir().join(format!("rmut-sig-{}", std::process::id()));
1174        std::fs::create_dir_all(&dir).unwrap();
1175        let file = dir.join("signature");
1176        std::fs::write(&file, "Ann\n\n\n").unwrap();
1177        // Trailing blank lines are the file's, not the signature's.
1178        assert_eq!(
1179            signature_text(file.to_str().unwrap()).as_deref(),
1180            Some("Ann")
1181        );
1182        assert_eq!(signature_text("echo hello|").as_deref(), Some("hello"));
1183        // Nothing readable is no signature, not an empty one.
1184        assert_eq!(signature_text(dir.join("gone").to_str().unwrap()), None);
1185        assert_eq!(signature_text("  "), None);
1186        assert_eq!(signature_text("true|"), None);
1187        std::fs::remove_dir_all(&dir).unwrap();
1188    }
1189
1190    #[test]
1191    fn list_post_addresses() {
1192        assert_eq!(
1193            list_post_address("<mailto:dev@example.com>").as_deref(),
1194            Some("dev@example.com")
1195        );
1196        assert_eq!(
1197            list_post_address("<mailto:dev@example.com?subject=help>").as_deref(),
1198            Some("dev@example.com")
1199        );
1200        assert_eq!(
1201            list_post_address("NOTE: <mailto:dev@example.com>, <http://x/post>").as_deref(),
1202            Some("dev@example.com")
1203        );
1204        // RFC 2369: a list that takes no posts, and a non-mail method.
1205        assert_eq!(list_post_address("NO"), None);
1206        assert_eq!(list_post_address("<http://example.com/post>"), None);
1207    }
1208
1209    #[test]
1210    fn followup_to_drops_me_only_when_subscribed() {
1211        let addrs = vec!["alex@example.com".to_string()];
1212        let me = Me::addresses(&addrs);
1213        let subscribed = followup_to(
1214            "dev@example.com, Alex <alex@example.com>",
1215            "",
1216            me,
1217            true,
1218            "Alex <alex@example.com>",
1219        );
1220        assert_eq!(subscribed, "dev@example.com");
1221        let unsubscribed = followup_to(
1222            "dev@example.com",
1223            "petr@example.com",
1224            me,
1225            false,
1226            "Alex <alex@example.com>",
1227        );
1228        assert_eq!(
1229            unsubscribed,
1230            "dev@example.com, petr@example.com, Alex <alex@example.com>"
1231        );
1232        // Already listed: no second copy of my address.
1233        let once = followup_to(
1234            "dev@example.com, alex@example.com",
1235            "",
1236            me,
1237            false,
1238            "Alex <alex@example.com>",
1239        );
1240        assert_eq!(once, "dev@example.com, alex@example.com");
1241    }
1242
1243    #[test]
1244    fn group_reply_drops_me_and_the_sender() {
1245        let addrs = vec!["alex@example.com".to_string()];
1246        let alternates = vec![crate::pattern::Matcher::new("^jp@old\\.example\\.com$")];
1247        let me = Me::new(&addrs, &alternates);
1248        let cc = group_recipients(
1249            "Team <team@example.com>, Alex <alex@example.com>, jp@old.example.com",
1250            "boss@example.com, team@example.com",
1251            "Petr <petr@example.com>",
1252            me,
1253            false,
1254        );
1255        // My exact address and my alternate are gone, the sender in To
1256        // is gone, and team@ appears once despite being listed twice.
1257        assert_eq!(cc, "Team <team@example.com>, boss@example.com");
1258        // $metoo keeps me on the copy.
1259        let cc = group_recipients(
1260            "team@example.com, alex@example.com",
1261            "",
1262            "petr@example.com",
1263            me,
1264            true,
1265        );
1266        assert_eq!(cc, "team@example.com, alex@example.com");
1267    }
1268
1269    #[test]
1270    fn text_flowed_declares_and_stuffs_the_body() {
1271        let entity = text_entity("plain\n>looks quoted\n", true);
1272        assert!(
1273            entity.starts_with("Content-Type: text/plain; charset=utf-8; format=flowed\r\n"),
1274            "{entity}"
1275        );
1276        assert!(
1277            entity.ends_with("plain\r\n >looks quoted\r\n"),
1278            "{entity:?}"
1279        );
1280        // Off, the part is what it always was.
1281        let plain = text_entity("plain\n>looks quoted\n", false);
1282        assert!(plain.starts_with("Content-Type: text/plain; charset=utf-8\r\n"));
1283        assert!(plain.ends_with("plain\r\n>looks quoted\r\n"), "{plain:?}");
1284    }
1285
1286    #[test]
1287    fn flow_plain_declares_an_unwrapped_draft() {
1288        let draft = "From: a@x\nTo: b@x\nSubject: s\n\n>quoted line\n";
1289        let out = flow_plain(draft);
1290        assert!(out.contains("Content-Type: text/plain; charset=utf-8; format=flowed\n"));
1291        assert!(out.ends_with("\n\n >quoted line\n"), "{out:?}");
1292        // A draft that declares its own type is left alone.
1293        let typed = "From: a@x\nContent-Type: text/x-diff\n\nbody\n";
1294        assert_eq!(flow_plain(typed), typed);
1295    }
1296
1297    #[test]
1298    fn draft_envelope_reads_the_header_block() {
1299        let draft = "From: Jane Doe <jane@example.com>\n\
1300                     To: Bob <BOB@work.example.com>, team@x\n\
1301                     Cc: boss@x\n\
1302                     Bcc: archive@x\n\
1303                     Subject: quarterly\n\n\
1304                     two\nlines\n";
1305        let env = draft_envelope(draft, std::path::Path::new("/tmp/draft"));
1306        assert_eq!(env.subject, "quarterly");
1307        assert_eq!(env.from_full, "Jane Doe <jane@example.com>");
1308        assert_eq!(env.to, ["bob@work.example.com", "team@x"]);
1309        // Bcc joins Cc, so a hook on ~c sees a blind recipient too.
1310        assert_eq!(env.cc, ["boss@x", "archive@x"]);
1311        assert_eq!(env.lines, Some(2));
1312        let hit = |p: &str| {
1313            crate::pattern::matches_in(
1314                &crate::pattern::parse(p).unwrap(),
1315                &env,
1316                crate::pattern::Scope::default(),
1317                None,
1318            )
1319        };
1320        assert!(hit("~t @work\\.example\\.com"));
1321        assert!(hit("~c archive@"));
1322        assert!(hit("~A"));
1323        assert!(!hit("~t nobody@"));
1324    }
1325
1326    #[test]
1327    fn my_hdr_merges_into_the_draft_head() {
1328        let draft = "From: jane@example.com\nTo: bob@x\nSubject: s\n\nbody\n";
1329        let merged = apply_my_hdr(
1330            draft,
1331            &[
1332                "Organization: Acme".to_string(),
1333                "From: Jane <jane@work.example.com>".into(),
1334                "Bcc: jane@example.com".into(),
1335                "To: archive@x".into(),
1336                "bogus".into(),
1337            ],
1338        );
1339        assert_eq!(
1340            merged,
1341            "From: Jane <jane@work.example.com>\n\
1342             To: bob@x, archive@x\n\
1343             Subject: s\n\
1344             Organization: Acme\n\
1345             Bcc: jane@example.com\n\
1346             \nbody\n"
1347        );
1348        // Nothing configured: the draft is untouched.
1349        assert_eq!(apply_my_hdr(draft, &[]), draft);
1350    }
1351
1352    /// A message sent at a known moment, for the format strings.
1353    fn quoted() -> Quoted<'static> {
1354        Quoted {
1355            from: "Jane Doe <jane@example.com>",
1356            subject: "Lunch",
1357            message_id: Some("<m1@example.com>"),
1358            // 2024-03-11 10:00:00 UTC.
1359            date: 1_710_151_200,
1360        }
1361    }
1362
1363    #[test]
1364    fn subjects_do_not_stack_prefixes() {
1365        let re = default_reply_regexp();
1366        assert_eq!(reply_subject("Lunch", &re), "Re: Lunch");
1367        assert_eq!(reply_subject("RE: Lunch", &re), "Re: Lunch");
1368        assert_eq!(reply_subject("Re[2]: Lunch", &re), "Re: Lunch");
1369        // A locale's own prefixes, and mutt's smart case: an
1370        // uppercase letter in the regex makes it exact.
1371        let aw = reply_regexp("^(re|aw|sv):[ \t]*").unwrap();
1372        assert_eq!(reply_subject("AW: Lunch", &aw), "Re: Lunch");
1373        let exact = reply_regexp("^(Re):[ \t]*").unwrap();
1374        assert_eq!(reply_subject("RE: Lunch", &exact), "Re: RE: Lunch");
1375        assert_eq!(
1376            forward_subject(DEFAULT_FORWARD_FORMAT, &quoted()),
1377            "[jane@example.com: Lunch]"
1378        );
1379    }
1380
1381    #[test]
1382    fn quote_prefixes_every_line_with_the_indent_string() {
1383        assert_eq!(
1384            quote("On X, Y wrote:", DEFAULT_INDENT, "a\nb"),
1385            "On X, Y wrote:\n> a\n> b\n"
1386        );
1387        assert_eq!(quote("head", "| ", "a"), "head\n| a\n");
1388    }
1389
1390    #[test]
1391    fn an_attribution_says_who_and_when() {
1392        let m = quoted();
1393        // The default names the author and the date it was sent.
1394        let line = attribution(DEFAULT_ATTRIBUTION, &m);
1395        assert!(line.starts_with("On "), "{line}");
1396        assert!(line.ends_with(", Jane Doe wrote:"), "{line}");
1397        // Every specifier mutt's does, and an unknown one stays put.
1398        assert_eq!(render_quoted("%a", &m), "jane@example.com");
1399        assert_eq!(render_quoted("%n", &m), "Jane Doe");
1400        assert_eq!(render_quoted("%f", &m), "Jane Doe <jane@example.com>");
1401        assert_eq!(render_quoted("%s", &m), "Lunch");
1402        assert_eq!(render_quoted("%i", &m), "m1@example.com");
1403        assert_eq!(render_quoted("100%%", &m), "100%");
1404        assert_eq!(render_quoted("%q", &m), "%q");
1405        // %{...} is the date in the caller's own words.
1406        assert_eq!(render_quoted("%{%Y}", &m), "2024");
1407        assert_eq!(render_quoted("[%{%Y}] %s", &m), "[2024] Lunch");
1408    }
1409
1410    #[test]
1411    fn a_name_falls_back_to_the_address() {
1412        let m = Quoted {
1413            from: "bare@example.com",
1414            subject: "x",
1415            message_id: None,
1416            date: 0,
1417        };
1418        assert_eq!(render_quoted("%n", &m), "bare@example.com");
1419        assert_eq!(render_quoted("%i", &m), "");
1420    }
1421
1422    #[test]
1423    fn draft_text_skips_empty_optional_headers() {
1424        let text = draft_text(
1425            &DraftHeaders {
1426                from: None,
1427                to: "a@x".into(),
1428                cc: Some("".into()),
1429                subject: "s".into(),
1430                in_reply_to: None,
1431                references: None,
1432            },
1433            "hi",
1434        );
1435        assert_eq!(text, "To: a@x\nSubject: s\n\nhi\n");
1436        let text = draft_text(
1437            &DraftHeaders {
1438                from: Some("Jane Work <jane@work.example.com>".into()),
1439                to: "a@x".into(),
1440                cc: None,
1441                subject: "s".into(),
1442                in_reply_to: None,
1443                references: None,
1444            },
1445            "hi",
1446        );
1447        assert!(text.starts_with("From: Jane Work <jane@work.example.com>\nTo: a@x\n"));
1448    }
1449
1450    #[test]
1451    fn reverse_from_finds_my_address_as_it_appeared() {
1452        let addrs = vec!["jane@example.com".to_string(), "old@example.com".into()];
1453        let me = Me::addresses(&addrs);
1454        // Display name kept, match case-insensitive.
1455        assert_eq!(
1456            reverse_from("Boss Me <Jane@example.com>, bob@y", "", me, true).as_deref(),
1457            Some("Boss Me <Jane@example.com>")
1458        );
1459        // mutt's $reverse_realname off: the address alone comes over.
1460        assert_eq!(
1461            reverse_from("Boss Me <Jane@example.com>, bob@y", "", me, false).as_deref(),
1462            Some("Jane@example.com")
1463        );
1464        // Bare address stays bare; Cc is searched after To.
1465        assert_eq!(
1466            reverse_from("bob@y", "old@example.com", me, true).as_deref(),
1467            Some("old@example.com")
1468        );
1469        assert_eq!(reverse_from("bob@y, eve@z", "", me, true), None);
1470        assert_eq!(reverse_from("", "", me, true), None);
1471    }
1472
1473    #[test]
1474    fn finalize_adds_missing_headers_once() {
1475        let draft = "To: a@x\nSubject: s\n\nbody\n";
1476        let out = finalize(draft, "me@host", "<id@host>", "DATE").unwrap();
1477        assert!(out.contains("From: me@host"));
1478        assert!(out.contains("Message-ID: <id@host>"));
1479        assert!(out.contains("Date: DATE"));
1480        assert!(out.ends_with("\n\nbody\n"));
1481        // User-provided From wins.
1482        let draft2 = "To: a@x\nFrom: custom@x\n\nbody\n";
1483        let out2 = finalize(draft2, "me@host", "<i>", "D").unwrap();
1484        assert!(out2.contains("From: custom@x"));
1485        assert!(!out2.contains("me@host"));
1486    }
1487
1488    #[test]
1489    fn user_agent_and_signature_placement() {
1490        let draft = "To: a@b\n\nhi";
1491        let plain = finalize_with(draft, "me@x", "<id@h>", "Mon", false).unwrap();
1492        assert!(!plain.contains("User-Agent"));
1493        let ua = finalize_with(draft, "me@x", "<id@h>", "Mon", true).unwrap();
1494        assert!(ua.contains(&user_agent_header()), "{ua}");
1495        // A draft that already carries one is left alone.
1496        let own = finalize_with(
1497            "To: a@b\nUser-Agent: mine\n\nhi",
1498            "me@x",
1499            "<id@h>",
1500            "Mon",
1501            true,
1502        )
1503        .unwrap();
1504        assert_eq!(own.matches("User-Agent").count(), 1, "{own}");
1505        // sig_on_top puts the signature above the body.
1506        let below = with_signature_at("the reply", "Jane", true, false);
1507        assert!(below.trim_end().ends_with("Jane"), "{below}");
1508        let above = with_signature_at("the reply", "Jane", true, true);
1509        assert!(above.starts_with("-- \nJane\n"), "{above}");
1510        assert!(above.trim_end().ends_with("the reply"), "{above}");
1511    }
1512
1513    #[test]
1514    fn finalize_rejects_missing_recipients() {
1515        assert!(finalize("Subject: s\n\nbody", "f", "<i>", "d").is_err());
1516        assert!(finalize("To:   \nSubject: s\n\nbody", "f", "<i>", "d").is_err());
1517        assert!(finalize("Bcc: a@x\n\nbody", "f", "<i>", "d").is_ok());
1518    }
1519
1520    #[test]
1521    fn bare_address_drops_display_name() {
1522        assert_eq!(
1523            bare_address("Jane <jane@x.org>").as_deref(),
1524            Some("jane@x.org")
1525        );
1526        assert_eq!(bare_address("jane@x.org").as_deref(), Some("jane@x.org"));
1527        assert_eq!(bare_address(""), None);
1528    }
1529
1530    #[test]
1531    fn smtp_envelope_collects_rcpts_and_strips_bcc() {
1532        let text =
1533            "To: Alice <a@x>, b@y\nCc: c@z\nBcc: hidden@q,\n also-hidden@q\nSubject: s\n\nbody\n";
1534        let (rcpts, out) = smtp_envelope(text).unwrap();
1535        assert_eq!(
1536            rcpts,
1537            vec!["a@x", "b@y", "c@z", "hidden@q", "also-hidden@q"]
1538        );
1539        assert!(!out.to_lowercase().contains("bcc"));
1540        assert!(!out.contains("hidden@q"));
1541        assert!(out.contains("To: Alice <a@x>, b@y\n"));
1542        assert!(out.ends_with("\n\nbody\n"));
1543    }
1544
1545    #[test]
1546    fn extract_attachments_takes_the_pseudo_headers_out() {
1547        let draft = "To: a@x\nAttach: /tmp/report.pdf the Q2 numbers\n\
1548                     attach: \"/tmp/two words.png\"\nAttach:\nSubject: s\n\nbody\n";
1549        let (out, files) = extract_attachments(draft);
1550        assert_eq!(out, "To: a@x\nSubject: s\n\nbody\n");
1551        assert_eq!(files.len(), 2);
1552        assert_eq!(files[0].path, PathBuf::from("/tmp/report.pdf"));
1553        assert_eq!(files[0].description.as_deref(), Some("the Q2 numbers"));
1554        assert_eq!(files[1].path, PathBuf::from("/tmp/two words.png"));
1555        assert_eq!(files[1].description, None);
1556    }
1557
1558    #[test]
1559    fn attach_lines_carry_a_type_override() {
1560        let draft = "Attach: /tmp/x.bin application/x-custom raw dump\n\
1561                     Attach: /tmp/y.txt see notes\n\n";
1562        let (_, files) = extract_attachments(draft);
1563        assert_eq!(files[0].mime.as_deref(), Some("application/x-custom"));
1564        assert_eq!(files[0].description.as_deref(), Some("raw dump"));
1565        // "see notes" is not a type/subtype token.
1566        assert_eq!(files[1].mime, None);
1567        assert_eq!(files[1].description.as_deref(), Some("see notes"));
1568        // attach_line writes back what extract_attachments reads.
1569        let line = attach_line(&files[0]);
1570        assert_eq!(line, "Attach: /tmp/x.bin application/x-custom raw dump");
1571        let (_, roundtrip) = extract_attachments(&format!("{line}\n\n"));
1572        assert_eq!(roundtrip[0].mime.as_deref(), Some("application/x-custom"));
1573        // A quoted path with spaces survives too.
1574        let spaced = Attachment {
1575            path: PathBuf::from("/tmp/two words.png"),
1576            mime: Some("image/png".into()),
1577            description: None,
1578            name: None,
1579            inline: false,
1580            unlink: false,
1581        };
1582        let (_, files) = extract_attachments(&format!("{}\n\n", attach_line(&spaced)));
1583        assert_eq!(files[0].path, PathBuf::from("/tmp/two words.png"));
1584        assert_eq!(files[0].mime.as_deref(), Some("image/png"));
1585    }
1586
1587    #[test]
1588    fn extract_attachments_leaves_plain_drafts_alone() {
1589        let draft = "To: a@x\nSubject: s\n\nAttach: not a header, body text\n";
1590        let (out, files) = extract_attachments(draft);
1591        assert_eq!(out, draft);
1592        assert!(files.is_empty());
1593    }
1594
1595    #[test]
1596    fn mixed_entity_encodes_files_and_original() {
1597        use mailparse::MailHeaderMap;
1598        let dir = std::env::temp_dir().join(format!("rmut-attach-test-{}", std::process::id()));
1599        std::fs::create_dir_all(&dir).unwrap();
1600        let blob: Vec<u8> = (0..=255u8).collect();
1601        std::fs::write(dir.join("blob.bin"), &blob).unwrap();
1602        let files = [Attachment {
1603            path: dir.join("blob.bin"),
1604            mime: None,
1605            description: Some("raw bytes".into()),
1606            name: None,
1607            inline: false,
1608            unlink: false,
1609        }];
1610        let orig = b"From: jane@x\r\nSubject: hi\r\n\r\noriginal body\r\n";
1611        let entity = mixed_entity("see attached", &files, Some(orig), false, false).unwrap();
1612        let mail = mailparse::parse_mail(entity.as_bytes()).unwrap();
1613        assert_eq!(mail.ctype.mimetype, "multipart/mixed");
1614        assert_eq!(mail.subparts.len(), 3);
1615        assert_eq!(mail.subparts[0].get_body().unwrap().trim(), "see attached");
1616        let file = &mail.subparts[1];
1617        assert_eq!(file.ctype.mimetype, "application/octet-stream");
1618        assert_eq!(file.get_body_raw().unwrap(), blob);
1619        let disp = file.get_headers().get_first_value("Content-Disposition");
1620        assert!(disp.unwrap().contains("filename=\"blob.bin\""));
1621        assert_eq!(
1622            file.get_headers()
1623                .get_first_value("Content-Description")
1624                .as_deref(),
1625            Some("raw bytes")
1626        );
1627        assert_eq!(mail.subparts[2].ctype.mimetype, "message/rfc822");
1628        assert!(
1629            mail.subparts[2]
1630                .get_body()
1631                .unwrap()
1632                .contains("original body")
1633        );
1634        std::fs::remove_dir_all(&dir).unwrap();
1635    }
1636
1637    #[test]
1638    fn mixed_entity_reports_a_missing_file() {
1639        let files = [Attachment {
1640            path: PathBuf::from("/nonexistent/nope.pdf"),
1641            mime: None,
1642            description: None,
1643            name: None,
1644            inline: false,
1645            unlink: false,
1646        }];
1647        let err = mixed_entity("hi", &files, None, false, false).unwrap_err();
1648        assert!(err.to_string().contains("/nonexistent/nope.pdf"));
1649    }
1650
1651    #[test]
1652    fn bounce_text_prepends_resent_headers() {
1653        let orig = b"From: jane@x\nSubject: hi\n\nbody\n";
1654        let out = bounce_text(orig, "Me <me@x>", "bob@y", "DATE", "<id@x>");
1655        assert!(out.starts_with("Resent-From: Me <me@x>\r\n"));
1656        assert!(out.contains("Resent-Date: DATE\r\n"));
1657        assert!(out.contains("Resent-To: bob@y\r\n"));
1658        assert!(out.ends_with("From: jane@x\nSubject: hi\n\nbody\n"));
1659    }
1660
1661    #[test]
1662    fn smtp_envelope_without_bcc_is_unchanged() {
1663        let text = "To: a@x\nSubject: s\n\nbody\n";
1664        let (rcpts, out) = smtp_envelope(text).unwrap();
1665        assert_eq!(rcpts, vec!["a@x"]);
1666        assert_eq!(out, text);
1667    }
1668
1669    #[test]
1670    fn attach_line_options_round_trip() {
1671        let mut a = Attachment::of(PathBuf::from("/tmp/q2 report.pdf"));
1672        a.mime = Some("application/pdf".into());
1673        a.name = Some("report.pdf".into());
1674        a.inline = true;
1675        a.unlink = true;
1676        a.description = Some("the Q2 numbers".into());
1677        let line = attach_line(&a);
1678        assert_eq!(
1679            line,
1680            "Attach: \"/tmp/q2 report.pdf\" application/pdf @name=\"report.pdf\" @inline @unlink the Q2 numbers"
1681        );
1682        let (_, back) = extract_attachments(&format!("To: x\n{line}\n\nbody"));
1683        assert_eq!(back.len(), 1);
1684        let b = &back[0];
1685        assert_eq!(b.path, a.path);
1686        assert_eq!(b.mime.as_deref(), Some("application/pdf"));
1687        assert_eq!(b.name.as_deref(), Some("report.pdf"));
1688        assert!(b.inline && b.unlink);
1689        assert_eq!(b.description.as_deref(), Some("the Q2 numbers"));
1690        // A line without options reads as before, and a description
1691        // starting with @ is a description.
1692        let (_, plain) = extract_attachments("Attach: /tmp/a.txt text/plain @home notes\n\n");
1693        assert!(!plain[0].inline && plain[0].name.is_none());
1694        assert_eq!(plain[0].description.as_deref(), Some("@home notes"));
1695    }
1696
1697    #[test]
1698    fn mixed_entity_honours_name_disposition_and_rfc822() {
1699        let dir = std::env::temp_dir().join(format!("rmut-attach-opts-{}", std::process::id()));
1700        std::fs::create_dir_all(&dir).unwrap();
1701        let file = dir.join("data.bin");
1702        std::fs::write(&file, b"xyz").unwrap();
1703        let msg = dir.join("1.host:2,S");
1704        std::fs::write(&msg, "From: a@x\nSubject: inner\n\nhello\n").unwrap();
1705        let mut a = Attachment::of(file.clone());
1706        a.name = Some("renamed.bin".into());
1707        a.inline = true;
1708        let mut m = Attachment::of(msg.clone());
1709        m.mime = Some("message/rfc822".into());
1710        let entity = mixed_entity("see attached", &[a, m], None, false, false).unwrap();
1711        assert!(
1712            entity.contains("Content-Disposition: inline; filename=\"renamed.bin\""),
1713            "{entity}"
1714        );
1715        assert!(
1716            entity.contains(
1717                "Content-Type: message/rfc822\r\nContent-Disposition: attachment\r\n\r\nFrom: a@x"
1718            ),
1719            "{entity}"
1720        );
1721        assert!(
1722            !entity.contains("filename=\"1.host"),
1723            "a message has no filename"
1724        );
1725        let _ = std::fs::remove_dir_all(&dir);
1726    }
1727
1728    #[test]
1729    fn attachment_names_and_descriptions_stay_one_header() {
1730        let dir = tempfile::tempdir().unwrap();
1731        let file = dir.path().join("x.bin");
1732        std::fs::write(&file, b"xyz").unwrap();
1733        let named = |name: &str, desc: &str| {
1734            let mut a = Attachment::of(file.clone());
1735            a.name = Some(name.into());
1736            a.description = Some(desc.into());
1737            let entity = mixed_entity("hi", &[a], None, false, false).unwrap();
1738            let raw = format!("MIME-Version: 1.0\r\n{entity}");
1739            let mail = mailparse::parse_mail(raw.as_bytes()).unwrap();
1740            let part = &mail.subparts[1];
1741            let filename = part.get_content_disposition().params["filename"].clone();
1742            let headers: Vec<String> = part.headers.iter().map(|h| h.get_key()).collect();
1743            use mailparse::MailHeaderMap as _;
1744            (
1745                filename,
1746                part.headers.get_first_value("Content-Description"),
1747                headers,
1748            )
1749        };
1750        let (name, desc, headers) = named("say \"hi\".txt", "ok\r\nContent-Type: text/html");
1751        assert_eq!(name, "say \"hi\".txt");
1752        assert_eq!(desc.as_deref(), Some("ok  Content-Type: text/html"));
1753        assert_eq!(headers.iter().filter(|k| *k == "Content-Type").count(), 1);
1754        let (name, _, _) = named("Příloha č. 1.pdf", "d");
1755        assert_eq!(name, "Příloha č. 1.pdf");
1756    }
1757}