Skip to main content

resend_rs/
emails.rs

1use std::sync::Arc;
2
3use reqwest::Method;
4use serde::{Deserialize, Deserializer};
5
6use crate::{
7    Config, Result,
8    list_opts::{ListOptions, ListResponse},
9    types::Attachment,
10};
11use crate::{
12    idempotent::Idempotent,
13    types::{
14        CancelScheduleResponse, CreateEmailBaseOptions, CreateEmailResponse, Email, EmailMetrics,
15        GetEmailMetricsOptions, ShareEmailOptions, ShareEmailResponse, UpdateEmailOptions,
16        UpdateEmailResponse,
17    },
18};
19
20/// `Resend` APIs for `/emails` endpoints.
21#[derive(Clone, Debug)]
22pub struct EmailsSvc(pub(crate) Arc<Config>);
23
24impl EmailsSvc {
25    /// Start sending emails through the `Resend` Email API.
26    ///
27    /// <https://resend.com/docs/api-reference/emails/send-email>
28    #[maybe_async::maybe_async]
29    pub async fn send(
30        &self,
31        email: impl Into<Idempotent<CreateEmailBaseOptions>>,
32    ) -> Result<CreateEmailResponse> {
33        let email: Idempotent<CreateEmailBaseOptions> = email.into();
34
35        let mut request = self.0.build(Method::POST, "/emails");
36
37        if let Some(ref idempotency_key) = email.idempotency_key {
38            request = request.header("Idempotency-Key", idempotency_key);
39        }
40
41        let response = self.0.send(request.json(&email)).await?;
42        let content = response.json::<CreateEmailResponse>().await?;
43
44        Ok(content)
45    }
46
47    /// Retrieve a single email.
48    ///
49    /// <https://resend.com/docs/api-reference/emails/retrieve-email>
50    #[maybe_async::maybe_async]
51    pub async fn get(&self, email_id: &str) -> Result<Email> {
52        let path = format!("/emails/{email_id}");
53
54        let request = self.0.build(Method::GET, &path);
55        let response = self.0.send(request).await?;
56        let content = response.json::<Email>().await?;
57
58        Ok(content)
59    }
60
61    /// Update a scheduled email.
62    ///
63    /// <https://resend.com/docs/api-reference/emails/update-email>
64    #[maybe_async::maybe_async]
65    pub async fn update(
66        &self,
67        email_id: &str,
68        update: UpdateEmailOptions,
69    ) -> Result<UpdateEmailResponse> {
70        let path = format!("/emails/{email_id}");
71
72        let request = self.0.build(Method::PATCH, &path);
73        let response = self.0.send(request.json(&update)).await?;
74        let content = response.json::<UpdateEmailResponse>().await?;
75
76        Ok(content)
77    }
78
79    /// Cancel a scheduled email.
80    ///
81    /// <https://resend.com/docs/api-reference/emails/cancel-email>
82    #[maybe_async::maybe_async]
83    pub async fn cancel(&self, email_id: &str) -> Result<CancelScheduleResponse> {
84        let path = format!("/emails/{email_id}/cancel");
85
86        let request = self.0.build(Method::POST, &path);
87        let response = self.0.send(request).await?;
88        let content = response.json::<CancelScheduleResponse>().await?;
89
90        Ok(content)
91    }
92
93    #[maybe_async::maybe_async]
94    pub async fn share(
95        &self,
96        email_id: &str,
97        options: ShareEmailOptions,
98    ) -> Result<ShareEmailResponse> {
99        let path = format!("/emails/{email_id}/share");
100
101        let request = self.0.build(Method::POST, &path);
102        let response = self.0.send(request.json(&options)).await?;
103        let content = response.json::<ShareEmailResponse>().await?;
104
105        Ok(content)
106    }
107
108    /// Retrieve a list of emails.
109    ///
110    /// - Default limit: 20
111    ///
112    /// <https://resend.com/docs/api-reference/emails/list-emails>
113    #[maybe_async::maybe_async]
114    pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Email>> {
115        let request = self.0.build(Method::GET, "/emails").query(&list_opts);
116        let response = self.0.send(request).await?;
117        let content = response.json::<ListResponse<Email>>().await?;
118
119        Ok(content)
120    }
121
122    /// Retrieve a single attachment from a sent email.
123    ///
124    /// <https://resend.com/docs/api-reference/attachments/retrieve-sent-email-attachment>
125    #[maybe_async::maybe_async]
126    pub async fn get_attachment(&self, email_id: &str, attachment_id: &str) -> Result<Attachment> {
127        let path = format!("/emails/{email_id}/attachments/{attachment_id}");
128
129        let request = self.0.build(Method::GET, &path);
130        let response = self.0.send(request).await?;
131        let content = response.json::<Attachment>().await?;
132
133        Ok(content)
134    }
135
136    /// Retrieve a list of email attachments and their contents.
137    ///
138    /// <https://resend.com/docs/api-reference/attachments/list-sent-email-attachments>
139    #[maybe_async::maybe_async]
140    pub async fn list_attachments<T>(
141        &self,
142        email_id: &str,
143        list_opts: ListOptions<T>,
144    ) -> Result<ListResponse<Attachment>> {
145        let path = format!("/emails/{email_id}/attachments");
146
147        let request = self.0.build(Method::GET, &path).query(&list_opts);
148        let response = self.0.send(request).await?;
149        let content = response.json::<ListResponse<Attachment>>().await?;
150
151        Ok(content)
152    }
153
154    /// Retrieve email delivery metrics aggregated over a date range.
155    ///
156    /// <https://resend.com/docs/api-reference/emails/metrics>
157    #[maybe_async::maybe_async]
158    pub async fn metrics<T>(&self, options: GetEmailMetricsOptions<T>) -> Result<EmailMetrics> {
159        let request = self.0.build(Method::GET, "/emails/metrics").query(&options);
160        let response = self.0.send(request).await?;
161        let content = response.json::<EmailMetrics>().await?;
162
163        Ok(content)
164    }
165}
166
167#[allow(unreachable_pub)]
168pub mod types {
169    use std::collections::HashMap;
170
171    use serde::{Deserialize, Serialize};
172
173    use crate::{
174        emails::{join_comma, parse_nullable_vec},
175        idempotent::Idempotent,
176        types::{BroadcastId, DomainId, TemplateId, TopicId},
177    };
178
179    crate::define_id_type!(EmailId);
180    crate::define_id_type!(AttachmentId);
181
182    /// All requisite components and associated data to send an email.
183    ///
184    /// See [`docs`].
185    ///
186    /// [`docs`]: https://resend.com/docs/api-reference/emails/send-email#body-parameters
187    #[must_use]
188    #[derive(Debug, Clone, Serialize)]
189    pub struct CreateEmailBaseOptions {
190        /// Sender email address.
191        ///
192        /// To include a friendly name, use the format:
193        ///
194        /// `Your Name <sender@domain.com>`
195        from: String,
196        /// Recipient email address. Max 50.
197        to: Vec<String>,
198        /// Email subject.
199        subject: String,
200
201        /// The HTML version of the message.
202        #[serde(skip_serializing_if = "Option::is_none")]
203        html: Option<String>,
204        /// The plain text version of the message.
205        #[serde(skip_serializing_if = "Option::is_none")]
206        text: Option<String>,
207
208        /// Bcc recipient email address.
209        #[serde(skip_serializing_if = "Option::is_none")]
210        bcc: Option<Vec<String>>,
211        /// Cc recipient email address.
212        #[serde(skip_serializing_if = "Option::is_none")]
213        cc: Option<Vec<String>>,
214        /// Reply-to email address.
215        #[serde(skip_serializing_if = "Option::is_none")]
216        reply_to: Option<Vec<String>>,
217        /// Custom headers to add to the email.
218        #[serde(skip_serializing_if = "Option::is_none")]
219        headers: Option<HashMap<String, String>>,
220        /// Filename and content of attachments (max 40mb per email).
221        #[serde(skip_serializing_if = "Option::is_none")]
222        attachments: Option<Vec<CreateAttachment>>,
223        /// Email tags.
224        #[serde(skip_serializing_if = "Option::is_none")]
225        tags: Option<Vec<Tag>>,
226        /// The template to use for the email.
227        #[serde(skip_serializing_if = "Option::is_none")]
228        template: Option<EmailTemplate>,
229        /// The topic ID to receive the email.
230        #[serde(skip_serializing_if = "Option::is_none")]
231        topic_id: Option<TopicId>,
232
233        /// Schedule email to be sent later. The date should be in ISO 8601 format
234        /// (e.g: `2024-08-05T11:52:01.858Z`).
235        #[serde(skip_serializing_if = "Option::is_none")]
236        scheduled_at: Option<String>,
237    }
238
239    impl CreateEmailBaseOptions {
240        /// Creates a new [`CreateEmailBaseOptions`].
241        ///
242        /// - `from`: Sender email address.
243        ///   To include a friendly name, use the format: `Your Name <sender@domain.com>`.
244        /// - `to`: Recipient email addresses. Max 50.
245        /// - `subject`: Email subject.
246        pub fn new<T, A>(from: impl Into<String>, to: T, subject: impl Into<String>) -> Self
247        where
248            T: IntoIterator<Item = A>,
249            A: Into<String>,
250        {
251            Self {
252                from: from.into(),
253                to: to.into_iter().map(Into::into).collect(),
254                subject: subject.into(),
255
256                html: None,
257                text: None,
258
259                bcc: None,
260                cc: None,
261                reply_to: None,
262
263                headers: None,
264                attachments: None,
265                tags: None,
266                template: None,
267                topic_id: None,
268                scheduled_at: None,
269            }
270        }
271
272        /// Adds or overwrites the HTML version of the message.
273        #[inline]
274        pub fn with_html(mut self, html: &str) -> Self {
275            self.html = Some(html.to_owned());
276            self
277        }
278
279        /// Adds or overwrites the plain text version of the message.
280        #[inline]
281        pub fn with_text(mut self, text: &str) -> Self {
282            self.text = Some(text.to_owned());
283            self
284        }
285
286        /// Attaches `bcc` recipient email address.
287        #[inline]
288        pub fn with_bcc(mut self, address: &str) -> Self {
289            let bcc = self.bcc.get_or_insert_with(Vec::new);
290            bcc.push(address.to_owned());
291            self
292        }
293
294        /// Attaches `cc` recipient email address.
295        #[inline]
296        pub fn with_cc(mut self, address: &str) -> Self {
297            let cc = self.cc.get_or_insert_with(Vec::new);
298            cc.push(address.to_owned());
299            self
300        }
301
302        /// Adds another `reply_to` address to the email.
303        #[inline]
304        pub fn with_reply(mut self, to: &str) -> Self {
305            let reply_to = self.reply_to.get_or_insert_with(Vec::new);
306            reply_to.push(to.to_owned());
307            self
308        }
309
310        /// Append multiple `reply_to` addresses to the email.
311        #[inline]
312        pub fn with_reply_multiple(mut self, to: &[String]) -> Self {
313            let reply_to = self.reply_to.get_or_insert_with(Vec::new);
314            reply_to.extend_from_slice(to);
315            self
316        }
317
318        /// Adds an email header.
319        #[inline]
320        pub fn with_header(mut self, name: &str, value: &str) -> Self {
321            let headers = self.headers.get_or_insert_with(HashMap::new);
322            let _unused = headers.insert(name.to_owned(), value.to_owned());
323
324            self
325        }
326
327        /// Adds another attachment.
328        ///
329        /// Limited to max 40mb per email.
330        #[inline]
331        pub fn with_attachment(mut self, file: impl Into<CreateAttachment>) -> Self {
332            let attachments = self.attachments.get_or_insert_with(Vec::new);
333            attachments.push(file.into());
334            self
335        }
336
337        /// Adds multiple attachments.
338        ///
339        /// Limited to max 40mb per email.
340        #[inline]
341        pub fn with_attachments(
342            mut self,
343            new_attachments: impl IntoIterator<Item = impl Into<CreateAttachment>>,
344        ) -> Self {
345            let attachments = self.attachments.get_or_insert_with(Vec::new);
346            attachments.extend(new_attachments.into_iter().map(Into::into));
347            self
348        }
349
350        /// Adds additional email tag.
351        #[inline]
352        pub fn with_tag(mut self, tag: impl Into<Tag>) -> Self {
353            let tags = self.tags.get_or_insert_with(Vec::new);
354            tags.push(tag.into());
355            self
356        }
357
358        /// Adds the template to use for the email.
359        #[inline]
360        pub fn with_template(mut self, template: impl Into<EmailTemplate>) -> Self {
361            self.template = Some(template.into());
362            self
363        }
364
365        /// Sets the topic ID to receive the email.
366        #[inline]
367        pub fn with_topic(mut self, topic_id: &str) -> Self {
368            self.topic_id = Some(TopicId::new(topic_id));
369            self
370        }
371
372        /// Schedule email to be sent later. The date should be in ISO 8601 format
373        /// (e.g: `2024-08-05T11:52:01.858Z`).
374        #[inline]
375        pub fn with_scheduled_at(mut self, scheduled_at: &str) -> Self {
376            self.scheduled_at = Some(scheduled_at.to_owned());
377            self
378        }
379
380        // Adds an `Idempotency-Key` header to the request.
381        #[inline]
382        pub fn with_idempotency_key(self, idempotency_key: &str) -> Idempotent<Self> {
383            Idempotent {
384                idempotency_key: Some(idempotency_key.to_owned()),
385                data: self,
386            }
387        }
388    }
389
390    #[derive(Debug, Clone, Serialize, Deserialize)]
391    pub struct CreateEmailResponse {
392        /// The ID of the sent email.
393        pub id: EmailId,
394    }
395
396    /// List of changes to apply to an [`Email`].
397    #[must_use]
398    #[derive(Debug, Default, Clone, Serialize)]
399    pub struct UpdateEmailOptions {
400        #[serde(skip_serializing_if = "Option::is_none")]
401        scheduled_at: Option<String>,
402    }
403
404    impl UpdateEmailOptions {
405        #[inline]
406        pub fn new() -> Self {
407            Self::default()
408        }
409
410        #[inline]
411        pub fn with_scheduled_at(mut self, scheduled_at: &str) -> Self {
412            self.scheduled_at = Some(scheduled_at.to_owned());
413            self
414        }
415    }
416
417    #[derive(Debug, Clone, Serialize, Deserialize)]
418    pub struct UpdateEmailResponse {
419        /// Unique identifier for the updated contact.
420        pub id: EmailId,
421    }
422
423    #[derive(Debug, Clone, Serialize, Deserialize)]
424    pub struct CancelScheduleResponse {
425        /// The ID of the cancelled email.
426        pub id: EmailId,
427    }
428
429    #[must_use]
430    #[derive(Debug, Default, Clone, Serialize)]
431    pub struct ShareEmailOptions {
432        #[serde(skip_serializing_if = "Option::is_none")]
433        expires_in: Option<String>,
434    }
435
436    impl ShareEmailOptions {
437        #[inline]
438        pub fn new() -> Self {
439            Self::default()
440        }
441
442        #[inline]
443        pub fn with_expires_in(mut self, expires_in: &str) -> Self {
444            self.expires_in = Some(expires_in.to_owned());
445            self
446        }
447    }
448
449    #[derive(Debug, Clone, Serialize, Deserialize)]
450    pub struct ShareEmailResponse {
451        /// The ID of the shared email.
452        pub id: EmailId,
453        /// The shareable link to the email.
454        pub url: String,
455    }
456
457    /// Name and value of the attached [`Email`] tag.
458    #[must_use]
459    #[derive(Debug, Clone, Serialize, Deserialize)]
460    pub struct Tag {
461        /// The name of the email tag. It can only contain ASCII letters (a–z, A–Z), numbers (0–9),
462        /// underscores (_), or dashes (-). It can contain no more than 256 characters.
463        name: String,
464        /// The value of the email tag. It can only contain ASCII letters (a–z, A–Z), numbers (0–9),
465        /// underscores (_), or dashes (-). It can contain no more than 256 characters.
466        value: String,
467    }
468
469    impl Tag {
470        /// Creates the new email [`Tag`] with a provided `name`.
471        ///
472        /// It can only contain ASCII letters (a–z, A–Z), numbers (0–9), underscores (_),
473        /// or dashes (-). It can contain no more than 256 characters.
474        #[inline]
475        pub fn new(name: &str, value: &str) -> Self {
476            Self {
477                name: name.to_owned(),
478                value: value.to_owned(),
479            }
480        }
481    }
482
483    /// Filename and content of the [`CreateEmailBaseOptions`] attachment.
484    ///
485    /// Limited to max 40mb per email.
486    #[must_use]
487    #[derive(Debug, Clone, Serialize)]
488    pub struct CreateAttachment {
489        /// Content or path of an attached file.
490        #[serde(flatten)]
491        content_or_path: ContentOrPath,
492        /// Name of attached file.
493        #[serde(skip_serializing_if = "Option::is_none")]
494        filename: Option<String>,
495        /// Optional content type for the attachment, if not set will be derived from the filename
496        /// property.
497        #[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
498        content_type: Option<String>,
499        /// Optional content ID for the attachment, to be used as a reference in the HTML content.
500        /// If set, this attachment will be sent as an inline attachment and you can reference it
501        /// in the HTML content using the `cid:` prefix.
502        #[serde(skip_serializing_if = "Option::is_none")]
503        content_id: Option<String>,
504    }
505
506    /// Content or path of the [`Attachment`].
507    #[must_use]
508    #[derive(Debug, Clone, Serialize)]
509    pub enum ContentOrPath {
510        /// Content of an attached file.
511        #[serde(rename = "content")]
512        Content(Vec<u8>),
513        /// Path where the attachment file is hosted.
514        #[serde(rename = "path")]
515        Path(String),
516    }
517
518    impl CreateAttachment {
519        /// Creates a new [`Attachment`] from the content of an attached file.
520        #[inline]
521        pub const fn from_content(content: Vec<u8>) -> Self {
522            Self {
523                content_or_path: ContentOrPath::Content(content),
524                filename: None,
525                content_type: None,
526                content_id: None,
527            }
528        }
529
530        /// Creates a new [`Attachment`] from the path where the attachment file is hosted.
531        #[inline]
532        pub fn from_path(path: &str) -> Self {
533            Self {
534                content_or_path: ContentOrPath::Path(path.to_owned()),
535                filename: None,
536                content_type: None,
537                content_id: None,
538            }
539        }
540
541        /// Adds a filename to the attached file.
542        #[inline]
543        pub fn with_filename(mut self, filename: &str) -> Self {
544            self.filename = Some(filename.to_owned());
545            self
546        }
547
548        /// Adds a contenent type to the attached file.
549        #[inline]
550        pub fn with_content_type(mut self, content_type: &str) -> Self {
551            self.content_type = Some(content_type.to_owned());
552            self
553        }
554
555        /// Adds an inline content id to the attached file.
556        #[deprecated(
557            since = "0.16.1",
558            note = "Parameter got internally renamed to just `content_id`. Use `with_content_id` instead."
559        )]
560        #[inline]
561        pub fn with_inline_content_id(mut self, inline_content_id: &str) -> Self {
562            self.content_id = Some(inline_content_id.to_owned());
563            self
564        }
565
566        #[inline]
567        pub fn with_content_id(mut self, content_id: &str) -> Self {
568            self.content_id = Some(content_id.to_owned());
569            self
570        }
571    }
572
573    impl From<Vec<u8>> for CreateAttachment {
574        #[inline]
575        fn from(value: Vec<u8>) -> Self {
576            Self::from_content(value)
577        }
578    }
579
580    impl From<&[u8]> for CreateAttachment {
581        #[inline]
582        fn from(value: &[u8]) -> Self {
583            value.to_vec().into()
584        }
585    }
586
587    /// Received email.
588    #[must_use]
589    #[derive(Debug, Clone, Serialize, Deserialize)]
590    pub struct Email {
591        /// The ID of the email.
592        pub id: EmailId,
593        /// RFC Message-ID header value for the email.
594        pub message_id: Option<String>,
595
596        /// Sender email address.
597        pub from: String,
598        /// Recipient email address.
599        pub to: Vec<String>,
600        /// The subject line of the email.
601        pub subject: String,
602
603        /// The date and time the email was created.
604        pub created_at: String,
605        /// The HTML body of the email.
606        pub html: Option<String>,
607        /// The plain text body of the email.
608        pub text: Option<String>,
609
610        /// The email addresses of the blind carbon copy recipients.
611        #[serde(deserialize_with = "parse_nullable_vec")]
612        pub bcc: Vec<String>,
613        /// The email addresses of the carbon copy recipients.
614        #[serde(deserialize_with = "parse_nullable_vec")]
615        pub cc: Vec<String>,
616        /// The email addresses to which replies should be sent.
617        pub reply_to: Option<Vec<String>>,
618        /// The status of the email.
619        pub last_event: EmailEvent,
620
621        /// The scheduled send time of the email.
622        #[serde(skip_serializing_if = "Option::is_none")]
623        pub scheduled_at: Option<String>,
624    }
625
626    /// Strongly typed `last_event`.
627    ///
628    /// <https://resend.com/docs/dashboard/emails/introduction#understand-email-events>
629    #[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
630    #[serde(rename_all = "snake_case")]
631    pub enum EmailEvent {
632        /// The recipient's mail server rejected the email.
633        ///
634        /// <https://resend.com/docs/dashboard/emails/email-bounces>
635        Bounced,
636        /// The scheduled email was canceled (by user).
637        Canceled,
638        /// The recipient clicked on a link in the email.
639        Clicked,
640        /// The email was successfully delivered to the recipient’s mail server, but the recipient marked it as spam.
641        Complained,
642        /// Resend successfully delivered the email to the recipient’s mail server.
643        Delivered,
644        /// The email couldn’t be delivered to the recipient’s mail server because a temporary issue occurred. Delivery delays can occur, for example, when the recipient’s inbox is full, or when the receiving email server experiences a transient issue.
645        DeliveryDelayed,
646        /// The email failed to be sent.
647        Failed,
648        /// The recipient opened the email.
649        Opened,
650        /// The email created from Broadcasts or Batches is queued for delivery.
651        Queued,
652        /// The email is scheduled for delivery.
653        Scheduled,
654        /// The email was sent successfully.
655        Sent,
656    }
657
658    #[must_use]
659    #[derive(Debug, Clone, Serialize, Deserialize)]
660    pub struct Attachment {
661        pub id: AttachmentId,
662        pub filename: Option<String>,
663        pub size: u32,
664        pub content_type: String,
665        pub content_disposition: ContentDisposition,
666        pub content_id: Option<String>,
667        pub download_url: String,
668        pub expires_at: String,
669    }
670
671    #[must_use]
672    #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
673    #[serde(rename_all = "snake_case")]
674    pub enum ContentDisposition {
675        Inline,
676        Attachment,
677    }
678
679    #[must_use]
680    #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
681    pub struct EmailTemplate {
682        pub id: TemplateId,
683        pub variables: Option<HashMap<String, serde_json::Value>>,
684    }
685
686    impl EmailTemplate {
687        pub fn new(id: &str) -> Self {
688            Self {
689                id: TemplateId::new(id),
690                variables: None,
691            }
692        }
693
694        /// Adds a variable
695        pub fn with_variable(mut self, key: &str, value: serde_json::Value) -> Self {
696            let variables = self.variables.get_or_insert_with(HashMap::new);
697            let _old = variables.insert(key.to_owned(), value);
698            self
699        }
700
701        /// Adds variables
702        pub fn with_variables(mut self, variables: HashMap<String, serde_json::Value>) -> Self {
703            let self_variables = self.variables.get_or_insert_with(HashMap::new);
704            self_variables.extend(variables);
705            self
706        }
707    }
708
709    #[derive(Debug, Clone, Copy)]
710    pub struct NoFilter;
711    #[derive(Debug, Clone, Copy)]
712    pub struct EmailFilter;
713    #[derive(Debug, Clone, Copy)]
714    pub struct BroadcastFilter;
715
716    /// Query parameters for [`crate::emails::EmailsSvc::metrics`].
717    ///
718    /// Note that [`GetEmailMetricsOptions::default()`] applies no filters, which means the
719    /// server-side defaults are used (last 7 days, `UTC`, daily granularity, all metrics, no
720    /// dimensions).
721    ///
722    /// <https://resend.com/docs/api-reference/emails/metrics>
723    #[must_use]
724    #[derive(Debug, Clone, Serialize)]
725    pub struct GetEmailMetricsOptions<State = NoFilter> {
726        #[serde(skip)]
727        _state: std::marker::PhantomData<State>,
728
729        #[serde(skip_serializing_if = "Option::is_none")]
730        start_date: Option<String>,
731        #[serde(skip_serializing_if = "Option::is_none")]
732        end_date: Option<String>,
733        #[serde(skip_serializing_if = "Option::is_none")]
734        timezone: Option<String>,
735        #[serde(skip_serializing_if = "Option::is_none")]
736        granularity: Option<MetricsGranularity>,
737        #[serde(skip_serializing_if = "Vec::is_empty", serialize_with = "join_comma")]
738        metrics: Vec<Metric>,
739        #[serde(skip_serializing_if = "Vec::is_empty", serialize_with = "join_comma")]
740        dimensions: Vec<Dimension>,
741        #[serde(
742            rename = "domain_id",
743            skip_serializing_if = "Vec::is_empty",
744            serialize_with = "join_comma"
745        )]
746        domain_ids: Vec<DomainId>,
747        #[serde(
748            rename = "email_id",
749            skip_serializing_if = "Vec::is_empty",
750            serialize_with = "join_comma"
751        )]
752        email_ids: Vec<EmailId>,
753        #[serde(
754            rename = "broadcast_id",
755            skip_serializing_if = "Vec::is_empty",
756            serialize_with = "join_comma"
757        )]
758        broadcast_ids: Vec<BroadcastId>,
759    }
760
761    impl Default for GetEmailMetricsOptions {
762        /// Creates a new [`GetEmailMetricsOptions`] applying no filters.
763        fn default() -> Self {
764            Self {
765                _state: std::marker::PhantomData::<NoFilter>,
766                start_date: Option::default(),
767                end_date: Option::default(),
768                timezone: Option::default(),
769                granularity: Option::default(),
770                metrics: Vec::default(),
771                dimensions: Vec::default(),
772                domain_ids: Vec::default(),
773                email_ids: Vec::default(),
774                broadcast_ids: Vec::default(),
775            }
776        }
777    }
778
779    impl GetEmailMetricsOptions<NoFilter> {
780        /// Adds the email dimension to break results down by. If none are added, only `totals` is
781        /// returned.
782        #[inline]
783        pub fn with_email_dimension(mut self) -> GetEmailMetricsOptions<EmailFilter> {
784            self.dimensions.push(Dimension::Email);
785
786            GetEmailMetricsOptions::<EmailFilter> {
787                _state: std::marker::PhantomData,
788                start_date: self.start_date,
789                end_date: self.end_date,
790                timezone: self.timezone,
791                granularity: self.granularity,
792                metrics: self.metrics,
793                dimensions: self.dimensions,
794                domain_ids: self.domain_ids,
795                email_ids: self.email_ids,
796                broadcast_ids: self.broadcast_ids,
797            }
798        }
799
800        /// Restricts results to an additional email ID. Max 100. Cannot be combined with
801        /// [`Dimension::Broadcast`] or a broadcast ID.
802        #[inline]
803        pub fn with_email_id(mut self, email_id: &str) -> GetEmailMetricsOptions<EmailFilter> {
804            self.email_ids.push(EmailId::new(email_id));
805
806            GetEmailMetricsOptions::<EmailFilter> {
807                _state: std::marker::PhantomData,
808                start_date: self.start_date,
809                end_date: self.end_date,
810                timezone: self.timezone,
811                granularity: self.granularity,
812                metrics: self.metrics,
813                dimensions: self.dimensions,
814                domain_ids: self.domain_ids,
815                email_ids: self.email_ids,
816                broadcast_ids: self.broadcast_ids,
817            }
818        }
819
820        /// Restricts results to additional email IDs. Max 100. Cannot be combined with
821        /// [`Dimension::Broadcast`] or a broadcast ID.
822        #[inline]
823        pub fn with_email_ids<T: AsRef<str>>(
824            mut self,
825            email_ids: impl IntoIterator<Item = T>,
826        ) -> GetEmailMetricsOptions<EmailFilter> {
827            self.email_ids
828                .extend(email_ids.into_iter().map(|id| EmailId::new(id.as_ref())));
829
830            GetEmailMetricsOptions::<EmailFilter> {
831                _state: std::marker::PhantomData,
832                start_date: self.start_date,
833                end_date: self.end_date,
834                timezone: self.timezone,
835                granularity: self.granularity,
836                metrics: self.metrics,
837                dimensions: self.dimensions,
838                domain_ids: self.domain_ids,
839                email_ids: self.email_ids,
840                broadcast_ids: self.broadcast_ids,
841            }
842        }
843
844        /// Adds the broadcast dimension to break results down by. If none are added, only `totals` is
845        /// returned.
846        #[inline]
847        pub fn with_broadcast_dimension(mut self) -> GetEmailMetricsOptions<BroadcastFilter> {
848            self.dimensions.push(Dimension::Broadcast);
849
850            GetEmailMetricsOptions::<BroadcastFilter> {
851                _state: std::marker::PhantomData,
852                start_date: self.start_date,
853                end_date: self.end_date,
854                timezone: self.timezone,
855                granularity: self.granularity,
856                metrics: self.metrics,
857                dimensions: self.dimensions,
858                domain_ids: self.domain_ids,
859                email_ids: self.email_ids,
860                broadcast_ids: self.broadcast_ids,
861            }
862        }
863
864        /// Restricts results to an additional broadcast ID. Max 100. Cannot be combined with
865        /// [`Dimension::Email`] or an email ID.
866        #[inline]
867        pub fn with_broadcast_id(
868            mut self,
869            broadcast_id: &str,
870        ) -> GetEmailMetricsOptions<BroadcastFilter> {
871            self.broadcast_ids.push(BroadcastId::new(broadcast_id));
872
873            GetEmailMetricsOptions::<BroadcastFilter> {
874                _state: std::marker::PhantomData,
875                start_date: self.start_date,
876                end_date: self.end_date,
877                timezone: self.timezone,
878                granularity: self.granularity,
879                metrics: self.metrics,
880                dimensions: self.dimensions,
881                domain_ids: self.domain_ids,
882                email_ids: self.email_ids,
883                broadcast_ids: self.broadcast_ids,
884            }
885        }
886
887        /// Restricts results to additional broadcast IDs. Max 100. Cannot be combined with
888        /// [`Dimension::Email`] or an email ID.
889        #[inline]
890        pub fn with_broadcast_ids<T: AsRef<str>>(
891            mut self,
892            broadcast_ids: impl IntoIterator<Item = T>,
893        ) -> GetEmailMetricsOptions<BroadcastFilter> {
894            self.broadcast_ids.extend(
895                broadcast_ids
896                    .into_iter()
897                    .map(|id| BroadcastId::new(id.as_ref())),
898            );
899
900            GetEmailMetricsOptions::<BroadcastFilter> {
901                _state: std::marker::PhantomData,
902                start_date: self.start_date,
903                end_date: self.end_date,
904                timezone: self.timezone,
905                granularity: self.granularity,
906                metrics: self.metrics,
907                dimensions: self.dimensions,
908                domain_ids: self.domain_ids,
909                email_ids: self.email_ids,
910                broadcast_ids: self.broadcast_ids,
911            }
912        }
913    }
914
915    impl<T> GetEmailMetricsOptions<T> {
916        /// Sets the start of the date range (ISO 8601 date or datetime).
917        #[inline]
918        pub fn with_start_date(mut self, start_date: &str) -> Self {
919            self.start_date = Some(start_date.to_owned());
920            self
921        }
922
923        /// Sets the end of the date range (ISO 8601 date or datetime).
924        #[inline]
925        pub fn with_end_date(mut self, end_date: &str) -> Self {
926            self.end_date = Some(end_date.to_owned());
927            self
928        }
929
930        /// Sets the IANA timezone, e.g. `America/New_York`. Defaults to `UTC`.
931        #[inline]
932        pub fn with_timezone(mut self, timezone: &str) -> Self {
933            self.timezone = Some(timezone.to_owned());
934            self
935        }
936
937        /// Sets the bucket size used when [`Dimension::Period`] is requested. Defaults to
938        /// [`MetricsGranularity::Daily`].
939        #[inline]
940        pub fn with_granularity(mut self, granularity: MetricsGranularity) -> Self {
941            self.granularity = Some(granularity);
942            self
943        }
944
945        /// Adds a metric to compute. If none are added, all metrics are computed.
946        #[inline]
947        pub fn with_metric(mut self, metric: Metric) -> Self {
948            self.metrics.push(metric);
949            self
950        }
951
952        /// Adds multiple metrics to compute. If none are added, all metrics are computed.
953        #[inline]
954        pub fn with_metrics(mut self, metrics: impl IntoIterator<Item = Metric>) -> Self {
955            self.metrics.extend(metrics);
956            self
957        }
958
959        /// Restricts results to an additional sending domain ID. Max 100.
960        #[inline]
961        pub fn with_domain_id(mut self, domain_id: &str) -> Self {
962            self.domain_ids.push(DomainId::new(domain_id));
963            self
964        }
965
966        /// Restricts results to additional sending domain IDs. Max 100.
967        #[inline]
968        pub fn with_domain_ids<K: AsRef<str>>(
969            mut self,
970            domain_ids: impl IntoIterator<Item = K>,
971        ) -> Self {
972            self.domain_ids
973                .extend(domain_ids.into_iter().map(|id| DomainId::new(id.as_ref())));
974            self
975        }
976
977        #[inline]
978        pub fn with_period_dimension(mut self) -> Self {
979            self.dimensions.push(Dimension::Period);
980            self
981        }
982
983        #[inline]
984        pub fn with_domain_dimension(mut self) -> Self {
985            self.dimensions.push(Dimension::Domain);
986            self
987        }
988    }
989
990    impl GetEmailMetricsOptions<EmailFilter> {
991        /// Adds the email dimension to break results down by. If none are added, only `totals` is
992        /// returned.
993        #[inline]
994        pub fn with_email_dimension(mut self) -> Self {
995            self.dimensions.push(Dimension::Email);
996            self
997        }
998
999        /// Restricts results to an additional email ID. Max 100. Cannot be combined with
1000        /// [`Dimension::Broadcast`] or a broadcast ID.
1001        #[inline]
1002        pub fn with_email_id(mut self, email_id: &str) -> Self {
1003            self.email_ids.push(EmailId::new(email_id));
1004            self
1005        }
1006
1007        /// Restricts results to additional email IDs. Max 100. Cannot be combined with
1008        /// [`Dimension::Broadcast`] or a broadcast ID.
1009        #[inline]
1010        pub fn with_email_ids<T: AsRef<str>>(
1011            mut self,
1012            email_ids: impl IntoIterator<Item = T>,
1013        ) -> Self {
1014            self.email_ids
1015                .extend(email_ids.into_iter().map(|id| EmailId::new(id.as_ref())));
1016            self
1017        }
1018    }
1019
1020    impl GetEmailMetricsOptions<BroadcastFilter> {
1021        /// Adds the broadcast dimension to break results down by. If none are added, only `totals` is
1022        /// returned.
1023        #[inline]
1024        pub fn with_broadcast_dimension(mut self) -> Self {
1025            self.dimensions.push(Dimension::Broadcast);
1026            self
1027        }
1028
1029        /// Restricts results to an additional broadcast ID. Max 100. Cannot be combined with
1030        /// [`Dimension::Email`] or an email ID.
1031        #[inline]
1032        pub fn with_broadcast_id(mut self, broadcast_id: &str) -> Self {
1033            self.broadcast_ids.push(BroadcastId::new(broadcast_id));
1034            self
1035        }
1036
1037        /// Restricts results to additional broadcast IDs. Max 100. Cannot be combined with
1038        /// [`Dimension::Email`] or an email ID.
1039        #[inline]
1040        pub fn with_broadcast_ids<T: AsRef<str>>(
1041            mut self,
1042            broadcast_ids: impl IntoIterator<Item = T>,
1043        ) -> Self {
1044            self.broadcast_ids.extend(
1045                broadcast_ids
1046                    .into_iter()
1047                    .map(|id| BroadcastId::new(id.as_ref())),
1048            );
1049            self
1050        }
1051    }
1052
1053    /// A metric available on the `/emails/metrics` endpoint.
1054    ///
1055    /// <https://resend.com/docs/api-reference/emails/metrics>
1056    #[must_use]
1057    #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1058    #[serde(rename_all = "snake_case")]
1059    pub enum Metric {
1060        Received,
1061        Delivered,
1062        Complained,
1063        Suppressed,
1064        Bounced,
1065        BouncedTransient,
1066        BouncedPermanent,
1067        BouncedUndetermined,
1068        Opened,
1069        Clicked,
1070        Unsubscribed,
1071        DeliveryDelayed,
1072        Failed,
1073        Sent,
1074        UniqueOpened,
1075        UniqueClicked,
1076        DeliveryRate,
1077        OpenRate,
1078        ClickRate,
1079        BounceRate,
1080        ComplaintRate,
1081        UnsubscribeRate,
1082    }
1083
1084    impl Metric {
1085        /// The wire representation of this [`Metric`], as used both in the `metrics` query
1086        /// parameter and in JSON responses.
1087        #[must_use]
1088        pub const fn as_str(self) -> &'static str {
1089            match self {
1090                Self::Received => "received",
1091                Self::Delivered => "delivered",
1092                Self::Complained => "complained",
1093                Self::Suppressed => "suppressed",
1094                Self::Bounced => "bounced",
1095                Self::BouncedTransient => "bounced_transient",
1096                Self::BouncedPermanent => "bounced_permanent",
1097                Self::BouncedUndetermined => "bounced_undetermined",
1098                Self::Opened => "opened",
1099                Self::Clicked => "clicked",
1100                Self::Unsubscribed => "unsubscribed",
1101                Self::DeliveryDelayed => "delivery_delayed",
1102                Self::Failed => "failed",
1103                Self::Sent => "sent",
1104                Self::UniqueOpened => "unique_opened",
1105                Self::UniqueClicked => "unique_clicked",
1106                Self::DeliveryRate => "delivery_rate",
1107                Self::OpenRate => "open_rate",
1108                Self::ClickRate => "click_rate",
1109                Self::BounceRate => "bounce_rate",
1110                Self::ComplaintRate => "complaint_rate",
1111                Self::UnsubscribeRate => "unsubscribe_rate",
1112            }
1113        }
1114    }
1115
1116    impl AsRef<str> for Metric {
1117        #[inline]
1118        fn as_ref(&self) -> &str {
1119            self.as_str()
1120        }
1121    }
1122
1123    impl Serialize for Metric {
1124        fn serialize<S>(&self, serializer: S) -> crate::Result<S::Ok, S::Error>
1125        where
1126            S: serde::Serializer,
1127        {
1128            serializer.serialize_str(self.as_str())
1129        }
1130    }
1131
1132    /// A dimension to break `/emails/metrics` results down by.
1133    ///
1134    /// Note that `email` cannot be combined with `broadcast`; [`crate::emails::EmailsSvc::metrics`]
1135    /// returns an error before sending the request if they are.
1136    ///
1137    /// <https://resend.com/docs/api-reference/emails/metrics>
1138    #[must_use]
1139    #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1140    #[serde(rename_all = "snake_case")]
1141    pub enum Dimension {
1142        Period,
1143        Domain,
1144        Email,
1145        Broadcast,
1146    }
1147
1148    impl Dimension {
1149        /// The wire representation of this [`Dimension`], as used both in the `dimensions`
1150        /// query parameter and in JSON responses.
1151        #[must_use]
1152        pub const fn as_str(self) -> &'static str {
1153            match self {
1154                Self::Period => "period",
1155                Self::Domain => "domain",
1156                Self::Email => "email",
1157                Self::Broadcast => "broadcast",
1158            }
1159        }
1160    }
1161
1162    impl AsRef<str> for Dimension {
1163        #[inline]
1164        fn as_ref(&self) -> &str {
1165            self.as_str()
1166        }
1167    }
1168
1169    impl Serialize for Dimension {
1170        fn serialize<S>(&self, serializer: S) -> crate::Result<S::Ok, S::Error>
1171        where
1172            S: serde::Serializer,
1173        {
1174            serializer.serialize_str(self.as_str())
1175        }
1176    }
1177
1178    /// Bucket size used when [`Dimension::Period`] is requested.
1179    #[must_use]
1180    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1181    #[serde(rename_all = "snake_case")]
1182    pub enum MetricsGranularity {
1183        Hourly,
1184        Daily,
1185        Weekly,
1186        Monthly,
1187    }
1188
1189    /// Response of [`crate::emails::EmailsSvc::metrics`].
1190    #[must_use]
1191    #[derive(Debug, Clone, Serialize, Deserialize)]
1192    pub struct EmailMetrics {
1193        /// Start of the date range these metrics cover.
1194        pub start_date: String,
1195        /// End of the date range these metrics cover.
1196        pub end_date: String,
1197        /// The metrics that were computed.
1198        pub metrics: Vec<Metric>,
1199        /// The dimensions results are broken down by.
1200        pub dimensions: Vec<Dimension>,
1201        /// The bucket size used for the [`Dimension::Period`] dimension.
1202        pub granularity: MetricsGranularity,
1203        /// Totals across the whole date range, keyed by metric name.
1204        pub totals: HashMap<String, f64>,
1205        /// Per-dimension breakdown. Absent when no `dimensions` were requested.
1206        #[serde(skip_serializing_if = "Option::is_none")]
1207        pub data: Option<Vec<EmailMetricsDataPoint>>,
1208    }
1209
1210    /// A single row of [`EmailMetrics::data`].
1211    ///
1212    /// Which dimension fields are populated depends on the requested [`Dimension`]s, and which
1213    /// metric fields are present (in [`EmailMetricsDataPoint::metrics`]) depends on the
1214    /// requested [`Metric`]s.
1215    #[must_use]
1216    #[derive(Debug, Clone, Serialize, Deserialize)]
1217    pub struct EmailMetricsDataPoint {
1218        /// Present when [`Dimension::Period`] was requested.
1219        #[serde(skip_serializing_if = "Option::is_none")]
1220        pub period: Option<String>,
1221        /// Present when [`Dimension::Domain`] was requested.
1222        #[serde(skip_serializing_if = "Option::is_none")]
1223        pub domain_id: Option<DomainId>,
1224        /// Present when [`Dimension::Domain`] was requested.
1225        #[serde(skip_serializing_if = "Option::is_none")]
1226        pub domain_name: Option<String>,
1227        /// Present when [`Dimension::Email`] was requested.
1228        #[serde(skip_serializing_if = "Option::is_none")]
1229        pub email_id: Option<EmailId>,
1230        /// Present when [`Dimension::Broadcast`] was requested.
1231        #[serde(skip_serializing_if = "Option::is_none")]
1232        pub broadcast_id: Option<BroadcastId>,
1233        /// Present when [`Dimension::Broadcast`] was requested.
1234        #[serde(skip_serializing_if = "Option::is_none")]
1235        pub broadcast_name: Option<String>,
1236        /// The requested metric values for this row, keyed by metric name.
1237        #[serde(flatten)]
1238        pub metrics: HashMap<String, f64>,
1239    }
1240}
1241
1242/// Turns:
1243/// - `null` -> `[]`
1244/// - `["text"]` -> `["text"]`
1245fn parse_nullable_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
1246where
1247    D: Deserializer<'de>,
1248{
1249    let opt = Option::deserialize(deserializer)?;
1250    Ok(opt.unwrap_or_else(Vec::new))
1251}
1252
1253/// Serializes a slice as a single comma-joined string, as expected by query parameters like
1254/// `metrics` or `dimensions` on the `/emails/metrics` endpoint.
1255fn join_comma<S, T>(items: &[T], serializer: S) -> Result<S::Ok, S::Error>
1256where
1257    S: serde::Serializer,
1258    T: AsRef<str>,
1259{
1260    let joined = items
1261        .iter()
1262        .map(AsRef::as_ref)
1263        .collect::<Vec<_>>()
1264        .join(",");
1265    serializer.serialize_str(&joined)
1266}
1267
1268#[cfg(test)]
1269#[allow(clippy::unwrap_used)]
1270#[allow(clippy::needless_return)]
1271mod test {
1272    #[cfg(not(feature = "blocking"))]
1273    use crate::{
1274        list_opts::ListOptions,
1275        types::{
1276            CreateAttachment, CreateTemplateOptions, EmailTemplate, ShareEmailOptions,
1277            UpdateEmailOptions, Variable, VariableType,
1278        },
1279    };
1280    use crate::{
1281        test::{CLIENT, DebugResult},
1282        types::{CreateEmailBaseOptions, Email, ShareEmailResponse, Tag},
1283    };
1284    #[cfg(not(feature = "blocking"))]
1285    use jiff::{Span, Timestamp, Zoned};
1286
1287    use std::collections::HashMap;
1288
1289    use crate::{
1290        Config,
1291        types::{Dimension, EmailMetrics, GetEmailMetricsOptions, Metric, MetricsGranularity},
1292    };
1293
1294    /// Builds the request `metrics()` would send and returns its decoded query parameters,
1295    /// without making any network calls.
1296    fn built_query<T>(opts: &GetEmailMetricsOptions<T>) -> HashMap<String, String> {
1297        let config = Config::builder("re_test_key").build();
1298        let request = config
1299            .build(reqwest::Method::GET, "/emails/metrics")
1300            .query(opts)
1301            .build()
1302            .unwrap();
1303
1304        request
1305            .url()
1306            .query_pairs()
1307            .map(|(k, v)| (k.into_owned(), v.into_owned()))
1308            .collect()
1309    }
1310
1311    #[tokio_shared_rt::test(shared = true)]
1312    #[serial_test::serial]
1313    #[cfg(not(feature = "blocking"))]
1314    async fn all() -> DebugResult<()> {
1315        let from = "Acme <onboarding@resend.dev>";
1316        let to = ["delivered@resend.dev"];
1317        let subject = "Hello World!";
1318
1319        let resend = &*CLIENT;
1320
1321        // Create
1322        #[allow(clippy::string_lit_as_bytes)] // False warning
1323        let email = CreateEmailBaseOptions::new(from, to, subject)
1324            .with_text("Hello World!")
1325            .with_attachment("Hello World as file.".as_bytes())
1326            .with_tag(Tag::new("category", "confirm_email"));
1327
1328        let email = resend.emails.send(email).await?;
1329
1330        std::thread::sleep(std::time::Duration::from_secs(1));
1331
1332        // Get
1333        let _email = resend.emails.get(&email.id).await?;
1334
1335        Ok(())
1336    }
1337
1338    #[test]
1339    fn deserialize_test() {
1340        let email = r#"{
1341            "object": "email",
1342            "id": "6757a66c-3a5b-49ee-98cc-fca7a5f423c0",
1343            "message_id": "<111-222-333@email.example.com>",
1344            "to": [
1345                "email@gmail.com"
1346            ],
1347            "from": "email@gmail.com>",
1348            "created_at": "2024-07-11 07:49:53.682607+00",
1349            "subject": "Subject",
1350            "bcc": null,
1351            "cc": null,
1352            "reply_to": null,
1353            "last_event": "delivery_delayed",
1354            "html": "<div></div>",
1355            "text": null,
1356            "scheduled_at": null
1357        }"#;
1358
1359        let res = serde_json::from_str::<Email>(email);
1360        assert!(res.is_ok());
1361        let res = res.unwrap();
1362        assert_eq!(res.message_id.unwrap(), "<111-222-333@email.example.com>");
1363        assert!(res.cc.is_empty());
1364        assert!(res.bcc.is_empty());
1365        assert!(res.text.is_none());
1366
1367        let email = r#"{
1368            "object": "email",
1369            "id": "6757a66c-3a5b-49ee-98cc-fca7a5f423c0",
1370            "message_id": "<111-222-333@email.example.com>",
1371            "to": [
1372                "email@gmail.com"
1373            ],
1374            "from": "email@gmail.com>",
1375            "created_at": "2024-07-11 07:49:53.682607+00",
1376            "subject": "Subject",
1377            "bcc": ["hello", "world"],
1378            "cc": ["!"],
1379            "reply_to": null,
1380            "last_event": "delivered",
1381            "html": "<div></div>",
1382            "text": "Not null",
1383            "scheduled_at": "2024-08-07 15:15:37+00"
1384        }"#;
1385
1386        let res = serde_json::from_str::<Email>(email);
1387        assert!(res.is_ok());
1388        let res = res.unwrap();
1389        assert!(!res.cc.is_empty());
1390        assert!(!res.bcc.is_empty());
1391        assert!(res.text.is_some());
1392    }
1393
1394    #[test]
1395    fn parse_share_email_response_test() {
1396        let data = r#"{
1397            "object": "email",
1398            "id": "6757a66c-3a5b-49ee-98cc-fca7a5f423c0",
1399            "url": "https://resend.com/share/6757a66c-3a5b-49ee-98cc-fca7a5f423c0"
1400        }"#;
1401
1402        let _parsed = serde_json::from_str::<ShareEmailResponse>(data).expect("Parsing failed");
1403    }
1404
1405    #[test]
1406    #[cfg(feature = "blocking")]
1407    fn all_blocking() -> DebugResult<()> {
1408        let from = "Acme <onboarding@resend.dev>";
1409        let to = ["delivered@resend.dev"];
1410        let subject = "Hello World!";
1411
1412        let resend = &*CLIENT;
1413        let email = CreateEmailBaseOptions::new(from, to, subject)
1414            .with_text("Hello World!")
1415            .with_tag(Tag::new("category", "confirm_email"));
1416
1417        let _email = resend.emails.send(email)?;
1418
1419        std::thread::sleep(std::time::Duration::from_millis(1100));
1420
1421        Ok(())
1422    }
1423
1424    #[tokio_shared_rt::test(shared = true)]
1425    #[serial_test::serial]
1426    #[cfg(not(feature = "blocking"))]
1427    async fn schedule_email() -> DebugResult<()> {
1428        use crate::emails::types::EmailEvent;
1429
1430        let now_plus_1h = Zoned::now()
1431            .checked_add(Span::new().hours(1))
1432            .expect("Valid date")
1433            .timestamp()
1434            .to_string();
1435
1436        let now_plus_2h = Zoned::now()
1437            .checked_add(Span::new().hours(2))
1438            .expect("Valid date")
1439            .timestamp()
1440            .to_string();
1441
1442        let from = "Acme <onboarding@resend.dev>";
1443        let to = ["delivered@resend.dev"];
1444        let subject = "Hello World!";
1445
1446        let resend = &*CLIENT;
1447
1448        // Create
1449        let email = CreateEmailBaseOptions::new(from, to, subject)
1450            .with_text("Hello World!")
1451            .with_scheduled_at(&now_plus_1h);
1452        let email = resend.emails.send(email).await?;
1453        std::thread::sleep(std::time::Duration::from_secs(5));
1454
1455        // Get
1456        let email = resend.emails.get(&email.id).await?;
1457        assert_eq!(email.last_event, EmailEvent::Scheduled);
1458        assert!(email.scheduled_at.is_some());
1459        let time = email
1460            .scheduled_at
1461            .unwrap()
1462            .parse::<Timestamp>()
1463            .expect("Valid timestamp");
1464        let time_delta = (time - Timestamp::now()).round(jiff::Unit::Hour).unwrap();
1465        assert_eq!(
1466            time_delta.compare(Span::new().hours(1)).unwrap(),
1467            std::cmp::Ordering::Equal
1468        );
1469
1470        // Update
1471        let changes = UpdateEmailOptions::new().with_scheduled_at(&now_plus_2h);
1472        let email = resend.emails.update(&email.id, changes).await?;
1473        std::thread::sleep(std::time::Duration::from_secs(1));
1474
1475        // Get
1476        let email = resend.emails.get(&email.id).await?;
1477        assert_eq!(email.last_event, EmailEvent::Scheduled);
1478        assert!(email.scheduled_at.is_some());
1479        let time = email
1480            .scheduled_at
1481            .unwrap()
1482            .parse::<Timestamp>()
1483            .expect("Valid timestamp");
1484        let time_delta = (time - Timestamp::now()).round(jiff::Unit::Hour).unwrap();
1485        assert_eq!(
1486            time_delta.compare(Span::new().hours(2)).unwrap(),
1487            std::cmp::Ordering::Equal
1488        );
1489
1490        // Cancel
1491        let _cancelled = resend.emails.cancel(&email.id).await?;
1492        std::thread::sleep(std::time::Duration::from_secs(1));
1493
1494        // Get again, make sure it was cancelled
1495        let email = resend.emails.get(&email.id).await?;
1496        assert_eq!(email.last_event, EmailEvent::Canceled);
1497
1498        Ok(())
1499    }
1500
1501    #[tokio_shared_rt::test(shared = true)]
1502    #[serial_test::serial]
1503    #[cfg(not(feature = "blocking"))]
1504    async fn share_email() -> DebugResult<()> {
1505        let from = "Acme <onboarding@resend.dev>";
1506        let to = ["delivered@resend.dev"];
1507        let subject = "Hello World!";
1508
1509        let resend = &*CLIENT;
1510
1511        let email = CreateEmailBaseOptions::new(from, to, subject).with_text("Hello World!");
1512        let email = resend.emails.send(email).await?;
1513        std::thread::sleep(std::time::Duration::from_secs(1));
1514
1515        let shared = resend
1516            .emails
1517            .share(&email.id, ShareEmailOptions::new())
1518            .await?;
1519        assert_eq!(shared.id, email.id);
1520        assert!(!shared.url.is_empty());
1521        std::thread::sleep(std::time::Duration::from_secs(1));
1522
1523        let shared = resend
1524            .emails
1525            .share(&email.id, ShareEmailOptions::new().with_expires_in("10m"))
1526            .await?;
1527        assert_eq!(shared.id, email.id);
1528        assert!(!shared.url.is_empty());
1529        std::thread::sleep(std::time::Duration::from_secs(1));
1530
1531        let shared = resend
1532            .emails
1533            .share(&email.id, ShareEmailOptions::new().with_expires_in("72h"))
1534            .await;
1535        assert!(shared.is_err());
1536        std::thread::sleep(std::time::Duration::from_secs(1));
1537
1538        let shared = resend
1539            .emails
1540            .share(
1541                &email.id,
1542                ShareEmailOptions::new().with_expires_in("not-a-duration"),
1543            )
1544            .await;
1545        assert!(matches!(shared, Err(crate::Error::Resend(_))));
1546        std::thread::sleep(std::time::Duration::from_secs(1));
1547
1548        let shared = resend
1549            .emails
1550            .share(
1551                "00000000-0000-0000-0000-000000000000",
1552                ShareEmailOptions::new(),
1553            )
1554            .await;
1555        assert!(matches!(shared, Err(crate::Error::Resend(_))));
1556
1557        Ok(())
1558    }
1559
1560    #[tokio_shared_rt::test(shared = true)]
1561    #[serial_test::serial]
1562    #[cfg(not(feature = "blocking"))]
1563    async fn list_emails() -> DebugResult<()> {
1564        let resend = &*CLIENT;
1565        std::thread::sleep(std::time::Duration::from_secs(1));
1566
1567        let list_opts = ListOptions::default()
1568            .with_limit(3)
1569            .list_before("71f170f3-826e-47e3-9128-a5958e3b375e");
1570
1571        let list = resend.emails.list(list_opts).await?;
1572
1573        // There now should be more
1574        assert!(list.has_more);
1575        // There should be 3 emails due to the limit we set
1576        assert_eq!(list.data.len(), 3);
1577
1578        Ok(())
1579    }
1580
1581    #[tokio_shared_rt::test(shared = true)]
1582    #[serial_test::serial]
1583    #[cfg(not(feature = "blocking"))]
1584    async fn attachments() -> DebugResult<()> {
1585        let resend = &*CLIENT;
1586        std::thread::sleep(std::time::Duration::from_secs(1));
1587
1588        let attachment = CreateAttachment::from_content(include_bytes!("../README.md").to_vec())
1589            .with_filename("README.md");
1590
1591        let from = "Acme <onboarding@resend.dev>";
1592        let to = ["delivered@resend.dev"];
1593        let subject = "Hello World!";
1594
1595        let email = CreateEmailBaseOptions::new(from, to, subject)
1596            .with_attachment(attachment)
1597            .with_text("Hello World!");
1598
1599        let email = resend.emails.send(email).await?;
1600        let email_id = &email.id;
1601        std::thread::sleep(std::time::Duration::from_secs(1));
1602
1603        let attachments = resend
1604            .emails
1605            .list_attachments(email_id, ListOptions::default())
1606            .await?;
1607        assert_eq!(attachments.data.len(), 1);
1608        let attachment_id = &attachments.data.first().unwrap().id;
1609
1610        let _attachment = resend
1611            .emails
1612            .get_attachment(email_id, attachment_id)
1613            .await?;
1614
1615        Ok(())
1616    }
1617
1618    #[tokio_shared_rt::test(shared = true)]
1619    #[serial_test::serial]
1620    #[cfg(not(feature = "blocking"))]
1621    async fn template() -> DebugResult<()> {
1622        use std::collections::HashMap;
1623
1624        let resend = &*CLIENT;
1625        std::thread::sleep(std::time::Duration::from_secs(1));
1626
1627        // Create template
1628        let name = "welcome-email";
1629        let html = "<strong>Hey, {{{NAME}}}, you are {{{AGE}}} years old.</strong>";
1630        let variables = [
1631            Variable::new("NAME", VariableType::String).with_fallback("user"),
1632            Variable::new("AGE", VariableType::Number).with_fallback(25),
1633            Variable::new("OPTIONAL_VARIABLE", VariableType::String).with_fallback(None::<String>),
1634        ];
1635        let opts = CreateTemplateOptions::new(name, html).with_variables(&variables);
1636        let template = resend.templates.create(opts).await?;
1637        std::thread::sleep(std::time::Duration::from_secs(2));
1638        let template = resend.templates.publish(&template.id).await?;
1639        std::thread::sleep(std::time::Duration::from_secs(2));
1640        let mut variables = HashMap::<String, serde_json::Value>::new();
1641        let _added = variables.insert("NAME".to_string(), serde_json::json!("Tony"));
1642        let _added = variables.insert("AGE".to_string(), serde_json::json!(25));
1643
1644        let template = EmailTemplate::new(&template.id).with_variables(variables);
1645        let template_id = &template.id.clone();
1646
1647        // Create email
1648        let from = "Acme <onboarding@resend.dev>";
1649        let to = ["delivered@resend.dev"];
1650        let subject = "hello world";
1651
1652        let email = CreateEmailBaseOptions::new(from, to, subject).with_template(template);
1653
1654        let _email = resend.emails.send(email).await?;
1655        std::thread::sleep(std::time::Duration::from_secs(2));
1656
1657        // Delete template
1658        let deleted = resend.templates.delete(template_id).await?;
1659        assert!(deleted.deleted);
1660
1661        Ok(())
1662    }
1663
1664    #[test]
1665    fn metrics_query_no_options() {
1666        let query = built_query(&GetEmailMetricsOptions::default());
1667        assert!(query.is_empty());
1668    }
1669
1670    #[test]
1671    fn metrics_query_multiple_dimensions() {
1672        let opts = GetEmailMetricsOptions::default()
1673            .with_period_dimension()
1674            .with_broadcast_dimension();
1675        let query = built_query(&opts);
1676        assert_eq!(
1677            query.get("dimensions").map(String::as_str),
1678            Some("period,broadcast")
1679        );
1680    }
1681
1682    #[test]
1683    fn metrics_query_domain_id_filter() {
1684        let single = GetEmailMetricsOptions::default().with_domain_id("d1");
1685        let query = built_query(&single);
1686        assert_eq!(query.get("domain_id").map(String::as_str), Some("d1"));
1687
1688        let multiple = GetEmailMetricsOptions::default().with_domain_ids(["d1", "d2", "d3"]);
1689        let query = built_query(&multiple);
1690        assert_eq!(query.get("domain_id").map(String::as_str), Some("d1,d2,d3"));
1691    }
1692
1693    #[test]
1694    fn metrics_query_email_id_filter() {
1695        let single = GetEmailMetricsOptions::default().with_email_id("e1");
1696        let query = built_query(&single);
1697        assert_eq!(query.get("email_id").map(String::as_str), Some("e1"));
1698
1699        let multiple = GetEmailMetricsOptions::default().with_email_ids(["e1", "e2"]);
1700        let query = built_query(&multiple);
1701        assert_eq!(query.get("email_id").map(String::as_str), Some("e1,e2"));
1702    }
1703
1704    #[test]
1705    fn metrics_query_broadcast_id_filter() {
1706        let single = GetEmailMetricsOptions::default().with_broadcast_id("b1");
1707        let query = built_query(&single);
1708        assert_eq!(query.get("broadcast_id").map(String::as_str), Some("b1"));
1709
1710        let multiple = GetEmailMetricsOptions::default().with_broadcast_ids(["b1", "b2"]);
1711        let query = built_query(&multiple);
1712        assert_eq!(query.get("broadcast_id").map(String::as_str), Some("b1,b2"));
1713    }
1714
1715    #[test]
1716    fn metrics_query_metrics_passed_through() {
1717        let single = GetEmailMetricsOptions::default().with_metric(Metric::Delivered);
1718        let query = built_query(&single);
1719        assert_eq!(query.get("metrics").map(String::as_str), Some("delivered"));
1720
1721        let multiple =
1722            GetEmailMetricsOptions::default().with_metrics([Metric::Delivered, Metric::Opened]);
1723        let query = built_query(&multiple);
1724        assert_eq!(
1725            query.get("metrics").map(String::as_str),
1726            Some("delivered,opened")
1727        );
1728    }
1729
1730    #[test]
1731    fn metrics_query_granularity_and_timezone_passed_through() {
1732        let opts = GetEmailMetricsOptions::default()
1733            .with_start_date("2026-07-01")
1734            .with_end_date("2026-07-08")
1735            .with_timezone("America/New_York")
1736            .with_granularity(MetricsGranularity::Weekly);
1737
1738        let query = built_query(&opts);
1739        assert_eq!(
1740            query.get("start_date").map(String::as_str),
1741            Some("2026-07-01")
1742        );
1743        assert_eq!(
1744            query.get("end_date").map(String::as_str),
1745            Some("2026-07-08")
1746        );
1747        assert_eq!(
1748            query.get("timezone").map(String::as_str),
1749            Some("America/New_York")
1750        );
1751        assert_eq!(query.get("granularity").map(String::as_str), Some("weekly"));
1752    }
1753
1754    #[test]
1755    fn deserialize_metrics_response_with_data() {
1756        let json = r#"{
1757            "object": "metrics",
1758            "start_date": "2026-07-01T00:00:00.000Z",
1759            "end_date": "2026-07-08T00:00:00.000Z",
1760            "metrics": ["delivered", "opened"],
1761            "dimensions": ["period", "broadcast"],
1762            "granularity": "daily",
1763            "totals": { "delivered": 100, "opened": 40 },
1764            "data": [
1765                {
1766                    "period": "2026-07-01",
1767                    "broadcast_id": "5c9c5f21-3b3a-4f0a-8f6b-3f2d1e6f6c9a",
1768                    "broadcast_name": "July Newsletter",
1769                    "delivered": 10,
1770                    "opened": 4
1771                }
1772            ]
1773        }"#;
1774
1775        let metrics: EmailMetrics = serde_json::from_str(json).unwrap();
1776
1777        assert_eq!(metrics.start_date, "2026-07-01T00:00:00.000Z");
1778        assert_eq!(metrics.end_date, "2026-07-08T00:00:00.000Z");
1779        assert_eq!(metrics.metrics, vec![Metric::Delivered, Metric::Opened]);
1780        assert_eq!(
1781            metrics.dimensions,
1782            vec![Dimension::Period, Dimension::Broadcast]
1783        );
1784        assert_eq!(metrics.granularity, MetricsGranularity::Daily);
1785        assert_eq!(metrics.totals.get("delivered"), Some(&100.0));
1786        assert_eq!(metrics.totals.get("opened"), Some(&40.0));
1787
1788        let data = metrics
1789            .data
1790            .expect("data present when dimensions requested");
1791        assert_eq!(data.len(), 1);
1792
1793        let row = data.first().expect("one data row");
1794        assert_eq!(row.period.as_deref(), Some("2026-07-01"));
1795        assert!(row.domain_id.is_none());
1796        assert!(row.email_id.is_none());
1797        assert_eq!(
1798            row.broadcast_id.as_deref(),
1799            Some("5c9c5f21-3b3a-4f0a-8f6b-3f2d1e6f6c9a")
1800        );
1801        assert_eq!(row.broadcast_name.as_deref(), Some("July Newsletter"));
1802        assert_eq!(row.metrics.get("delivered"), Some(&10.0));
1803        assert_eq!(row.metrics.get("opened"), Some(&4.0));
1804    }
1805
1806    #[test]
1807    fn deserialize_metrics_response_without_dimensions() {
1808        let json = r#"{
1809            "object": "metrics",
1810            "start_date": "2026-07-01T00:00:00.000Z",
1811            "end_date": "2026-07-08T00:00:00.000Z",
1812            "metrics": ["delivered"],
1813            "dimensions": [],
1814            "granularity": "daily",
1815            "totals": { "delivered": 100 }
1816        }"#;
1817
1818        let metrics: EmailMetrics = serde_json::from_str(json).unwrap();
1819
1820        assert!(metrics.dimensions.is_empty());
1821        assert!(metrics.data.is_none());
1822    }
1823}