use crate::model::{Action, MailboxKind, Message, MessageContent, MessageId};
use anyhow::Result;
use lettre::message::{
Attachment as LettreAttachment, MultiPart, SinglePart,
header::ContentType as LettreContentType, header::HeaderName as LettreHeaderName,
header::HeaderValue as LettreHeaderValue,
};
use std::sync::mpsc::Receiver;
#[cfg(debug_assertions)]
mod debug_log;
pub mod gmail;
pub mod jmap;
pub mod mock;
#[derive(Clone, Debug)]
pub enum BackendEvent {
NewMessage(Message),
MessageFlagsChanged(Message),
MessageDeleted(MessageId),
}
#[derive(Clone, Debug)]
pub struct ActionStatus {
pub action: Action,
pub result: std::result::Result<(), String>,
}
#[derive(Clone, Debug)]
pub struct MailboxSnapshot {
pub total: usize,
pub messages: Vec<Message>,
}
#[derive(Clone, Debug, Default)]
pub struct OutgoingMessage {
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub subject: String,
pub text_body: String,
pub html_body: String,
pub attachments: Vec<OutgoingAttachment>,
}
#[derive(Clone, Debug)]
pub struct OutgoingAttachment {
pub filename: String,
pub mime_type: String,
pub data: Vec<u8>,
}
impl OutgoingAttachment {
pub fn size(&self) -> usize {
self.data.len()
}
}
const MAILER: &str = concat!("Elma ", env!("CARGO_PKG_VERSION"));
pub(crate) fn mailer_header() -> LettreHeaderValue {
LettreHeaderValue::new(
LettreHeaderName::new_from_ascii_str("X-Mailer"),
MAILER.to_string(),
)
}
pub(crate) fn build_compose_body(
text_body: String,
html_body: String,
attachments: Vec<OutgoingAttachment>,
) -> Result<MultiPart> {
let alternative = MultiPart::alternative()
.singlepart(SinglePart::plain(text_body))
.singlepart(SinglePart::html(html_body));
if attachments.is_empty() {
return Ok(alternative);
}
let mut mixed = MultiPart::mixed().multipart(alternative);
for attachment in attachments {
let content_type: LettreContentType = attachment
.mime_type
.parse()
.unwrap_or_else(|_| LettreContentType::parse("application/octet-stream").unwrap());
let part = LettreAttachment::new(attachment.filename).body(attachment.data, content_type);
mixed = mixed.singlepart(part);
}
Ok(mixed)
}
pub(crate) struct LeafPart<'a> {
pub(crate) major_type: &'a str,
pub(crate) has_filename: bool,
pub(crate) disposition: Option<&'a str>,
pub(crate) has_content_id: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PartRole {
Body,
Inline,
Attachment,
}
impl LeafPart<'_> {
pub(crate) fn role(&self) -> PartRole {
if self
.disposition
.is_some_and(|value| value.eq_ignore_ascii_case("attachment"))
{
return PartRole::Attachment;
}
if self.has_content_id
&& self
.disposition
.is_none_or(|value| value.eq_ignore_ascii_case("inline"))
{
return PartRole::Inline;
}
if self.has_filename {
return PartRole::Attachment;
}
if self.major_type.eq_ignore_ascii_case("multipart")
|| self.major_type.eq_ignore_ascii_case("text")
{
PartRole::Body
} else {
PartRole::Attachment
}
}
pub(crate) fn is_attachment(&self) -> bool {
matches!(self.role(), PartRole::Attachment)
}
}
pub trait MailBackend: Send + Sync {
fn load_mailbox(
&self,
mailbox: MailboxKind,
) -> Result<(MailboxSnapshot, Receiver<BackendEvent>)>;
fn load_message(&self, message_id: MessageId) -> Result<MessageContent>;
fn apply_actions(&self, actions: Vec<Action>) -> Result<Receiver<ActionStatus>>;
fn apply_immediate_actions(&self, actions: Vec<Action>) -> Result<Receiver<ActionStatus>> {
self.apply_actions(actions)
}
fn send_message(&self, message: OutgoingMessage) -> Result<()>;
fn save_draft(&self, message: OutgoingMessage) -> Result<()>;
fn fetch_attachment_blob(&self, _blob_id: &str) -> Result<Vec<u8>> {
Err(anyhow::anyhow!(
"this backend does not support on-demand attachment download"
))
}
}
#[cfg(test)]
mod tests {
use super::{LeafPart, PartRole, mailer_header};
#[test]
fn an_outgoing_message_names_elma_as_its_mailer() {
let message = lettre::Message::builder()
.from("her@example.com".parse().expect("the sender parses"))
.to("him@example.com".parse().expect("the recipient parses"))
.subject("Lunch?")
.raw_header(mailer_header())
.body(String::from("Half twelve?"))
.expect("the message builds");
let formatted = String::from_utf8(message.formatted()).expect("the message is text");
assert!(
formatted.contains(&format!("X-Mailer: Elma {}\r\n", env!("CARGO_PKG_VERSION"))),
"{formatted}"
);
}
fn part(major_type: &str, has_filename: bool) -> LeafPart<'_> {
LeafPart {
major_type,
has_filename,
disposition: None,
has_content_id: false,
}
}
#[test]
fn body_text_is_not_an_attachment() {
assert!(!part("text", false).is_attachment());
assert!(!part("multipart", false).is_attachment());
}
#[test]
fn a_named_part_is_an_attachment_whatever_its_type() {
assert!(part("text", true).is_attachment());
assert!(part("application", true).is_attachment());
}
#[test]
fn a_part_the_reader_cannot_see_otherwise_is_an_attachment() {
assert!(part("application", false).is_attachment());
assert!(part("image", false).is_attachment());
}
#[test]
fn a_part_the_body_references_is_a_file_without_being_an_attachment() {
let referenced = LeafPart {
major_type: "image",
has_filename: true,
disposition: Some("inline"),
has_content_id: true,
};
assert_eq!(referenced.role(), PartRole::Inline);
assert_eq!(part("text", false).role(), PartRole::Body);
assert_eq!(part("application", true).role(), PartRole::Attachment);
}
#[test]
fn an_image_the_body_can_reference_is_not_an_attachment() {
let referenced = LeafPart {
major_type: "image",
has_filename: true,
disposition: Some("inline"),
has_content_id: true,
};
assert!(!referenced.is_attachment());
assert!(
!LeafPart {
disposition: None,
..referenced
}
.is_attachment()
);
assert!(
LeafPart {
has_content_id: false,
..referenced
}
.is_attachment()
);
assert!(
LeafPart {
disposition: Some("attachment"),
..referenced
}
.is_attachment()
);
}
}