mailcrab 1.7.0

Email test server for development, written in Rust
Documentation
use base64ct::Encoding;
use chrono::{DateTime, Local};
use mail_parser::MimeHeaders;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::warn;
use uuid::Uuid;

use crate::error::Error;

pub type MessageId = Uuid;

#[derive(Deserialize, Debug)]
pub enum Action {
    RemoveAll,
    #[allow(unused)]
    Remove(MessageId),
    #[allow(unused)]
    Open(MessageId),
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AttachmentMetadata {
    pub filename: String,
    mime: String,
    size: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MailMessageMetadata {
    pub id: MessageId,
    from: Address,
    to: Vec<Address>,
    subject: String,
    pub time: i64,
    date: String,
    size: String,
    opened: bool,
    pub has_html: bool,
    pub has_plain: bool,
    pub attachments: Vec<AttachmentMetadata>,
    pub envelope_from: String,
    pub envelope_recipients: Vec<String>,
}

impl From<MailMessage> for MailMessageMetadata {
    fn from(message: MailMessage) -> Self {
        let MailMessage {
            id,
            from,
            to,
            subject,
            time,
            date,
            size,
            html,
            text,
            opened,
            attachments,
            envelope_from,
            envelope_recipients,
            ..
        } = message;
        MailMessageMetadata {
            id,
            from,
            to,
            subject,
            time,
            date,
            size,
            has_html: !html.is_empty(),
            has_plain: !text.is_empty(),
            opened,
            attachments: attachments
                .into_iter()
                .map(|a| AttachmentMetadata {
                    filename: a.filename,
                    mime: a.mime,
                    size: a.size,
                })
                .collect::<Vec<AttachmentMetadata>>(),
            envelope_from,
            envelope_recipients,
        }
    }
}

#[derive(Clone, Debug, Serialize)]
pub struct Attachment {
    filename: String,
    content_id: Option<String>,
    mime: String,
    size: String,
    #[serde(skip)]
    content: String,
}

impl From<&mail_parser::MessagePart<'_>> for Attachment {
    fn from(part: &mail_parser::MessagePart) -> Self {
        let filename = part.attachment_name().unwrap_or_default().to_string();
        let mime = match part.content_type() {
            Some(content_type) => match &content_type.c_subtype {
                Some(subtype) => format!("{}/{}", content_type.c_type, subtype),
                None => content_type.c_type.to_string(),
            },
            None => "application/octet-stream".to_owned(),
        };

        Attachment {
            filename,
            mime,
            content_id: part.content_id().map(|s| s.to_owned()),
            size: humansize::format_size(part.contents().len(), humansize::DECIMAL),
            content: base64ct::Base64::encode_string(part.contents()),
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct Address {
    name: Option<String>,
    email: Option<String>,
}

impl From<&mail_parser::Addr<'_>> for Address {
    fn from(addr: &mail_parser::Addr) -> Self {
        Address {
            name: addr.name.clone().map(|v| v.to_string()),
            email: addr.address.clone().map(|v| v.to_string()),
        }
    }
}

#[derive(Clone, Debug, Serialize, Default)]
pub struct MailMessage {
    pub id: MessageId,
    pub time: i64,
    from: Address,
    to: Vec<Address>,
    subject: String,
    date: String,
    size: String,
    opened: bool,
    headers: HashMap<String, String>,
    text: String,
    html: String,
    pub attachments: Vec<Attachment>,
    #[serde(skip)]
    raw: String,
    pub envelope_from: String,
    pub envelope_recipients: Vec<String>,
}

impl MailMessage {
    pub fn open(&mut self) {
        self.opened = true;
    }

    pub fn raw_bytes(&self) -> Option<Vec<u8>> {
        base64ct::Base64::decode_vec(&self.raw).ok()
    }

    pub fn attachment_content(&self, index: usize) -> Option<(String, String, Vec<u8>)> {
        let a = self.attachments.get(index)?;
        let bytes = base64ct::Base64::decode_vec(&a.content).ok()?;
        Some((a.filename.clone(), a.mime.clone(), bytes))
    }

    pub fn render(&self, prefix: &str) -> String {
        if self.html.is_empty() {
            return self.text.clone();
        }

        let prefix = prefix.trim_end_matches('/');
        let mut html = self.html.clone();

        for (index, attachment) in self.attachments.iter().enumerate() {
            if let Some(content_id) = &attachment.content_id {
                let cid = format!("cid:{}", content_id.trim_start_matches("cid:"));
                let url = format!("{}/api/message/{}/attachment/{}", prefix, self.id, index);
                html = html.replace(&cid, &url);
            }
        }

        html
    }
}

impl TryFrom<mail_parser::Message<'_>> for MailMessage {
    type Error = Error;

    fn try_from(message: mail_parser::Message) -> Result<Self, Self::Error> {
        let from = match message.from().and_then(|f| f.first()) {
            Some(addr) => addr.into(),
            _ => {
                warn!("Could not parse 'From' address header, setting placeholder address.");

                Address {
                    name: Some("No from header".to_string()),
                    email: Some("no-from-header@example.com".to_string()),
                }
            }
        };

        let to = match message.to().and_then(|a| a.as_list()) {
            Some(list) => list
                .iter()
                .map(|addr| addr.into())
                .collect::<Vec<Address>>(),
            _ => {
                warn!("Could not parse 'To' address header, setting placeholder address.");

                vec![Address {
                    name: Some("No to header".to_string()),
                    email: Some("no-to-header@example.com".to_string()),
                }]
            }
        };

        let subject = message.subject().unwrap_or_default().to_owned();

        let text = match message
            .text_bodies()
            .find(|p| p.is_text() && !p.is_text_html())
        {
            Some(item) => item.to_string(),
            _ => Default::default(),
        };

        let html = match message.html_bodies().find(|p| p.is_text_html()) {
            Some(item) => item.to_string(),
            _ => Default::default(),
        };

        let attachments = message
            .attachments()
            .map(|attachement| attachement.into())
            .collect::<Vec<Attachment>>();

        let date: DateTime<Local> = match message.date() {
            Some(date) => match DateTime::parse_from_rfc2822(date.to_rfc3339().as_str()) {
                Ok(date_time) => date_time.into(),
                _ => Local::now(),
            },
            None => Local::now(),
        };

        let raw = base64ct::Base64::encode_string(&message.raw_message);

        let mut headers = HashMap::<String, String>::new();

        for (key, value) in message.headers_raw() {
            headers.insert(key.to_string(), value.to_string());
        }

        let size = humansize::format_size(message.raw_message.len(), humansize::DECIMAL);

        Ok(MailMessage {
            id: Uuid::new_v4(),
            from,
            to,
            subject,
            time: date.timestamp(),
            date: date.format("%Y-%m-%d %H:%M:%S").to_string(),
            size,
            text,
            html,
            opened: false,
            attachments,
            raw,
            headers,
            ..MailMessage::default()
        })
    }
}