Writing your own mail transport
Rullst Mail ships four delivery transports: Resend, AWS SES,
SendPulse and SMTP, plus the log, memory and offline mock_*
drivers for development and tests. Every other provider is integrated by the
application through the same MailDriver trait the built-in transports
implement. Version 13 removed the SendGrid, Postmark, Mailjet, Mailtrap and
Azure Communication Services transports; see the
migration guide
row “Mail providers removed”.
This guide walks through a complete transport for a fictitious HTTP email API,
AcmeMail. The code below is included from
rullst-mail/examples/custom_transport.rs,
which CI compiles and which runs offline from a clone of the Rullst repository:
cargo run -p rullst-mail --example custom_transport
In an application, the same types are available through the facade as
rullst::mail::* with the mail feature. The example also uses reqwest,
secrecy, serde_json, chrono and async-trait (MailDriver is an
#[async_trait] trait; the facade’s rullst::async_trait works too); add
equivalent crates to your own Cargo.toml.
What the framework does and what the transport does
| Responsibility | Owner |
|---|---|
| Header-injection, recipient, link and homograph checks, outbound secret redaction, and attachment count, size and CID bounds | DeliveryPipeline::prepare, which the transport calls first |
| Default sender, queueing and scheduling, tenant routing, failover, suppression, attachment inspection and observations | The Mail facade, TenantMailResolver, FailoverDriver, SuppressionGuard, AttachmentInspectionGuard and ObservedMailDriver, which wrap any MailDriver |
| Authenticating to the provider, mapping the message to its API, rejecting what the API cannot express, bounding and checking the receipt, and classifying failures | Your MailDriver implementation |
| Sender-domain verification, provider limits, bounce and complaint handling, retention and the provider contract | Your application and provider account |
The transport is trusted server code. A transport that cannot express part of a message (a schedule, an inline image, an unsubscribe header) must return an error instead of sending the message without it.
1. Implement MailDriver
/// Stable low-cardinality label used in errors and telemetry.
const PROVIDER: &str = "acmemail";
/// Largest delivery receipt read from AcmeMail.
const MAX_RECEIPT_BYTES: usize = 16 * 1024;
/// Messages the offline fixture captured instead of sending.
pub type OfflineOutbox = Arc<Mutex<Vec<Message>>>;
/// Transport for the fictitious AcmeMail HTTP API.
pub struct AcmeMailDriver {
api_key: SecretString,
endpoint: String,
client: reqwest::Client,
outbox: OfflineOutbox,
}
impl AcmeMailDriver {
/// Creates the transport. Empty or `mock_*` keys select the offline fixture.
pub fn try_new(api_key: impl Into<String>) -> Result<Self, MailError> {
let api_key = api_key.into();
if api_key.contains(['\r', '\n']) || api_key.len() > 4096 {
return Err(MailError::ConfigError(
"AcmeMail API key is malformed".into(),
));
}
// No redirects (they would forward the key and the message), and
// deadlines for connecting and for the whole exchange.
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.build()
.map_err(|_| MailError::ConfigError("cannot build the AcmeMail client".into()))?;
Ok(Self {
api_key: SecretString::from(api_key),
endpoint: "https://api.acmemail.invalid/v1/messages".into(),
client,
outbox: OfflineOutbox::default(),
})
}
/// Uses another endpoint: HTTPS, or plain HTTP to a loopback test fixture.
pub fn try_with_endpoint(mut self, endpoint: impl Into<String>) -> Result<Self, MailError> {
let endpoint = endpoint.into();
let loopback = endpoint.starts_with("http://127.0.0.1:");
if !endpoint.starts_with("https://") && !loopback {
return Err(MailError::ConfigError(
"AcmeMail endpoint must use HTTPS".into(),
));
}
self.endpoint = endpoint;
Ok(self)
}
/// Whether the key selects the real API or the offline fixture.
pub fn delivery_mode(&self) -> DeliveryMode {
credential_mode(self.api_key.expose_secret())
}
/// The offline fixture's captured messages, shared with every clone.
pub fn offline_outbox(&self) -> OfflineOutbox {
Arc::clone(&self.outbox)
}
/// The API request body. Content the API cannot express is an error,
/// never silently dropped.
fn payload(message: &Message) -> Result<Value, MailError> {
let from = message.from.as_deref().ok_or_else(|| {
MailError::ConfigError(
"AcmeMail requires a verified sender: set `from`, or MAIL_FROM for the Mail facade"
.into(),
)
})?;
if message
.send_at
.is_some_and(|send_at| send_at > chrono::Utc::now())
{
return Err(MailError::ConfigError(
"AcmeMail cannot schedule mail; use Mail::enqueue with a durable queue".into(),
));
}
if message.attachments.iter().any(|item| item.is_inline()) {
return Err(MailError::ValidationError(
"AcmeMail has no inline images; use a transport that supports CIDs".into(),
));
}
let mut headers = serde_json::Map::new();
if let Some(value) = message.list_unsubscribe_header() {
headers.insert("List-Unsubscribe".into(), Value::String(value));
// RFC 8058 one-click unsubscription requires an HTTPS URI.
if message
.unsubscribe_url
.as_deref()
.is_some_and(|url| url.starts_with("https://"))
{
headers.insert(
"List-Unsubscribe-Post".into(),
Value::String("List-Unsubscribe=One-Click".into()),
);
}
}
let attachments: Vec<Value> = message
.attachments
.iter()
.map(|item| {
json!({
"filename": item.filename,
"content_type": item.mime_type,
"content": item.to_base64(),
})
})
.collect();
Ok(json!({
"from": from,
"to": message.to,
"subject": message.subject,
"text": message.body_text,
"html": message.body_html,
"headers": headers,
"attachments": attachments,
}))
}
/// Reads at most `MAX_RECEIPT_BYTES` and requires a message ID.
async fn receipt(mut response: reqwest::Response) -> Result<(), MailError> {
let invalid = || MailError::SendError("AcmeMail returned an invalid receipt".into());
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| MailError::transport(PROVIDER, "receipt read failed"))?
{
if body.len() + chunk.len() > MAX_RECEIPT_BYTES {
return Err(invalid());
}
body.extend_from_slice(&chunk);
}
let receipt: Value = serde_json::from_slice(&body).map_err(|_| invalid())?;
match receipt.get("id").and_then(Value::as_str) {
Some(id) if !id.is_empty() && id.len() <= 128 => Ok(()),
_ => Err(invalid()),
}
}
}
#[async_trait]
impl MailDriver for AcmeMailDriver {
async fn send(&self, message: &Message) -> Result<(), MailError> {
// The shared pre-flight checks: header injection, recipients,
// outbound secrets, links and attachment bounds.
let prepared = DeliveryPipeline::prepare(message)?;
let message = prepared.message();
let body = Self::payload(message)?;
if self.delivery_mode() == DeliveryMode::OfflineMock {
self.outbox
.lock()
.map_err(|_| MailError::DriverError("AcmeMail outbox is unavailable".into()))?
.push(message.clone());
return Ok(());
}
let response = self
.client
.post(&self.endpoint)
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await
.map_err(|_| MailError::transport(PROVIDER, "request failed before a response"))?;
if response.status().is_success() {
return Self::receipt(response).await;
}
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.map(|seconds| Duration::from_secs(seconds.min(86_400)));
// Error bodies can echo addresses and content: keep only the status
// and the retry delay. 429 becomes `RateLimited`, 5xx is transient.
Err(MailError::from_provider_response(
PROVIDER,
response.status().as_u16(),
"provider response body omitted",
retry_after,
))
}
}
Points that matter for security and testing:
- Run the pipeline first.
DeliveryPipeline::prepareapplies the same pre-flight checks as the built-in transports; sendprepared.message(), never the original. - Never invent a sender. A message without
fromfails withMailError::ConfigErrorbefore any request. TheMailfacade fills it fromMAIL_FROMor[mail] frombefore the transport sees it. - Reject what the API cannot express. A future
send_atwithout provider scheduling, inline CID images or headers the API has no field for are errors, not silent drops. Durable scheduling belongs toMail::enqueue. - Offline fallback. An empty or
mock_*key selectsDeliveryMode::OfflineMockthroughcredential_mode, and the transport captures the message without network I/O, as section 3.5 of Rullst’sAGENTS.mdrequires. Validate the payload before that check so the fixture enforces the live contract. - Bound the HTTP exchange. Disable redirects (they would forward the key and the message), set connect and request deadlines, require HTTPS except for a loopback test fixture and read at most a bounded receipt.
- Classify failures.
MailError::from_provider_responseturns HTTP 429 intoRateLimitedwith itsRetry-Afterand 5xx into a transient error;MailError::transportmarks a failure before any response.FailoverDrivermoves only transient and rate-limited failures to a fallback. Keep provider error bodies out of the error: they can echo addresses and content. - Accept only a real receipt. Success requires the provider’s message ID;
a 2xx without one is a
SendError. Acceptance is not proof of inbox delivery. - Idempotency.
send_with_delivery_iddefaults to at-least-once. Override it only when the provider deduplicates by a key you forward.
2. Install it
// 4. Install the transport for the `Mail` facade (and its queue worker)...
let transport = AcmeMailDriver::try_new("mock_acme")?;
let facade_outbox = transport.offline_outbox();
Mail::set_driver(Box::new(transport));
Mail::send_now(welcome("[email protected]")).await?;
Mail::reset_driver();
// ...or for one tenant, with the global default for everyone else.
let (default_driver, default_store) = MemoryDriver::isolated();
let resolver = TenantMailResolver::with_default(default_driver);
resolver.register("tenant_acme", AcmeMailDriver::try_new("mock_tenant")?)?;
resolver
.send_for_tenant("tenant_acme", &welcome("[email protected]"))
.await?;
resolver
.send_for_tenant("tenant_other", &welcome("[email protected]"))
.await?;
// Shared guards wrap a custom transport like a built-in one.
let store = InMemorySuppressionStore::new(16, 16)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
store
.record(SuppressionEvent::try_new(
PROVIDER,
"evt_bounce_1",
"[email protected]",
SuppressionReason::HardBounce,
now,
)?)
.await?;
let guarded = SuppressionGuard::new(AcmeMailDriver::try_new("mock_acme")?, store);
assert!(matches!(
guarded.send(&welcome("[email protected]")).await,
Err(MailError::SuppressedRecipient { .. })
));
Mail::set_driver replaces the facade’s configured driver for every send and
for the queue worker (register_mail_handler) in that process, so install it
at start-up in every process that sends or works the mail queue.
Mail::reset_driver returns to MAIL_DRIVER. A TenantMailResolver selects a
transport per tenant, with a default for the others. Suppression, attachment
inspection and observation wrap the transport with static dispatch, as they
wrap the built-in ones.
A MAIL_DRIVER value cannot name a custom transport: the facade builds only
the kept drivers from configuration, and a removed driver name fails with
MailError::ConfigError instead of falling back.
3. Test the transport
The example’s main exercises the contract offline:
- a
mock_*key captures the message and never reaches the network; - a message without a sender, and one with an inline image, fail before any request;
- against a loopback HTTP fixture, a 202 receipt succeeds, a 429 becomes
RateLimitedwith a seven-secondRetry-After, and a 503 fails over to the fallback driver; - the facade, a tenant resolver and a suppression guard use the transport.
Add the provider’s documented request and response examples as fixtures, and validate the live transport against a sandbox account and a verified sender before enabling it in production. Tests with mocks establish local contracts, not provider interoperability.