Skip to main content

cratefield_core/ports/
mailer.rs

1//! The `Mailer` port (architecture section 5). The Resend adapter is the
2//! reference implementation (issue #6).
3
4use async_trait::async_trait;
5use std::time::Duration;
6use thiserror::Error;
7
8/// An outbound mail. `text` is always sent alongside `html`.
9#[derive(Debug, Clone)]
10pub struct Message {
11    pub to: String,
12    pub from: String,
13    pub reply_to: Option<String>,
14    pub subject: String,
15    pub html: String,
16    pub text: String,
17    /// Passed through as an `Idempotency-Key` where the provider supports it.
18    pub idempotency_key: Option<String>,
19    pub tags: Vec<String>,
20}
21
22/// Result of a send attempt.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum SendOutcome {
25    /// Sent; carries the provider message id.
26    Sent { id: String },
27    /// The adapter is not configured (no API key / unverified sending
28    /// domain). The endpoint reports `503 mail-not-configured` so forms can
29    /// degrade instead of breaking.
30    NotConfigured,
31}
32
33/// Mailer failures, mapped by the adapter from provider responses.
34///
35/// `Display` output is safe for logs: it never includes the API key.
36#[derive(Debug, Clone, Error)]
37pub enum MailError {
38    #[error("mailer rejected the request as unauthorized (check the API key)")]
39    Unauthorized,
40    #[error("sending domain {domain:?} is not verified with the mailer")]
41    DomainNotVerified { domain: String },
42    #[error("mailer rejected the message as invalid: {detail}")]
43    Invalid { detail: String },
44    #[error("mailer rate limited the request; retry after {retry_after:?}")]
45    RateLimited { retry_after: Option<Duration> },
46    #[error("mailer upstream error: {0}")]
47    Upstream(String),
48    #[error("mailer transport error: {0}")]
49    Transport(String),
50}
51
52#[async_trait]
53pub trait Mailer: Send + Sync {
54    async fn send(&self, message: Message) -> Result<SendOutcome, MailError>;
55}