bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Mailer capability trait. See `plan/ecosystem/02-capabilities.md`.
//!
//! A `MailerPlugin` sends transactional email. The trait is shaped so
//! that vendor-specific details never leak: `@bext/mailer-smtp`
//! (lettre-backed) and `@bext/mailer-ses` (AWS SES) both satisfy the
//! same surface, and a project can swap between them by editing
//! `bext.config.toml` without touching code.
//!
//! # Design notes
//!
//! - **Pre-rendered bodies.** Callers hand the mailer a fully rendered
//!   `text` and/or `html` body. Templating lives in the future
//!   `Template` capability; the mailer is a transport.
//! - **Opaque message id.** `SendOutcome::id` is a vendor-assigned
//!   string (SMTP `Message-ID`, SES `MessageId`, ...) that callers
//!   treat as an opaque correlation handle. The trait makes no
//!   promises about its structure.
//! - **Batch as a first-class op.** SES natively sends many messages
//!   per API call and the cost difference matters. The default
//!   `send_batch` impl loops over `send` for transports that don't
//!   batch natively.
//! - **Generic error shape.** `MailerError` variants classify
//!   failures in a way both SMTP and API backends can populate. No
//!   `ses_error_code`, no `smtp_reply_code`; backends that want to
//!   surface their own detail pass it in the variant's `String`.

use serde::{Deserialize, Serialize};

/// An RFC 5322 mailbox: an email address plus an optional display
/// name. Both SMTP and SES accept the same shape; neither drives the
/// field layout.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Address {
    /// `local@domain` — MUST be a syntactically valid email address.
    pub email: String,
    /// Optional display name. When present the backend MUST format
    /// the envelope as `"Name" <email@domain>`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

impl Address {
    /// Convenience constructor for an address with no display name.
    pub fn new(email: impl Into<String>) -> Self {
        Self { email: email.into(), name: None }
    }

    /// Convenience constructor for an address with a display name.
    pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
        Self { email: email.into(), name: Some(name.into()) }
    }
}

/// A single file attached to an outgoing message.
///
/// Backends that have attachment size limits (SES: 40 MB per message)
/// surface violations via [`MailerError::Rejected`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachment {
    /// Filename shown to the recipient (e.g., `invoice.pdf`).
    pub filename: String,
    /// MIME type (e.g., `application/pdf`). Defaults to
    /// `application/octet-stream` if empty.
    pub content_type: String,
    /// Raw attachment bytes. For large attachments, transports are
    /// free to stream from a temp file; the API hands over owned
    /// bytes for simplicity.
    pub bytes: Vec<u8>,
    /// Optional `Content-ID` for inline attachments referenced from
    /// HTML bodies (`<img src="cid:...">`). `None` means a regular
    /// non-inline attachment.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content_id: Option<String>,
}

/// A message to send. Pre-rendered: callers supply finished bodies.
///
/// At least one of `text` or `html` MUST be `Some` — a backend that
/// receives a message with both bodies empty returns
/// [`MailerError::InvalidMessage`] without attempting a send.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
    /// Sender. Used for both the `From:` header and the SMTP
    /// envelope sender. To separate them (DSN routing, bounce
    /// handling) set `return_path`.
    pub from: Address,

    /// Primary recipients. MUST be non-empty.
    pub to: Vec<Address>,

    /// Carbon-copy recipients. May be empty.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub cc: Vec<Address>,

    /// Blind carbon-copy recipients. May be empty.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub bcc: Vec<Address>,

    /// Reply-to addresses. Empty means "use `from`".
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub reply_to: Vec<Address>,

    /// Optional envelope sender (SMTP MAIL FROM / SES Return-Path).
    /// `None` means the backend should reuse `from.email`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub return_path: Option<String>,

    /// Subject line. UTF-8; backends handle MIME-encoding if needed.
    pub subject: String,

    /// Plain-text body. Either this or `html` (or both) MUST be set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,

    /// HTML body. Either this or `text` (or both) MUST be set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub html: Option<String>,

    /// File attachments. May be empty.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub attachments: Vec<Attachment>,

    /// Extra headers. Use for standards-defined headers like
    /// `List-Unsubscribe`, `List-Unsubscribe-Post`,
    /// `In-Reply-To`, `References`. Keys are case-insensitive.
    ///
    /// Backends MAY reject (`MailerError::InvalidMessage`) headers
    /// they explicitly forbid (e.g., `Bcc`, `Date`, `Message-ID`
    /// when they assign them themselves).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub headers: Vec<(String, String)>,
}

/// Result of a successful send.
///
/// The `id` is an opaque, backend-assigned identifier. Do not parse
/// it; use it only as a correlation handle (logs, webhook matching,
/// idempotency keys).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SendOutcome {
    /// Backend-assigned message id.
    pub id: String,
}

/// Why a send failed, classified in a backend-neutral way.
///
/// The inner `String` carries backend-provided detail suitable for
/// logs and error pages. Do not parse it; use the variant for
/// branching.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MailerError {
    /// The message is malformed before the backend even tries to
    /// send: missing recipient, empty body, forbidden header,
    /// attachment too large. The caller MUST NOT retry blindly —
    /// the message needs to change.
    InvalidMessage(String),
    /// The caller is not authorised to send as `from.email` (SES
    /// unverified sender, SMTP AUTH refused for that domain, etc).
    /// Usually a config problem.
    Unauthorized(String),
    /// The backend explicitly rejected the message after accepting
    /// the request: policy reasons, spam filter, recipient on
    /// suppression list. Do not retry.
    Rejected(String),
    /// Transient delivery problem at the transport layer (TCP
    /// reset, DNS failure, 5xx SMTP reply, 5xx API response).
    /// Safe to retry with backoff.
    Transport(String),
    /// The backend asked the caller to slow down (SMTP 421, SES
    /// throttling exception). Retry after a delay.
    RateLimited(String),
    /// Anything the backend couldn't classify. Treat like
    /// `Transport` unless you have reason to believe otherwise.
    Unknown(String),
}

