grapheme-stdlib 0.6.1

Standard library operation implementations for Grapheme
Documentation
use lettre::message::header::ContentType;
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use serde_json::{json, Value as JsonValue};
use std::env;

pub fn send_provider(args: &JsonValue, provider: &str) -> JsonValue {
    let request = match EmailSendRequest::from_args(args, provider) {
        Ok(request) => request,
        Err(err) => return json!({ "accepted": false, "error": err }),
    };

    if request.to.is_empty() {
        return json!({ "accepted": false, "error": "missing email recipient: to" });
    }

    match send_with_lettre(&request) {
        Ok(()) => json!({
            "accepted": true,
            "provider": request.provider,
            "server": request.server_label(),
            "from": request.from,
            "to": request.to,
            "subject": request.subject,
        }),
        Err(err) => json!({
            "accepted": false,
            "provider": request.provider,
            "server": request.server_label(),
            "error": err,
        }),
    }
}

pub fn providers() -> JsonValue {
    let providers = provider_catalog()
        .into_iter()
        .map(|provider| provider.to_json())
        .collect::<Vec<_>>();

    json!({
        "count": providers.len(),
        "providers": providers,
    })
}

pub fn capabilities(args: &JsonValue) -> JsonValue {
    let Some(target) = args
        .get("provider")
        .and_then(|v| v.as_str())
        .map(|s| s.to_ascii_lowercase())
    else {
        return providers();
    };

    let Some(provider) = provider_catalog().into_iter().find(|p| p.id == target) else {
        return json!({
            "error": format!("unknown provider '{}'", target),
            "available_providers": provider_catalog().into_iter().map(|p| p.id).collect::<Vec<_>>()
        });
    };

    json!({ "provider": provider.to_json() })
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TransportSecurity {
    Plain,
    StartTls,
    Tls,
}

#[derive(Debug, Clone)]
struct EmailSendRequest {
    provider: String,
    to: String,
    from: String,
    subject: String,
    body: String,
    host: String,
    port: u16,
    username: Option<String>,
    password: Option<String>,
    security: TransportSecurity,
}

impl EmailSendRequest {
    fn from_args(args: &JsonValue, provider: &str) -> Result<Self, String> {
        let provider = provider.to_ascii_lowercase();
        let defaults = provider_defaults(&provider);

        let host = arg_str(args, "host")
            .or_else(|| arg_str(args, "server"))
            .or_else(|| env::var(defaults.host_env).ok())
            .unwrap_or_else(|| defaults.host.to_string());
        let port = arg_u16(args, "port")
            .or_else(|| env::var(defaults.port_env).ok().and_then(|v| v.parse().ok()))
            .unwrap_or(defaults.port);
        let username = arg_str(args, "username")
            .or_else(|| env::var(defaults.username_env).ok());
        let password = arg_str(args, "password")
            .or_else(|| env::var(defaults.password_env).ok());
        let security = arg_security(args).unwrap_or(defaults.security);

        Ok(Self {
            provider,
            to: arg_str(args, "to").unwrap_or_default(),
            from: arg_str(args, "from")
                .or_else(|| env::var("EMAIL_FROM").ok())
                .unwrap_or_else(|| defaults.from.to_string()),
            subject: arg_str(args, "subject").unwrap_or_else(|| "(no subject)".to_string()),
            body: arg_str(args, "body").unwrap_or_else(|| "(empty body)".to_string()),
            host,
            port,
            username,
            password,
            security,
        })
    }

    fn server_label(&self) -> String {
        format!("{}:{}", self.host, self.port)
    }
}

struct ProviderDefaults {
    host: &'static str,
    port: u16,
    from: &'static str,
    security: TransportSecurity,
    host_env: &'static str,
    port_env: &'static str,
    username_env: &'static str,
    password_env: &'static str,
}

fn provider_defaults(provider: &str) -> ProviderDefaults {
    match provider {
        "gmail" => ProviderDefaults {
            host: "smtp.gmail.com",
            port: 587,
            from: "grapheme@localhost",
            security: TransportSecurity::StartTls,
            host_env: "GMAIL_SMTP_HOST",
            port_env: "GMAIL_SMTP_PORT",
            username_env: "GMAIL_USERNAME",
            password_env: "GMAIL_APP_PASSWORD",
        },
        _ => ProviderDefaults {
            host: "127.0.0.1",
            port: 25,
            from: "grapheme@localhost",
            security: TransportSecurity::Plain,
            host_env: "SMTP_HOST",
            port_env: "SMTP_PORT",
            username_env: "SMTP_USERNAME",
            password_env: "SMTP_PASSWORD",
        },
    }
}

fn send_with_lettre(request: &EmailSendRequest) -> Result<(), String> {
    let from: Mailbox = request
        .from
        .parse()
        .map_err(|err| format!("invalid from address: {err}"))?;
    let to: Mailbox = request
        .to
        .parse()
        .map_err(|err| format!("invalid to address: {err}"))?;

    let email = Message::builder()
        .from(from)
        .to(to)
        .subject(request.subject.clone())
        .header(ContentType::TEXT_PLAIN)
        .body(request.body.clone())
        .map_err(|err| format!("email build failed: {err}"))?;

    let mailer = build_mailer(request)?;

    mailer
        .send(&email)
        .map_err(|err| format!("email send failed: {err}"))?;
    Ok(())
}

fn build_mailer(request: &EmailSendRequest) -> Result<SmtpTransport, String> {
    let creds = match (&request.username, &request.password) {
        (Some(username), Some(password)) => {
            Some(Credentials::new(username.clone(), password.clone()))
        }
        _ => None,
    };

    let mailer = match request.security {
        TransportSecurity::StartTls => SmtpTransport::starttls_relay(&request.host)
            .map_err(|err| format!("smtp starttls relay failed: {err}"))?
            .port(request.port),
        TransportSecurity::Tls => SmtpTransport::relay(&request.host)
            .map_err(|err| format!("smtp relay failed: {err}"))?
            .port(request.port),
        TransportSecurity::Plain => SmtpTransport::builder_dangerous(&request.host).port(request.port),
    };

    Ok(if let Some(creds) = creds {
        mailer.credentials(creds).build()
    } else {
        mailer.build()
    })
}

#[derive(Debug, Clone)]
struct EmailProvider {
    id: &'static str,
    status: &'static str,
    note: &'static str,
}

impl EmailProvider {
    fn to_json(&self) -> JsonValue {
        json!({
            "id": self.id,
            "status": self.status,
            "note": self.note,
        })
    }
}

fn provider_catalog() -> Vec<EmailProvider> {
    vec![
        EmailProvider {
            id: "smtp",
            status: "available",
            note: "Generic SMTP via lettre; supports host/port/username/password and SMTP_* env vars.",
        },
        EmailProvider {
            id: "gmail",
            status: "available",
            note: "Gmail preset (smtp.gmail.com:587 STARTTLS); set GMAIL_USERNAME and GMAIL_APP_PASSWORD.",
        },
    ]
}

fn arg_str(args: &JsonValue, key: &str) -> Option<String> {
    args.get(key)
        .and_then(|v| v.as_str())
        .map(ToOwned::to_owned)
        .or_else(|| {
            args.get("__input")
                .and_then(|v| v.as_object())
                .and_then(|obj| obj.get(key))
                .and_then(|v| v.as_str())
                .map(ToOwned::to_owned)
        })
}

fn arg_u16(args: &JsonValue, key: &str) -> Option<u16> {
    args.get(key)
        .and_then(|v| v.as_u64().map(|n| n as u16))
        .or_else(|| {
            args.get("__input")
                .and_then(|v| v.as_object())
                .and_then(|obj| obj.get(key))
                .and_then(|v| v.as_u64().map(|n| n as u16))
        })
}

fn arg_security(args: &JsonValue) -> Option<TransportSecurity> {
    let raw = arg_str(args, "security")
        .or_else(|| arg_str(args, "tls"))
        .map(|s| s.to_ascii_lowercase())?;

    match raw.as_str() {
        "plain" | "none" | "false" | "0" => Some(TransportSecurity::Plain),
        "starttls" | "start_tls" | "tls" | "true" | "1" => Some(TransportSecurity::StartTls),
        "ssl" | "smtps" => Some(TransportSecurity::Tls),
        _ => None,
    }
}

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

    #[test]
    fn send_provider_requires_recipient() {
        let out = send_provider(&json!({ "subject": "hi" }), "smtp");
        assert_eq!(out.get("accepted").and_then(|v| v.as_bool()), Some(false));
    }

    #[test]
    fn capabilities_lists_smtp_and_gmail() {
        let out = capabilities(&json!({}));
        let count = out.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
        assert_eq!(count, 2);
    }

    #[test]
    fn gmail_defaults_to_starttls_host() {
        let req = EmailSendRequest::from_args(&json!({ "to": "a@example.com" }), "gmail")
            .expect("gmail request should parse");
        assert_eq!(req.host, "smtp.gmail.com");
        assert_eq!(req.port, 587);
        assert_eq!(req.security, TransportSecurity::StartTls);
    }
}