cloudiful-notifier 0.2.1

Async notification delivery library for ntfy, generic webhooks, DingTalk robots, and SMTP email.
Documentation
use crate::core::{MessageEnvelope, NotifierError};
use lettre::{
    Message, Tokio1Executor,
    message::MultiPart,
    transport::smtp::{
        AsyncSmtpTransport,
        authentication::Credentials,
    },
};

use super::{EmailChannel, EmailTlsMode};

pub(crate) type EmailTransport = AsyncSmtpTransport<Tokio1Executor>;

pub(crate) fn build_message(
    channel: &EmailChannel,
    envelope: &MessageEnvelope,
) -> Result<Message, NotifierError> {
    let subject = envelope
        .title
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| invalid_message("email subject requires `title`"))?;
    if channel.to.is_empty() {
        return Err(invalid_message("email channel requires at least one `to` recipient"));
    }
    validate_auth_pair(channel)?;

    let mut builder = Message::builder().from(parse_mailbox(&channel.from, "from")?);
    for recipient in &channel.to {
        builder = builder.to(parse_mailbox(recipient, "to")?);
    }
    if let Some(reply_to) = channel.reply_to.as_deref() {
        builder = builder.reply_to(parse_mailbox(reply_to, "reply_to")?);
    }
    builder = builder.subject(subject);

    if let Some(html_body) = envelope.html_body.as_ref() {
        builder
            .multipart(MultiPart::alternative_plain_html(
                envelope.body.clone(),
                html_body.clone(),
            ))
            .map_err(|error| invalid_message(&format!("failed to build email body: {error}")))
    } else {
        builder
            .body(envelope.body.clone())
            .map_err(|error| invalid_message(&format!("failed to build email body: {error}")))
    }
}

pub(crate) fn build_transport(channel: &EmailChannel) -> Result<EmailTransport, NotifierError> {
    validate_auth_pair(channel)?;

    let mut builder = match channel.tls_mode {
        EmailTlsMode::ImplicitTls => AsyncSmtpTransport::<Tokio1Executor>::relay(&channel.smtp_host)
            .map_err(|error| transport_error(error.to_string()))?,
        EmailTlsMode::StartTls => {
            AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&channel.smtp_host)
                .map_err(|error| transport_error(error.to_string()))?
        }
        EmailTlsMode::Plain => {
            AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(channel.smtp_host.clone())
        }
    };

    if let Some(port) = channel.smtp_port {
        builder = builder.port(port);
    }

    if let (Some(username), Some(password)) = (&channel.username, &channel.password) {
        builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
    }

    Ok(builder.build())
}

fn parse_mailbox(value: &str, field: &str) -> Result<lettre::message::Mailbox, NotifierError> {
    value
        .parse()
        .map_err(|error| invalid_message(&format!("invalid `{field}` address: {error}")))
}

fn validate_auth_pair(channel: &EmailChannel) -> Result<(), NotifierError> {
    match (&channel.username, &channel.password) {
        (Some(_), Some(_)) | (None, None) => Ok(()),
        _ => Err(invalid_message(
            "email authentication requires both `username` and `password`",
        )),
    }
}

fn invalid_message(message: &str) -> NotifierError {
    NotifierError::InvalidMessage {
        provider: "email",
        message: message.to_string(),
    }
}

fn transport_error(message: String) -> NotifierError {
    NotifierError::Transport {
        provider: "email",
        message,
    }
}