elektromail 0.1.1

A minimal, Rust-based IMAP + SMTP mail server for local development and testing
Documentation
use std::fmt::Write;
use std::sync::Arc;

use serde::Deserialize;

/// Delivery policy hook for RCPT and DATA processing.
pub trait DeliveryPolicy: Send + Sync {
    /// Decide how to handle a recipient during RCPT.
    fn on_rcpt(&self, recipient: &str, mail_from: Option<&str>) -> RcptDecision;
    /// Decide what DSN messages to emit after DATA is accepted.
    fn on_data(
        &self,
        mail_from: Option<&str>,
        recipients: &[RcptContext],
        data: &[u8],
    ) -> Vec<DsnRecipient>;
}

/// Shared, thread-safe delivery policy handle.
pub type SharedDeliveryPolicy = Arc<dyn DeliveryPolicy>;

/// Action to take for a DSN rule.
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DsnAction {
    /// Reject the recipient at RCPT time with a 5xx response.
    Reject,
    /// Accept at RCPT but generate a DSN after DATA.
    Bounce,
    /// Explicitly accept the recipient.
    Accept,
}

/// A DSN rule matched against recipient and sender addresses.
#[derive(Clone, Debug, Deserialize)]
pub struct DsnRule {
    /// Optional recipient pattern (supports `*` wildcards).
    #[serde(default)]
    pub recipient: Option<String>,
    /// Optional sender pattern (supports `*` wildcards).
    #[serde(default)]
    pub sender: Option<String>,
    /// Action to take when the rule matches.
    pub action: DsnAction,
    /// Optional DSN status (e.g., `5.1.1`).
    #[serde(default)]
    pub status: Option<String>,
    /// Optional diagnostic message for DSN or reject responses.
    #[serde(default)]
    pub diagnostic: Option<String>,
    /// Optional SMTP response code for rejects (defaults to 550).
    #[serde(default)]
    pub code: Option<u16>,
}

/// Decision for a single RCPT command.
#[derive(Clone, Debug)]
pub enum RcptDecision {
    /// Accept and deliver the message.
    Accept,
    /// Reject with SMTP status code and message.
    Reject { code: u16, message: String },
    /// Accept but generate a DSN after DATA.
    Bounce { status: String, diagnostic: String },
}

/// Captured recipient data for DATA processing.
#[derive(Clone, Debug)]
pub struct RcptContext {
    /// Full recipient address.
    pub address: String,
    /// Local user portion used for mailbox delivery.
    pub user: String,
    /// Decision captured at RCPT time.
    pub decision: RcptDecision,
}

/// DSN recipient details for reporting.
#[derive(Clone, Debug)]
pub struct DsnRecipient {
    /// Full recipient address.
    pub address: String,
    /// DSN status code (e.g., `5.1.1`).
    pub status: String,
    /// Diagnostic message.
    pub diagnostic: String,
}

/// Rule-based delivery policy for DSN generation.
#[derive(Clone, Debug, Default)]
pub struct DsnPolicy {
    rules: Vec<DsnRule>,
}

impl DsnPolicy {
    /// Build a DSN policy from explicit rules.
    pub fn from_rules(rules: Vec<DsnRule>) -> Self {
        Self { rules }
    }

    /// Build a DSN policy from `ELEKTROMAIL_DSN_RULES`.
    pub fn from_env() -> Self {
        let raw = match std::env::var("ELEKTROMAIL_DSN_RULES") {
            Ok(value) => value.trim().to_string(),
            Err(_) => return Self::default(),
        };
        if raw.is_empty() {
            return Self::default();
        }
        let rules: Vec<DsnRule> = serde_json::from_str(&raw).unwrap_or_default();
        Self { rules }
    }
}

impl DeliveryPolicy for DsnPolicy {
    fn on_rcpt(&self, recipient: &str, mail_from: Option<&str>) -> RcptDecision {
        for rule in &self.rules {
            if rule.matches(recipient, mail_from) {
                return rule.to_decision();
            }
        }
        RcptDecision::Accept
    }

    fn on_data(
        &self,
        _mail_from: Option<&str>,
        recipients: &[RcptContext],
        _data: &[u8],
    ) -> Vec<DsnRecipient> {
        recipients
            .iter()
            .filter_map(|recipient| match &recipient.decision {
                RcptDecision::Bounce { status, diagnostic } => Some(DsnRecipient {
                    address: recipient.address.clone(),
                    status: status.clone(),
                    diagnostic: diagnostic.clone(),
                }),
                _ => None,
            })
            .collect()
    }
}

impl DsnRule {
    fn matches(&self, recipient: &str, mail_from: Option<&str>) -> bool {
        let recipient_ok = match &self.recipient {
            Some(pattern) => matches_pattern(pattern, recipient),
            None => true,
        };
        let sender_ok = match (&self.sender, mail_from) {
            (Some(pattern), Some(sender)) => matches_pattern(pattern, sender),
            (Some(_), None) => false,
            (None, _) => true,
        };
        recipient_ok && sender_ok
    }

    fn to_decision(&self) -> RcptDecision {
        match self.action {
            DsnAction::Accept => RcptDecision::Accept,
            DsnAction::Reject => RcptDecision::Reject {
                code: self.code.unwrap_or(550),
                message: self
                    .diagnostic
                    .clone()
                    .unwrap_or_else(|| "Rejected".to_string()),
            },
            DsnAction::Bounce => RcptDecision::Bounce {
                status: self.status.clone().unwrap_or_else(|| "5.1.1".to_string()),
                diagnostic: self
                    .diagnostic
                    .clone()
                    .unwrap_or_else(|| "Delivery failed".to_string()),
            },
        }
    }
}

fn matches_pattern(pattern: &str, value: &str) -> bool {
    let pattern = pattern.to_ascii_lowercase();
    let value = value.to_ascii_lowercase();
    if pattern == "*" {
        return true;
    }
    if !pattern.contains('*') {
        return pattern == value;
    }
    let parts: Vec<&str> = pattern.split('*').collect();
    let mut index = 0usize;
    let mut first = true;
    for part in &parts {
        if part.is_empty() {
            continue;
        }
        if first && !pattern.starts_with('*') {
            if !value.starts_with(part) {
                return false;
            }
            index = part.len();
            first = false;
            continue;
        }
        if let Some(pos) = value[index..].find(part) {
            index += pos + part.len();
        } else {
            return false;
        }
        first = false;
    }
    if !pattern.ends_with('*') {
        if let Some(last) = parts.iter().rev().find(|part| !part.is_empty()) {
            return value.ends_with(last);
        }
    }
    true
}

pub(crate) fn build_dsn_message(sender: &str, recipients: &[DsnRecipient]) -> Vec<u8> {
    let boundary = "dsn-boundary";
    let mut delivery = String::new();
    for recipient in recipients {
        let _ = write!(
            delivery,
            "Final-Recipient: rfc822; {}\r\nAction: failed\r\nStatus: {}\r\nDiagnostic-Code: smtp; {}\r\n\r\n",
            recipient.address, recipient.status, recipient.diagnostic
        );
    }
    let message = format!(
        "From: MAILER-DAEMON <mailer-daemon@localhost>\r\nTo: {sender}\r\nSubject: Delivery Status Notification (Failure)\r\nMIME-Version: 1.0\r\nContent-Type: multipart/report; report-type=delivery-status; boundary=\"{boundary}\"\r\n\r\n--{boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThis is a delivery status notification generated by Elektromail.\r\n\r\n--{boundary}\r\nContent-Type: message/delivery-status\r\n\r\n{delivery}--{boundary}--\r\n"
    );
    message.into_bytes()
}