Skip to main content

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}