Skip to main content

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