impl MailerError {
    pub fn invalid(message: impl Into<String>) -> Self {
        MailerError::InvalidMessage(message.into())
    }

    pub fn unauthorized(message: impl Into<String>) -> Self {
        MailerError::Unauthorized(message.into())
    }

    pub fn rejected(message: impl Into<String>) -> Self {
        MailerError::Rejected(message.into())
    }

    pub fn transport(message: impl Into<String>) -> Self {
        MailerError::Transport(message.into())
    }

    pub fn rate_limited(message: impl Into<String>) -> Self {
        MailerError::RateLimited(message.into())
    }

    pub fn unknown(message: impl Into<String>) -> Self {
        MailerError::Unknown(message.into())
    }

    /// Borrow the inner detail string, regardless of variant.
    pub fn message(&self) -> &str {
        match self {
            MailerError::InvalidMessage(m)
            | MailerError::Unauthorized(m)
            | MailerError::Rejected(m)
            | MailerError::Transport(m)
            | MailerError::RateLimited(m)
            | MailerError::Unknown(m) => m,
        }
    }
}

impl std::fmt::Display for MailerError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let tag = match self {
            MailerError::InvalidMessage(_) => "invalid-message",
            MailerError::Unauthorized(_) => "unauthorized",
            MailerError::Rejected(_) => "rejected",
            MailerError::Transport(_) => "transport",
            MailerError::RateLimited(_) => "rate-limited",
            MailerError::Unknown(_) => "unknown",
        };
        write!(f, "mailer {}: {}", tag, self.message())
    }
}

impl std::error::Error for MailerError {}

/// Per-message outcome for a batch send: success with an id, or
/// failure with a classified error. The index in the returned vector
/// lines up 1:1 with the index in the input vector, so callers can
/// correlate partial failures.
pub type BatchOutcome = Result<SendOutcome, MailerError>;

/// A plugin that sends transactional email.
///
/// All methods are synchronous and return `Result` values to match
/// the rest of `bext-plugin-api` (WASM guests cannot express async
/// traits today). Async backends bridge by blocking on their own
/// runtime inside the trait body.
///
/// Host functions `mailer.send` and `mailer.send_batch` call through
/// to the currently registered implementation.
pub trait MailerPlugin: Send + Sync {
    /// Unique identifier, e.g. `"smtp"`, `"ses"`.
    fn name(&self) -> &str;

    /// Send a single message. Backends MAY buffer internally but
    /// MUST have handed the message off to the transport before
    /// returning `Ok`.
    fn send(&self, msg: Message) -> Result<SendOutcome, MailerError>;

    /// Send many messages in one call. Default impl loops over
    /// [`send`](MailerPlugin::send); backends that support native
    /// batching (SES `SendBulkEmail`) override this for efficiency.
    ///
    /// Returns one outcome per input message in the same order.
    /// A transport-level failure that aborts the whole batch is
    /// reflected by every entry carrying the same error kind.
    fn send_batch(&self, msgs: Vec<Message>) -> Vec<BatchOutcome> {
        msgs.into_iter().map(|m| self.send(m)).collect()
    }

    /// Optional health check. Default: always healthy. Backends that
    /// maintain a connection pool (SMTP) override this to probe the
    /// pool; API-based backends (SES) usually leave it as-is.
    fn is_healthy(&self) -> bool {
        true
    }
}

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

    #[test]
    fn address_constructors() {
        let a = Address::new("a@b");
        assert_eq!(a.email, "a@b");
        assert!(a.name.is_none());

        let b = Address::named("a@b", "Alice");
        assert_eq!(b.name.as_deref(), Some("Alice"));
    }

    #[test]
    fn mailer_error_helpers_and_display() {
        let e = MailerError::invalid("no recipient");
        assert!(matches!(e, MailerError::InvalidMessage(_)));
        assert_eq!(e.message(), "no recipient");
        assert!(e.to_string().contains("invalid-message"));
        assert!(e.to_string().contains("no recipient"));

        let t = MailerError::transport("dns fail");
        assert!(matches!(t, MailerError::Transport(_)));
    }

    struct LoopMailer;
    impl MailerPlugin for LoopMailer {
        fn name(&self) -> &str { "loop" }
        fn send(&self, msg: Message) -> Result<SendOutcome, MailerError> {
            if msg.to.is_empty() {
                return Err(MailerError::invalid("no recipient"));
            }
            Ok(SendOutcome { id: format!("loop-{}", msg.to[0].email) })
        }
    }

    #[test]
    fn default_batch_preserves_order_and_errors() {
        let m = LoopMailer;
        let out = m.send_batch(vec![
            Message { to: vec![Address::new("a@b")], ..Default::default() },
            Message { to: vec![], ..Default::default() },
            Message { to: vec![Address::new("c@d")], ..Default::default() },
        ]);
        assert_eq!(out.len(), 3);
        assert_eq!(out[0].as_ref().unwrap().id, "loop-a@b");
        assert!(matches!(
            out[1].as_ref().unwrap_err(),
            MailerError::InvalidMessage(_)
        ));
        assert_eq!(out[2].as_ref().unwrap().id, "loop-c@d");
    }
}