email_transport/transport.rs
1//! Structured and raw transport traits, capability metadata, and send reports.
2
3use core::future::Future;
4use core::pin::Pin;
5use std::borrow::Cow;
6use std::time::Duration;
7
8use email_message::{EmailAddress, Envelope, Header, Message, OutboundMessage};
9use time::format_description::well_known::Rfc2822;
10
11pub use crate::options::{
12 CorrelationId, IdempotencyKey, SendOptions, TransportOption, TransportOptions,
13};
14#[cfg(feature = "serde")]
15pub use crate::options::{
16 SendOptionsSeed, TransportOptionRegistry, TransportOptionRegistryError, TransportOptionsSeed,
17};
18
19/// Transport for implementations that accept structured [`OutboundMessage`] values.
20///
21/// # Method discipline
22///
23/// Only [`send`](Transport::send) is required. Callers without per-send
24/// overrides pass `&SendOptions::default()`. Adapters that require
25/// [`TransportOptions`] (advertised through
26/// `Capabilities::structured_send == StructuredSendCapability::RequiresTransportOptions`)
27/// should return `ErrorKind::UnsupportedFeature` when the required typed slot
28/// is missing, so callers can distinguish a capability-mismatch error from
29/// a message-validation error.
30///
31/// [`send_owned`](Transport::send_owned) is the owned-input counterpart.
32/// It has a default-forward impl that routes through [`send`](Transport::send)
33/// via a borrow. Adapters that can avoid an internal clone (e.g. moving the
34/// [`OutboundMessage`] body bytes into a request body) should override
35/// `send_owned` directly.
36///
37/// # Cancellation
38///
39/// `send` and `send_owned` are *not* cancellation-safe. Dropping the
40/// returned future before completion does not guarantee the message was
41/// not delivered: HTTP-backed providers may have already accepted the
42/// request server-side, and SMTP transports may have torn down the
43/// connection mid-handshake with indeterminate state. Use
44/// [`SendOptions::idempotency_key`] for replay safety when retries cross
45/// a cancellation boundary, and [`SendOptions::timeout`] to bound
46/// provider-call duration.
47pub trait Transport: RuntimeBound {
48 /// Advertise the structured-send features supported by this transport.
49 fn capabilities(&self) -> Capabilities {
50 Capabilities {
51 structured_send: StructuredSendCapability::Supported,
52 ..Capabilities::default()
53 }
54 }
55
56 /// Send `message` with the supplied per-send `options`.
57 ///
58 /// Callers without overrides pass `&SendOptions::default()`. See the
59 /// trait-level "Cancellation" section.
60 ///
61 /// # Errors
62 ///
63 /// Returns [`TransportError`] when validation, request construction, or
64 /// provider delivery fails.
65 fn send<'a>(
66 &'a self,
67 message: &'a OutboundMessage,
68 options: &'a SendOptions,
69 ) -> impl Future<Output = Result<SendReport, TransportError>> + MaybeSend + 'a;
70
71 /// Send `message` (owned) with the supplied per-send `options`.
72 ///
73 /// Default impl forwards to [`send`](Transport::send) via a borrow.
74 /// Override when an adapter can move the [`OutboundMessage`] into a
75 /// provider-specific request body without cloning.
76 ///
77 /// # Errors
78 ///
79 /// Returns [`TransportError`] under the same conditions as [`Self::send`].
80 fn send_owned<'a>(
81 &'a self,
82 message: OutboundMessage,
83 options: &'a SendOptions,
84 ) -> impl Future<Output = Result<SendReport, TransportError>> + MaybeSend + 'a {
85 async move { self.send(&message, options).await }
86 }
87}
88
89/// Transport for implementations that accept a pre-rendered RFC822 message and
90/// an explicit envelope (typically SMTP).
91///
92/// # Envelope source
93///
94/// The `envelope` argument to [`send_raw`](RawTransport::send_raw) and
95/// [`send_raw_owned`](RawTransport::send_raw_owned) is authoritative. Raw
96/// transports ignore [`SendOptions::envelope`]; that option exists only for
97/// structured [`Transport`] calls where the message is still the primary input
98/// and a caller may ask a capable adapter to override its derived envelope.
99///
100/// # Method discipline
101///
102/// Only [`send_raw`](RawTransport::send_raw) is required.
103/// [`send_raw_owned`](RawTransport::send_raw_owned) carries a default-forward
104/// impl that routes through the borrowed method, mirroring [`Transport`].
105/// Adapters that can move the envelope and RFC822 bytes into a provider
106/// API without cloning (e.g. lettre's SMTP state machine) should override
107/// `send_raw_owned` directly.
108///
109/// # Cancellation
110///
111/// `send_raw` and `send_raw_owned` are *not* cancellation-safe. Dropping the
112/// returned future may leave the underlying connection in indeterminate state
113/// (mid-`DATA`, mid-`RCPT`, etc.) and the provider may have already accepted
114/// the message. Use [`SendOptions::idempotency_key`] for replay safety where
115/// the provider supports it.
116pub trait RawTransport: RuntimeBound {
117 /// Advertise the raw-send features supported by this transport.
118 fn capabilities(&self) -> Capabilities {
119 Capabilities {
120 raw_rfc822: true,
121 custom_envelope: true,
122 ..Capabilities::default()
123 }
124 }
125
126 /// Send the pre-rendered RFC822 `rfc822` bytes with the supplied
127 /// authoritative `envelope`.
128 ///
129 /// [`SendOptions::envelope`] is ignored on this path. See the trait-level
130 /// "Envelope source" and "Cancellation" sections.
131 ///
132 /// # Errors
133 ///
134 /// Returns [`TransportError`] when validation, protocol setup, or provider
135 /// delivery fails.
136 fn send_raw<'a>(
137 &'a self,
138 envelope: &'a Envelope,
139 rfc822: &'a [u8],
140 options: &'a SendOptions,
141 ) -> impl Future<Output = Result<SendReport, TransportError>> + MaybeSend + 'a;
142
143 /// Owned variant of [`RawTransport::send_raw`] for callers that already
144 /// own the authoritative envelope and bytes and want to hand them over
145 /// without an extra borrow.
146 ///
147 /// Default impl forwards to [`send_raw`](RawTransport::send_raw) via a
148 /// borrow. Override when an adapter can move the envelope and bytes into
149 /// a provider-specific API without cloning (e.g. lettre's SMTP state
150 /// machine).
151 ///
152 /// # Errors
153 ///
154 /// Returns [`TransportError`] under the same conditions as
155 /// [`Self::send_raw`].
156 fn send_raw_owned<'a>(
157 &'a self,
158 envelope: Envelope,
159 rfc822: Vec<u8>,
160 options: &'a SendOptions,
161 ) -> impl Future<Output = Result<SendReport, TransportError>> + MaybeSend + 'a {
162 async move { self.send_raw(&envelope, &rfc822, options).await }
163 }
164}
165
166/// Advertised feature set of a [`Transport`] or [`RawTransport`].
167///
168/// Capabilities are advisory by default: the trait contracts do not
169/// auto-validate per-send `SendOptions` against advertised flags. Callers
170/// consult `capabilities()` before constructing options and skip features
171/// the transport does not support; adapters silently ignore unsupported
172/// options unless the flag's tier below says otherwise.
173/// Capability flags describe transport intent; they never enforce a safety
174/// control. Controls that constrain delivery belong in core [`SendOptions`],
175/// where every transport must honor them or fail the send, rather than in an
176/// advisory capability or provider-specific [`TransportOption`].
177///
178/// # Tiers
179///
180/// **Enforced.** [`StructuredSendCapability::RequiresTransportOptions`] is
181/// the one capability the kernel turns into a hard error: adapters that
182/// advertise it return [`ErrorKind::UnsupportedFeature`] from
183/// [`Transport::send`] when the required typed slot is missing.
184///
185/// **Honored when present.** `idempotency_key` and `timeout` are read by
186/// adapters that advertise them and ignored by adapters that do not. The
187/// kernel does not check that an advertising adapter actually applies the
188/// option.
189///
190/// **Hints.** `raw_rfc822`, `custom_envelope`, `custom_headers`,
191/// `attachments`, `inline_attachments`, and `attachment_references` are purely
192/// declarative. They communicate intent to callers; the kernel neither
193/// validates inputs against the flag nor checks that an advertising adapter
194/// handles them correctly.
195///
196/// # Limits
197///
198/// The struct is a flat set of advisory booleans plus one tri-state
199/// (`structured_send`). It does **not** model:
200///
201/// - **Per-field cardinality.** "Postmark requires at least one `To`
202/// recipient", "Loops accepts exactly one recipient", "Mailgun caps
203/// `bcc` at N" are not expressible. Such constraints surface as
204/// [`ErrorKind::Validation`] from [`Transport::send`] when the
205/// adapter rejects the shape.
206/// - **Per-provider required fields.** Loops's `transactional_id`
207/// requirement is enforced via
208/// [`StructuredSendCapability::RequiresTransportOptions`] plus an
209/// adapter-side check; the kernel cannot otherwise advertise
210/// "field X must be present".
211/// - **Body-shape constraints.** Whether an adapter accepts only
212/// `Body::Text`, only `Body::Html`, both, or arbitrary `Body::Mime`
213/// trees is not advertised.
214/// - **Custom-envelope semantics.** The `custom_envelope` flag today
215/// says "the adapter has an envelope concept", not "the adapter
216/// honors [`SendOptions::envelope`] verbatim". Structured HTTP
217/// adapters that build the provider request from
218/// `message.to/cc/bcc` may still advertise it. See the
219/// [`SendReport::accepted`] caveat for the resulting reporting
220/// asymmetry under an envelope override.
221///
222/// Callers should be prepared for [`ErrorKind::Validation`] (or
223/// [`ErrorKind::UnsupportedFeature`] in the
224/// `RequiresTransportOptions` case) from [`Transport::send`] even when
225/// `capabilities()` looks compatible with their inputs.
226///
227/// # Examples
228///
229/// The worker layer reads capabilities to decide whether to forward an
230/// `idempotency_key` from the queue payload.
231///
232/// ```rust
233/// use email_transport::{Capabilities, StructuredSendCapability};
234///
235/// let capabilities = Capabilities::new()
236/// .with_structured_send(StructuredSendCapability::Supported)
237/// .with_idempotency_key(true);
238///
239/// assert!(capabilities.idempotency_key);
240/// ```
241#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
242#[non_exhaustive]
243#[allow(
244 clippy::struct_excessive_bools,
245 reason = "transport capabilities are intentionally independent feature flags"
246)]
247pub struct Capabilities {
248 /// Accepts an explicit envelope and pre-rendered RFC 822 bytes.
249 pub raw_rfc822: bool,
250 /// Level of support for structured message sends.
251 pub structured_send: StructuredSendCapability,
252 /// Honors [`SendOptions::envelope`] on structured sends.
253 pub custom_envelope: bool,
254 /// Forwards message headers not modeled as provider fields.
255 pub custom_headers: bool,
256 /// Supports regular attachments.
257 pub attachments: bool,
258 /// Supports inline attachments referenced by content ID.
259 pub inline_attachments: bool,
260 /// Accepts unresolved attachment references for preparation before send.
261 pub attachment_references: bool,
262 /// Forwards [`SendOptions::idempotency_key`] to the provider.
263 pub idempotency_key: bool,
264 /// Enforces [`SendOptions::timeout`] around the provider call.
265 pub timeout: bool,
266}
267
268impl Capabilities {
269 /// Create a capability set with every feature unsupported.
270 #[must_use]
271 pub fn new() -> Self {
272 Self::default()
273 }
274
275 /// Set whether raw RFC 822 delivery is supported.
276 #[must_use]
277 pub const fn with_raw_rfc822(mut self, value: bool) -> Self {
278 self.raw_rfc822 = value;
279 self
280 }
281
282 /// Set the structured-send support level.
283 #[must_use]
284 pub const fn with_structured_send(mut self, value: StructuredSendCapability) -> Self {
285 self.structured_send = value;
286 self
287 }
288
289 /// Set whether structured sends honor custom envelopes.
290 #[must_use]
291 pub const fn with_custom_envelope(mut self, value: bool) -> Self {
292 self.custom_envelope = value;
293 self
294 }
295
296 /// Set whether custom message headers are forwarded.
297 #[must_use]
298 pub const fn with_custom_headers(mut self, value: bool) -> Self {
299 self.custom_headers = value;
300 self
301 }
302
303 /// Set whether regular attachments are supported.
304 #[must_use]
305 pub const fn with_attachments(mut self, value: bool) -> Self {
306 self.attachments = value;
307 self
308 }
309
310 /// Set whether inline attachments are supported.
311 #[must_use]
312 pub const fn with_inline_attachments(mut self, value: bool) -> Self {
313 self.inline_attachments = value;
314 self
315 }
316
317 /// Set whether unresolved attachment references are supported.
318 #[must_use]
319 pub const fn with_attachment_references(mut self, value: bool) -> Self {
320 self.attachment_references = value;
321 self
322 }
323
324 /// Set whether provider idempotency keys are supported.
325 #[must_use]
326 pub const fn with_idempotency_key(mut self, value: bool) -> Self {
327 self.idempotency_key = value;
328 self
329 }
330
331 /// Set whether per-send provider-call timeouts are supported.
332 #[must_use]
333 pub const fn with_timeout(mut self, value: bool) -> Self {
334 self.timeout = value;
335 self
336 }
337}
338
339/// Structured message support advertised by a [`Transport`].
340#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
341#[non_exhaustive]
342pub enum StructuredSendCapability {
343 /// Structured messages are not accepted.
344 #[default]
345 Unsupported,
346 /// Structured messages are accepted without mandatory provider options.
347 Supported,
348 /// Structured messages require a provider-specific [`TransportOption`].
349 RequiresTransportOptions,
350}
351
352#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
353#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
354#[derive(Clone, Debug, PartialEq, Eq)]
355#[non_exhaustive]
356/// Successful provider handoff metadata returned by a transport.
357pub struct SendReport {
358 /// Stable provider identifier, such as `"resend"` or `"smtp"`.
359 pub provider: Cow<'static, str>,
360 /// Provider-assigned message identifier, when one was returned.
361 pub provider_message_id: Option<String>,
362 /// Recipient list the adapter logically accepted for handoff.
363 ///
364 /// The default derivation, computed by [`structured_accepted_for`],
365 /// returns `options.envelope.rcpt_to` only when the caller supplies an
366 /// override **and** the transport advertises
367 /// [`Capabilities::custom_envelope`]. Otherwise it reports the
368 /// recipients implied by the message itself (`To`, `Cc`, and `Bcc`).
369 ///
370 /// For [`RawTransport`] adapters (Lettre / SMTP) this matches the
371 /// recipient list actually handed to the provider's `RCPT TO` step.
372 ///
373 /// Single-recipient providers (Loops) populate the single address that
374 /// was actually handed to the provider; that adapter's API does not
375 /// support multi-recipient delivery, so the list reflects what the
376 /// adapter sent rather than what was on the message.
377 ///
378 /// Adapters do **not** consult provider responses to populate this field;
379 /// for provider-confirmed deliveries see
380 /// [`SendReport::provider_message_id`] and the provider's webhook events.
381 ///
382 /// Unsupported [`SendOptions::envelope`] overrides remain advisory and
383 /// may be ignored by transports that do not advertise
384 /// [`Capabilities::custom_envelope`]; they are not reflected in this
385 /// field unless the adapter actually honors them.
386 pub accepted: Vec<EmailAddress>,
387}
388
389impl SendReport {
390 /// Create an empty handoff report for `provider`.
391 #[must_use]
392 pub fn new(provider: impl Into<Cow<'static, str>>) -> Self {
393 Self {
394 provider: provider.into(),
395 provider_message_id: None,
396 accepted: Vec::new(),
397 }
398 }
399
400 /// Record the provider-assigned message identifier.
401 #[must_use]
402 pub fn with_provider_message_id(mut self, id: impl Into<String>) -> Self {
403 self.provider_message_id = Some(id.into());
404 self
405 }
406
407 /// Record the recipients logically accepted for handoff.
408 #[must_use]
409 pub fn with_accepted<I>(mut self, accepted: I) -> Self
410 where
411 I: IntoIterator<Item = EmailAddress>,
412 {
413 self.accepted = accepted.into_iter().collect();
414 self
415 }
416}
417
418/// Canonical category for a transport failure.
419#[derive(Clone, Debug, PartialEq, Eq)]
420#[non_exhaustive]
421pub enum ErrorKind {
422 /// Message or option validation failed before provider acceptance.
423 Validation,
424 /// Provider credentials are missing or invalid.
425 Authentication,
426 /// Credentials are valid but not permitted to perform the send.
427 Authorization,
428 /// The provider rejected the attempt because a rate limit was reached.
429 RateLimited,
430 /// In-flight provider/network call timed out. Retryable, a fresh
431 /// attempt may complete within the budget.
432 Timeout,
433 /// A transient network or connection failure occurred.
434 TransientNetwork,
435 /// The provider reported a retryable service failure.
436 TransientProvider,
437 /// The provider reported a non-retryable service failure.
438 PermanentProvider,
439 /// The requested message or option feature is unsupported.
440 UnsupportedFeature,
441 /// An unexpected adapter or SDK failure occurred.
442 Internal,
443}
444
445impl ErrorKind {
446 /// Map an HTTP status code to a canonical [`ErrorKind`].
447 ///
448 /// Intended for the **failure path only**, call it on the status of a
449 /// non-success response. 1xx, 2xx, and 3xx codes are not failures and
450 /// the mapping for them ([`ErrorKind::PermanentProvider`]) is not
451 /// meaningful; check `StatusCode::is_success` (or equivalent) before
452 /// reaching for this constructor.
453 ///
454 /// The mapping:
455 ///
456 /// - `400 | 422` -> [`ErrorKind::Validation`]
457 /// - `401` -> [`ErrorKind::Authentication`]
458 /// - `403` -> [`ErrorKind::Authorization`]
459 /// - `408` -> [`ErrorKind::Timeout`] (RFC 7231 ยง6.5.7, explicitly retryable)
460 /// - `425` -> [`ErrorKind::TransientNetwork`] (RFC 8470, Too Early)
461 /// - `429` -> [`ErrorKind::RateLimited`]
462 /// - `501 | 505 | 510 | 511` -> [`ErrorKind::PermanentProvider`] (terminal
463 /// server-side errors; retrying produces the same result)
464 /// - other `5xx` -> [`ErrorKind::TransientProvider`]
465 /// - everything else (including unrecognized `4xx`) ->
466 /// [`ErrorKind::PermanentProvider`]
467 ///
468 /// Adapters that need provider-specific quirks (e.g. Loops mapping `404`
469 /// and `409` to [`ErrorKind::Validation`]) should match those codes
470 /// inline before falling through to this constructor.
471 #[must_use]
472 pub const fn from_http_status(status: u16) -> Self {
473 match status {
474 400 | 422 => Self::Validation,
475 401 => Self::Authentication,
476 403 => Self::Authorization,
477 408 => Self::Timeout,
478 425 => Self::TransientNetwork,
479 429 => Self::RateLimited,
480 501 | 505 | 510 | 511 => Self::PermanentProvider,
481 500..=599 => Self::TransientProvider,
482 _ => Self::PermanentProvider,
483 }
484 }
485}
486
487impl std::fmt::Display for ErrorKind {
488 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 let label = match self {
490 Self::Validation => "validation",
491 Self::Authentication => "authentication",
492 Self::Authorization => "authorization",
493 Self::RateLimited => "rate-limited",
494 Self::Timeout => "timeout",
495 Self::TransientNetwork => "transient-network",
496 Self::TransientProvider => "transient-provider",
497 Self::PermanentProvider => "permanent-provider",
498 Self::UnsupportedFeature => "unsupported-feature",
499 Self::Internal => "internal",
500 };
501 f.write_str(label)
502 }
503}
504
505#[derive(Debug, thiserror::Error)]
506#[error("{kind}: {message}")]
507#[non_exhaustive]
508/// Transport failure with canonical classification and provider metadata.
509pub struct TransportError {
510 /// Canonical failure category used for retry decisions.
511 pub kind: ErrorKind,
512 /// Human-readable failure description.
513 pub message: String,
514 /// HTTP status code from the provider response, when applicable. Use
515 /// [`TransportError::with_http_status`] to set. Adapters whose
516 /// underlying protocol is not HTTP (e.g. SMTP via Lettre) leave this
517 /// `None` and surface protocol-specific reply codes through
518 /// [`TransportError::provider_error_code`] instead.
519 pub http_status: Option<u16>,
520 /// Provider-specific machine-readable error code, when available.
521 pub provider_error_code: Option<String>,
522 /// Provider or HTTP request identifier useful for support diagnostics.
523 pub request_id: Option<String>,
524 /// Provider-advised delay before retrying the operation.
525 pub retry_after: Option<Duration>,
526 /// Underlying source error chain. Read through the
527 /// [`std::error::Error::source`] impl; the field is private so the
528 /// kernel can change the boxing strategy without breaking callers.
529 #[source]
530 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
531}
532
533impl TransportError {
534 /// Create an error with no provider metadata or source.
535 #[must_use]
536 pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
537 Self {
538 kind,
539 message: message.into(),
540 http_status: None,
541 provider_error_code: None,
542 request_id: None,
543 retry_after: None,
544 source: None,
545 }
546 }
547
548 /// Record the HTTP status code from the provider response.
549 #[must_use]
550 pub const fn with_http_status(mut self, status: u16) -> Self {
551 self.http_status = Some(status);
552 self
553 }
554
555 /// Record a provider-specific machine-readable error code.
556 #[must_use]
557 pub fn with_provider_error_code(mut self, code: impl Into<String>) -> Self {
558 self.provider_error_code = Some(code.into());
559 self
560 }
561
562 /// Record the provider-advised retry delay.
563 #[must_use]
564 pub const fn with_retry_after(mut self, retry_after: Duration) -> Self {
565 self.retry_after = Some(retry_after);
566 self
567 }
568
569 /// Attach the underlying SDK, protocol, or validation error.
570 #[must_use]
571 pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
572 self.source = Some(Box::new(source));
573 self
574 }
575
576 /// Return whether retrying the send may succeed without changing input.
577 #[must_use]
578 pub const fn is_retryable(&self) -> bool {
579 matches!(
580 self.kind,
581 ErrorKind::RateLimited
582 | ErrorKind::Timeout
583 | ErrorKind::TransientNetwork
584 | ErrorKind::TransientProvider
585 )
586 }
587
588 /// Return whether the failure should terminate retries.
589 #[must_use]
590 pub const fn is_terminal(&self) -> bool {
591 !self.is_retryable()
592 }
593
594 /// Return whether the operation exceeded its time budget.
595 #[must_use]
596 pub const fn is_timeout(&self) -> bool {
597 matches!(self.kind, ErrorKind::Timeout)
598 }
599}
600
601/// Marker trait that resolves to `Send + Sync` on native targets and to no
602/// bound on `wasm32`.
603///
604/// Used as the runtime supertrait for [`Transport`] and [`RawTransport`].
605/// On native, every receiver must be `Send + Sync` because send futures may be
606/// driven on any runtime thread; on `wasm32` the bound is dropped to
607/// match the single-threaded browser/worker future model.
608///
609/// This is the same shape as [`MaybeSend`] but for the receiver instead of
610/// the future. Together they let one trait declaration cover both targets.
611///
612/// You should not implement this trait directly; the blanket impl below
613/// covers every type that satisfies the underlying bound.
614#[cfg(not(target_arch = "wasm32"))]
615pub trait RuntimeBound: Send + Sync {}
616
617#[cfg(not(target_arch = "wasm32"))]
618impl<T: Send + Sync + ?Sized> RuntimeBound for T {}
619
620/// Marker trait that resolves to `Send + Sync` on native targets and to no
621/// bound on `wasm32`. See the native-target docs for the full rationale.
622#[cfg(target_arch = "wasm32")]
623pub trait RuntimeBound {}
624
625#[cfg(target_arch = "wasm32")]
626impl<T: ?Sized> RuntimeBound for T {}
627
628/// Trait alias that resolves to [`Send`] on native targets and to no bound
629/// on `wasm32`.
630///
631/// Used in the AFIT method return positions of [`Transport`] and
632/// [`RawTransport`] so a single trait declaration covers both platforms.
633/// On native, every returned future must be `Send` so caller-side
634/// orchestrators can `tokio::spawn` them; on `wasm32`, browser-runtime
635/// futures hold `!Send` JS handles (`web_sys::JsValue`,
636/// `worker::Request`), so requiring `Send` would fail to compile for
637/// any wasm transport.
638///
639/// This is the matrix-rust-sdk `SendOutsideWasm` pattern documented in
640/// [matrix-org/matrix-rust-sdk#5082](https://github.com/matrix-org/matrix-rust-sdk/pull/5082).
641///
642/// # Implementation note
643///
644/// Do not implement this trait directly. The blanket impl below covers every
645/// type that satisfies the underlying bound on each target, and the auto-trait
646/// rules of `async fn` propagate `Send`-ness through the marker automatically:
647/// an `async` block whose captures are `Send` produces a `Send` future, which
648/// then satisfies `MaybeSend` via the blanket.
649///
650/// # Future
651///
652/// This marker exists because [return-type notation (RFC 3654)](https://github.com/rust-lang/rust/issues/109417)
653/// is not stable. Once it stabilizes, callers will be able to write
654/// `where T::send_with(..): Send` at spawn sites and this trait can be
655/// deleted. The stabilization push closed unmerged in late 2025
656/// ([rust-lang/rust#138424](https://github.com/rust-lang/rust/pull/138424));
657/// no near-term replacement is on the roadmap. If the wasm story ever needs
658/// to diverge from native independently of RTN, the planned escape hatch is
659/// the `async-graphql` `boxed-trait` feature flag pattern.
660#[cfg(not(target_arch = "wasm32"))]
661pub trait MaybeSend: Send {}
662
663#[cfg(not(target_arch = "wasm32"))]
664impl<T: Send + ?Sized> MaybeSend for T {}
665
666/// Trait alias that resolves to [`Send`] on native targets and to no bound
667/// on `wasm32`. See the native-target docs for the full rationale.
668#[cfg(target_arch = "wasm32")]
669pub trait MaybeSend {}
670
671#[cfg(target_arch = "wasm32")]
672impl<T: ?Sized> MaybeSend for T {}
673
674#[cfg(target_arch = "wasm32")]
675#[doc(hidden)]
676pub type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
677
678#[cfg(not(target_arch = "wasm32"))]
679#[doc(hidden)]
680pub type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
681
682mod sealed {
683 pub trait ErasedTransport {}
684 pub trait ErasedRawTransport {}
685}
686
687/// Object-safe adapter for [`Transport`].
688///
689/// Sealed: only types that implement [`Transport`] satisfy this trait.
690/// Hold trait objects through [`DynTransport`] / [`SharedTransport`]; do not
691/// name `ErasedTransport` directly. The exact erasure mechanism (boxed
692/// futures today, possibly RTN later) is an implementation detail.
693pub trait ErasedTransport: RuntimeBound + sealed::ErasedTransport {
694 /// Return the wrapped transport's advertised capabilities.
695 fn capabilities(&self) -> Capabilities;
696
697 /// Send a borrowed structured message through the wrapped transport.
698 fn send<'a>(
699 &'a self,
700 message: &'a OutboundMessage,
701 options: &'a SendOptions,
702 ) -> BoxFut<'a, Result<SendReport, TransportError>>;
703
704 /// Send an owned structured message through the wrapped transport.
705 fn send_owned<'a>(
706 &'a self,
707 message: OutboundMessage,
708 options: &'a SendOptions,
709 ) -> BoxFut<'a, Result<SendReport, TransportError>>;
710}
711
712impl<T: Transport + ?Sized> sealed::ErasedTransport for T {}
713
714impl<T> ErasedTransport for T
715where
716 T: Transport + ?Sized,
717{
718 fn capabilities(&self) -> Capabilities {
719 Transport::capabilities(self)
720 }
721
722 fn send<'a>(
723 &'a self,
724 message: &'a OutboundMessage,
725 options: &'a SendOptions,
726 ) -> BoxFut<'a, Result<SendReport, TransportError>> {
727 Box::pin(Transport::send(self, message, options))
728 }
729
730 fn send_owned<'a>(
731 &'a self,
732 message: OutboundMessage,
733 options: &'a SendOptions,
734 ) -> BoxFut<'a, Result<SendReport, TransportError>> {
735 Box::pin(Transport::send_owned(self, message, options))
736 }
737}
738
739/// Object-safe raw transport adapter for [`RawTransport`].
740///
741/// Sealed: only types that implement [`RawTransport`] satisfy this trait.
742/// Hold trait objects through [`DynRawTransport`] / [`SharedRawTransport`].
743pub trait ErasedRawTransport: RuntimeBound + sealed::ErasedRawTransport {
744 /// Return the wrapped raw transport's advertised capabilities.
745 fn capabilities(&self) -> Capabilities;
746
747 /// Send borrowed envelope and RFC 822 data through the wrapped transport.
748 fn send_raw<'a>(
749 &'a self,
750 envelope: &'a Envelope,
751 rfc822: &'a [u8],
752 options: &'a SendOptions,
753 ) -> BoxFut<'a, Result<SendReport, TransportError>>;
754
755 /// Send owned envelope and RFC 822 data through the wrapped transport.
756 fn send_raw_owned<'a>(
757 &'a self,
758 envelope: Envelope,
759 rfc822: Vec<u8>,
760 options: &'a SendOptions,
761 ) -> BoxFut<'a, Result<SendReport, TransportError>>;
762}
763
764impl<T: RawTransport + ?Sized> sealed::ErasedRawTransport for T {}
765
766impl<T> ErasedRawTransport for T
767where
768 T: RawTransport + ?Sized,
769{
770 fn capabilities(&self) -> Capabilities {
771 RawTransport::capabilities(self)
772 }
773
774 fn send_raw<'a>(
775 &'a self,
776 envelope: &'a Envelope,
777 rfc822: &'a [u8],
778 options: &'a SendOptions,
779 ) -> BoxFut<'a, Result<SendReport, TransportError>> {
780 Box::pin(RawTransport::send_raw(self, envelope, rfc822, options))
781 }
782
783 fn send_raw_owned<'a>(
784 &'a self,
785 envelope: Envelope,
786 rfc822: Vec<u8>,
787 options: &'a SendOptions,
788 ) -> BoxFut<'a, Result<SendReport, TransportError>> {
789 Box::pin(RawTransport::send_raw_owned(
790 self, envelope, rfc822, options,
791 ))
792 }
793}
794
795/// Object-safe structured transport trait object.
796pub type DynTransport = dyn ErasedTransport;
797
798/// Shared structured transport handle.
799pub type SharedTransport = std::sync::Arc<DynTransport>;
800
801/// Object-safe raw transport trait object.
802pub type DynRawTransport = dyn ErasedRawTransport;
803
804/// Shared raw transport handle.
805pub type SharedRawTransport = std::sync::Arc<DynRawTransport>;
806
807/// Returns all envelope recipient [`EmailAddress`]s implied by `To`, `Cc`, and `Bcc`.
808#[must_use]
809pub fn accepted_recipient_emails(message: &Message) -> Vec<EmailAddress> {
810 message
811 .to()
812 .iter()
813 .chain(message.cc())
814 .chain(message.bcc())
815 .flat_map(email_message::Address::mailboxes)
816 .map(|mailbox| mailbox.email().clone())
817 .collect()
818}
819
820/// Returns the [`SendReport::accepted`] list per the documented spec.
821///
822/// Yields `options.envelope.rcpt_to().to_vec()` only when the caller supplied
823/// an envelope override and `capabilities.custom_envelope` is true; otherwise
824/// yields [`accepted_recipient_emails`]. Use this from every structured
825/// [`Transport`] adapter whose accepted-recipient semantics match its
826/// capabilities so the field reports what the adapter actually attempted to
827/// hand to the provider.
828#[must_use]
829pub fn structured_accepted_for(
830 message: &Message,
831 options: &SendOptions,
832 capabilities: Capabilities,
833) -> Vec<EmailAddress> {
834 if capabilities.custom_envelope
835 && let Some(envelope) = options.envelope.as_ref()
836 {
837 return envelope.rcpt_to().to_vec();
838 }
839
840 accepted_recipient_emails(message)
841}
842
843/// Builds standard structured headers that provider APIs usually model as
844/// custom headers rather than first-class request fields.
845///
846/// # Errors
847///
848/// Returns [`TransportError`] if a standard value cannot be formatted or
849/// represented as a valid [`Header`]. The underlying formatting or header
850/// validation error is retained as its source.
851pub fn standard_message_headers(message: &Message) -> Result<Vec<Header>, TransportError> {
852 let mut headers = Vec::new();
853
854 if let Some(sender) = message.sender() {
855 headers.push(Header::new("Sender", sender.to_string()).map_err(|error| {
856 TransportError::new(ErrorKind::Validation, error.to_string()).with_source(error)
857 })?);
858 }
859
860 if let Some(date) = message.date() {
861 headers.push(
862 Header::new(
863 "Date",
864 date.format(&Rfc2822).map_err(|error| {
865 TransportError::new(ErrorKind::Validation, error.to_string()).with_source(error)
866 })?,
867 )
868 .map_err(|error| {
869 TransportError::new(ErrorKind::Validation, error.to_string()).with_source(error)
870 })?,
871 );
872 }
873
874 if let Some(message_id) = message.message_id() {
875 headers.push(
876 Header::new("Message-ID", message_id.to_string()).map_err(|error| {
877 TransportError::new(ErrorKind::Validation, error.to_string()).with_source(error)
878 })?,
879 );
880 }
881
882 Ok(headers)
883}
884
885#[cfg(test)]
886mod tests {
887 use email_message::{Address, Body, EmailAddress, Envelope, Message};
888 use time::OffsetDateTime;
889
890 #[cfg(any(feature = "serde", feature = "schemars"))]
891 use super::SendReport;
892 use super::{
893 Capabilities, ErrorKind, SendOptions, StructuredSendCapability, TransportError,
894 standard_message_headers, structured_accepted_for,
895 };
896
897 fn message_with_recipient(recipient: &str) -> Message {
898 Message::builder(Body::text("hello"))
899 .from_mailbox("sender@example.com".parse().expect("sender parses"))
900 .to(vec![Address::Mailbox(
901 recipient.parse().expect("recipient parses"),
902 )])
903 .build()
904 .expect("message validates")
905 }
906
907 fn options_with_envelope(recipient: &str) -> SendOptions {
908 SendOptions::new().with_envelope(Envelope::new(
909 Some(
910 "bounce@example.com"
911 .parse::<EmailAddress>()
912 .expect("from parses"),
913 ),
914 vec![recipient.parse::<EmailAddress>().expect("rcpt parses")],
915 ))
916 }
917
918 fn accepted_strings(accepted: &[EmailAddress]) -> Vec<&str> {
919 accepted.iter().map(EmailAddress::as_str).collect()
920 }
921
922 #[test]
923 fn capabilities_default_to_false() {
924 assert_eq!(
925 Capabilities::default(),
926 Capabilities {
927 raw_rfc822: false,
928 structured_send: StructuredSendCapability::Unsupported,
929 custom_envelope: false,
930 custom_headers: false,
931 attachments: false,
932 inline_attachments: false,
933 attachment_references: false,
934 idempotency_key: false,
935 timeout: false,
936 }
937 );
938 }
939
940 #[test]
941 fn structured_accepted_ignores_envelope_when_capability_is_false() {
942 let message = message_with_recipient("message@example.com");
943 let options = options_with_envelope("envelope@example.com");
944
945 let accepted = structured_accepted_for(&message, &options, Capabilities::new());
946
947 assert_eq!(accepted_strings(&accepted), vec!["message@example.com"]);
948 }
949
950 #[test]
951 fn structured_accepted_uses_envelope_when_capability_is_true() {
952 let message = message_with_recipient("message@example.com");
953 let options = options_with_envelope("envelope@example.com");
954 let capabilities = Capabilities::new().with_custom_envelope(true);
955
956 let accepted = structured_accepted_for(&message, &options, capabilities);
957
958 assert_eq!(accepted_strings(&accepted), vec!["envelope@example.com"]);
959 }
960
961 #[test]
962 fn standard_headers_preserve_date_format_error_source() {
963 let date = OffsetDateTime::from_unix_timestamp(-11_676_096_000)
964 .expect("1600-01-01 is representable");
965 let message = Message::builder(Body::text("hello"))
966 .from_mailbox("sender@example.com".parse().expect("sender parses"))
967 .to(vec![Address::Mailbox(
968 "recipient@example.com".parse().expect("recipient parses"),
969 )])
970 .date(date)
971 .build()
972 .expect("message validates");
973
974 let error =
975 standard_message_headers(&message).expect_err("RFC 2822 rejects years before 1900");
976
977 assert_eq!(error.kind, ErrorKind::Validation);
978 assert!(std::error::Error::source(&error).is_some());
979 }
980
981 #[cfg(feature = "serde")]
982 #[test]
983 fn send_report_round_trips_through_serde() {
984 let report = SendReport::new("postmark")
985 .with_provider_message_id("message-id")
986 .with_accepted(["recipient@example.com"
987 .parse::<EmailAddress>()
988 .expect("recipient parses")]);
989
990 let json = serde_json::to_value(&report).expect("send report serializes");
991 assert_eq!(
992 json,
993 serde_json::json!({
994 "provider": "postmark",
995 "provider_message_id": "message-id",
996 "accepted": ["recipient@example.com"],
997 })
998 );
999
1000 let back: SendReport = serde_json::from_value(json).expect("send report deserializes");
1001 assert_eq!(back, report);
1002 }
1003
1004 #[cfg(feature = "schemars")]
1005 #[test]
1006 fn send_report_schema_includes_public_wire_fields() {
1007 let schema = schemars::schema_for!(SendReport);
1008 let value = schema.as_value();
1009
1010 assert!(value.pointer("/properties/provider").is_some());
1011 assert!(value.pointer("/properties/provider_message_id").is_some());
1012 assert!(value.pointer("/properties/accepted").is_some());
1013 }
1014
1015 #[test]
1016 fn transport_error_kind_predicates_classify_each_variant() {
1017 let cases = [
1018 (ErrorKind::Validation, "validation"),
1019 (ErrorKind::Authentication, "auth"),
1020 (ErrorKind::Authorization, "authz"),
1021 (ErrorKind::RateLimited, "rate"),
1022 (ErrorKind::Timeout, "timeout"),
1023 (ErrorKind::TransientNetwork, "net"),
1024 (ErrorKind::TransientProvider, "transient"),
1025 (ErrorKind::PermanentProvider, "permanent"),
1026 (ErrorKind::UnsupportedFeature, "unsupported"),
1027 (ErrorKind::Internal, "internal"),
1028 ];
1029
1030 for (kind, label) in cases {
1031 let err = TransportError::new(kind.clone(), label);
1032
1033 let retryable = matches!(
1034 kind,
1035 ErrorKind::RateLimited
1036 | ErrorKind::Timeout
1037 | ErrorKind::TransientNetwork
1038 | ErrorKind::TransientProvider
1039 );
1040 assert_eq!(err.is_retryable(), retryable, "{label}: is_retryable");
1041 assert_eq!(err.is_terminal(), !retryable, "{label}: is_terminal");
1042
1043 assert_eq!(
1044 err.is_timeout(),
1045 matches!(kind, ErrorKind::Timeout),
1046 "{label}: is_timeout"
1047 );
1048 }
1049 }
1050
1051 #[test]
1052 fn from_http_status_maps_documented_codes() {
1053 assert_eq!(ErrorKind::from_http_status(400), ErrorKind::Validation);
1054 assert_eq!(ErrorKind::from_http_status(422), ErrorKind::Validation);
1055 assert_eq!(ErrorKind::from_http_status(401), ErrorKind::Authentication);
1056 assert_eq!(ErrorKind::from_http_status(403), ErrorKind::Authorization);
1057 assert_eq!(ErrorKind::from_http_status(408), ErrorKind::Timeout);
1058 assert_eq!(
1059 ErrorKind::from_http_status(425),
1060 ErrorKind::TransientNetwork
1061 );
1062 assert_eq!(ErrorKind::from_http_status(429), ErrorKind::RateLimited);
1063 assert_eq!(
1064 ErrorKind::from_http_status(500),
1065 ErrorKind::TransientProvider
1066 );
1067 assert_eq!(
1068 ErrorKind::from_http_status(599),
1069 ErrorKind::TransientProvider
1070 );
1071 for code in [501u16, 505, 510, 511] {
1072 assert_eq!(
1073 ErrorKind::from_http_status(code),
1074 ErrorKind::PermanentProvider,
1075 "code {code}"
1076 );
1077 }
1078 assert_eq!(
1079 ErrorKind::from_http_status(418),
1080 ErrorKind::PermanentProvider
1081 );
1082 }
1083
1084 #[test]
1085 fn from_http_status_408_is_retryable_timeout() {
1086 let kind = ErrorKind::from_http_status(408);
1087 assert_eq!(kind, ErrorKind::Timeout);
1088 let err = TransportError::new(kind, "request timeout");
1089 assert!(err.is_retryable());
1090 assert!(err.is_timeout());
1091 }
1092
1093 #[test]
1094 fn from_http_status_terminal_5xx_is_not_retryable() {
1095 for code in [501u16, 505, 510, 511] {
1096 let kind = ErrorKind::from_http_status(code);
1097 let err = TransportError::new(kind, "terminal");
1098 assert!(!err.is_retryable(), "{code} must not be retryable");
1099 assert!(err.is_terminal(), "{code} must be terminal");
1100 }
1101 }
1102}