Skip to main content

resend_rs/
broadcasts.rs

1use std::sync::Arc;
2
3use reqwest::Method;
4use types::{ListRecipientsOptions, UpdateBroadcastOptions, UpdateBroadcastResponse};
5
6use crate::{Config, Result, list_opts::ListResponse};
7use crate::{
8    list_opts::ListOptions,
9    types::{
10        Broadcast, BroadcastClickedLink, BroadcastRecipient, CancelBroadcastResponse,
11        CreateBroadcastOptions, CreateBroadcastResponse, RemoveBroadcastResponse,
12        SendBroadcastOptions, SendBroadcastResponse,
13    },
14};
15
16/// `Resend` APIs for `/broadcasts` endpoints.
17#[derive(Clone, Debug)]
18pub struct BroadcastsSvc(pub(crate) Arc<Config>);
19
20impl BroadcastsSvc {
21    /// Create a new broadcast to send to your audience.
22    ///
23    /// <https://resend.com/docs/api-reference/broadcasts/create-broadcast>
24    #[maybe_async::maybe_async]
25    pub async fn create(
26        &self,
27        broadcast: CreateBroadcastOptions,
28    ) -> Result<CreateBroadcastResponse> {
29        let request = self.0.build(Method::POST, "/broadcasts");
30        let response = self.0.send(request.json(&broadcast)).await?;
31        let content = response.json::<CreateBroadcastResponse>().await?;
32
33        Ok(content)
34    }
35
36    /// Start sending broadcasts to your audience through the Resend API.
37    ///
38    /// <https://resend.com/docs/api-reference/broadcasts/send-broadcast>
39    #[maybe_async::maybe_async]
40    pub async fn send(&self, broadcast: SendBroadcastOptions) -> Result<SendBroadcastResponse> {
41        let path = format!("/broadcasts/{}/send", broadcast.broadcast_id);
42
43        let request = self.0.build(Method::POST, &path);
44        let response = self.0.send(request.json(&broadcast)).await?;
45        let content = response.json::<SendBroadcastResponse>().await?;
46
47        Ok(content)
48    }
49
50    /// Retrieve a list of broadcasts.
51    ///
52    /// - Default limit: no limit (return everything)
53    ///
54    /// <https://resend.com/docs/api-reference/broadcasts/list-broadcasts>
55    #[maybe_async::maybe_async]
56    pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Broadcast>> {
57        let request = self.0.build(Method::GET, "/broadcasts").query(&list_opts);
58        let response = self.0.send(request).await?;
59        let content = response.json::<ListResponse<Broadcast>>().await?;
60
61        Ok(content)
62    }
63
64    /// Retrieve a single broadcast.
65    ///
66    /// <https://resend.com/docs/api-reference/broadcasts/get-broadcast>
67    #[maybe_async::maybe_async]
68    pub async fn get(&self, broadcast_id: &str) -> Result<Broadcast> {
69        let path = format!("/broadcasts/{broadcast_id}");
70
71        let request = self.0.build(Method::GET, &path);
72        let response = self.0.send(request).await?;
73        let content = response.json::<Broadcast>().await?;
74
75        Ok(content)
76    }
77
78    #[maybe_async::maybe_async]
79    pub async fn cancel(&self, broadcast_id: &str) -> Result<CancelBroadcastResponse> {
80        let path = format!("/broadcasts/{broadcast_id}/cancel");
81
82        let request = self.0.build(Method::POST, &path);
83        let response = self.0.send(request).await?;
84        let content = response.json::<CancelBroadcastResponse>().await?;
85
86        Ok(content)
87    }
88
89    /// Remove an existing broadcast.
90    ///
91    /// <https://resend.com/docs/api-reference/broadcasts/delete-broadcast>
92    #[maybe_async::maybe_async]
93    pub async fn delete(&self, broadcast_id: &str) -> Result<bool> {
94        let path = format!("/broadcasts/{broadcast_id}");
95
96        let request = self.0.build(Method::DELETE, &path);
97        let response = self.0.send(request).await?;
98        let content = response.json::<RemoveBroadcastResponse>().await?;
99
100        Ok(content.deleted)
101    }
102
103    /// Retrieve the links clicked in a broadcast, ranked by total clicks.
104    ///
105    /// <https://resend.com/docs/api-reference/broadcasts/list-broadcast-clicked-links>
106    #[maybe_async::maybe_async]
107    pub async fn clicked_links<T>(
108        &self,
109        broadcast_id: &str,
110        list_opts: ListOptions<T>,
111    ) -> Result<ListResponse<BroadcastClickedLink>> {
112        let path = format!("/broadcasts/{broadcast_id}/clicked-links");
113
114        let request = self.0.build(Method::GET, &path).query(&list_opts);
115        let response = self.0.send(request).await?;
116        let content = response
117            .json::<ListResponse<BroadcastClickedLink>>()
118            .await?;
119
120        Ok(content)
121    }
122
123    /// Update a broadcast to send to your audience.
124    #[maybe_async::maybe_async]
125    pub async fn update(
126        &self,
127        broadcast_id: &str,
128        update: UpdateBroadcastOptions,
129    ) -> Result<UpdateBroadcastResponse> {
130        let path = format!("/broadcasts/{broadcast_id}");
131
132        let request = self.0.build(Method::PATCH, &path);
133        let response = self.0.send(request.json(&update)).await?;
134        let content = response.json::<UpdateBroadcastResponse>().await?;
135
136        Ok(content)
137    }
138
139    /// Retrieve the recipients of a broadcast for a given event type, such as who opened,
140    /// clicked, or bounced.
141    ///
142    /// - Default limit: 20
143    ///
144    /// <https://resend.com/docs/api-reference/broadcasts/list-broadcast-recipients>
145    #[maybe_async::maybe_async]
146    pub async fn recipients<T>(
147        &self,
148        broadcast_id: &str,
149        list_opts: ListRecipientsOptions<T>,
150    ) -> Result<ListResponse<BroadcastRecipient>> {
151        let path = format!("/broadcasts/{broadcast_id}/recipients");
152
153        let request = self.0.build(Method::GET, &path).query(&list_opts);
154        let response = self.0.send(request).await?;
155        let content = response.json::<ListResponse<BroadcastRecipient>>().await?;
156
157        Ok(content)
158    }
159}
160
161#[allow(unreachable_pub)]
162pub mod types {
163    use ecow::EcoString;
164    use serde::{Deserialize, Serialize};
165
166    use crate::{
167        list_opts::{ListAfter, ListBefore, ListOptions, TimeNotSpecified},
168        types::{ContactId, SegmentId},
169    };
170
171    /// Details of a new `Broadcast`.
172    #[must_use]
173    #[derive(Debug, Clone, Serialize)]
174    pub struct CreateBroadcastOptions {
175        audience_id: String,
176        from: String,
177        subject: String,
178        #[serde(skip_serializing_if = "Option::is_none")]
179        reply_to: Option<Vec<String>>,
180        #[serde(skip_serializing_if = "Option::is_none")]
181        html: Option<String>,
182        #[serde(skip_serializing_if = "Option::is_none")]
183        text: Option<String>,
184        #[serde(skip_serializing_if = "Option::is_none")]
185        name: Option<String>,
186        #[serde(skip_serializing_if = "Option::is_none")]
187        send: Option<bool>,
188        #[serde(skip_serializing_if = "Option::is_none")]
189        scheduled_at: Option<String>,
190    }
191
192    impl CreateBroadcastOptions {
193        /// Creates a new [`CreateBroadcastOptions`].
194        ///
195        /// - `audience_id`: The ID of the audience you want to send to.
196        /// - `from`: To include a friendly name, use the format `"Your Name <sender@domain.com>"`.
197        /// - `subject`: Email subject.
198        pub fn new(audience_id: &str, from: &str, subject: &str) -> Self {
199            Self {
200                audience_id: audience_id.to_string(),
201                from: from.to_string(),
202                subject: subject.to_string(),
203                reply_to: None,
204                html: None,
205                text: None,
206                name: None,
207                send: None,
208                scheduled_at: None,
209            }
210        }
211
212        /// Appends `reply_to` address to the broadcast.
213        #[inline]
214        pub fn with_reply(mut self, to: &str) -> Self {
215            let reply_to = self.reply_to.get_or_insert_with(Vec::new);
216            reply_to.push(to.to_owned());
217            self
218        }
219
220        /// Appends multiple `reply_to` addresses to the broadcast.
221        #[inline]
222        pub fn with_reply_multiple(mut self, to: &[String]) -> Self {
223            let reply_to = self.reply_to.get_or_insert_with(Vec::new);
224            reply_to.extend_from_slice(to);
225            self
226        }
227
228        /// Adds or overwrites the HTML version of the message.
229        #[inline]
230        pub fn with_html(mut self, html: &str) -> Self {
231            self.html = Some(html.to_owned());
232            self
233        }
234
235        /// Adds or overwrites the plain text version of the message.
236        #[inline]
237        pub fn with_text(mut self, text: &str) -> Self {
238            self.text = Some(text.to_owned());
239            self
240        }
241
242        /// Sets the broadast name.
243        #[inline]
244        pub fn with_name(mut self, name: &str) -> Self {
245            self.name = Some(name.to_owned());
246            self
247        }
248
249        /// When set to `true`, the broadcast will be sent or scheduled (if `scheduled_at` is
250        /// provided) without requiring a separate call to the
251        /// [`crate::broadcasts::BroadcastsSvc::send`] endpoint.
252        #[inline]
253        pub fn with_send(mut self, send: bool) -> Self {
254            self.send = Some(send);
255            self
256        }
257
258        /// Schedule email to be sent later. The date should be in language natural (e.g.: in 1 min)
259        /// or ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z).
260        #[inline]
261        pub fn with_scheduled_at(mut self, scheduled_at: &str) -> Self {
262            self.scheduled_at = Some(scheduled_at.to_owned());
263            self
264        }
265    }
266
267    #[must_use]
268    #[derive(Debug, Clone, Serialize, Default)]
269    pub struct UpdateBroadcastOptions {
270        #[serde(skip_serializing_if = "Option::is_none")]
271        from: Option<String>,
272        #[serde(skip_serializing_if = "Option::is_none")]
273        subject: Option<String>,
274        #[serde(skip_serializing_if = "Option::is_none")]
275        reply_to: Option<Vec<String>>,
276        #[serde(skip_serializing_if = "Option::is_none")]
277        html: Option<String>,
278        #[serde(skip_serializing_if = "Option::is_none")]
279        text: Option<String>,
280        #[serde(skip_serializing_if = "Option::is_none")]
281        name: Option<String>,
282    }
283
284    impl UpdateBroadcastOptions {
285        /// Creates a new [`UpdateBroadcastOptions`].
286        pub fn new() -> Self {
287            Self::default()
288        }
289
290        /// Adds or overwrites the sender email address.
291        #[inline]
292        pub fn with_from(mut self, from: &str) -> Self {
293            self.from = Some(from.to_owned());
294            self
295        }
296
297        /// Adds or overwrites the subject.
298        #[inline]
299        pub fn with_subject(mut self, subject: &str) -> Self {
300            self.subject = Some(subject.to_owned());
301            self
302        }
303
304        /// Appends `reply_to` address to the broadcast.
305        pub fn with_reply(mut self, to: &str) -> Self {
306            let reply_to = self.reply_to.get_or_insert_with(Vec::new);
307            reply_to.push(to.to_owned());
308            self
309        }
310
311        /// Appends multiple `reply_to` addresses to the broadcast.
312        #[inline]
313        pub fn with_reply_multiple(mut self, to: &[String]) -> Self {
314            let reply_to = self.reply_to.get_or_insert_with(Vec::new);
315            reply_to.extend_from_slice(to);
316            self
317        }
318
319        /// Adds or overwrites the HTML version of the message.
320        #[inline]
321        pub fn with_html(mut self, html: &str) -> Self {
322            self.html = Some(html.to_owned());
323            self
324        }
325
326        /// Adds or overwrites the plain text version of the message.
327        #[inline]
328        pub fn with_text(mut self, text: &str) -> Self {
329            self.text = Some(text.to_owned());
330            self
331        }
332
333        /// Sets the broadast name.
334        #[inline]
335        pub fn with_name(mut self, name: &str) -> Self {
336            self.name = Some(name.to_owned());
337            self
338        }
339    }
340
341    #[derive(Debug, Clone, Serialize, Deserialize)]
342    pub struct UpdateBroadcastResponse {
343        /// Unique identifier for the updated broadcast.
344        pub id: BroadcastId,
345    }
346
347    crate::define_id_type!(BroadcastId);
348
349    #[derive(Debug, Clone, Serialize, Deserialize)]
350    pub struct CreateBroadcastResponse {
351        /// The ID of the created broadcast.
352        pub id: BroadcastId,
353    }
354
355    #[must_use]
356    #[derive(Debug, Clone, Serialize)]
357    pub struct SendBroadcastOptions {
358        pub(crate) broadcast_id: BroadcastId,
359
360        #[serde(skip_serializing_if = "Option::is_none")]
361        scheduled_at: Option<String>,
362    }
363
364    impl SendBroadcastOptions {
365        pub fn new(broadcast_id: &str) -> Self {
366            let broadcast_id = BroadcastId(EcoString::from(broadcast_id.to_owned()));
367
368            Self {
369                broadcast_id,
370                scheduled_at: None,
371            }
372        }
373
374        /// Schedule email to be sent later. The date should be in language natural (e.g.: in 1 min)
375        /// or ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z).
376        #[inline]
377        pub fn with_scheduled_at(mut self, scheduled_at: &str) -> Self {
378            self.scheduled_at = Some(scheduled_at.to_owned());
379            self
380        }
381    }
382
383    #[derive(Debug, Clone, Serialize, Deserialize)]
384    pub struct SendBroadcastResponse {
385        /// The ID of the sent broadcast.
386        pub id: BroadcastId,
387    }
388
389    #[must_use]
390    #[derive(Debug, Clone, Serialize, Deserialize)]
391    pub struct Broadcast {
392        pub id: BroadcastId,
393        pub name: String,
394        pub audience_id: SegmentId,
395        pub status: String,
396        pub created_at: String,
397        pub scheduled_at: Option<String>,
398        pub sent_at: Option<String>,
399        pub from: Option<String>,
400        pub subject: Option<String>,
401        pub reply_to: Option<Vec<String>>,
402        pub preview_text: Option<String>,
403        pub text: Option<String>,
404        pub html: Option<String>,
405    }
406
407    #[derive(Debug, Clone, Serialize, Deserialize)]
408    pub struct CancelBroadcastResponse {
409        pub id: BroadcastId,
410    }
411
412    #[derive(Debug, Clone, Serialize, Deserialize)]
413    pub struct RemoveBroadcastResponse {
414        /// The ID of the broadcast.
415        #[allow(dead_code)]
416        pub id: BroadcastId,
417        /// The deleted attribute indicates that the corresponding broadcast has been deleted.
418        pub deleted: bool,
419    }
420
421    /// The recipient event type to filter by when listing [`BroadcastRecipient`]s.
422    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
423    #[must_use]
424    #[serde(rename_all = "snake_case")]
425    pub enum BroadcastRecipientEventType {
426        Sent,
427        Delivered,
428        Opened,
429        Clicked,
430        Bounced,
431        Complained,
432        Unsubscribed,
433        Suppressed,
434    }
435
436    /// The classification of a bounce for a [`BroadcastRecipient`].
437    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
438    #[must_use]
439    #[serde(rename_all = "snake_case")]
440    pub enum BroadcastRecipientBounceType {
441        Permanent,
442        Transient,
443        Undetermined,
444    }
445
446    /// Query parameters for [`crate::broadcasts::BroadcastsSvc::recipients`].
447    ///
448    /// `before` and `after` are mutually exclusive; use [`ListRecipientsOptions::list_before`] or
449    /// [`ListRecipientsOptions::list_after`] to pick one, same as [`ListOptions`].
450    ///
451    /// <https://resend.com/docs/api-reference/broadcasts/list-broadcast-recipients>
452    ///
453    /// ## Example
454    ///
455    /// ```
456    /// # use resend_rs::types::{BroadcastRecipientEventType, ListRecipientsOptions};
457    /// let list_opts =
458    ///   ListRecipientsOptions::new(BroadcastRecipientEventType::Clicked).with_limit(10);
459    /// ```
460    #[must_use]
461    #[derive(Debug, Clone, Serialize)]
462    // The `List` parameter only ever reaches the wire through `pagination`'s own (bound-free)
463    // `Serialize` impl, so it never needs to implement `Serialize` itself; the phantom marker
464    // types (e.g. `TimeNotSpecified`) intentionally don't.
465    #[serde(bound(serialize = ""))]
466    pub struct ListRecipientsOptions<List = TimeNotSpecified> {
467        #[serde(rename = "type")]
468        r#type: BroadcastRecipientEventType,
469
470        #[serde(skip_serializing_if = "Option::is_none")]
471        email: Option<String>,
472
473        #[serde(skip_serializing_if = "Option::is_none")]
474        bounce_type: Option<BroadcastRecipientBounceType>,
475
476        #[serde(flatten)]
477        pagination: ListOptions<List>,
478    }
479
480    impl ListRecipientsOptions<TimeNotSpecified> {
481        /// Creates a new [`ListRecipientsOptions`], filtering recipients by the given event
482        /// `type`.
483        pub fn new(event_type: BroadcastRecipientEventType) -> Self {
484            Self {
485                r#type: event_type,
486                email: None,
487                bounce_type: None,
488                pagination: ListOptions::default(),
489            }
490        }
491
492        /// The id before which we'll retrieve the items. This id will *not* be included in the
493        /// list.
494        #[inline]
495        pub fn list_before(self, id: &str) -> ListRecipientsOptions<ListBefore> {
496            ListRecipientsOptions {
497                r#type: self.r#type,
498                email: self.email,
499                bounce_type: self.bounce_type,
500                pagination: self.pagination.list_before(id),
501            }
502        }
503
504        /// The id after which we'll retrieve the items. This id will *not* be included in the
505        /// list.
506        #[inline]
507        pub fn list_after(self, id: &str) -> ListRecipientsOptions<ListAfter> {
508            ListRecipientsOptions {
509                r#type: self.r#type,
510                email: self.email,
511                bounce_type: self.bounce_type,
512                pagination: self.pagination.list_after(id),
513            }
514        }
515    }
516
517    impl<T> ListRecipientsOptions<T> {
518        /// Number of recipients to retrieve.
519        ///
520        /// - min: 1
521        /// - max: 100
522        /// - default: 20
523        #[inline]
524        pub fn with_limit(mut self, limit: u8) -> Self {
525            self.pagination = self.pagination.with_limit(limit);
526            self
527        }
528
529        /// Filters recipients whose email contains this value.
530        #[inline]
531        pub fn with_email(mut self, email: &str) -> Self {
532            self.email = Some(email.to_owned());
533            self
534        }
535
536        /// Filters bounced recipients by bounce type.
537        ///
538        /// Only meaningful when `type` is [`BroadcastRecipientEventType::Bounced`].
539        #[inline]
540        pub fn with_bounce_type(mut self, bounce_type: BroadcastRecipientBounceType) -> Self {
541            self.bounce_type = Some(bounce_type);
542            self
543        }
544    }
545
546    /// A link clicked by a [`BroadcastRecipient`]. Only present when `type` is `clicked`.
547    #[must_use]
548    #[derive(Debug, Clone, Serialize, Deserialize)]
549    pub struct BroadcastRecipientClickedLink {
550        pub url: String,
551        pub clicks: u32,
552    }
553
554    /// A single recipient of a broadcast, matching the requested event `type`.
555    ///
556    /// <https://resend.com/docs/api-reference/broadcasts/list-broadcast-recipients>
557    #[must_use]
558    #[derive(Debug, Clone, Serialize, Deserialize)]
559    pub struct BroadcastRecipient {
560        /// Opaque cursor identifying this row, used only for pagination. This does not identify
561        /// any entity in Resend; use [`BroadcastRecipient::contact_id`] to reference the contact.
562        pub id: String,
563        /// The ID of the contact associated with this recipient. `None` if the recipient's email
564        /// no longer maps to a contact.
565        pub contact_id: Option<ContactId>,
566        pub email: String,
567        /// The number of times this recipient triggered the event. Only present when the
568        /// requested `type` is `opened` or `clicked`.
569        #[serde(default, skip_serializing_if = "Option::is_none")]
570        pub count: Option<u32>,
571        /// The type of bounce. Only present when the requested `type` is `bounced`.
572        #[serde(default, skip_serializing_if = "Option::is_none")]
573        pub bounce_type: Option<BroadcastRecipientBounceType>,
574        /// The links this recipient clicked. Only present when the requested `type` is `clicked`.
575        #[serde(default, skip_serializing_if = "Option::is_none")]
576        pub clicked_links: Option<Vec<BroadcastRecipientClickedLink>>,
577    }
578
579    #[derive(Debug, Clone, Serialize, Deserialize)]
580    pub struct BroadcastClickedLink {
581        /// An opaque cursor for this row, used only for pagination. It does not identify any
582        /// entity in Resend.
583        pub id: String,
584        /// The URL that was clicked.
585        pub url: String,
586        /// Total number of clicks on this URL.
587        pub clicks: u64,
588        /// Number of unique clicks on this URL.
589        pub unique_clicks: u64,
590    }
591}
592
593#[cfg(test)]
594#[allow(clippy::needless_return, clippy::indexing_slicing)]
595mod test {
596    #[cfg(not(feature = "blocking"))]
597    use crate::{
598        list_opts::ListOptions,
599        test::{CLIENT, DebugResult},
600        types::{
601            CreateBroadcastOptions, CreateContactOptions, SendBroadcastOptions,
602            UpdateBroadcastOptions,
603        },
604    };
605
606    use super::types::{
607        Broadcast, BroadcastClickedLink, BroadcastRecipient, BroadcastRecipientEventType,
608        CancelBroadcastResponse, ListRecipientsOptions,
609    };
610
611    #[tokio_shared_rt::test(shared = true)]
612    #[serial_test::serial]
613    #[cfg(not(feature = "blocking"))]
614    #[ignore = "Can no longer send broadcasts from the resend.dev domain"]
615    async fn create_send_broadcast() -> DebugResult<()> {
616        let resend = &*CLIENT;
617        std::thread::sleep(std::time::Duration::from_secs(1));
618
619        let audience_id = resend.segments.create("audience").await?.id;
620
621        let contact = CreateContactOptions::new("steve.wozniak@gmail.com")
622            .with_first_name("Steve")
623            .with_last_name("Wozniak")
624            .with_unsubscribed(false)
625            .with_audience_id(&audience_id);
626
627        let _contact_id = resend.contacts.create(contact).await?;
628
629        let from = "Acme <onboarding@resend.dev>";
630        let subject = "hello world";
631        let html =
632            "Hi {{{FIRST_NAME|there}}}, you can unsubscribe here: {{{RESEND_UNSUBSCRIBE_URL}}}";
633
634        std::thread::sleep(std::time::Duration::from_secs(2));
635
636        // Create
637        let broadcast = CreateBroadcastOptions::new(&audience_id, from, subject).with_html(html);
638        let res = resend.broadcasts.create(broadcast).await?;
639
640        std::thread::sleep(std::time::Duration::from_secs(4));
641
642        // Send
643        let opts = SendBroadcastOptions::new(&res.id);
644        let _res = resend.broadcasts.send(opts).await?;
645
646        // Cleanup
647        std::thread::sleep(std::time::Duration::from_secs(2));
648
649        let deleted = resend.segments.delete(&audience_id).await?;
650        std::thread::sleep(std::time::Duration::from_secs(1));
651
652        assert!(deleted);
653
654        Ok(())
655    }
656
657    #[tokio_shared_rt::test(shared = true)]
658    #[serial_test::serial]
659    #[cfg(not(feature = "blocking"))]
660    #[ignore = "Can no longer send broadcasts from the resend.dev domain"]
661    async fn list_get_broadcast() -> DebugResult<()> {
662        let resend = &*CLIENT;
663        std::thread::sleep(std::time::Duration::from_secs(1));
664
665        let broadcasts = resend.broadcasts.list(ListOptions::default()).await?;
666        assert!(!broadcasts.data.is_empty(), "No broadcasts found");
667        let broadcast = broadcasts[0].clone();
668
669        let _res = resend.broadcasts.get(&broadcast.id.clone()).await?;
670        let _deleted = resend.broadcasts.delete(&broadcast.id).await;
671        // TODO: This does not seem to be the case anymore?
672        // Already used broadcasts cant be deleted
673        // assert!(deleted.is_err());
674
675        // Create fresh broadcast and delete that instead
676        let audience_id = resend.segments.create("audience").await?.id;
677        let from = "Acme <onboarding@resend.dev>";
678        let subject = "hello world";
679        let text = "text";
680
681        let broadcast = CreateBroadcastOptions::new(&audience_id, from, subject).with_text(text);
682        let res = resend.broadcasts.create(broadcast).await?;
683        std::thread::sleep(std::time::Duration::from_secs(2));
684        let deleted_broadcast = resend.broadcasts.delete(&res.id).await;
685        let deleted_audience = resend.segments.delete(&audience_id).await;
686        std::thread::sleep(std::time::Duration::from_secs(1));
687
688        assert!(deleted_broadcast.is_ok());
689        assert!(deleted_audience.is_ok());
690
691        Ok(())
692    }
693
694    #[tokio_shared_rt::test(shared = true)]
695    #[serial_test::serial]
696    #[cfg(not(feature = "blocking"))]
697    #[ignore = "Can no longer send broadcasts from the resend.dev domain"]
698    async fn update_broadcast() -> DebugResult<()> {
699        let resend = &*CLIENT;
700        std::thread::sleep(std::time::Duration::from_secs(1));
701
702        // Create audience & broadcast
703        let audience_id = resend.segments.create("audience").await?.id;
704        let from = "Acme <onboarding@resend.dev>";
705        let subject = "hello world";
706
707        let create_broadcast =
708            CreateBroadcastOptions::new(&audience_id, from, subject).with_text("text");
709        let broadcast_id = resend.broadcasts.create(create_broadcast).await?.id;
710        std::thread::sleep(std::time::Duration::from_secs(2));
711
712        // Assert subject == initial subject
713        let broadcast = resend.broadcasts.get(&broadcast_id).await?;
714        assert_eq!(Some(subject.to_string()), broadcast.subject);
715
716        std::thread::sleep(std::time::Duration::from_secs(2));
717
718        // Update subject
719        let subject = "updated";
720        let opts = UpdateBroadcastOptions::new().with_subject(subject);
721        let _unused = resend.broadcasts.update(&broadcast_id, opts).await?;
722
723        // Assert subject == updated subject
724        let broadcast = resend.broadcasts.get(&broadcast_id).await?;
725        assert_eq!(Some(subject.to_string()), broadcast.subject);
726
727        // Delete
728        let deleted = resend.broadcasts.delete(&broadcast_id).await?;
729        assert!(deleted);
730
731        Ok(())
732    }
733
734    #[test]
735    fn parse_broadcast_test() {
736        let data = r#"{
737    "object": "broadcast",
738    "id": "498ee8e4-7aa2-4eb5-9f04-4194848049d1",
739    "name": "Untitled",
740    "audience_id": "fd644f07-a05a-467e-9bae-23bb7c35766a",
741    "from": "Acme <onboarding@resend.dev>",
742    "subject": "Hello!",
743    "reply_to": [],
744    "preview_text": null,
745    "status": "scheduled",
746    "created_at": "2024-12-18 18:05:09.905933+00",
747    "scheduled_at": null,
748    "sent_at": null
749}"#;
750
751        let _parsed = serde_json::from_str::<Broadcast>(data).expect("Parsing failed");
752    }
753
754    #[test]
755    fn parse_cancel_broadcast_response_test() {
756        let data = r#"{
757    "object": "broadcast",
758    "id": "498ee8e4-7aa2-4eb5-9f04-4194848049d1"
759}"#;
760
761        let _parsed =
762            serde_json::from_str::<CancelBroadcastResponse>(data).expect("Parsing failed");
763    }
764
765    #[test]
766    fn parse_recipients_response_sent_test() {
767        let data = r#"{
768    "object": "list",
769    "has_more": false,
770    "data": [
771        {
772            "id": "b2Zmc2V0OjA",
773            "contact_id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
774            "email": "steve.wozniak@gmail.com"
775        },
776        {
777            "id": "b2Zmc2V0OjE",
778            "contact_id": null,
779            "email": "dana@example.com"
780        }
781    ]
782}"#;
783
784        let parsed =
785            serde_json::from_str::<crate::list_opts::ListResponse<BroadcastRecipient>>(data)
786                .expect("Parsing failed");
787
788        assert!(!parsed.has_more);
789        assert_eq!(parsed.len(), 2);
790        assert_eq!(
791            parsed[0].contact_id.as_deref(),
792            Some("e169aa45-1ecf-4183-9955-b1499d5701d3")
793        );
794        assert!(parsed[1].contact_id.is_none());
795        assert!(parsed[0].count.is_none());
796        assert!(parsed[0].bounce_type.is_none());
797        assert!(parsed[0].clicked_links.is_none());
798    }
799
800    #[test]
801    fn parse_recipients_response_opened_test() {
802        let data = r#"{
803    "id": "b2Zmc2V0OjA",
804    "contact_id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
805    "email": "steve.wozniak@gmail.com",
806    "count": 3
807}"#;
808
809        let parsed = serde_json::from_str::<BroadcastRecipient>(data).expect("Parsing failed");
810
811        assert_eq!(parsed.count, Some(3));
812        assert!(parsed.bounce_type.is_none());
813        assert!(parsed.clicked_links.is_none());
814    }
815
816    #[test]
817    fn parse_recipients_response_clicked_test() {
818        let data = r#"{
819    "id": "b2Zmc2V0OjA",
820    "contact_id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
821    "email": "carter@example.com",
822    "count": 3,
823    "clicked_links": [
824        { "url": "https://resend.com/pricing", "clicks": 2 },
825        { "url": "https://resend.com/docs", "clicks": 1 }
826    ]
827}"#;
828
829        let parsed = serde_json::from_str::<BroadcastRecipient>(data).expect("Parsing failed");
830
831        assert_eq!(parsed.count, Some(3));
832        let clicked_links = parsed.clicked_links.expect("clicked_links should be set");
833        assert_eq!(clicked_links.len(), 2);
834        assert_eq!(clicked_links[0].url, "https://resend.com/pricing");
835        assert_eq!(clicked_links[0].clicks, 2);
836    }
837
838    #[test]
839    fn parse_recipients_response_bounced_test() {
840        let data = r#"{
841    "id": "b2Zmc2V0OjA",
842    "contact_id": null,
843    "email": "bounced@example.com",
844    "bounce_type": "permanent"
845}"#;
846
847        let parsed = serde_json::from_str::<BroadcastRecipient>(data).expect("Parsing failed");
848
849        assert!(parsed.contact_id.is_none());
850        assert!(parsed.count.is_none());
851        assert!(parsed.clicked_links.is_none());
852        assert_eq!(
853            parsed.bounce_type,
854            Some(super::types::BroadcastRecipientBounceType::Permanent)
855        );
856    }
857
858    #[test]
859    fn serialize_list_recipients_options_test() {
860        use super::types::BroadcastRecipientBounceType;
861
862        let opts = ListRecipientsOptions::new(BroadcastRecipientEventType::Bounced)
863            .with_email("steve")
864            .with_bounce_type(BroadcastRecipientBounceType::Permanent)
865            .with_limit(10)
866            .list_after("cursor-123");
867
868        let json = serde_json::to_value(&opts).expect("Failed to serialize");
869
870        assert_eq!(json["type"], "bounced");
871        assert_eq!(json["email"], "steve");
872        assert_eq!(json["bounce_type"], "permanent");
873        assert_eq!(json["limit"], 10);
874        assert_eq!(json["after"], "cursor-123");
875        assert!(json.get("before").is_none() || json["before"].is_null());
876    }
877
878    #[tokio_shared_rt::test(shared = true)]
879    #[serial_test::serial]
880    #[cfg(not(feature = "blocking"))]
881    #[ignore = "requires RESEND_API_KEY and network access"]
882    async fn recipients_not_found() -> DebugResult<()> {
883        let resend = &*CLIENT;
884        std::thread::sleep(std::time::Duration::from_secs(1));
885
886        let list_opts = ListRecipientsOptions::new(BroadcastRecipientEventType::Sent);
887        let result = resend
888            .broadcasts
889            .recipients("00000000-0000-0000-0000-000000000000", list_opts)
890            .await;
891
892        assert!(result.is_err());
893
894        Ok(())
895    }
896
897    #[test]
898    fn parse_broadcast_clicked_links_test() {
899        let data = r#"{
900          "object": "list",
901          "has_more": true,
902          "data": [
903            {
904              "id": "b2Zmc2V0OjA",
905              "url": "https://resend.com/pricing",
906              "clicks": 42,
907              "unique_clicks": 30
908            },
909            {
910              "id": "b2Zmc2V0OjE",
911              "url": "https://resend.com/docs",
912              "clicks": 17,
913              "unique_clicks": 15
914            }
915          ]
916        }"#;
917
918        let _parsed =
919            serde_json::from_str::<crate::list_opts::ListResponse<BroadcastClickedLink>>(data)
920                .expect("Parsing failed");
921    }
922}