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#[derive(Clone, Debug)]
22pub struct EmailsSvc(pub(crate) Arc<Config>);
23
24impl EmailsSvc {
25 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
188 #[derive(Debug, Clone, Serialize)]
189 pub struct CreateEmailBaseOptions {
190 from: String,
196 to: Vec<String>,
198 subject: String,
200
201 #[serde(skip_serializing_if = "Option::is_none")]
203 html: Option<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 text: Option<String>,
207
208 #[serde(skip_serializing_if = "Option::is_none")]
210 bcc: Option<Vec<String>>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 cc: Option<Vec<String>>,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 reply_to: Option<Vec<String>>,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 headers: Option<HashMap<String, String>>,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 attachments: Option<Vec<CreateAttachment>>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 tags: Option<Vec<Tag>>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 template: Option<EmailTemplate>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 topic_id: Option<TopicId>,
232
233 #[serde(skip_serializing_if = "Option::is_none")]
236 scheduled_at: Option<String>,
237 }
238
239 impl CreateEmailBaseOptions {
240 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 #[inline]
274 pub fn with_html(mut self, html: &str) -> Self {
275 self.html = Some(html.to_owned());
276 self
277 }
278
279 #[inline]
281 pub fn with_text(mut self, text: &str) -> Self {
282 self.text = Some(text.to_owned());
283 self
284 }
285
286 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[inline]
360 pub fn with_template(mut self, template: impl Into<EmailTemplate>) -> Self {
361 self.template = Some(template.into());
362 self
363 }
364
365 #[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 #[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 #[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 pub id: EmailId,
394 }
395
396 #[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 pub id: EmailId,
421 }
422
423 #[derive(Debug, Clone, Serialize, Deserialize)]
424 pub struct CancelScheduleResponse {
425 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 pub id: EmailId,
453 pub url: String,
455 }
456
457 #[must_use]
459 #[derive(Debug, Clone, Serialize, Deserialize)]
460 pub struct Tag {
461 name: String,
464 value: String,
467 }
468
469 impl Tag {
470 #[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 #[must_use]
487 #[derive(Debug, Clone, Serialize)]
488 pub struct CreateAttachment {
489 #[serde(flatten)]
491 content_or_path: ContentOrPath,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 filename: Option<String>,
495 #[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
498 content_type: Option<String>,
499 #[serde(skip_serializing_if = "Option::is_none")]
503 content_id: Option<String>,
504 }
505
506 #[must_use]
508 #[derive(Debug, Clone, Serialize)]
509 pub enum ContentOrPath {
510 #[serde(rename = "content")]
512 Content(Vec<u8>),
513 #[serde(rename = "path")]
515 Path(String),
516 }
517
518 impl CreateAttachment {
519 #[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 #[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 #[inline]
543 pub fn with_filename(mut self, filename: &str) -> Self {
544 self.filename = Some(filename.to_owned());
545 self
546 }
547
548 #[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 #[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 #[must_use]
589 #[derive(Debug, Clone, Serialize, Deserialize)]
590 pub struct Email {
591 pub id: EmailId,
593 pub message_id: Option<String>,
595
596 pub from: String,
598 pub to: Vec<String>,
600 pub subject: String,
602
603 pub created_at: String,
605 pub html: Option<String>,
607 pub text: Option<String>,
609
610 #[serde(deserialize_with = "parse_nullable_vec")]
612 pub bcc: Vec<String>,
613 #[serde(deserialize_with = "parse_nullable_vec")]
615 pub cc: Vec<String>,
616 pub reply_to: Option<Vec<String>>,
618 pub last_event: EmailEvent,
620
621 #[serde(skip_serializing_if = "Option::is_none")]
623 pub scheduled_at: Option<String>,
624 }
625
626 #[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
630 #[serde(rename_all = "snake_case")]
631 pub enum EmailEvent {
632 Bounced,
636 Canceled,
638 Clicked,
640 Complained,
642 Delivered,
644 DeliveryDelayed,
646 Failed,
648 Opened,
650 Queued,
652 Scheduled,
654 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[inline]
932 pub fn with_timezone(mut self, timezone: &str) -> Self {
933 self.timezone = Some(timezone.to_owned());
934 self
935 }
936
937 #[inline]
940 pub fn with_granularity(mut self, granularity: MetricsGranularity) -> Self {
941 self.granularity = Some(granularity);
942 self
943 }
944
945 #[inline]
947 pub fn with_metric(mut self, metric: Metric) -> Self {
948 self.metrics.push(metric);
949 self
950 }
951
952 #[inline]
954 pub fn with_metrics(mut self, metrics: impl IntoIterator<Item = Metric>) -> Self {
955 self.metrics.extend(metrics);
956 self
957 }
958
959 #[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 #[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 #[inline]
994 pub fn with_email_dimension(mut self) -> Self {
995 self.dimensions.push(Dimension::Email);
996 self
997 }
998
999 #[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 #[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 #[inline]
1024 pub fn with_broadcast_dimension(mut self) -> Self {
1025 self.dimensions.push(Dimension::Broadcast);
1026 self
1027 }
1028
1029 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
1191 #[derive(Debug, Clone, Serialize, Deserialize)]
1192 pub struct EmailMetrics {
1193 pub start_date: String,
1195 pub end_date: String,
1197 pub metrics: Vec<Metric>,
1199 pub dimensions: Vec<Dimension>,
1201 pub granularity: MetricsGranularity,
1203 pub totals: HashMap<String, f64>,
1205 #[serde(skip_serializing_if = "Option::is_none")]
1207 pub data: Option<Vec<EmailMetricsDataPoint>>,
1208 }
1209
1210 #[must_use]
1216 #[derive(Debug, Clone, Serialize, Deserialize)]
1217 pub struct EmailMetricsDataPoint {
1218 #[serde(skip_serializing_if = "Option::is_none")]
1220 pub period: Option<String>,
1221 #[serde(skip_serializing_if = "Option::is_none")]
1223 pub domain_id: Option<DomainId>,
1224 #[serde(skip_serializing_if = "Option::is_none")]
1226 pub domain_name: Option<String>,
1227 #[serde(skip_serializing_if = "Option::is_none")]
1229 pub email_id: Option<EmailId>,
1230 #[serde(skip_serializing_if = "Option::is_none")]
1232 pub broadcast_id: Option<BroadcastId>,
1233 #[serde(skip_serializing_if = "Option::is_none")]
1235 pub broadcast_name: Option<String>,
1236 #[serde(flatten)]
1238 pub metrics: HashMap<String, f64>,
1239 }
1240}
1241
1242fn 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
1253fn 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 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 #[allow(clippy::string_lit_as_bytes)] 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 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 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 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 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 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 let _cancelled = resend.emails.cancel(&email.id).await?;
1492 std::thread::sleep(std::time::Duration::from_secs(1));
1493
1494 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 assert!(list.has_more);
1575 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 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 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 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}