rok-mail 0.1.0

Email support for the rok ecosystem — Mailable trait, log/SMTP drivers
Documentation
use crate::{MailConfig, MailError, Mailable};

// ── Postmark ──────────────────────────────────────────────────────────────────

#[cfg(feature = "postmark")]
pub(crate) async fn send_postmark(
    mailable: &dyn Mailable,
    to: &str,
    config: &MailConfig,
) -> Result<(), MailError> {
    let api_key = config
        .postmark_api_key
        .as_deref()
        .ok_or_else(|| MailError::MissingApiKey("postmark".into()))?;

    let from = format!("{} <{}>", config.from_name, config.from_address);
    let mut body = serde_json::json!({
        "From":    from,
        "To":      to,
        "Subject": mailable.subject(),
        "TextBody": mailable.body(),
    });

    if let Some(html) = mailable.html_body() {
        body["HtmlBody"] = serde_json::Value::String(html);
    }

    let client = reqwest::Client::new();
    let resp = client
        .post("https://api.postmarkapp.com/email")
        .header("X-Postmark-Server-Token", api_key)
        .header("Content-Type", "application/json")
        .json(&body)
        .send()
        .await
        .map_err(|e| MailError::Http(e.to_string()))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let text = resp.text().await.unwrap_or_default();
        return Err(MailError::Http(format!("Postmark {status}: {text}")));
    }
    Ok(())
}

// ── Resend ────────────────────────────────────────────────────────────────────

#[cfg(feature = "resend")]
pub(crate) async fn send_resend(
    mailable: &dyn Mailable,
    to: &str,
    config: &MailConfig,
) -> Result<(), MailError> {
    let api_key = config
        .resend_api_key
        .as_deref()
        .ok_or_else(|| MailError::MissingApiKey("resend".into()))?;

    let from = format!("{} <{}>", config.from_name, config.from_address);
    let mut body = serde_json::json!({
        "from":    from,
        "to":      [to],
        "subject": mailable.subject(),
        "text":    mailable.body(),
    });

    if let Some(html) = mailable.html_body() {
        body["html"] = serde_json::Value::String(html);
    }

    let client = reqwest::Client::new();
    let resp = client
        .post("https://api.resend.com/emails")
        .header("Authorization", format!("Bearer {api_key}"))
        .json(&body)
        .send()
        .await
        .map_err(|e| MailError::Http(e.to_string()))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let text = resp.text().await.unwrap_or_default();
        return Err(MailError::Http(format!("Resend {status}: {text}")));
    }
    Ok(())
}

pub(crate) async fn send_log(
    mailable: &dyn Mailable,
    to: &str,
    config: &MailConfig,
) -> Result<(), MailError> {
    println!(
        "[rok-mail:log]\n  To:      {to}\n  From:    {} <{}>\n  Subject: {}\n  Body:\n{}",
        config.from_name,
        config.from_address,
        mailable.subject(),
        mailable.body(),
    );
    Ok(())
}

#[cfg(feature = "smtp")]
pub(crate) async fn send_smtp(
    mailable: &dyn Mailable,
    to: &str,
    config: &MailConfig,
) -> Result<(), MailError> {
    use lettre::{
        message::{header::ContentType, Mailbox, MultiPart, SinglePart},
        transport::smtp::authentication::Credentials,
        AsyncTransport, Message, Tokio1Executor,
    };

    let host = config
        .smtp_host
        .as_deref()
        .ok_or(MailError::MissingSmtpHost)?;

    let from: Mailbox = format!("{} <{}>", config.from_name, config.from_address)
        .parse()
        .map_err(|e: lettre::address::AddressError| MailError::Smtp(e.to_string()))?;
    let recipient: Mailbox = to
        .parse()
        .map_err(|e: lettre::address::AddressError| MailError::Smtp(e.to_string()))?;

    let builder = Message::builder()
        .from(from)
        .to(recipient)
        .subject(mailable.subject());

    let email = match mailable.html_body() {
        Some(html) => builder
            .multipart(
                MultiPart::alternative()
                    .singlepart(
                        SinglePart::builder()
                            .header(ContentType::TEXT_PLAIN)
                            .body(mailable.body()),
                    )
                    .singlepart(
                        SinglePart::builder()
                            .header(ContentType::TEXT_HTML)
                            .body(html),
                    ),
            )
            .map_err(|e| MailError::Smtp(e.to_string()))?,
        None => builder
            .header(ContentType::TEXT_PLAIN)
            .body(mailable.body())
            .map_err(|e| MailError::Smtp(e.to_string()))?,
    };

    use lettre::AsyncSmtpTransport;

    let transport: AsyncSmtpTransport<Tokio1Executor> = match config.smtp_encryption.as_str() {
        "tls" => {
            let mut b = AsyncSmtpTransport::<Tokio1Executor>::relay(host)
                .map_err(|e| MailError::Smtp(e.to_string()))?
                .port(config.smtp_port);
            if let (Some(user), Some(pass)) = (&config.smtp_username, &config.smtp_password) {
                b = b.credentials(Credentials::new(user.clone(), pass.clone()));
            }
            b.build()
        }
        "none" => {
            let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)
                .port(config.smtp_port);
            if let (Some(user), Some(pass)) = (&config.smtp_username, &config.smtp_password) {
                b = b.credentials(Credentials::new(user.clone(), pass.clone()));
            }
            b.build()
        }
        // "starttls" or anything else
        _ => {
            let mut b = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)
                .map_err(|e| MailError::Smtp(e.to_string()))?
                .port(config.smtp_port);
            if let (Some(user), Some(pass)) = (&config.smtp_username, &config.smtp_password) {
                b = b.credentials(Credentials::new(user.clone(), pass.clone()));
            }
            b.build()
        }
    };

    transport
        .send(email)
        .await
        .map_err(|e| MailError::Smtp(e.to_string()))?;

    Ok(())
}