sendword 0.9.0

Simple HTTP webhook to command runner sidecar. Frontend for managing hooks, JSON state for config portability, SQLite for execution history and logs.
Documentation
use std::future::Future;
use std::pin::Pin;

use allowthem_core::{
    AuthError, EmailBranding, EmailMessage, EmailSender, SmtpConfig as AllowThemSmtpConfig,
    SmtpEmailSender as AllowThemSmtpEmailSender, SmtpTls,
};
use lettre::message::Mailbox;

use crate::config::SmtpConfig;

pub struct SmtpEmailSender {
    inner: AllowThemSmtpEmailSender,
}

impl SmtpEmailSender {
    pub fn new(config: &SmtpConfig) -> Result<Self, eyre::Error> {
        let inner = AllowThemSmtpEmailSender::new(build_smtp_config(config)?, email_branding())
            .map_err(|e| eyre::eyre!("invalid SMTP config: {e}"))?;

        Ok(Self { inner })
    }
}

impl EmailSender for SmtpEmailSender {
    fn send<'a>(
        &'a self,
        message: &'a EmailMessage,
    ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
        self.inner.send(message)
    }
}

fn build_smtp_config(config: &SmtpConfig) -> Result<AllowThemSmtpConfig, eyre::Error> {
    let from: Mailbox = config
        .from
        .parse()
        .map_err(|e| eyre::eyre!("invalid SMTP from address: {e}"))?;

    Ok(AllowThemSmtpConfig {
        host: config.host.clone(),
        port: config.port,
        username: Some(config.username.clone()),
        password: Some(config.password.clone()),
        from_address: from.email.to_string(),
        from_name: from.name,
        tls: if config.starttls {
            SmtpTls::StartTls
        } else {
            SmtpTls::ImplicitTls
        },
    })
}

fn email_branding() -> EmailBranding {
    EmailBranding {
        app_name: "sendword".to_owned(),
        logo_url: None,
        footer_line: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn smtp_config(from: &str, starttls: bool) -> SmtpConfig {
        SmtpConfig {
            host: "smtp.example.com".to_owned(),
            port: 587,
            username: "user".to_owned(),
            password: "pass".to_owned(),
            from: from.to_owned(),
            starttls,
        }
    }

    #[test]
    fn build_smtp_config_preserves_display_name_sender() {
        let config = build_smtp_config(&smtp_config("Sendword <sendword@example.com>", true))
            .expect("valid SMTP config");

        assert_eq!(config.host, "smtp.example.com");
        assert_eq!(config.port, 587);
        assert_eq!(config.username.as_deref(), Some("user"));
        assert_eq!(config.password.as_deref(), Some("pass"));
        assert_eq!(config.from_address, "sendword@example.com");
        assert_eq!(config.from_name.as_deref(), Some("Sendword"));
        assert_eq!(config.tls, SmtpTls::StartTls);
    }

    #[test]
    fn build_smtp_config_uses_implicit_tls_when_starttls_is_disabled() {
        let config = build_smtp_config(&smtp_config("sendword@example.com", false))
            .expect("valid SMTP config");

        assert_eq!(config.from_address, "sendword@example.com");
        assert_eq!(config.from_name, None);
        assert_eq!(config.tls, SmtpTls::ImplicitTls);
    }

    #[test]
    fn build_smtp_config_rejects_invalid_sender_address() {
        let err = build_smtp_config(&smtp_config("not an email address", true))
            .expect_err("invalid sender should fail");

        assert!(
            err.to_string().contains("invalid SMTP from address"),
            "unexpected error: {err}"
        );
    }
}