1#![allow(dead_code)]
7
8use std::{collections::HashMap, sync::Arc};
9
10use reqwest::Method;
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 Config, Result,
15 list_opts::{ListOptions, ListResponse},
16 types::{
17 BroadcastId, CreateEventOptions, CreateEventResponse, DeleteEventResponse, Domain, EmailId,
18 GetEventResponse, InboundAttachment, SegmentId, SendEventOptions, SendEventResponse,
19 TemplateId, UpdateEventOptions, UpdateEventResponse,
20 },
21};
22
23#[derive(Clone, Debug)]
25pub struct EventsSvc(pub(crate) Arc<Config>);
26
27impl EventsSvc {
28 #[maybe_async::maybe_async]
32 pub async fn create(&self, event: CreateEventOptions) -> Result<CreateEventResponse> {
33 let request = self.0.build(Method::POST, "/events");
34 let response = self.0.send(request.json(&event)).await?;
35 let content = response.json::<CreateEventResponse>().await?;
36
37 Ok(content)
38 }
39
40 #[maybe_async::maybe_async]
44 pub async fn send(&self, opts: SendEventOptions) -> Result<SendEventResponse> {
45 let request = self.0.build(Method::POST, "/events/send");
46 let response = self.0.send(request.json(&opts)).await?;
47 let content = response.json::<SendEventResponse>().await?;
48
49 Ok(content)
50 }
51
52 #[maybe_async::maybe_async]
56 pub async fn get(&self, event_id: &str) -> Result<GetEventResponse> {
57 let path = format!("/events/{event_id}");
58
59 let request = self.0.build(Method::GET, &path);
60 let response = self.0.send(request).await?;
61 let content = response.json::<GetEventResponse>().await?;
62
63 Ok(content)
64 }
65
66 #[maybe_async::maybe_async]
70 pub async fn list<T>(
71 &self,
72 list_opts: ListOptions<T>,
73 ) -> Result<ListResponse<GetEventResponse>> {
74 let request = self.0.build(Method::GET, "/events").query(&list_opts);
75 let response = self.0.send(request).await?;
76 let content = response.json::<ListResponse<GetEventResponse>>().await?;
77
78 Ok(content)
79 }
80
81 #[maybe_async::maybe_async]
85 pub async fn update(
86 &self,
87 event_id: &str,
88 update: UpdateEventOptions,
89 ) -> Result<UpdateEventResponse> {
90 let path = format!("/events/{event_id}");
91
92 let request = self.0.build(Method::PATCH, &path);
93 let response = self.0.send(request.json(&update)).await?;
94 let content = response.json::<UpdateEventResponse>().await?;
95
96 Ok(content)
97 }
98
99 #[maybe_async::maybe_async]
103 pub async fn delete(&self, event_id: &str) -> Result<DeleteEventResponse> {
104 let path = format!("/events/{event_id}");
105
106 let request = self.0.build(Method::DELETE, &path);
107 let response = self.0.send(request).await?;
108 let content = response.json::<DeleteEventResponse>().await?;
109
110 Ok(content)
111 }
112}
113
114#[allow(unreachable_pub)]
115pub mod types {
116 use serde::{Deserialize, Serialize};
117 use serde_json::Value;
118
119 crate::define_id_type!(EventId);
120
121 #[must_use]
122 #[derive(Debug, Clone, Serialize)]
123 pub struct CreateEventOptions {
124 pub name: String,
125 pub schema: Value,
126 }
127
128 #[derive(Debug, Clone, Serialize, Deserialize)]
129 pub struct CreateEventResponse {
130 pub id: EventId,
131 }
132
133 #[must_use]
134 #[derive(Debug, Clone, Serialize)]
135 #[serde(rename_all = "snake_case")]
136 pub enum ContactIdOrEmail {
137 ContactId(String),
138 Email(String),
139 }
140
141 #[must_use]
142 #[derive(Debug, Clone, Serialize)]
143 pub struct SendEventOptions {
144 pub event: String,
145 #[serde(flatten)]
146 pub contact_id_or_email: ContactIdOrEmail,
147 pub payload: Value,
148 }
149
150 #[must_use]
151 #[derive(Debug, Clone, Serialize)]
152 pub struct UpdateEventOptions {
153 pub schema: Value,
154 }
155
156 #[derive(Debug, Clone, Serialize, Deserialize)]
157 pub struct SendEventResponse {
158 pub event: String,
159 }
160
161 #[derive(Debug, Clone, Serialize, Deserialize)]
162 pub struct GetEventResponse {
163 pub id: EventId,
164 pub name: String,
165 pub schema: Option<Value>,
166 pub created_at: String,
167 pub updated_at: Option<String>,
168 }
169
170 #[derive(Debug, Clone, Serialize, Deserialize)]
171 pub struct DeleteEventResponse {
172 pub id: EventId,
173 }
174
175 #[derive(Debug, Clone, Serialize, Deserialize)]
176 pub struct UpdateEventResponse {
177 pub id: EventId,
178 }
179}
180
181pub fn try_parse_event(data: &str) -> Result<Event> {
209 serde_json::from_str::<Event>(data).map_err(|e| crate::Error::Parse {
210 message: "Could not parse event".to_owned(),
211 source: Some(Box::new(e)),
212 })
213}
214
215pub fn try_parse_event_type(data: &str) -> Result<EventType> {
226 serde_json::from_str::<EventType>(data).map_err(|e| crate::Error::Parse {
227 message: "Could not parse event type".to_owned(),
228 source: Some(Box::new(e)),
229 })
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(untagged)]
235#[allow(clippy::large_enum_variant)]
236pub enum Event {
237 EmailEvent(EmailEvent),
238 ContactEvent(ContactEvent),
239 DomainEvent(DomainEvent),
240 SuppressionEvent(SuppressionEvent),
241}
242
243#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
245#[serde(untagged)]
246pub enum EventType {
247 EmailEventType(EmailEventType),
248 ContactEventType(ContactEventType),
249 DomainEventType(DomainEventType),
250 SuppressionEventType(SuppressionEventType),
251}
252
253impl From<EmailEventType> for EventType {
254 fn from(value: EmailEventType) -> Self {
255 Self::EmailEventType(value)
256 }
257}
258
259impl From<ContactEventType> for EventType {
260 fn from(value: ContactEventType) -> Self {
261 Self::ContactEventType(value)
262 }
263}
264
265impl From<DomainEventType> for EventType {
266 fn from(value: DomainEventType) -> Self {
267 Self::DomainEventType(value)
268 }
269}
270
271impl From<SuppressionEventType> for EventType {
272 fn from(value: SuppressionEventType) -> Self {
273 Self::SuppressionEventType(value)
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct EmailEvent {
279 #[serde(rename = "type")]
280 pub r#type: EmailEventType,
281 pub created_at: String,
282 pub data: EmailBody,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ContactEvent {
287 #[serde(rename = "type")]
288 pub r#type: ContactEventType,
289 pub created_at: String,
290 pub data: ContactBody,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct DomainEvent {
295 #[serde(rename = "type")]
296 pub r#type: DomainEventType,
297 pub created_at: String,
298 pub data: Domain,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SuppressionEvent {
303 #[serde(rename = "type")]
304 pub r#type: SuppressionEventType,
305 pub created_at: String,
306 pub data: SuppressionBody,
307}
308
309#[derive(Debug, Copy, Clone, Deserialize, Serialize)]
310#[cfg_attr(test, derive(strum::EnumCount))]
311pub enum EmailEventType {
312 #[serde(rename = "email.sent")]
313 EmailSent,
314 #[serde(rename = "email.suppressed")]
315 EmailSuppressed,
316 #[serde(rename = "email.delivered")]
317 EmailDelivered,
318 #[serde(rename = "email.delivery_delayed")]
319 EmailDeliveryDelayed,
320 #[serde(rename = "email.complained")]
321 EmailComplained,
322 #[serde(rename = "email.bounced")]
323 EmailBounced,
324 #[serde(rename = "email.opened")]
325 EmailOpened,
326 #[serde(rename = "email.clicked")]
327 EmailClicked,
328 #[serde(rename = "email.received")]
329 EmailReceived,
330 #[serde(rename = "email.scheduled")]
331 EmailScheduled,
332 #[serde(rename = "email.failed")]
333 EmailFailed,
334}
335
336#[derive(Debug, Copy, Clone, Deserialize, Serialize)]
337#[cfg_attr(test, derive(strum::EnumCount))]
338pub enum ContactEventType {
339 #[serde(rename = "contact.created")]
340 ContactCreated,
341 #[serde(rename = "contact.updated")]
342 ContactUpdated,
343 #[serde(rename = "contact.deleted")]
344 ContactDeleted,
345}
346
347#[derive(Debug, Copy, Clone, Deserialize, Serialize)]
348#[cfg_attr(test, derive(strum::EnumCount))]
349pub enum DomainEventType {
350 #[serde(rename = "domain.created")]
351 DomainCreated,
352 #[serde(rename = "domain.updated")]
353 DomainUpdated,
354 #[serde(rename = "domain.deleted")]
355 DomainDeleted,
356}
357
358#[derive(Debug, Copy, Clone, Deserialize, Serialize)]
359#[cfg_attr(test, derive(strum::EnumCount))]
360pub enum SuppressionEventType {
361 #[serde(rename = "suppression.added")]
362 SuppressionAdded,
363 #[serde(rename = "suppression.removed")]
364 SuppressionRemoved,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct EmailBody {
369 pub broadcast_id: Option<BroadcastId>,
370 pub created_at: String,
371 pub email_id: EmailId,
372 pub message_id: String,
374 pub from: String,
375 pub to: Vec<String>,
376 pub subject: String,
377 pub template_id: Option<TemplateId>,
378
379 #[serde(flatten)]
380 pub received: Option<Received>,
381 pub click: Option<Click>,
382 pub bounce: Option<Bounce>,
383 pub failed: Option<Failed>,
384 pub suppressed: Option<Suppressed>,
385
386 #[serde(default)]
387 pub tags: HashMap<String, String>,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct Suppressed {
393 pub message: String,
394 #[serde(rename = "type")]
395 pub r#type: String,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct Received {
401 pub bcc: Vec<String>,
402 pub cc: Vec<String>,
403 #[serde(default)]
404 pub received_for: Vec<String>,
405 pub attachments: Vec<InboundAttachment>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct Failed {
411 pub reason: String,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct Bounce {
417 pub message: String,
418 #[serde(rename = "subType")]
419 pub sub_type: BounceType,
420 #[serde(rename = "type")]
421 pub r#type: String,
422}
423
424#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
425pub enum BounceType {
426 Suppressed,
427 MessageRejected,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct Click {
433 #[serde(rename = "ipAddress")]
434 pub ip_address: String,
435 pub link: String,
436 pub timestamp: String,
437 #[serde(rename = "userAgent")]
438 pub user_agent: String,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ContactBody {
443 pub id: String,
444 pub audience_id: String,
445 pub segment_ids: Vec<SegmentId>,
446 pub created_at: String,
447 pub updated_at: String,
448 pub email: String,
449 pub first_name: Option<String>,
450 pub last_name: Option<String>,
451 pub unsubscribed: bool,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct SuppressionBody {
456 pub id: String,
457 pub email: String,
458 pub origin: SuppressionOriginType,
459 pub source_id: Option<String>,
460 pub created_at: String,
461}
462
463#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
464#[serde(rename_all = "snake_case")]
465pub enum SuppressionOriginType {
466 Bounce,
467 Complaint,
468 Manual,
469}
470
471#[allow(clippy::unwrap_used)]
472#[cfg(test)]
473mod test {
474 use crate::types::SendEventOptions;
475 #[cfg(not(feature = "blocking"))]
476 use crate::{
477 events::try_parse_event_type,
478 list_opts::ListOptions,
479 test::{CLIENT, DebugResult},
480 types::{CreateContactOptions, CreateEventOptions},
481 };
482 #[cfg(not(feature = "blocking"))]
483 use strum::EnumCount;
484
485 use crate::{
486 events::{
487 ContactEventType, DomainEventType, EmailEventType, Event, SuppressionEventType,
488 try_parse_event,
489 },
490 types::ContactIdOrEmail,
491 };
492
493 use serde_json::json;
494
495 #[test]
496 fn serialize_send() {
497 let opts = SendEventOptions {
498 event: "user.created".to_owned(),
499 contact_id_or_email: ContactIdOrEmail::ContactId("contact".to_string()),
500 payload: json!({
501 "plan": "pro"
502 }),
503 };
504 let res = serde_json::to_string(&opts).unwrap();
505 println!("{res}");
506 }
507
508 #[tokio_shared_rt::test(shared = true)]
509 #[serial_test::serial]
510 #[cfg(not(feature = "blocking"))]
511 async fn all() -> DebugResult<()> {
512 use crate::types::UpdateEventOptions;
513
514 let resend = &*CLIENT;
515
516 let opts = CreateEventOptions {
518 name: "user.created".to_owned(),
519 schema: json!({
520 "plan": "string"
521 }),
522 };
523 let _event = resend.events.create(opts).await?;
524
525 let opts = CreateContactOptions::new("steve.wozniak@gmail.com");
527 let contact = resend.contacts.create(opts).await?;
528 std::thread::sleep(std::time::Duration::from_secs(2));
529
530 let opts = SendEventOptions {
531 event: "user.created".to_owned(),
532 contact_id_or_email: ContactIdOrEmail::ContactId(contact.to_string()),
533 payload: json!({
534 "plan": "pro"
535 }),
536 };
537 let event = resend.events.send(opts).await?;
538 std::thread::sleep(std::time::Duration::from_secs(2));
539
540 let event = resend.events.get(&event.event).await?;
542
543 let events = resend.events.list(ListOptions::default()).await?;
545 assert!(!events.is_empty());
546
547 let opts = UpdateEventOptions {
549 schema: json!({
550 "plan": "string",
551 "trial": "boolean"
552 }),
553 };
554 let event = resend.events.update(&event.id, opts).await?;
555
556 let _deleted = resend.events.delete(&event.id).await?;
558 let _deleted = resend.contacts.delete("steve.wozniak@gmail.com").await?;
559
560 Ok(())
561 }
562
563 #[test]
564 fn email_sent() {
565 let data = r#"
566 {
567 "type": "email.sent",
568 "created_at": "2024-02-22T23:41:12.126Z",
569 "data": {
570 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
571 "created_at": "2024-02-22T23:41:11.894Z",
572 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
573 "message_id": "<111-222-333@email.example.com>",
574 "from": "Acme <onboarding@resend.dev>",
575 "to": ["delivered@resend.dev"],
576 "subject": "Sending this example",
577 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
578 "tags": {
579 "category": "confirm_email"
580 }
581 }
582 }"#;
583
584 let parsed = try_parse_event(data);
585 assert!(parsed.is_ok());
586 let parsed = parsed.unwrap();
587
588 if let Event::EmailEvent(email_event) = parsed {
589 assert!(matches!(email_event.r#type, EmailEventType::EmailSent));
590 assert_eq!(
591 email_event.data.message_id,
592 "<111-222-333@email.example.com>"
593 );
594 } else {
595 panic!("Wrong parsing");
596 }
597 }
598
599 #[test]
600 fn email_delivered() {
601 let data = r#"
602 {
603 "type": "email.delivered",
604 "created_at": "2024-02-22T23:41:12.126Z",
605 "data": {
606 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
607 "created_at": "2024-02-22T23:41:11.894Z",
608 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
609 "message_id": "<111-222-333@email.example.com>",
610 "from": "Acme <onboarding@resend.dev>",
611 "to": ["delivered@resend.dev"],
612 "subject": "Sending this example",
613 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
614 "tags": {
615 "category": "confirm_email"
616 }
617 }
618 }"#;
619
620 let parsed = try_parse_event(data);
621 assert!(parsed.is_ok());
622 let parsed = parsed.unwrap();
623
624 if let Event::EmailEvent(email_event) = parsed {
625 assert!(matches!(email_event.r#type, EmailEventType::EmailDelivered));
626 } else {
627 panic!("Wrong parsing");
628 }
629 }
630
631 #[test]
632 fn email_delivery_delayed() {
633 let data = r#"
634 {
635 "type": "email.delivery_delayed",
636 "created_at": "2024-02-22T23:41:12.126Z",
637 "data": {
638 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
639 "created_at": "2024-02-22T23:41:11.894Z",
640 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
641 "message_id": "<111-222-333@email.example.com>",
642 "from": "Acme <onboarding@resend.dev>",
643 "to": ["delivered@resend.dev"],
644 "subject": "Sending this example",
645 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
646 "tags": {
647 "category": "confirm_email"
648 }
649 }
650 }"#;
651
652 let parsed = try_parse_event(data);
653 assert!(parsed.is_ok());
654 let parsed = parsed.unwrap();
655
656 if let Event::EmailEvent(email_event) = parsed {
657 assert!(matches!(
658 email_event.r#type,
659 EmailEventType::EmailDeliveryDelayed
660 ));
661 } else {
662 panic!("Wrong parsing");
663 }
664 }
665
666 #[test]
667 fn email_complained() {
668 let data = r#"
669 {
670 "type": "email.complained",
671 "created_at": "2024-02-22T23:41:12.126Z",
672 "data": {
673 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
674 "created_at": "2024-02-22T23:41:11.894Z",
675 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
676 "message_id": "<111-222-333@email.example.com>",
677 "from": "Acme <onboarding@resend.dev>",
678 "to": ["delivered@resend.dev"],
679 "subject": "Sending this example",
680 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
681 "tags": {
682 "category": "confirm_email"
683 }
684 }
685 }"#;
686
687 let parsed = try_parse_event(data);
688 assert!(parsed.is_ok());
689 let parsed = parsed.unwrap();
690
691 if let Event::EmailEvent(email_event) = parsed {
692 assert!(matches!(
693 email_event.r#type,
694 EmailEventType::EmailComplained
695 ));
696 } else {
697 panic!("Wrong parsing");
698 }
699 }
700
701 #[test]
702 fn email_bounced() {
703 let data = r#"
704 {
705 "type": "email.bounced",
706 "created_at": "2024-11-22T23:41:12.126Z",
707 "data": {
708 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
709 "created_at": "2024-11-22T23:41:11.894Z",
710 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
711 "message_id": "<111-222-333@email.example.com>",
712 "from": "Acme <onboarding@resend.dev>",
713 "to": ["delivered@resend.dev"],
714 "subject": "Sending this example",
715 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
716 "bounce": {
717 "message": "The recipient's email address is on the suppression list because it has a recent history of producing hard bounces.",
718 "subType": "Suppressed",
719 "type": "Permanent"
720 },
721 "tags": {
722 "category": "confirm_email"
723 }
724 }
725 }"#;
726
727 let parsed = try_parse_event(data);
728 assert!(parsed.is_ok());
729 let parsed = parsed.unwrap();
730
731 if let Event::EmailEvent(email_event) = parsed {
732 assert!(matches!(email_event.r#type, EmailEventType::EmailBounced));
733 assert!(email_event.data.bounce.is_some());
734 } else {
735 panic!("Wrong parsing");
736 }
737 }
738
739 #[test]
740 fn email_opened() {
741 let data = r#"
742 {
743 "type": "email.opened",
744 "created_at": "2024-02-22T23:41:12.126Z",
745 "data": {
746 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
747 "created_at": "2024-02-22T23:41:11.894Z",
748 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
749 "message_id": "<111-222-333@email.example.com>",
750 "from": "Acme <onboarding@resend.dev>",
751 "to": ["delivered@resend.dev"],
752 "subject": "Sending this example",
753 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
754 "tags": {
755 "category": "confirm_email"
756 }
757 }
758 }"#;
759
760 let parsed = try_parse_event(data);
761 assert!(parsed.is_ok());
762 let parsed = parsed.unwrap();
763
764 if let Event::EmailEvent(email_event) = parsed {
765 assert!(matches!(email_event.r#type, EmailEventType::EmailOpened));
766 } else {
767 panic!("Wrong parsing");
768 }
769 }
770
771 #[test]
772 fn email_clicked() {
773 let data = r#"
774 {
775 "type": "email.clicked",
776 "created_at": "2024-11-22T23:41:12.126Z",
777 "data": {
778 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
779 "created_at": "2024-11-22T23:41:11.894Z",
780 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
781 "message_id": "<111-222-333@email.example.com>",
782 "from": "Acme <onboarding@resend.dev>",
783 "to": ["delivered@resend.dev"],
784 "click": {
785 "ipAddress": "122.115.53.11",
786 "link": "https://resend.com",
787 "timestamp": "2024-11-24T05:00:57.163Z",
788 "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"
789 },
790 "subject": "Sending this example",
791 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
792 "tags": {
793 "category": "confirm_email"
794 }
795 }
796 }"#;
797
798 let parsed = try_parse_event(data);
799 assert!(parsed.is_ok());
800 let parsed = parsed.unwrap();
801
802 if let Event::EmailEvent(email_event) = parsed {
803 assert!(matches!(email_event.r#type, EmailEventType::EmailClicked));
804 assert!(email_event.data.click.is_some());
805 assert!(!email_event.data.tags.is_empty());
806 } else {
807 panic!("Wrong parsing");
808 }
809 }
810
811 #[test]
812 fn email_failed() {
813 let data = r#"
814 {
815 "type": "email.failed",
816 "created_at": "2024-11-22T23:41:12.126Z",
817 "data": {
818 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
819 "created_at": "2024-11-22T23:41:11.894Z",
820 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
821 "message_id": "<111-222-333@email.example.com>",
822 "from": "Acme <onboarding@resend.dev>",
823 "to": ["delivered@resend.dev"],
824 "subject": "Sending this example",
825 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
826 "failed": {
827 "reason": "reached_daily_quota"
828 },
829 "tags": {
830 "category": "confirm_email"
831 }
832 }
833 }"#;
834
835 let parsed = try_parse_event(data);
836 assert!(parsed.is_ok());
837 let parsed = parsed.unwrap();
838
839 if let Event::EmailEvent(email_event) = parsed {
840 assert!(matches!(email_event.r#type, EmailEventType::EmailFailed));
841 assert!(email_event.data.failed.is_some());
842 assert!(!email_event.data.tags.is_empty());
843 } else {
844 panic!("Wrong parsing");
845 }
846 }
847
848 #[test]
849 fn email_received() {
850 let data = r#"
851 {
852 "type": "email.received",
853 "created_at": "2024-02-22T23:41:12.126Z",
854 "data": {
855 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
856 "created_at": "2024-02-22T23:41:11.894Z",
857 "from": "Acme <onboarding@resend.dev>",
858 "to": ["delivered@resend.dev"],
859 "bcc": [],
860 "cc": [],
861 "received_for": ["forwarded@example.com"],
862 "message_id": "<example+123>",
863 "subject": "Sending this example",
864 "attachments": [
865 {
866 "id": "2a0c9ce0-3112-4728-976e-47ddcd16a318",
867 "filename": "avatar.png",
868 "content_type": "image/png",
869 "content_disposition": "inline",
870 "content_id": "img001"
871 }
872 ]
873 }
874 }"#;
875
876 let parsed = try_parse_event(data);
877 assert!(parsed.is_ok());
878 let parsed = parsed.unwrap();
879
880 if let Event::EmailEvent(email_event) = parsed {
881 assert!(matches!(email_event.r#type, EmailEventType::EmailReceived));
882 assert_eq!(email_event.data.message_id, "<example+123>");
883 assert!(email_event.data.received.is_some());
884 assert!(email_event.data.tags.is_empty());
885
886 let received = email_event.data.received.unwrap();
887 assert_eq!(received.attachments.len(), 1);
888 assert!(received.cc.is_empty());
889 assert!(received.bcc.is_empty());
890 assert_eq!(received.received_for, vec!["forwarded@example.com"]);
891 } else {
892 panic!("Wrong parsing");
893 }
894 }
895
896 #[test]
897 fn email_scheduled() {
898 let data = r#"
899 {
900 "type": "email.scheduled",
901 "created_at": "2024-02-22T23:41:12.126Z",
902 "data": {
903 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
904 "created_at": "2024-02-22T23:41:11.894Z",
905 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
906 "message_id": "<111-222-333@email.example.com>",
907 "from": "Acme <onboarding@resend.dev>",
908 "to": ["delivered@resend.dev"],
909 "subject": "Sending this example",
910 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
911 "tags": {
912 "category": "confirm_email"
913 }
914 }
915 }"#;
916
917 let parsed = try_parse_event(data);
918 assert!(parsed.is_ok());
919 let parsed = parsed.unwrap();
920
921 if let Event::EmailEvent(email_event) = parsed {
922 assert!(matches!(email_event.r#type, EmailEventType::EmailScheduled));
923 assert!(email_event.data.received.is_none());
924 assert!(!email_event.data.tags.is_empty());
925 } else {
926 panic!("Wrong parsing");
927 }
928 }
929
930 #[test]
931 fn email_suppressed() {
932 let data = r#"
933 {
934 "type": "email.suppressed",
935 "created_at": "2024-11-22T23:41:12.126Z",
936 "data": {
937 "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
938 "created_at": "2024-11-22T23:41:11.894Z",
939 "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
940 "message_id": "<111-222-333@email.example.com>",
941 "from": "Acme <onboarding@resend.dev>",
942 "to": ["delivered@resend.dev"],
943 "subject": "Sending this example",
944 "template_id": "43f68331-0622-4e15-8202-246a0388854b",
945 "suppressed": {
946 "message": "Resend has suppressed sending to this address because it is on the account-level suppression list. This does not count toward your bounce rate metric",
947 "type": "OnAccountSuppressionList"
948 },
949 "tags": {
950 "category": "confirm_email"
951 }
952 }
953 }"#;
954
955 let parsed = try_parse_event(data);
956 assert!(parsed.is_ok());
957 let parsed = parsed.unwrap();
958
959 if let Event::EmailEvent(email_event) = parsed {
960 assert!(matches!(
961 email_event.r#type,
962 EmailEventType::EmailSuppressed
963 ));
964 assert!(email_event.data.received.is_none());
965 assert!(!email_event.data.tags.is_empty());
966 assert!(email_event.data.suppressed.is_some());
967 } else {
968 panic!("Wrong parsing");
969 }
970 }
971
972 #[test]
973 fn contact_created() {
974 let data = r#"
975 {
976 "type": "contact.created",
977 "created_at": "2024-11-17T19:32:22.980Z",
978 "data": {
979 "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
980 "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
981 "segment_ids": ["78261eea-8f8b-4381-83c6-79fa7120f1cf"],
982 "created_at": "2024-11-17T19:32:22.980Z",
983 "updated_at": "2024-11-17T19:32:22.980Z",
984 "email": "steve.wozniak@gmail.com",
985 "first_name": "Steve",
986 "last_name": "Wozniak",
987 "unsubscribed": false
988 }
989 }"#;
990
991 let parsed = try_parse_event(data);
992 assert!(parsed.is_ok());
993 let parsed = parsed.unwrap();
994
995 if let Event::ContactEvent(contact_event) = parsed {
996 assert!(matches!(
997 contact_event.r#type,
998 ContactEventType::ContactCreated
999 ));
1000 assert_eq!(contact_event.data.segment_ids.len(), 1);
1001 } else {
1002 panic!("Wrong parsing");
1003 }
1004 }
1005
1006 #[test]
1007 fn contact_updated() {
1008 let data = r#"
1009 {
1010 "type": "contact.updated",
1011 "created_at": "2024-10-11T23:47:56.678Z",
1012 "data": {
1013 "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
1014 "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
1015 "segment_ids": ["78261eea-8f8b-4381-83c6-79fa7120f1cf"],
1016 "created_at": "2024-10-10T15:11:54.110Z",
1017 "updated_at": "2024-10-11T23:47:56.678Z",
1018 "email": "steve.wozniak@gmail.com",
1019 "first_name": "Steve",
1020 "last_name": "Wozniak",
1021 "unsubscribed": false
1022 }
1023 }"#;
1024
1025 let parsed = try_parse_event(data);
1026 assert!(parsed.is_ok());
1027 let parsed = parsed.unwrap();
1028
1029 if let Event::ContactEvent(contact_event) = parsed {
1030 assert!(matches!(
1031 contact_event.r#type,
1032 ContactEventType::ContactUpdated
1033 ));
1034 assert_eq!(contact_event.data.segment_ids.len(), 1);
1035 } else {
1036 panic!("Wrong parsing");
1037 }
1038 }
1039
1040 #[test]
1041 fn contact_deleted() {
1042 let data = r#"
1043 {
1044 "type": "contact.deleted",
1045 "created_at": "2024-11-17T19:32:22.980Z",
1046 "data": {
1047 "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
1048 "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
1049 "segment_ids": ["78261eea-8f8b-4381-83c6-79fa7120f1cf"],
1050 "created_at": "2024-11-10T15:11:54.110Z",
1051 "updated_at": "2024-11-17T19:32:22.980Z",
1052 "email": "steve.wozniak@gmail.com",
1053 "first_name": "Steve",
1054 "last_name": "Wozniak",
1055 "unsubscribed": false
1056 }
1057 }"#;
1058
1059 let parsed = try_parse_event(data);
1060 assert!(parsed.is_ok());
1061 let parsed = parsed.unwrap();
1062
1063 if let Event::ContactEvent(contact_event) = parsed {
1064 assert!(matches!(
1065 contact_event.r#type,
1066 ContactEventType::ContactDeleted
1067 ));
1068 assert_eq!(contact_event.data.segment_ids.len(), 1);
1069 } else {
1070 panic!("Wrong parsing");
1071 }
1072 }
1073
1074 #[test]
1075 fn domain_created() {
1076 let data = r#"{
1077 "type": "domain.created",
1078 "created_at": "2026-11-17T19:32:22.980Z",
1079 "data": {
1080 "id": "d91cd9bd-1176-453e-8fc1-35364d380206",
1081 "name": "example.com",
1082 "status": "not_started",
1083 "created_at": "2026-04-26T20:21:26.347Z",
1084 "region": "us-east-1",
1085 "capabilities": {
1086 "sending": "enabled",
1087 "receiving": "disabled"
1088 },
1089 "records": [
1090 {
1091 "record": "SPF",
1092 "name": "send",
1093 "type": "MX",
1094 "ttl": "Auto",
1095 "status": "not_started",
1096 "value": "feedback-smtp.us-east-1.amazonses.com",
1097 "priority": 10
1098 },
1099 {
1100 "record": "SPF",
1101 "name": "send",
1102 "value": "\"v=spf1 include:amazonses.com ~all\"",
1103 "type": "TXT",
1104 "ttl": "Auto",
1105 "status": "not_started"
1106 },
1107 {
1108 "record": "DKIM",
1109 "name": "resend._domainkey",
1110 "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDsc4Lh8xilsngyKEgN2S84+21gn+x6SEXtjWvPiAAmnmggr5FWG42WnqczpzQ/mNblqHz4CDwUum6LtY6SdoOlDmrhvp5khA3cd661W9FlK3yp7+jVACQElS7d9O6jv8VsBbVg4COess3gyLE5RyxqF1vYsrEXqyM8TBz1n5AGkQIDAQA2",
1111 "type": "TXT",
1112 "status": "not_started",
1113 "ttl": "Auto"
1114 }
1115 ]
1116 }
1117 }"#;
1118
1119 let parsed = try_parse_event(data);
1120 assert!(parsed.is_ok());
1121 let parsed = parsed.unwrap();
1122
1123 if let Event::DomainEvent(domain_event) = parsed {
1124 assert!(matches!(
1125 domain_event.r#type,
1126 DomainEventType::DomainCreated
1127 ));
1128 assert!(domain_event.data.records.is_some_and(|r| r.len() == 3));
1129 } else {
1130 panic!("Wrong parsing");
1131 }
1132 }
1133
1134 #[test]
1135 fn domain_updated() {
1136 let data = r#"{
1137 "type": "domain.updated",
1138 "created_at": "2026-11-17T19:32:22.980Z",
1139 "data": {
1140 "id": "d91cd9bd-1176-453e-8fc1-35364d380206",
1141 "name": "example.com",
1142 "status": "not_started",
1143 "created_at": "2026-04-26T20:21:26.347Z",
1144 "region": "us-east-1",
1145 "capabilities": {
1146 "sending": "enabled",
1147 "receiving": "enabled"
1148 },
1149 "records": [
1150 {
1151 "record": "SPF",
1152 "name": "send",
1153 "type": "MX",
1154 "ttl": "Auto",
1155 "status": "not_started",
1156 "value": "feedback-smtp.us-east-1.amazonses.com",
1157 "priority": 10
1158 },
1159 {
1160 "record": "SPF",
1161 "name": "send",
1162 "value": "\"v=spf1 include:amazonses.com ~all\"",
1163 "type": "TXT",
1164 "ttl": "Auto",
1165 "status": "not_started"
1166 },
1167 {
1168 "record": "DKIM",
1169 "name": "resend._domainkey",
1170 "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDsc4Lh8xilsngyKEgN2S84+21gn+x6SEXtjWvPiAAmnmggr5FWG42WnqczpzQ/mNblqHz4CDwUum6LtY6SdoOlDmrhvp5khA3cd661W9FlK3yp7+jVACQElS7d9O6jv8VsBbVg4COess3gyLE5RyxqF1vYsrEXqyM8TBz1n5AGkQIDAQA2",
1171 "type": "TXT",
1172 "status": "not_started",
1173 "ttl": "Auto"
1174 },
1175 {
1176 "name": "inbound.yourdomain.tld",
1177 "priority": 10,
1178 "record": "Receiving MX",
1179 "status": "pending",
1180 "ttl": "Auto",
1181 "type": "MX",
1182 "value": "inbound-smtp.us-east-1.amazonaws.com"
1183 }
1184 ]
1185 }
1186 }"#;
1187
1188 let parsed = try_parse_event(data);
1189 assert!(parsed.is_ok());
1190 let parsed = parsed.unwrap();
1191
1192 if let Event::DomainEvent(domain_event) = parsed {
1193 assert!(matches!(
1194 domain_event.r#type,
1195 DomainEventType::DomainUpdated
1196 ));
1197 assert!(domain_event.data.records.is_some_and(|r| r.len() == 4));
1198 } else {
1199 panic!("Wrong parsing");
1200 }
1201 }
1202
1203 #[test]
1204 fn domain_deleted() {
1205 let data = r#"{
1206 "type": "domain.deleted",
1207 "created_at": "2026-11-17T19:32:22.980Z",
1208 "data": {
1209 "id": "d91cd9bd-1176-453e-8fc1-35364d380206",
1210 "name": "example.com",
1211 "status": "not_started",
1212 "created_at": "2026-04-26T20:21:26.347Z",
1213 "region": "us-east-1",
1214 "capabilities": {
1215 "sending": "enabled",
1216 "receiving": "disabled"
1217 },
1218 "records": [
1219 {
1220 "record": "SPF",
1221 "name": "send",
1222 "type": "MX",
1223 "ttl": "Auto",
1224 "status": "not_started",
1225 "value": "feedback-smtp.us-east-1.amazonses.com",
1226 "priority": 10
1227 },
1228 {
1229 "record": "SPF",
1230 "name": "send",
1231 "value": "\"v=spf1 include:amazonses.com ~all\"",
1232 "type": "TXT",
1233 "ttl": "Auto",
1234 "status": "not_started"
1235 },
1236 {
1237 "record": "DKIM",
1238 "name": "resend._domainkey",
1239 "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDsc4Lh8xilsngyKEgN2S84+21gn+x6SEXtjWvPiAAmnmggr5FWG42WnqczpzQ/mNblqHz4CDwUum6LtY6SdoOlDmrhvp5khA3cd661W9FlK3yp7+jVACQElS7d9O6jv8VsBbVg4COess3gyLE5RyxqF1vYsrEXqyM8TBz1n5AGkQIDAQA2",
1240 "type": "TXT",
1241 "status": "not_started",
1242 "ttl": "Auto"
1243 }
1244 ]
1245 }
1246 }"#;
1247
1248 let parsed = try_parse_event(data);
1249 assert!(parsed.is_ok());
1250 let parsed = parsed.unwrap();
1251
1252 if let Event::DomainEvent(domain_event) = parsed {
1253 assert!(matches!(
1254 domain_event.r#type,
1255 DomainEventType::DomainDeleted
1256 ));
1257 assert!(domain_event.data.records.is_some_and(|r| r.len() == 3));
1258 } else {
1259 panic!("Wrong parsing");
1260 }
1261 }
1262
1263 #[test]
1264 fn suppression_added() {
1265 let data = r#"{
1266 "type": "suppression.added",
1267 "created_at": "2026-11-17T19:32:22.980Z",
1268 "data": {
1269 "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
1270 "email": "steve.wozniak@gmail.com",
1271 "origin": "bounce",
1272 "source_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
1273 "created_at": "2026-11-17T19:32:22.980Z"
1274 }
1275 }"#;
1276
1277 let parsed = try_parse_event(data);
1278 assert!(parsed.is_ok());
1279 let parsed = parsed.unwrap();
1280
1281 if let Event::SuppressionEvent(contact_event) = parsed {
1282 assert!(matches!(
1283 contact_event.r#type,
1284 SuppressionEventType::SuppressionAdded
1285 ));
1286 assert!(contact_event.data.source_id.is_some());
1287 } else {
1288 panic!("Wrong parsing");
1289 }
1290 }
1291
1292 #[test]
1293 fn suppression_removed() {
1294 let data = r#"{
1295 "type": "suppression.removed",
1296 "created_at": "2026-11-17T19:32:22.980Z",
1297 "data": {
1298 "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
1299 "email": "steve.wozniak@gmail.com",
1300 "origin": "manual",
1301 "source_id": null,
1302 "created_at": "2026-11-15T08:12:45.120Z"
1303 }
1304 }"#;
1305
1306 let parsed = try_parse_event(data);
1307 assert!(parsed.is_ok());
1308 let parsed = parsed.unwrap();
1309
1310 if let Event::SuppressionEvent(contact_event) = parsed {
1311 assert!(matches!(
1312 contact_event.r#type,
1313 SuppressionEventType::SuppressionRemoved
1314 ));
1315 assert!(contact_event.data.source_id.is_none());
1316 } else {
1317 panic!("Wrong parsing");
1318 }
1319 }
1320
1321 #[allow(clippy::unwrap_used)]
1323 #[tokio_shared_rt::test(shared = true)]
1324 #[serial_test::serial]
1325 #[cfg(not(feature = "blocking"))]
1326 async fn events_up_to_date() -> DebugResult<()> {
1327 let response = reqwest::get("https://resend.com/docs/dashboard/webhooks/event-types")
1328 .await
1329 .unwrap();
1330
1331 let html = response.text().await.unwrap();
1332
1333 let fragment = scraper::Html::parse_document(&html);
1334 let selector =
1335 scraper::Selector::parse("#content > div > div > div > span > a > code").unwrap();
1336
1337 let expected = EmailEventType::COUNT
1338 + ContactEventType::COUNT
1339 + DomainEventType::COUNT
1340 + SuppressionEventType::COUNT;
1341 let actual = fragment
1342 .select(&selector)
1343 .map(|el| el.inner_html())
1344 .collect::<Vec<_>>();
1345
1346 for el in &actual {
1347 let parsed = try_parse_event_type(&format!("\"{el}\""));
1349 assert!(parsed.is_ok(), "Could not parse: {el}");
1350 }
1351
1352 assert_eq!(expected, actual.len());
1353
1354 Ok(())
1355 }
1356}