Skip to main content

resend_rs/
events.rs

1//! Parsing for Resend's Events.
2//!
3//! For an example on how to add (Axum) middleware that verifies incoming event signatures,
4//! check out [this example](https://github.com/resend/resend-rust/blob/main/examples/axum-verify-event-middleware.rs).
5
6#![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/// `Resend` APIs for `/events` endpoints.
24#[derive(Clone, Debug)]
25pub struct EventsSvc(pub(crate) Arc<Config>);
26
27impl EventsSvc {
28    /// Create a new event that can be used to trigger automations.
29    ///
30    /// <https://resend.com/docs/api-reference/events/create-event>
31    #[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    /// Send a named event to trigger matching automations.
41    ///
42    /// <https://resend.com/docs/api-reference/events/send-event>
43    #[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    /// Retrieve a single event by ID or name.
53    ///
54    /// <https://resend.com/docs/api-reference/events/get-event>
55    #[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    /// Retrieve a list of events.
67    ///
68    /// <https://resend.com/docs/api-reference/events/list-events>
69    #[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    /// Update an existing event schema.
82    ///
83    /// <https://resend.com/docs/api-reference/events/update-event>
84    #[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    /// Remove an existing event.
100    ///
101    /// <https://resend.com/docs/api-reference/events/delete-event>
102    #[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
181/// Parses a JSON event into an [`Event`].
182/// ## Example
183///
184/// ```
185/// use resend_rs::events::{Event, try_parse_event};
186///
187/// let data = r#"
188///   {
189///     "type": "email.sent",
190///     "created_at": "2024-02-22T23:41:12.126Z",
191///     "data": {
192///       "broadcast_id": "8b146471-e88e-4322-86af-016cd36fd216",
193///       "created_at": "2024-02-22T23:41:11.894Z",
194///       "email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
195///       "message_id": "<111-222-333@email.example.com>",
196///       "from": "Acme <onboarding@resend.dev>",
197///       "to": ["delivered@resend.dev"],
198///       "subject": "Sending this example",
199///       "template_id": "43f68331-0622-4e15-8202-246a0388854b",
200///       "tags": {
201///         "category": "confirm_email"
202///       }
203///     }
204/// }"#;
205///
206///  let parsed: Result<Event, resend_rs::Error> = try_parse_event(data);
207/// ```
208pub 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
215/// Parses an event type string into an [`EventType`].
216/// ## Example
217///
218/// ```
219/// use resend_rs::events::{EventType, try_parse_event_type};
220///
221/// let data = "\"email.sent\"";
222///
223///  let parsed: Result<EventType, resend_rs::Error> = try_parse_event_type(data);
224/// ```
225pub 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/// Represents any [Resend Event](https://resend.com/docs/dashboard/webhooks/event-types).
233#[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/// Represents any [Resend Event Type](https://resend.com/docs/dashboard/webhooks/event-types).
244#[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    /// RFC Message-ID header value for the email.
373    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/// Extra data only populated in [`EmailEventType::EmailSuppressed`] events.
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct Suppressed {
393    pub message: String,
394    #[serde(rename = "type")]
395    pub r#type: String,
396}
397
398/// Extra data only populated in [`EmailEventType::EmailReceived`] events.
399#[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/// Extra data only populated in [`EmailEventType::EmailFailed`] events.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct Failed {
411    pub reason: String,
412}
413
414/// Extra data only populated in [`EmailEventType::EmailBounced`] events.
415#[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/// Extra data only populated in [`EmailEventType::EmailClicked`] events.
431#[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        // Create
517        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        // Send
526        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        // Get
541        let event = resend.events.get(&event.event).await?;
542
543        // List
544        let events = resend.events.list(ListOptions::default()).await?;
545        assert!(!events.is_empty());
546
547        // Update
548        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        // Delete
557        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    /// Similar to the test in `error.rs`
1322    #[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            // Add quotes around it
1348            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}