Skip to main content

restate_email/
contract.rs

1//! SDK-independent request and response contract for the email service.
2
3use email_message::OutboundMessage;
4use email_transport::{SendOptions, SendReport, TransportOptionRegistry, string_newtype};
5use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, Visitor};
6use serde::{Deserialize, Serialize};
7
8string_newtype! {
9    /// Configured transport key (e.g. `"primary"`, `"fallback"`).
10    ///
11    /// End-user code should use [`Self::new`] or [`std::str::FromStr`] for
12    /// values originating outside trusted code paths.
13    @unchecked TransportKey
14}
15
16/// Queue payload consumed by `Email.send`.
17///
18/// Provider-specific [`SendOptions::transport_options`] are a best-effort union.
19/// Callers may include options for every provider they support; the worker
20/// hydrates registered provider slices and ignores unrecognized providers.
21/// Switching the selected transport may therefore drop provider-specific
22/// behavior.
23///
24/// Deserialization requires a [`TransportOptionRegistry`] and is intentionally
25/// available only through [`SendRequestSeed`].
26#[derive(Debug, Serialize)]
27#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
28#[cfg_attr(feature = "schemars", schemars(example = example_send_request()))]
29pub struct SendRequest {
30    /// Configured transport profile to use for this send.
31    pub transport: TransportKey,
32    /// Validated outbound message payload.
33    pub message: OutboundMessage,
34    /// Send-time metadata and best-effort provider-specific transport options.
35    #[serde(default)]
36    #[cfg_attr(feature = "schemars", schemars(default))]
37    pub options: SendOptions,
38}
39
40/// Registry-driven deserializer for [`SendRequest`].
41///
42/// Unknown provider option keys are ignored so a queued payload can carry
43/// options for transports that are not installed in a particular worker.
44pub struct SendRequestSeed<'a> {
45    registry: &'a TransportOptionRegistry,
46}
47
48impl<'a> SendRequestSeed<'a> {
49    /// Create a request seed backed by `registry`.
50    #[must_use]
51    pub const fn new(registry: &'a TransportOptionRegistry) -> Self {
52        Self { registry }
53    }
54}
55
56impl<'de> DeserializeSeed<'de> for SendRequestSeed<'_> {
57    type Value = SendRequest;
58
59    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
60    where
61        D: serde::Deserializer<'de>,
62    {
63        deserializer.deserialize_map(SendRequestVisitor {
64            registry: self.registry,
65        })
66    }
67}
68
69struct SendRequestVisitor<'a> {
70    registry: &'a TransportOptionRegistry,
71}
72
73#[derive(Deserialize)]
74#[serde(field_identifier, rename_all = "snake_case")]
75enum SendRequestField {
76    Transport,
77    Message,
78    Options,
79    #[serde(other)]
80    Other,
81}
82
83impl<'de> Visitor<'de> for SendRequestVisitor<'_> {
84    type Value = SendRequest;
85
86    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        formatter.write_str("an email send request")
88    }
89
90    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
91    where
92        A: MapAccess<'de>,
93    {
94        let mut transport = None;
95        let mut message = None;
96        let mut options = None;
97
98        while let Some(field) = map.next_key::<SendRequestField>()? {
99            match field {
100                SendRequestField::Transport => {
101                    if transport.is_some() {
102                        return Err(serde::de::Error::duplicate_field("transport"));
103                    }
104                    transport = Some(map.next_value()?);
105                }
106                SendRequestField::Message => {
107                    if message.is_some() {
108                        return Err(serde::de::Error::duplicate_field("message"));
109                    }
110                    message = Some(map.next_value()?);
111                }
112                SendRequestField::Options => {
113                    if options.is_some() {
114                        return Err(serde::de::Error::duplicate_field("options"));
115                    }
116                    options = Some(
117                        map.next_value_seed(
118                            self.registry
119                                .send_options_seed()
120                                .ignore_unknown_transport_options(),
121                        )?,
122                    );
123                }
124                SendRequestField::Other => {
125                    map.next_value::<IgnoredAny>()?;
126                }
127            }
128        }
129
130        Ok(SendRequest {
131            transport: transport.ok_or_else(|| serde::de::Error::missing_field("transport"))?,
132            message: message.ok_or_else(|| serde::de::Error::missing_field("message"))?,
133            options: options.unwrap_or_default(),
134        })
135    }
136}
137
138/// Wire-stable response shape returned by the Restate `Email.send` handler.
139#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
140#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
141#[cfg_attr(feature = "schemars", schemars(example = example_send_response()))]
142#[non_exhaustive]
143pub struct SendResponse {
144    /// Provider send report returned by the selected transport.
145    pub report: SendReport,
146}
147
148impl From<SendReport> for SendResponse {
149    fn from(report: SendReport) -> Self {
150        Self { report }
151    }
152}
153
154#[cfg(feature = "schemars")]
155fn example_send_response() -> serde_json::Value {
156    serde_json::json!({
157        "report": {
158            "provider": "your-provider",
159            "provider_message_id": "184fa9a3-f967-4a98-9d8f-57152e7cbe64",
160            "accepted": ["alice@example.com", "bob@example.com"],
161        },
162    })
163}
164
165#[cfg(feature = "schemars")]
166fn example_send_request() -> serde_json::Value {
167    serde_json::json!({
168        "transport": "your-transport",
169        "options": {
170            "idempotency_key": "foo",
171            "transport_options": {
172                "your-transport": {"tags": [{"name": "campaign", "value": "test"}]}
173            },
174        },
175        "message": {
176            "from": {"type": "mailbox", "name": "Alice", "email": "alice@example.com"},
177            "to": [{"type": "mailbox", "name": "Bob", "email": "bob@example.com"}],
178            "subject": "Test email",
179            "body": {"type": "text", "text": "Hello everyone! This is a test email."},
180        },
181    })
182}
183
184#[cfg(all(test, feature = "schemars"))]
185mod tests {
186    use email_transport::TransportOptionRegistry;
187    use serde::de::DeserializeSeed as _;
188
189    #[cfg(feature = "schemars")]
190    #[test]
191    fn schema_example_is_a_valid_send_request() {
192        let value = super::example_send_request();
193
194        super::SendRequestSeed::new(&TransportOptionRegistry::new())
195            .deserialize(value)
196            .expect("schema example should deserialize");
197    }
198}