resend_rs/
batch.rs

1use std::sync::Arc;
2
3use reqwest::Method;
4
5use crate::{
6    Config, Result,
7    batch::types::BatchValidation,
8    emails::types::CreateEmailBaseOptions,
9    idempotent::Idempotent,
10    types::{CreateEmailResponse, SendEmailBatchPermissiveResponse},
11};
12
13/// `Resend` APIs for `/emails` endpoints.
14#[derive(Clone, Debug)]
15pub struct BatchSvc(pub(crate) Arc<Config>);
16
17impl BatchSvc {
18    /// Trigger up to 100 batch emails at once.
19    ///
20    /// Instead of sending one email per HTTP request, we provide a batching endpoint
21    /// that permits you to send up to 100 emails in a single API call.
22    ///
23    /// <https://resend.com/docs/api-reference/emails/send-batch-emails>
24    #[maybe_async::maybe_async]
25    pub async fn send<T>(
26        &self,
27        emails: impl Into<Idempotent<T>>,
28    ) -> Result<Vec<CreateEmailResponse>>
29    where
30        T: IntoIterator<Item = CreateEmailBaseOptions> + Send,
31    {
32        Ok(self
33            .send_with_batch_validation(emails, BatchValidation::default())
34            .await?
35            .data)
36    }
37
38    /// The same as [`BatchSvc::send`] but allows you to specify a [`BatchValidation`] mode.
39    #[maybe_async::maybe_async]
40    pub async fn send_with_batch_validation<T>(
41        &self,
42        emails: impl Into<Idempotent<T>>,
43        batch_validation: BatchValidation,
44    ) -> Result<SendEmailBatchPermissiveResponse>
45    where
46        T: IntoIterator<Item = CreateEmailBaseOptions> + Send,
47    {
48        let emails: Idempotent<T> = emails.into();
49
50        let emails: Vec<_> = emails.data.into_iter().collect();
51
52        let mut request = self.0.build(Method::POST, "/emails/batch");
53
54        request = request.header("x-batch-validation", batch_validation.to_string());
55
56        let response = self.0.send(request.json(&emails)).await?;
57        let content = response.json::<SendEmailBatchPermissiveResponse>().await?;
58
59        Ok(content)
60    }
61}
62
63#[allow(unreachable_pub)]
64pub mod types {
65    use crate::types::CreateEmailResponse;
66
67    /// Batch validation modes control how emails are validated in batch sending.
68    #[must_use]
69    #[derive(Debug, Copy, Clone)]
70    pub enum BatchValidation {
71        /// Strict mode (default)
72        ///
73        /// Strict mode only sends the batch if all emails in the batch request are valid.
74        /// - Atomic behavior: if any email in the batch fails validation, the entire batch is rejected
75        /// - Error details: only the validation error causing the failure is returned
76        Strict,
77        // Permissive mode processes all emails, allowing for partial success.
78        Permissive,
79    }
80
81    impl Default for BatchValidation {
82        fn default() -> Self {
83            Self::Strict
84        }
85    }
86
87    impl std::fmt::Display for BatchValidation {
88        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89            match self {
90                Self::Strict => write!(f, "strict"),
91                Self::Permissive => write!(f, "permissive"),
92            }
93        }
94    }
95
96    #[derive(Debug, Clone, serde::Deserialize)]
97    pub struct SendEmailBatchResponse {
98        /// The IDs of the sent emails.
99        pub data: Vec<CreateEmailResponse>,
100    }
101
102    #[derive(Debug, Clone, serde::Deserialize)]
103    pub struct SendEmailBatchPermissiveResponse {
104        /// The IDs of the sent emails.
105        pub data: Vec<CreateEmailResponse>,
106        /// Array of objects for emails which could not be created due to validation errors.
107        #[serde(default)]
108        pub errors: Vec<PermissiveBatchErrors>,
109    }
110
111    #[derive(Debug, Clone, serde::Deserialize)]
112    pub struct PermissiveBatchErrors {
113        /// Index of the email in the batch request
114        pub index: i32,
115        /// Error message identifying the validation error
116        pub message: String,
117    }
118}
119
120#[cfg(test)]
121mod test {
122    use crate::test::{CLIENT, DebugResult};
123    use crate::types::{
124        BatchValidation, CreateEmailBaseOptions, CreateTemplateOptions, EmailEvent, EmailTemplate,
125        Variable, VariableType,
126    };
127
128    #[tokio_shared_rt::test(shared = true)]
129    #[cfg(not(feature = "blocking"))]
130    #[allow(clippy::unwrap_used, clippy::indexing_slicing)]
131    async fn strict_error() -> DebugResult<()> {
132        let resend = &*CLIENT;
133        std::thread::sleep(std::time::Duration::from_secs(1));
134
135        let emails = vec![
136            CreateEmailBaseOptions::new(
137                "Acme <onboarding@resend.dev>",
138                vec!["delivered@resend.dev"],
139                "hello world",
140            )
141            .with_html("<h1>it works!</h1>"),
142            CreateEmailBaseOptions::new(
143                "Acme <onboarding@resend.dev>",
144                vec!["NOTantosnis.barotsis@gmail.com"],
145                "world hello",
146            )
147            .with_html("<p>it works!</p>"),
148        ];
149
150        let emails = resend
151            .batch
152            .send_with_batch_validation(emails, BatchValidation::Strict)
153            .await;
154
155        // This should be a "global" error because we are in strict mode
156        assert!(emails.is_err());
157
158        Ok(())
159    }
160
161    #[tokio_shared_rt::test(shared = true)]
162    #[cfg(not(feature = "blocking"))]
163    #[allow(clippy::unwrap_used, clippy::indexing_slicing)]
164    async fn permissive_error() -> DebugResult<()> {
165        let resend = &*CLIENT;
166        std::thread::sleep(std::time::Duration::from_secs(1));
167
168        let emails = vec![
169            CreateEmailBaseOptions::new(
170                "Acme <onboarding@resend.dev>",
171                vec!["delivered@resend.dev"],
172                "hello world",
173            )
174            .with_html("<h1>it works!</h1>"),
175            CreateEmailBaseOptions::new(
176                "Acme <onboarding@resend.dev>",
177                vec!["someotheremail@gmail.com"],
178                "world hello",
179            )
180            .with_html("<p>it works!</p>"),
181        ];
182
183        let emails = resend
184            .batch
185            .send_with_batch_validation(emails, BatchValidation::Permissive)
186            .await;
187
188        // This should not be a "global" error because we are in permissive mode
189        assert!(emails.is_ok());
190        let emails = emails.unwrap();
191
192        // There should be one error but apparently the errors array is empty
193        // check with a get instead
194        std::thread::sleep(std::time::Duration::from_secs(4));
195        let failed_id = &emails.data[1].id;
196        let status = resend.emails.get(failed_id).await?;
197        assert_eq!(status.last_event, EmailEvent::Failed);
198
199        Ok(())
200    }
201
202    #[tokio_shared_rt::test(shared = true)]
203    #[cfg(not(feature = "blocking"))]
204    #[allow(clippy::unwrap_used, clippy::indexing_slicing)]
205    async fn permissive_ok() -> DebugResult<()> {
206        let resend = &*CLIENT;
207        std::thread::sleep(std::time::Duration::from_secs(1));
208
209        let emails = vec![
210            CreateEmailBaseOptions::new(
211                "Acme <onboarding@resend.dev>",
212                vec!["delivered@resend.dev"],
213                "hello world",
214            )
215            .with_html("<h1>it works!</h1>"),
216            CreateEmailBaseOptions::new(
217                "Acme <onboarding@resend.dev>",
218                vec!["delivered@resend.dev"],
219                "world hello",
220            )
221            .with_html("<p>it works!</p>"),
222        ];
223
224        let emails = resend
225            .batch
226            .send_with_batch_validation(emails, BatchValidation::Permissive)
227            .await;
228
229        // This should be all ok
230        assert!(emails.is_ok());
231        let emails = emails.unwrap();
232
233        // There should be no errors
234        assert!(emails.errors.is_empty());
235
236        Ok(())
237    }
238
239    #[tokio_shared_rt::test(shared = true)]
240    #[cfg(not(feature = "blocking"))]
241    #[allow(clippy::unwrap_used, clippy::indexing_slicing)]
242    async fn strict_ok() -> DebugResult<()> {
243        let resend = &*CLIENT;
244        std::thread::sleep(std::time::Duration::from_secs(1));
245
246        let emails = vec![
247            CreateEmailBaseOptions::new(
248                "Acme <onboarding@resend.dev>",
249                vec!["delivered@resend.dev"],
250                "hello world",
251            )
252            .with_html("<h1>it works!</h1>"),
253            CreateEmailBaseOptions::new(
254                "Acme <onboarding@resend.dev>",
255                vec!["delivered@resend.dev"],
256                "world hello",
257            )
258            .with_html("<p>it works!</p>"),
259        ];
260
261        let emails = resend.batch.send(emails).await;
262
263        // This should be all ok
264        assert!(emails.is_ok());
265        let _emails = emails.unwrap();
266
267        Ok(())
268    }
269
270    #[tokio_shared_rt::test(shared = true)]
271    #[cfg(not(feature = "blocking"))]
272    async fn template() -> DebugResult<()> {
273        use std::collections::HashMap;
274
275        let resend = &*CLIENT;
276        std::thread::sleep(std::time::Duration::from_secs(1));
277
278        // Create template
279        let name = "welcome-email";
280        let html = "<strong>Hey, {{{NAME}}}, you are {{{AGE}}} years old.</strong>";
281        let variables = [
282            Variable::new("NAME", VariableType::String).with_fallback("user"),
283            Variable::new("AGE", VariableType::Number).with_fallback(25),
284            Variable::new("OPTIONAL_VARIABLE", VariableType::String).with_fallback(None::<String>),
285        ];
286        let opts = CreateTemplateOptions::new(name, html).with_variables(&variables);
287        let template = resend.templates.create(opts).await?;
288        std::thread::sleep(std::time::Duration::from_secs(2));
289        let template = resend.templates.publish(&template.id).await?;
290        std::thread::sleep(std::time::Duration::from_secs(2));
291
292        let mut variables1 = HashMap::<String, serde_json::Value>::new();
293        let _added = variables1.insert("NAME".to_string(), serde_json::json!("Tony"));
294        let _added = variables1.insert("AGE".to_string(), serde_json::json!(25));
295
296        let template1 = EmailTemplate::new(&template.id).with_variables(variables1);
297        let template_id = &template1.id.clone();
298
299        let mut variables2 = HashMap::<String, serde_json::Value>::new();
300        let _added = variables2.insert("NAME".to_string(), serde_json::json!("Not Tony"));
301        let _added = variables2.insert("AGE".to_string(), serde_json::json!(42));
302
303        let template2 = EmailTemplate::new(&template.id).with_variables(variables2);
304        let _ = &template2.id.clone();
305
306        // Create email
307        let from = "Acme <onboarding@resend.dev>";
308        let to = ["delivered@resend.dev"];
309        let subject = "hello world";
310
311        let emails = vec![
312            CreateEmailBaseOptions::new(from, to, subject).with_template(template1),
313            CreateEmailBaseOptions::new(from, to, subject).with_template(template2),
314        ];
315
316        let _email = resend.batch.send(emails).await?;
317        std::thread::sleep(std::time::Duration::from_secs(2));
318
319        // Delete template
320        let deleted = resend.templates.delete(template_id).await?;
321        assert!(deleted.deleted);
322
323        Ok(())
324    }
325}