cloudiful-notifier 0.2.1

Async notification delivery library for ntfy, generic webhooks, DingTalk robots, and SMTP email.
Documentation
use crate::core::{DeliveryChannel, DeliveryResult, MessageEnvelope, NotifierError};

use super::{
    EmailChannel, EmailTlsMode,
    message::{build_message, build_transport},
    transport::{TransportKind, transport_kind},
};

fn sample_channel() -> EmailChannel {
    EmailChannel {
        smtp_host: "smtp.example.com".to_string(),
        smtp_port: Some(2525),
        tls_mode: EmailTlsMode::ImplicitTls,
        username: Some("user".to_string()),
        password: Some("secret".to_string()),
        from: "Ops <ops@example.com>".to_string(),
        to: vec!["Alice <alice@example.com>".to_string()],
        reply_to: Some("noreply@example.com".to_string()),
    }
}

#[test]
fn email_builds_plain_text_message() {
    let message = MessageEnvelope::new("plain body").with_title("Important");

    let email = build_message(&sample_channel(), &message).unwrap();
    let formatted = String::from_utf8(email.formatted()).unwrap();

    assert!(formatted.contains("Subject: Important"));
    assert!(!formatted.contains("multipart/alternative"));
    assert!(formatted.contains("\r\n\r\nplain body"));
}

#[test]
fn email_builds_plain_and_html_message() {
    let message = MessageEnvelope::new("plain body")
        .with_title("Important")
        .with_html_body("<p>html body</p>");

    let email = build_message(&sample_channel(), &message).unwrap();
    let formatted = String::from_utf8(email.formatted()).unwrap();

    assert!(formatted.contains("Subject: Important"));
    assert!(formatted.contains("Content-Type: multipart/alternative;"));
    assert!(formatted.contains("Content-Type: text/plain; charset=utf-8"));
    assert!(formatted.contains("Content-Type: text/html; charset=utf-8"));
    assert!(formatted.contains("plain body"));
    assert!(formatted.contains("<p>html body</p>"));
}

#[test]
fn email_requires_title() {
    let error = build_message(&sample_channel(), &MessageEnvelope::new("plain body")).unwrap_err();

    assert_eq!(
        error,
        NotifierError::InvalidMessage {
            provider: "email",
            message: "email subject requires `title`".to_string(),
        }
    );
}

#[test]
fn email_requires_recipients() {
    let mut channel = sample_channel();
    channel.to.clear();

    let error = build_message(
        &channel,
        &MessageEnvelope::new("plain body").with_title("Important"),
    )
    .unwrap_err();

    assert_eq!(
        error,
        NotifierError::InvalidMessage {
            provider: "email",
            message: "email channel requires at least one `to` recipient".to_string(),
        }
    );
}

#[test]
fn email_rejects_invalid_addresses() {
    let mut channel = sample_channel();
    channel.to = vec!["not-an-email".to_string()];

    let error = build_message(
        &channel,
        &MessageEnvelope::new("plain body").with_title("Important"),
    )
    .unwrap_err();

    assert!(matches!(
        error,
        NotifierError::InvalidMessage {
            provider: "email",
            message,
        } if message.starts_with("invalid `to` address:")
    ));
}

#[test]
fn email_requires_auth_pair() {
    let mut channel = sample_channel();
    channel.password = None;

    let error = build_message(
        &channel,
        &MessageEnvelope::new("plain body").with_title("Important"),
    )
    .unwrap_err();

    assert_eq!(
        error,
        NotifierError::InvalidMessage {
            provider: "email",
            message: "email authentication requires both `username` and `password`".to_string(),
        }
    );
}

#[test]
fn email_tls_modes_map_to_expected_transport_kind() {
    assert_eq!(
        transport_kind(EmailTlsMode::ImplicitTls),
        TransportKind::Relay
    );
    assert_eq!(
        transport_kind(EmailTlsMode::StartTls),
        TransportKind::StartTlsRelay
    );
    assert_eq!(transport_kind(EmailTlsMode::Plain), TransportKind::Plain);
}

#[test]
fn email_build_transport_allows_custom_port() {
    let channel = sample_channel();

    let _transport = build_transport(&channel).unwrap();
}

struct StubEmailChannel;

impl DeliveryChannel for StubEmailChannel {
    async fn deliver(
        &self,
        _http_client: &reqwest::Client,
        _message: &MessageEnvelope,
    ) -> Result<DeliveryResult, NotifierError> {
        Ok(DeliveryResult { http_status: None })
    }
}

#[tokio::test]
async fn facade_like_send_contract_accepts_email_style_channel() {
    let result = StubEmailChannel
        .deliver(
            &reqwest::Client::new(),
            &MessageEnvelope::new("plain body").with_title("Important"),
        )
        .await
        .unwrap();

    assert_eq!(result.http_status, None);
}