ecr-store 0.1.2

Mail storage for ecr: notmuch queries, MIME parsing and sanitization, sync and send
Documentation
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;

use crate::error::{Error, Result};
use ecr_core::account::Account;
use ecr_core::compose::Draft;
use mail_builder::MessageBuilder;

pub fn build(account: &Account, draft: &Draft) -> Result<Vec<u8>> {
    draft.is_sendable().map_err(|reason| Error::InvalidDraft {
        reason: reason.to_string(),
    })?;

    let from = account.address.clone().ok_or_else(|| Error::InvalidDraft {
        reason: format!("account {} has no address to send from", account.id),
    })?;

    let mut builder = MessageBuilder::new()
        .from(from.as_str())
        .subject(draft.subject.as_str())
        .text_body(draft.body.as_str());

    if !draft.to.is_empty() {
        builder = builder.to(addresses(&draft.to));
    }
    if !draft.cc.is_empty() {
        builder = builder.cc(addresses(&draft.cc));
    }
    if !draft.bcc.is_empty() {
        builder = builder.bcc(addresses(&draft.bcc));
    }
    if let Some(in_reply_to) = &draft.in_reply_to {
        builder = builder.in_reply_to(in_reply_to.as_str());
    }
    if !draft.references.is_empty() {
        builder = builder.references(references(&draft.references));
    }

    for attachment in &draft.attachments {
        let bytes = STANDARD
            .decode(attachment.data_b64.as_bytes())
            .map_err(|e| Error::InvalidDraft {
                reason: format!("attachment {}: {e}", attachment.filename),
            })?;

        let content_type = if attachment.content_type.trim().is_empty() {
            "application/octet-stream"
        } else {
            attachment.content_type.as_str()
        };

        builder = builder.attachment(content_type, attachment.filename.as_str(), bytes);
    }

    builder.write_to_vec().map_err(|e| Error::InvalidDraft {
        reason: e.to_string(),
    })
}

fn addresses(list: &[String]) -> Vec<String> {
    list.iter().map(|a| a.trim().to_string()).collect()
}

