http-smtp-rele 0.4.0

Minimal, secure HTTP-to-SMTP submission relay
Documentation
//! SMTP relay transport.
//!
//! Implements RFC 062–063: initializes the async SMTP transport to localhost:25
//! and submits mail messages. SMTP errors are mapped to `AppError`.
//!
//! # Transport
//!
//! Uses `lettre::AsyncSmtpTransport` over plain TCP to localhost (no TLS
//! needed for loopback relay). The transport is initialized once and stored
//! in `AppState`.
//!
//! # Timeout
//!
//! Submission is wrapped in `tokio::time::timeout` using
//! `config.smtp.submission_timeout_seconds`.

use std::time::Duration;

use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use tokio::time::timeout;
use tracing::error;

use crate::{config::SmtpConfig, error::AppError};

// ---------------------------------------------------------------------------
// Transport type alias
// ---------------------------------------------------------------------------

/// Async SMTP transport used for all submissions.
pub type SmtpTransport = AsyncSmtpTransport<Tokio1Executor>;

// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------

/// Build the SMTP transport from config (RFC 301, RFC 402).
///
/// Selects the transport based on `smtp.tls`:
/// - `"none"`:     plain TCP (loopback relay, uses `builder_dangerous`)
/// - `"starttls"`: STARTTLS on connect (typically port 587)
/// - `"tls"`:      implicit TLS wrapper (typically port 465)
///
/// SMTP AUTH credentials are injected when both `auth_user` and `auth_password`
/// are configured.
pub fn build_transport(cfg: &SmtpConfig) -> Result<SmtpTransport, AppError> {
    use lettre::transport::smtp::authentication::Credentials;

    let creds = if let (Some(user), Some(pass)) = (&cfg.auth_user, &cfg.auth_password) {
        tracing::debug!(smtp_user = %user, "SMTP AUTH enabled");
        Some(Credentials::new(user.clone(), pass.expose().to_string()))
    } else {
        None
    };

    let timeout = Some(Duration::from_secs(cfg.connect_timeout_seconds));

    let transport = match cfg.tls.as_str() {
        "starttls" => {
            let mut b = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
                .map_err(|e| {
                    tracing::error!(error = %e, "STARTTLS transport build failed");
                    AppError::Internal
                })?
                .port(cfg.port)
                .timeout(timeout);
            if let Some(c) = creds { b = b.credentials(c); }
            b.build()
        }
        "tls" => {
            let mut b = AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)
                .map_err(|e| {
                    tracing::error!(error = %e, "TLS transport build failed");
                    AppError::Internal
                })?
                .port(cfg.port)
                .timeout(timeout);
            if let Some(c) = creds { b = b.credentials(c); }
            b.build()
        }
        _ => {
            // "none" — plain TCP (loopback relay)
            let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
                .port(cfg.port)
                .timeout(timeout);
            if let Some(c) = creds { b = b.credentials(c); }
            b.build()
        }
    };

    Ok(transport)
}

// ---------------------------------------------------------------------------
// Submission
// ---------------------------------------------------------------------------

/// Submit a `lettre::Message` to the SMTP server.
///
/// - Wraps the SMTP call in a timeout (`submission_timeout_seconds`).
/// - Maps SMTP-level errors to `AppError::SmtpUnavailable`.
///
/// # Errors
///
/// | Condition           | Error                  |
/// |---------------------|------------------------|
/// | Timeout             | `AppError::SmtpUnavailable` |
/// | Connection refused  | `AppError::SmtpUnavailable` |
/// | SMTP rejection      | `AppError::SmtpUnavailable` |
pub async fn submit(
    transport: &SmtpTransport,
    message: Message,
    timeout_seconds: u64,
) -> Result<(), AppError> {
    let result = timeout(
        Duration::from_secs(timeout_seconds),
        transport.send(message),
    )
    .await;

    match result {
        Ok(Ok(_response)) => Ok(()),
        Ok(Err(e)) => {
            error!(smtp_error = %e, "SMTP submission failed");
            Err(AppError::SmtpUnavailable)
        }
        Err(_elapsed) => {
            error!("SMTP submission timed out after {timeout_seconds}s");
            Err(AppError::SmtpUnavailable)
        }
    }
}

// ---------------------------------------------------------------------------
// Readiness check (RFC 064)
// ---------------------------------------------------------------------------

/// Test whether the SMTP server is reachable by attempting a TCP connection.
///
/// Used by `/readyz`. Returns `true` if SMTP responds, `false` otherwise.
pub async fn is_smtp_reachable(cfg: &SmtpConfig) -> bool {
    let addr = format!("{}:{}", cfg.host, cfg.port);
    timeout(
        Duration::from_secs(cfg.connect_timeout_seconds),
        tokio::net::TcpStream::connect(&addr),
    )
    .await
    .map(|r| r.is_ok())
    .unwrap_or(false)
}

// ---------------------------------------------------------------------------
// Pipe mode submission (RFC 304)
// ---------------------------------------------------------------------------

/// Submit a mail message via a pipe command (e.g., `sendmail -t`).
///
/// The message is formatted as a raw RFC 5322 string and piped to the
/// configured command via stdin. Used when `smtp.mode = "pipe"`.
///
/// # Errors
///
/// Returns `AppError::SmtpUnavailable` if the command fails to start or exits
/// with a non-zero status code.
pub async fn submit_pipe(
    message: Message,
    pipe_command: &str,
    timeout_seconds: u64,
) -> Result<(), AppError> {
    use tokio::process::Command;
    use tokio::io::AsyncWriteExt;
    use std::process::Stdio;

    // Format the message to bytes (RFC 5322).
    let raw = message.formatted();

    let result = tokio::time::timeout(
        std::time::Duration::from_secs(timeout_seconds),
        async {
            let mut child = Command::new(pipe_command)
                .arg("-t")
                .stdin(Stdio::piped())
                .stdout(Stdio::null())
                .stderr(Stdio::piped())
                .spawn()
                .map_err(|e| {
                    tracing::error!(
                        command = pipe_command,
                        error = %e,
                        "failed to spawn pipe command"
                    );
                    AppError::SmtpUnavailable
                })?;

            if let Some(mut stdin) = child.stdin.take() {
                stdin.write_all(&raw).await.map_err(|e| {
                    tracing::error!(error = %e, "failed to write to pipe command stdin");
                    AppError::SmtpUnavailable
                })?;
            }

            let output = child.wait_with_output().await.map_err(|e| {
                tracing::error!(error = %e, "pipe command wait failed");
                AppError::SmtpUnavailable
            })?;

            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                tracing::error!(
                    command = pipe_command,
                    exit_code = ?output.status.code(),
                    stderr = %stderr,
                    "pipe command exited with error"
                );
                return Err(AppError::SmtpUnavailable);
            }

            Ok(())
        }
    ).await;

    match result {
        Ok(r) => r,
        Err(_elapsed) => {
            tracing::error!(
                command = pipe_command,
                timeout_seconds,
                "pipe command timed out"
            );
            Err(AppError::SmtpUnavailable)
        }
    }
}