fn references(list: &[String]) -> Vec<String> {
    list.iter()
        .map(|r| r.trim().trim_matches(['<', '>']).to_string())
        .filter(|r| !r.is_empty())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use ecr_core::account::AccountId;
    use ecr_core::compose::Attachment;

    fn account() -> Account {
        Account {
            id: AccountId::from("main"),
            display_name: "Alice".to_string(),
            maildir_path: "/tmp/Mail/main".into(),
            address: Some("alice@example.com".to_string()),
            mbsync_channel: Some("main".to_string()),
            msmtp_account: Some("main".to_string()),
            folders: Vec::new(),
        }
    }

    fn draft() -> Draft {
        Draft {
            to: vec!["someone@example.com".to_string()],
            subject: "Hello".to_string(),
            body: "Hi there.".to_string(),
            ..Default::default()
        }
    }

    fn built(draft: &Draft) -> String {
        String::from_utf8(build(&account(), draft).unwrap()).unwrap()
    }

    #[test]
    fn builds_a_minimal_rfc5322_message() {
        let raw = built(&draft());

        assert!(raw.contains("From:"), "{raw}");
        assert!(raw.contains("alice@example.com"), "{raw}");
        assert!(raw.contains("To:"), "{raw}");
        assert!(raw.contains("someone@example.com"), "{raw}");
        assert!(raw.contains("Subject: Hello"), "{raw}");
        assert!(raw.contains("Hi there."), "{raw}");
    }

    #[test]
    fn includes_a_message_id_and_date() {
        let raw = built(&draft());
        assert!(raw.contains("Message-ID:"), "{raw}");
        assert!(raw.contains("Date:"), "{raw}");
    }

    #[test]
    fn threading_headers_are_written_when_replying() {
        let draft = Draft {
            in_reply_to: Some("original@example.com".to_string()),
            references: vec![
                "older@example.com".to_string(),
                "original@example.com".to_string(),
            ],
            ..draft()
        };
        let raw = built(&draft);

        assert!(raw.contains("In-Reply-To:"), "{raw}");
        assert!(raw.contains("References:"), "{raw}");
        assert!(raw.contains("older@example.com"), "{raw}");
    }

    #[test]
    fn cc_and_bcc_are_written() {
        let draft = Draft {
            cc: vec!["cc@example.com".to_string()],
            bcc: vec!["bcc@example.com".to_string()],
            ..draft()
        };
        let raw = built(&draft);

        assert!(raw.contains("cc@example.com"), "{raw}");
        assert!(raw.contains("bcc@example.com"), "{raw}");
    }

    #[test]
    fn a_unicode_subject_is_encoded_rather_than_emitted_raw() {
        let draft = Draft {
            subject: "Café ☁".to_string(),
            ..draft()
        };
        let raw = built(&draft);

        assert!(raw.contains("=?utf-8?"), "subject should be encoded: {raw}");
    }

    #[test]
    fn a_draft_with_no_recipients_is_refused() {
        let err = build(&account(), &Draft::default()).unwrap_err();
        assert!(matches!(err, Error::InvalidDraft { .. }), "{err:?}");
    }

    #[test]
    fn an_account_with_no_address_cannot_send() {
        let mut account = account();
        account.address = None;

        let err = build(&account, &draft()).unwrap_err();
        assert!(err.to_string().contains("no address"), "{err}");
    }

    #[test]
    fn an_attachment_becomes_a_mime_part() {
        let draft = Draft {
            attachments: vec![Attachment {
                filename: "notes.txt".to_string(),
                content_type: "text/plain".to_string(),
                data_b64: STANDARD.encode("hello attachment"),
            }],
            ..draft()
        };
        let raw = built(&draft);

        assert!(raw.contains("multipart/mixed"), "{raw}");
        assert!(raw.contains("notes.txt"), "{raw}");
        assert!(raw.contains("attachment"), "{raw}");
        assert!(raw.contains("Hi there."), "the body survives: {raw}");
    }

    #[test]
    fn several_attachments_all_travel() {
        let file = |name: &str| Attachment {
            filename: name.to_string(),
            content_type: "application/pdf".to_string(),
            data_b64: STANDARD.encode("%PDF-1.4"),
        };
        let draft = Draft {
            attachments: vec![file("one.pdf"), file("two.pdf")],
            ..draft()
        };
        let raw = built(&draft);

        assert!(raw.contains("one.pdf"), "{raw}");
        assert!(raw.contains("two.pdf"), "{raw}");
    }

    #[test]
    fn a_message_with_no_attachments_stays_a_plain_body() {
        let raw = built(&draft());
        assert!(!raw.contains("multipart"), "{raw}");
    }

    #[test]
    fn an_empty_content_type_falls_back_rather_than_producing_a_broken_header() {
        let draft = Draft {
            attachments: vec![Attachment {
                filename: "blob.bin".to_string(),
                content_type: String::new(),
                data_b64: STANDARD.encode([0u8, 1, 2, 3]),
            }],
            ..draft()
        };
        assert!(built(&draft).contains("application/octet-stream"));
    }

    #[test]
    fn undecodable_base64_is_refused_rather_than_sent_as_rubbish() {
        let draft = Draft {
            attachments: vec![Attachment {
                filename: "bad.bin".to_string(),
                content_type: "application/octet-stream".to_string(),
                data_b64: "not!valid!base64".to_string(),
            }],
            ..draft()
        };

        let err = build(&account(), &draft).unwrap_err();
        assert!(err.to_string().contains("bad.bin"), "{err}");
    }

    #[test]
    fn a_header_injection_attempt_never_reaches_the_builder() {
        let draft = Draft {
            subject: "hi\r\nBcc: victim@example.com".to_string(),
            ..draft()
        };

        let err = build(&account(), &draft).unwrap_err();
        assert!(matches!(err, Error::InvalidDraft { .. }), "{err:?}");
    }
}