Skip to main content

resend_rs/
contacts.rs

1use std::io::Read;
2use std::sync::Arc;
3use std::{fmt, fs::File};
4
5#[cfg(feature = "blocking")]
6use reqwest::blocking::multipart::{Form, Part};
7#[cfg(not(feature = "blocking"))]
8use reqwest::multipart::{Form, Part};
9
10use reqwest::Method;
11
12use self::types::UpdateContactResponse;
13use crate::{
14    Config, Error, Result,
15    contacts::types::ContactPropertyChanges,
16    list_opts::ListOptions,
17    types::{
18        AddContactSegmentResponse, ContactImport, ContactProperty, ContactTopic,
19        CreateContactImportOptions, CreateContactImportResponse, CreateContactPropertyOptions,
20        CreateContactPropertyResponse, DeleteContactPropertyResponse, RemoveContactSegmentResponse,
21        Segment, UpdateContactPropertyResponse, UpdateContactTopicOptions,
22    },
23};
24use crate::{
25    list_opts::ListResponse,
26    types::{Contact, ContactChanges, ContactId, CreateContactOptions},
27};
28
29/// `Resend` APIs for `/audiences/:id/contacts` endpoints.
30#[derive(Clone)]
31pub struct ContactsSvc(pub(crate) Arc<Config>);
32
33impl ContactsSvc {
34    /// Create a contact.
35    ///
36    /// <https://resend.com/docs/api-reference/contacts/create-contact>
37    #[maybe_async::maybe_async]
38    pub async fn create(&self, contact: CreateContactOptions) -> Result<ContactId> {
39        let path = contact.audience_id.as_ref().map_or_else(
40            || "/contacts".to_string(),
41            |audience_id| format!("/audiences/{audience_id}/contacts"),
42        );
43
44        let request = self.0.build(Method::POST, &path);
45        let response = self.0.send(request.json(&contact)).await?;
46        let content = response.json::<types::CreateContactResponse>().await?;
47
48        Ok(content.id)
49    }
50
51    /// Retrieve a single contact.
52    ///
53    /// <https://resend.com/docs/api-reference/contacts/get-contact>
54    #[maybe_async::maybe_async]
55    pub async fn get(&self, contact_id_or_email: &str) -> Result<Contact> {
56        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
57        let path = format!("/contacts/{contact_id_or_email}");
58
59        let request = self.0.build(Method::GET, &path);
60        let response = self.0.send(request).await?;
61        let content = response.json::<Contact>().await?;
62
63        Ok(content)
64    }
65
66    /// Update an existing contact.
67    ///
68    /// <https://resend.com/docs/api-reference/contacts/update-contact>
69    #[maybe_async::maybe_async]
70    pub async fn update(
71        &self,
72        contact_id_or_email: &str,
73        update: ContactChanges,
74    ) -> Result<UpdateContactResponse> {
75        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
76        let path = format!("/contacts/{contact_id_or_email}");
77
78        let request = self.0.build(Method::PATCH, &path);
79        let response = self.0.send(request.json(&update)).await?;
80        let content = response.json::<UpdateContactResponse>().await?;
81
82        Ok(content)
83    }
84
85    /// Remove an existing contact.
86    ///
87    /// <https://resend.com/docs/api-reference/contacts/delete-contact>
88    #[maybe_async::maybe_async]
89    pub async fn delete(&self, contact_id_or_email: &str) -> Result<bool> {
90        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
91        let path = format!("/contacts/{contact_id_or_email}");
92
93        let request = self.0.build(Method::DELETE, &path);
94        let response = self.0.send(request).await?;
95        let content = response.json::<types::DeleteContactResponse>().await?;
96
97        Ok(content.deleted)
98    }
99
100    /// Retrieves a list contacts from an audience.
101    ///
102    /// - Default limit: no limit (return everything)
103    ///
104    /// <https://resend.com/docs/api-reference/contacts/list-contacts>
105    #[maybe_async::maybe_async]
106    pub async fn list<T>(
107        &self,
108        audience: &str,
109        list_opts: ListOptions<T>,
110    ) -> Result<ListResponse<Contact>> {
111        let path = format!("/audiences/{audience}/contacts");
112
113        let request = self.0.build(Method::GET, &path).query(&list_opts);
114        let response = self.0.send(request).await?;
115        let content = response.json::<ListResponse<Contact>>().await?;
116
117        Ok(content)
118    }
119
120    /// Retrieve a list of topics subscriptions for a contact.
121    ///
122    /// <https://resend.com/docs/api-reference/contacts/get-contact-topic>
123    #[maybe_async::maybe_async]
124    pub async fn get_contact_topics<T>(
125        &self,
126        contact_id_or_email: &str,
127        list_opts: ListOptions<T>,
128    ) -> Result<ListResponse<ContactTopic>> {
129        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
130        let path = format!("/contacts/{contact_id_or_email}/topics");
131
132        let request = self.0.build(Method::GET, &path).query(&list_opts);
133        let response = self.0.send(request).await?;
134        let content = response.json::<ListResponse<ContactTopic>>().await?;
135
136        Ok(content)
137    }
138
139    /// Update an existing topic subscription for a contact.
140    ///
141    /// <https://resend.com/docs/api-reference/contacts/update-contact-topic>
142    #[maybe_async::maybe_async]
143    pub async fn update_contact_topics(
144        &self,
145        contact_id_or_email: &str,
146        topics: impl Into<Vec<UpdateContactTopicOptions>>,
147    ) -> Result<UpdateContactResponse> {
148        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
149        let path = format!("/contacts/{contact_id_or_email}/topics");
150
151        let request = self.0.build(Method::PATCH, &path);
152        let response = self.0.send(request.json(&topics.into())).await?;
153        let content = response.json::<UpdateContactResponse>().await?;
154
155        Ok(content)
156    }
157
158    /// Add an existing contact to a segment.
159    ///
160    /// <https://resend.com/docs/api-reference/contacts/add-contact-to-segment>
161    #[maybe_async::maybe_async]
162    pub async fn add_contact_segment(
163        &self,
164        contact_id_or_email: &str,
165        segment_id: &str,
166    ) -> Result<AddContactSegmentResponse> {
167        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
168        let path = format!("/contacts/{contact_id_or_email}/segments/{segment_id}");
169
170        let request = self.0.build(Method::POST, &path);
171        let response = self.0.send(request).await?;
172        let content = response.json::<AddContactSegmentResponse>().await?;
173
174        Ok(content)
175    }
176
177    /// Remove an existing contact from a segment.
178    ///
179    /// <https://resend.com/docs/api-reference/contacts/delete-contact-segment>
180    #[maybe_async::maybe_async]
181    pub async fn delete_contact_segment(
182        &self,
183        contact_id_or_email: &str,
184        segment_id: &str,
185    ) -> Result<RemoveContactSegmentResponse> {
186        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
187        let path = format!("/contacts/{contact_id_or_email}/segments/{segment_id}");
188
189        let request = self.0.build(Method::DELETE, &path);
190        let response = self.0.send(request).await?;
191        let content = response.json::<RemoveContactSegmentResponse>().await?;
192
193        Ok(content)
194    }
195
196    /// Retrieve a list of segments that a contact is part of.
197    ///
198    /// <https://resend.com/docs/api-reference/contacts/list-contact-segments>
199    #[maybe_async::maybe_async]
200    pub async fn list_contact_segment<T>(
201        &self,
202        contact_id_or_email: &str,
203        list_opts: ListOptions<T>,
204    ) -> Result<ListResponse<Segment>> {
205        let contact_id_or_email = urlencoding::encode(contact_id_or_email);
206        let path = format!("/contacts/{contact_id_or_email}/segments/");
207
208        let request = self.0.build(Method::GET, &path).query(&list_opts);
209        let response = self.0.send(request).await?;
210        let content = response.json::<ListResponse<Segment>>().await?;
211
212        Ok(content)
213    }
214
215    /// Create a custom property for your contacts.
216    ///
217    /// <https://resend.com/docs/api-reference/contact-properties/create-contact-property>
218    #[maybe_async::maybe_async]
219    pub async fn create_property(
220        &self,
221        contact_property: CreateContactPropertyOptions,
222    ) -> Result<CreateContactPropertyResponse> {
223        let path = "/contact-properties";
224
225        let request = self.0.build(Method::POST, path);
226        let response = self.0.send(request.json(&contact_property)).await?;
227        let content = response.json::<CreateContactPropertyResponse>().await?;
228
229        Ok(content)
230    }
231
232    /// Retrieve a contact property by its ID.
233    ///
234    /// <https://resend.com/docs/api-reference/contact-properties/get-contact-property>
235    #[maybe_async::maybe_async]
236    pub async fn get_property(&self, contact_property_id: &str) -> Result<ContactProperty> {
237        let path = format!("/contact-properties/{contact_property_id}");
238
239        let request = self.0.build(Method::GET, &path);
240        let response = self.0.send(request).await?;
241        let content = response.json::<ContactProperty>().await?;
242
243        Ok(content)
244    }
245
246    /// Update an existing contact property.
247    ///
248    /// <https://resend.com/docs/api-reference/contact-properties/update-contact-property>
249    #[maybe_async::maybe_async]
250    pub async fn update_property(
251        &self,
252        contact_property_id: &str,
253        update: ContactPropertyChanges,
254    ) -> Result<UpdateContactPropertyResponse> {
255        let path = format!("/contact-properties/{contact_property_id}");
256
257        let request = self.0.build(Method::PATCH, &path);
258        let response = self.0.send(request.json(&update)).await?;
259        let content = response.json::<UpdateContactPropertyResponse>().await?;
260
261        Ok(content)
262    }
263
264    /// Remove an existing contact property.
265    ///
266    /// <https://resend.com/docs/api-reference/contact-properties/delete-contact-property>
267    #[maybe_async::maybe_async]
268    pub async fn delete_property(
269        &self,
270        contact_property_id: &str,
271    ) -> Result<DeleteContactPropertyResponse> {
272        let path = format!("/contact-properties/{contact_property_id}");
273
274        let request = self.0.build(Method::DELETE, &path);
275        let response = self.0.send(request).await?;
276        let content = response.json::<DeleteContactPropertyResponse>().await?;
277
278        Ok(content)
279    }
280
281    /// Retrieve a list of contact properties.
282    ///
283    /// - Default limit: 20
284    ///
285    /// <https://resend.com/docs/api-reference/contact-properties/list-contact-properties>
286    #[maybe_async::maybe_async]
287    pub async fn list_properties<T>(
288        &self,
289        list_opts: ListOptions<T>,
290    ) -> Result<ListResponse<ContactProperty>> {
291        let path = "/contact-properties";
292
293        let request = self.0.build(Method::GET, path).query(&list_opts);
294        let response = self.0.send(request).await?;
295        let content = response.json::<ListResponse<ContactProperty>>().await?;
296
297        Ok(content)
298    }
299
300    /// Create a contact import.
301    ///
302    /// # Important
303    ///
304    /// Make sure the file handle has read permissions. You can ensure this by either opening it via [`File::open`] or
305    /// using [`std::fs::OpenOptions`] and setting `read` to `true`.
306    ///
307    /// <https://resend.com/docs/api-reference/contacts/create-contact-import>
308    #[maybe_async::maybe_async]
309    pub async fn create_import(
310        &self,
311        mut file: File,
312        contact_import: CreateContactImportOptions,
313    ) -> Result<CreateContactImportResponse> {
314        let path = "/contacts/imports";
315
316        let mut contents = String::new();
317        #[allow(clippy::verbose_file_reads)]
318        let _size = file
319            .read_to_string(&mut contents)
320            .map_err(|err| Error::Parse {
321                message: "Error while reading file".to_owned(),
322                source: Some(Box::new(err)),
323            })?;
324
325        let mut form = Form::new().part(
326            "file",
327            Part::text(contents)
328                .mime_str("text/csv")?
329                .file_name("foo.csv"),
330        );
331        form = Self::add_non_json_part(form, "on_conflict", contact_import.on_conflict.as_ref())?;
332        form = Self::add_non_json_part(form, "segments", contact_import.segments.as_ref())?;
333        form = Self::add_non_json_part(form, "topics", contact_import.topics.as_ref())?;
334
335        if let Some(column_map) = contact_import.column_map {
336            let json = serde_json::to_string(&column_map).map_err(|e| Error::Parse {
337                message: "Could not convert field to JSON".to_owned(),
338                source: Some(Box::new(e)),
339            })?;
340            form = form.part("column_map".to_owned(), Part::text(json));
341        }
342
343        let request = self.0.build(Method::POST, path).multipart(form);
344        let response = self.0.send(request).await?;
345        let content = response.json::<CreateContactImportResponse>().await?;
346
347        Ok(content)
348    }
349
350    fn add_non_json_part<T: serde::Serialize>(
351        form: Form,
352        field_name: &str,
353        value: Option<&T>,
354    ) -> Result<Form, Error> {
355        match value {
356            Some(v) => {
357                let val = serde_json::to_value(v).map_err(|e| Error::Parse {
358                    message: "Could not convert field to JSON".to_owned(),
359                    source: Some(Box::new(e)),
360                })?;
361
362                let json = match val {
363                    serde_json::Value::String(s) => s,
364                    other => other.to_string(),
365                };
366
367                Ok(form.part(field_name.to_owned(), Part::text(json)))
368            }
369            None => Ok(form),
370        }
371    }
372
373    /// Retrieve a single contact import.
374    ///
375    /// <https://resend.com/docs/api-reference/contacts/get-contact-import>
376    #[maybe_async::maybe_async]
377    pub async fn get_import(&self, contact_import_id: &str) -> Result<ContactImport> {
378        let path = format!("/contacts/imports/{contact_import_id}");
379
380        let request = self.0.build(Method::GET, &path);
381        let response = self.0.send(request).await?;
382        let content = response.json::<ContactImport>().await?;
383
384        Ok(content)
385    }
386
387    /// Retrieve a list of contact imports.
388    ///
389    /// - Default limit: 10
390    ///
391    /// <https://resend.com/docs/api-reference/contacts/list-contact-imports>
392    #[maybe_async::maybe_async]
393    pub async fn list_imports<T>(
394        &self,
395        list_opts: ListOptions<T>,
396    ) -> Result<ListResponse<ContactImport>> {
397        let path = "/contacts/imports".to_string();
398
399        let request = self.0.build(Method::GET, &path).query(&list_opts);
400        let response = self.0.send(request).await?;
401        let content = response.json::<ListResponse<ContactImport>>().await?;
402
403        Ok(content)
404    }
405}
406
407impl fmt::Debug for ContactsSvc {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        fmt::Debug::fmt(&self.0, f)
410    }
411}
412
413#[allow(unreachable_pub)]
414pub mod types {
415    use std::collections::HashMap;
416
417    use serde::{Deserialize, Serialize};
418
419    use crate::{
420        topics::types::TopicId,
421        types::{SegmentId, SubscriptionType},
422    };
423
424    crate::define_id_type!(ContactId);
425    crate::define_id_type!(ContactPropertyId);
426    crate::define_id_type!(ContactImportId);
427
428    #[derive(Debug, Clone, Serialize, Deserialize)]
429    pub struct SegmentObject {
430        pub id: SegmentId,
431    }
432
433    /// Details of a new [`Contact`].
434    #[must_use]
435    #[derive(Debug, Clone, Serialize)]
436    pub struct CreateContactOptions {
437        /// Email address of the contact.
438        email: String,
439
440        /// The Audience ID.
441        pub(crate) audience_id: Option<String>,
442
443        /// First name of the contact.
444        #[serde(skip_serializing_if = "Option::is_none")]
445        first_name: Option<String>,
446        /// Last name of the contact.
447        #[serde(skip_serializing_if = "Option::is_none")]
448        last_name: Option<String>,
449        /// Indicates if the contact is unsubscribed.
450        #[serde(skip_serializing_if = "Option::is_none")]
451        unsubscribed: Option<bool>,
452        /// Custom properties for the contact.
453        #[serde(skip_serializing_if = "Option::is_none")]
454        properties: Option<HashMap<String, String>>,
455        /// Segment IDs to add the contact to.
456        #[serde(skip_serializing_if = "Option::is_none")]
457        segments: Option<Vec<SegmentObject>>,
458        /// Topic subscriptions for the contact.
459        #[serde(skip_serializing_if = "Option::is_none")]
460        topics: Option<Vec<UpdateContactTopicOptions>>,
461    }
462
463    impl CreateContactOptions {
464        /// Creates a new [`CreateContactOptions`].
465        pub fn new(email: &str) -> Self {
466            Self {
467                email: email.to_owned(),
468                audience_id: None,
469                first_name: None,
470                last_name: None,
471                unsubscribed: None,
472                properties: None,
473                segments: None,
474                topics: None,
475            }
476        }
477
478        /// Adds the audience id to the contact.
479        #[inline]
480        pub fn with_audience_id(mut self, audience_id: &str) -> Self {
481            self.audience_id = Some(audience_id.to_owned());
482            self
483        }
484
485        /// Adds the first name to the contact.
486        #[inline]
487        pub fn with_first_name(mut self, name: &str) -> Self {
488            self.first_name = Some(name.to_owned());
489            self
490        }
491
492        /// Adds the last name to the contact.
493        #[inline]
494        pub fn with_last_name(mut self, name: &str) -> Self {
495            self.last_name = Some(name.to_owned());
496            self
497        }
498
499        /// Toggles the unsubscribe status to `unsubscribe`.
500        #[inline]
501        pub const fn with_unsubscribed(mut self, unsubscribed: bool) -> Self {
502            self.unsubscribed = Some(unsubscribed);
503            self
504        }
505
506        /// Adds a custom property to the contact.
507        #[inline]
508        pub fn with_property(mut self, key: &str, value: &str) -> Self {
509            let properties = self.properties.get_or_insert_with(HashMap::new);
510            let _old = properties.insert(key.to_owned(), value.to_owned());
511            self
512        }
513
514        /// Adds custom properties to the contact.
515        #[inline]
516        pub fn with_properties(mut self, properties: HashMap<String, String>) -> Self {
517            let self_properties = self.properties.get_or_insert_with(HashMap::new);
518            self_properties.extend(properties);
519            self
520        }
521
522        /// Adds a segment ID to add the contact to.
523        #[inline]
524        pub fn with_segment(mut self, id: &str) -> Self {
525            let id = SegmentObject {
526                id: SegmentId::new(id),
527            };
528            let segments = self.segments.get_or_insert_with(Vec::new);
529            segments.push(id);
530            self
531        }
532
533        /// Adds multiple segment IDs to add the contact to.
534        #[inline]
535        pub fn with_segments(mut self, ids: &[String]) -> Self {
536            let segments = self.segments.get_or_insert_with(Vec::new);
537            for id in ids {
538                let id = SegmentObject {
539                    id: SegmentId::new(id),
540                };
541                segments.push(id);
542            }
543            self
544        }
545
546        /// Adds a topic subscription for the contact.
547        #[inline]
548        pub fn with_topic(mut self, topic: UpdateContactTopicOptions) -> Self {
549            let topics = self.topics.get_or_insert_with(Vec::new);
550            topics.push(topic);
551            self
552        }
553
554        /// Adds multiple topic subscriptions for the contact.
555        #[inline]
556        pub fn with_topics(mut self, topics: &[UpdateContactTopicOptions]) -> Self {
557            let topics_vec = self.topics.get_or_insert_with(Vec::new);
558            topics_vec.extend_from_slice(topics);
559            self
560        }
561    }
562
563    #[derive(Debug, Clone, Serialize, Deserialize)]
564    pub struct CreateContactResponse {
565        /// Unique identifier for the created contact.
566        pub id: ContactId,
567    }
568
569    /// Details of an existing contact.
570    #[must_use]
571    #[derive(Debug, Clone, Serialize, Deserialize)]
572    pub struct Contact {
573        /// Unique identifier for the contact.
574        pub id: ContactId,
575        /// Email address of the contact.
576        pub email: String,
577        /// First name of the contact.
578        pub first_name: Option<String>,
579        /// Last name of the contact.
580        pub last_name: Option<String>,
581        /// Indicates if the contact is unsubscribed.
582        pub unsubscribed: bool,
583        /// Timestamp indicating when the contact was created.
584        pub created_at: String,
585        /// Custom properties for the contact.
586        #[serde(default)]
587        pub properties: Option<HashMap<String, ContactPropertyResponse>>,
588    }
589
590    /// List of changes to apply to a [`Contact`].
591    #[must_use]
592    #[derive(Debug, Default, Clone, Serialize)]
593    pub struct ContactChanges {
594        /// First name of the contact.
595        #[serde(skip_serializing_if = "Option::is_none")]
596        first_name: Option<String>,
597        /// Last name of the contact.
598        #[serde(skip_serializing_if = "Option::is_none")]
599        last_name: Option<String>,
600        /// Indicates the subscription status of the contact.
601        #[serde(skip_serializing_if = "Option::is_none")]
602        unsubscribed: Option<bool>,
603    }
604
605    impl ContactChanges {
606        /// Creates a new [`ContactChanges`].
607        #[inline]
608        pub fn new() -> Self {
609            Self::default()
610        }
611
612        /// Updates the first name of the contact.
613        #[inline]
614        pub fn with_first_name(mut self, name: &str) -> Self {
615            self.first_name = Some(name.to_owned());
616            self
617        }
618
619        /// Updates the last name of the contact.
620        #[inline]
621        pub fn with_last_name(mut self, name: &str) -> Self {
622            self.last_name = Some(name.to_owned());
623            self
624        }
625
626        /// Updates the unsubscribe status of the contact.
627        #[inline]
628        pub const fn with_unsubscribed(mut self, unsubscribed: bool) -> Self {
629            self.unsubscribed = Some(unsubscribed);
630            self
631        }
632    }
633
634    #[derive(Debug, Clone, Serialize, Deserialize)]
635    pub struct UpdateContactResponse {
636        /// Unique identifier for the updated contact.
637        pub id: ContactId,
638    }
639
640    #[derive(Debug, Clone, Serialize, Deserialize)]
641    pub struct DeleteContactResponse {
642        /// The ID of the contact.
643        #[allow(dead_code)]
644        pub contact: ContactId,
645        /// Indicates whether the contact was deleted successfully.
646        pub deleted: bool,
647    }
648
649    #[derive(Serialize, Deserialize, Debug, Clone)]
650    pub struct ContactTopic {
651        pub id: TopicId,
652        pub name: String,
653        pub description: Option<String>,
654        pub subscription: SubscriptionType,
655        pub created_at: String,
656    }
657
658    /// See [relevant docs].
659    ///
660    /// [relevant docs]: <https://resend.com/docs/api-reference/contacts/update-contact-topic#body-parameters>
661    #[must_use]
662    #[derive(Debug, Clone, Serialize)]
663    pub struct UpdateContactTopicOptions {
664        id: String,
665        subscription: SubscriptionType,
666    }
667
668    impl UpdateContactTopicOptions {
669        /// `id`: The Topic ID.
670        /// `subscription`: The subscription action.
671        pub fn new(id: impl Into<String>, subscription: SubscriptionType) -> Self {
672            Self {
673                id: id.into(),
674                subscription,
675            }
676        }
677    }
678
679    #[must_use]
680    #[derive(Debug, Clone, Serialize, Deserialize)]
681    pub struct AddContactSegmentResponse {
682        pub id: SegmentId,
683    }
684
685    #[must_use]
686    #[derive(Debug, Clone, Serialize, Deserialize)]
687    pub struct RemoveContactSegmentResponse {
688        pub id: SegmentId,
689        pub deleted: bool,
690    }
691
692    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
693    #[must_use]
694    #[serde(rename_all = "snake_case")]
695    pub enum PropertyType {
696        String,
697        Number,
698    }
699
700    /// Details of a new [`ContactProperty`].
701    #[must_use]
702    #[derive(Debug, Clone, Serialize)]
703    pub struct CreateContactPropertyOptions {
704        key: String,
705        #[serde(rename = "type")]
706        r#type: PropertyType,
707        fallback_value: Option<serde_json::Value>,
708    }
709
710    impl CreateContactPropertyOptions {
711        /// - `key`: The property key. Max length is `50` characters. Only alphanumeric characters
712        ///   and underscores are allowed.
713        /// - `r#type`: The property type.
714        pub fn new(key: impl Into<String>, r#type: PropertyType) -> Self {
715            Self {
716                key: key.into(),
717                r#type,
718                fallback_value: None,
719            }
720        }
721
722        /// The default value to use when the property is not set for a contact. Must match the
723        /// type specified in the `r#type` field.
724        pub fn with_fallback(mut self, fallback: impl Into<serde_json::Value>) -> Self {
725            self.fallback_value = Some(fallback.into());
726            self
727        }
728    }
729
730    #[derive(Debug, Clone, Serialize, Deserialize)]
731    pub struct CreateContactPropertyResponse {
732        pub id: ContactPropertyId,
733    }
734
735    #[derive(Debug, Clone, Serialize, Deserialize)]
736    pub struct ContactProperty {
737        pub id: ContactPropertyId,
738        pub created_at: String,
739        pub key: String,
740        #[serde(rename = "type")]
741        pub r#type: PropertyType,
742        pub fallback_value: Option<serde_json::Value>,
743    }
744
745    #[derive(Debug, Clone, Serialize, Deserialize)]
746    pub struct ContactPropertyResponse {
747        pub value: String,
748        #[serde(rename = "type")]
749        pub r#type: PropertyType,
750    }
751
752    /// List of changes to apply to a [`ContactProperty`].
753    #[must_use]
754    #[derive(Debug, Default, Clone, Serialize)]
755    pub struct ContactPropertyChanges {
756        #[serde(skip_serializing_if = "Option::is_none")]
757        fallback_value: Option<serde_json::Value>,
758    }
759
760    impl ContactPropertyChanges {
761        /// The default value to use when the property is not set for a contact. Must match the
762        /// type of the property.
763        pub fn with_fallback(mut self, fallback: impl Into<serde_json::Value>) -> Self {
764            self.fallback_value = Some(fallback.into());
765            self
766        }
767    }
768
769    #[derive(Debug, Clone, Serialize, Deserialize)]
770    pub struct UpdateContactPropertyResponse {
771        /// Unique identifier for the updated contact property.
772        pub id: ContactPropertyId,
773    }
774
775    #[derive(Debug, Clone, Serialize, Deserialize)]
776    pub struct DeleteContactPropertyResponse {
777        #[allow(dead_code)]
778        pub id: ContactPropertyId,
779        pub deleted: bool,
780    }
781
782    #[must_use]
783    #[derive(Debug, Clone, Serialize, Default)]
784    pub struct CreateContactImportOptions {
785        #[serde(skip_serializing_if = "Option::is_none")]
786        pub(crate) column_map: Option<ContactImportColumnMap>,
787        #[serde(skip_serializing_if = "Option::is_none")]
788        pub(crate) on_conflict: Option<ContactImportOnConflict>,
789        #[serde(skip_serializing_if = "Option::is_none")]
790        pub(crate) segments: Option<Vec<SegmentObject>>,
791        #[serde(skip_serializing_if = "Option::is_none")]
792        pub(crate) topics: Option<Vec<ContactImportTopic>>,
793    }
794
795    impl CreateContactImportOptions {
796        /// Creates a new [`CreateContactImportOptions`].
797        pub fn new() -> Self {
798            Self {
799                column_map: None,
800                on_conflict: None,
801                segments: None,
802                topics: None,
803            }
804        }
805
806        #[inline]
807        pub fn with_column_map(mut self, column_map: ContactImportColumnMap) -> Self {
808            self.column_map = Some(column_map);
809            self
810        }
811
812        #[inline]
813        pub fn with_on_conflict(mut self, on_conflict: ContactImportOnConflict) -> Self {
814            self.on_conflict = Some(on_conflict);
815            self
816        }
817
818        /// Adds a segment ID to add the contact import to.
819        #[inline]
820        pub fn with_segment(mut self, id: &str) -> Self {
821            let id = SegmentObject {
822                id: SegmentId::new(id),
823            };
824            let segments = self.segments.get_or_insert_with(Vec::new);
825            segments.push(id);
826            self
827        }
828
829        /// Adds multiple segment IDs to add the contact import to.
830        #[inline]
831        pub fn with_segments(mut self, ids: &[String]) -> Self {
832            let segments = self.segments.get_or_insert_with(Vec::new);
833            for id in ids {
834                let id = SegmentObject {
835                    id: SegmentId::new(id),
836                };
837                segments.push(id);
838            }
839            self
840        }
841
842        /// Adds a topic subscription for the contact import.
843        #[inline]
844        pub fn with_topic(mut self, topic: ContactImportTopic) -> Self {
845            let topics = self.topics.get_or_insert_with(Vec::new);
846            topics.push(topic);
847            self
848        }
849
850        /// Adds multiple topic subscriptions for the contact import.
851        #[inline]
852        pub fn with_topics(mut self, topics: &[ContactImportTopic]) -> Self {
853            let topics_vec = self.topics.get_or_insert_with(Vec::new);
854            topics_vec.extend_from_slice(topics);
855            self
856        }
857    }
858
859    #[must_use]
860    #[derive(Debug, Clone, Serialize, Default)]
861    pub struct ContactImportColumnMap {
862        #[serde(skip_serializing_if = "Option::is_none")]
863        email: Option<String>,
864        #[serde(skip_serializing_if = "Option::is_none")]
865        first_name: Option<String>,
866        #[serde(skip_serializing_if = "Option::is_none")]
867        last_name: Option<String>,
868        #[serde(skip_serializing_if = "Option::is_none")]
869        unsubscribed: Option<String>,
870        #[serde(skip_serializing_if = "Option::is_none")]
871        properties: Option<HashMap<String, ContactImportPropertyMapping>>,
872    }
873
874    impl ContactImportColumnMap {
875        pub fn new() -> Self {
876            Self {
877                email: None,
878                first_name: None,
879                last_name: None,
880                unsubscribed: None,
881                properties: None,
882            }
883        }
884
885        pub fn with_email(mut self, email: &str) -> Self {
886            self.email = Some(email.to_owned());
887            self
888        }
889
890        pub fn with_first_name(mut self, first_name: &str) -> Self {
891            self.first_name = Some(first_name.to_owned());
892            self
893        }
894
895        pub fn with_last_name(mut self, last_name: &str) -> Self {
896            self.last_name = Some(last_name.to_owned());
897            self
898        }
899
900        pub fn with_unsubscribed(mut self, unsubscribed: &str) -> Self {
901            self.unsubscribed = Some(unsubscribed.to_owned());
902            self
903        }
904
905        /// Adds a custom property.
906        #[inline]
907        pub fn with_property(mut self, key: &str, value: ContactImportPropertyMapping) -> Self {
908            let properties = self.properties.get_or_insert_with(HashMap::new);
909            let _old = properties.insert(key.to_owned(), value);
910            self
911        }
912
913        /// Adds custom properties.
914        #[inline]
915        pub fn with_properties(
916            mut self,
917            properties: HashMap<String, ContactImportPropertyMapping>,
918        ) -> Self {
919            let self_properties = self.properties.get_or_insert_with(HashMap::new);
920            self_properties.extend(properties);
921            self
922        }
923    }
924
925    #[must_use]
926    #[derive(Debug, Clone, Serialize)]
927    pub struct ContactImportPropertyMapping {
928        pub column: String,
929        #[serde(rename = "type")]
930        pub r#type: ContactImportPropertyType,
931    }
932
933    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
934    #[must_use]
935    #[serde(rename_all = "snake_case")]
936    pub enum ContactImportPropertyType {
937        String,
938        Number,
939        Boolean,
940    }
941
942    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
943    #[must_use]
944    #[serde(rename_all = "snake_case")]
945    pub enum ContactImportOnConflict {
946        Upsert,
947        Skip,
948    }
949
950    #[derive(Debug, Clone, Serialize, Deserialize)]
951    pub struct CreateContactImportResponse {
952        /// Unique identifier for the created contact import.
953        pub id: ContactImportId,
954    }
955
956    #[derive(Debug, Clone, Serialize, Deserialize)]
957    pub struct ContactImportTopic {
958        pub id: String,
959        pub subscription: ContactImportTopicSubscription,
960    }
961
962    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
963    #[must_use]
964    #[serde(rename_all = "snake_case")]
965    pub enum ContactImportTopicSubscription {
966        OptIn,
967        OptOut,
968    }
969
970    // counts: ContactImportCounts;
971    #[derive(Debug, Clone, Serialize, Deserialize)]
972    pub struct ContactImport {
973        pub id: ContactImportId,
974        pub status: ContactImportStatus,
975        pub created_at: String,
976        pub completed_at: Option<String>,
977        pub counts: ContactImportCounts,
978    }
979
980    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
981    #[must_use]
982    #[serde(rename_all = "snake_case")]
983    pub enum ContactImportStatus {
984        Queued,
985        InProgress,
986        Completed,
987        Failed,
988    }
989
990    #[derive(Debug, Clone, Serialize, Deserialize, Copy)]
991    pub struct ContactImportCounts {
992        total: u32,
993        created: u32,
994        updated: u32,
995        skipped: u32,
996        failed: u32,
997    }
998}
999
1000#[cfg(test)]
1001#[allow(clippy::unwrap_used, clippy::needless_return)]
1002mod test {
1003    use std::collections::HashMap;
1004
1005    #[cfg(not(feature = "blocking"))]
1006    use crate::{
1007        list_opts::ListOptions,
1008        test::{CLIENT, DebugResult},
1009        types::{ContactChanges, CreateTopicOptions, SubscriptionType, UpdateContactTopicOptions},
1010    };
1011
1012    use crate::types::{Contact, ContactProperty, CreateContactOptions};
1013
1014    #[tokio_shared_rt::test(shared = true)]
1015    #[serial_test::serial]
1016    #[cfg(not(feature = "blocking"))]
1017    async fn no_audience() -> DebugResult<()> {
1018        let resend = &*CLIENT;
1019
1020        let contact = CreateContactOptions::new("steve.wozniak@gmail.com")
1021            .with_first_name("Steve")
1022            .with_last_name("Wozniak")
1023            .with_unsubscribed(false);
1024        let id = resend.contacts.create(contact).await?;
1025        std::thread::sleep(std::time::Duration::from_secs(4));
1026
1027        let deleted = resend.contacts.delete(&id).await?;
1028        assert!(deleted);
1029
1030        std::thread::sleep(std::time::Duration::from_secs(4));
1031
1032        Ok(())
1033    }
1034
1035    #[tokio_shared_rt::test(shared = true)]
1036    #[serial_test::serial]
1037    #[cfg(not(feature = "blocking"))]
1038    async fn all() -> DebugResult<()> {
1039        let resend = &*CLIENT;
1040        let audience = "test_contacts";
1041
1042        // Create audience.
1043        let audience = resend.segments.create(audience).await?;
1044        let audience_id = audience.id;
1045        std::thread::sleep(std::time::Duration::from_secs(4));
1046
1047        // Create.
1048        let contact = CreateContactOptions::new("antonios.barotsis@pm.me")
1049            .with_first_name("Antonios")
1050            .with_last_name("Barotsis")
1051            .with_unsubscribed(false)
1052            .with_audience_id(&audience_id);
1053        let id = resend.contacts.create(contact).await?;
1054        std::thread::sleep(std::time::Duration::from_secs(4));
1055
1056        // Get topic
1057        let topics = resend
1058            .contacts
1059            .get_contact_topics(&id, ListOptions::default())
1060            .await?;
1061        assert!(topics.data.is_empty());
1062
1063        // Update topic
1064        let topic = resend
1065            .topics
1066            .create(CreateTopicOptions::new(
1067                "Weekly Newsletter",
1068                SubscriptionType::OptIn,
1069            ))
1070            .await?;
1071        let topics = [UpdateContactTopicOptions::new(
1072            topic.id.to_string(),
1073            SubscriptionType::OptIn,
1074        )];
1075        let _topics = resend.contacts.update_contact_topics(&id, topics).await?;
1076        std::thread::sleep(std::time::Duration::from_secs(4));
1077
1078        // Get topic
1079        let topics = resend
1080            .contacts
1081            .get_contact_topics(&id, ListOptions::default())
1082            .await?;
1083        assert!(!topics.data.is_empty());
1084
1085        // Update.
1086        let changes = ContactChanges::new().with_unsubscribed(true);
1087        let _contact = resend.contacts.update(&id, changes).await?;
1088        std::thread::sleep(std::time::Duration::from_secs(4));
1089
1090        // Retrieve.
1091        let contact = resend.contacts.get(&id).await?;
1092        assert!(contact.unsubscribed);
1093
1094        // Retrieve by email.
1095        let contact = resend.contacts.get("antonios.barotsis@pm.me").await?;
1096        assert!(contact.unsubscribed);
1097
1098        // List.
1099        let contacts = resend
1100            .contacts
1101            .list(&audience_id, ListOptions::default())
1102            .await?;
1103        assert_eq!(contacts.len(), 1);
1104
1105        // Delete.
1106        let deleted = resend.contacts.delete(&id).await?;
1107        assert!(deleted);
1108
1109        // Delete audience.
1110        let deleted = resend.segments.delete(&audience_id.clone()).await?;
1111        assert!(deleted);
1112        std::thread::sleep(std::time::Duration::from_secs(1));
1113
1114        // Delete topic
1115        let deleted = resend.topics.delete(&topic.id).await?;
1116        assert!(deleted.deleted);
1117
1118        // List.
1119        let contacts = resend
1120            .contacts
1121            .list(&audience_id, ListOptions::default())
1122            .await?;
1123        assert!(contacts.is_empty());
1124
1125        std::thread::sleep(std::time::Duration::from_secs(4));
1126
1127        Ok(())
1128    }
1129
1130    #[tokio_shared_rt::test(shared = true)]
1131    #[serial_test::serial]
1132    #[cfg(not(feature = "blocking"))]
1133    async fn contact_properties() -> DebugResult<()> {
1134        use crate::types::CreateContactPropertyOptions;
1135
1136        let resend = &*CLIENT;
1137
1138        let contact_property =
1139            CreateContactPropertyOptions::new("key", crate::types::PropertyType::String);
1140        let contact_property = resend.contacts.create_property(contact_property).await?;
1141
1142        let contact = CreateContactOptions::new("steve.wozniak@gmail.com")
1143            .with_first_name("Steve")
1144            .with_last_name("Wozniak")
1145            .with_unsubscribed(false)
1146            .with_property("key", "value");
1147
1148        let contact = resend.contacts.create(contact).await?;
1149
1150        let contact = resend.contacts.get(&contact).await?;
1151
1152        let deleted = resend.contacts.delete(&contact.id).await?;
1153        assert!(deleted);
1154        let deleted = resend
1155            .contacts
1156            .delete_property(&contact_property.id)
1157            .await?;
1158        assert!(deleted.deleted);
1159
1160        Ok(())
1161    }
1162
1163    #[tokio_shared_rt::test(shared = true)]
1164    #[serial_test::serial]
1165    #[cfg(not(feature = "blocking"))]
1166    async fn segments() -> DebugResult<()> {
1167        let resend = &*CLIENT;
1168
1169        // Create segment
1170        let segment = resend.segments.create("registered users").await?;
1171        std::thread::sleep(std::time::Duration::from_secs(2));
1172
1173        // Create contact
1174        let contact = CreateContactOptions::new("antonios.barotsis@pm.me")
1175            .with_first_name("Antonios")
1176            .with_last_name("Barotsis");
1177        let contact_id = resend.contacts.create(contact).await?;
1178        std::thread::sleep(std::time::Duration::from_secs(2));
1179
1180        let _added = resend
1181            .contacts
1182            .add_contact_segment(&contact_id, &segment.id)
1183            .await?;
1184        std::thread::sleep(std::time::Duration::from_secs(4));
1185
1186        let list = resend
1187            .contacts
1188            .list_contact_segment(&contact_id, ListOptions::default())
1189            .await?;
1190        assert!(!list.data.is_empty());
1191
1192        let deleted = resend
1193            .contacts
1194            .delete_contact_segment(&contact_id, &segment.id)
1195            .await?;
1196        assert!(deleted.deleted);
1197        std::thread::sleep(std::time::Duration::from_secs(2));
1198
1199        // Delete
1200        let deleted = resend.contacts.delete(&contact_id).await?;
1201        assert!(deleted);
1202        let deleted = resend.segments.delete(&segment.id).await?;
1203        assert!(deleted);
1204
1205        std::thread::sleep(std::time::Duration::from_secs(4));
1206
1207        Ok(())
1208    }
1209
1210    #[tokio_shared_rt::test(shared = true)]
1211    #[serial_test::serial]
1212    #[cfg(not(feature = "blocking"))]
1213    async fn properties() -> DebugResult<()> {
1214        use crate::{
1215            contacts::types::ContactPropertyChanges,
1216            types::{CreateContactPropertyOptions, PropertyType},
1217        };
1218
1219        let resend = &*CLIENT;
1220
1221        // Create
1222        let contact_property =
1223            CreateContactPropertyOptions::new("company_name", PropertyType::String)
1224                .with_fallback("Acme Corp");
1225        let contact_property = resend.contacts.create_property(contact_property).await?;
1226        std::thread::sleep(std::time::Duration::from_secs(2));
1227
1228        // Get
1229        let contact_property = resend.contacts.get_property(&contact_property.id).await?;
1230
1231        // Update
1232        let update = ContactPropertyChanges::default().with_fallback("Example Company");
1233        let contact_property = resend
1234            .contacts
1235            .update_property(&contact_property.id, update)
1236            .await?;
1237
1238        // List
1239        let contact_properties = resend
1240            .contacts
1241            .list_properties(ListOptions::default())
1242            .await?;
1243        assert!(!contact_properties.is_empty());
1244
1245        // Delete
1246        let deleted = resend
1247            .contacts
1248            .delete_property(&contact_property.id)
1249            .await?;
1250        assert!(deleted.deleted);
1251
1252        std::thread::sleep(std::time::Duration::from_secs(4));
1253
1254        Ok(())
1255    }
1256
1257    #[tokio_shared_rt::test(shared = true)]
1258    #[serial_test::serial]
1259    #[cfg(not(feature = "blocking"))]
1260    async fn contact_import() -> DebugResult<()> {
1261        use crate::{
1262            contacts::types::{
1263                ContactImportColumnMap, ContactImportOnConflict::Upsert,
1264                ContactImportPropertyMapping, ContactImportPropertyType::String,
1265            },
1266            types::CreateContactImportOptions,
1267        };
1268        use std::fs::File;
1269        use std::io::prelude::*;
1270
1271        let resend = &*CLIENT;
1272
1273        // Create
1274        let mut file = File::create("foo.csv").unwrap();
1275        file.write_all(
1276            b"Email,First Name,Last Name,Plan\nonboarding@resend.dev,John,Onboarding,El Plan",
1277        )
1278        .unwrap();
1279
1280        let import = CreateContactImportOptions::new()
1281            .with_column_map(
1282                ContactImportColumnMap::new()
1283                    .with_email("Email")
1284                    .with_first_name("First Name")
1285                    .with_last_name("Last Name")
1286                    .with_property(
1287                        "plan",
1288                        ContactImportPropertyMapping {
1289                            column: "Plan".to_owned(),
1290                            r#type: String,
1291                        },
1292                    ),
1293            )
1294            .with_on_conflict(Upsert);
1295
1296        let import = resend
1297            .contacts
1298            .create_import(File::open("foo.csv").unwrap(), import)
1299            .await?;
1300
1301        std::thread::sleep(std::time::Duration::from_secs(2));
1302
1303        // Get
1304        let _import = resend.contacts.get_import(&import.id).await?;
1305
1306        // List
1307        let imports = resend.contacts.list_imports(ListOptions::default()).await?;
1308        assert!(!imports.is_empty());
1309
1310        // Delete
1311        let deleted = resend.contacts.delete("onboarding@resend.dev").await?;
1312        assert!(deleted);
1313        let properties = resend
1314            .contacts
1315            .list_properties(ListOptions::default())
1316            .await?;
1317        let deleted = resend.contacts.delete_property(&properties[0].id).await?;
1318        assert!(deleted.deleted);
1319
1320        std::fs::remove_file("foo.csv").unwrap();
1321
1322        Ok(())
1323    }
1324
1325    #[test]
1326    fn deserialize_test() {
1327        let contact_property = r#"{
1328  "object": "contact_property",
1329  "id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e",
1330  "key": "company_name",
1331  "type": "string",
1332  "fallback_value": "Acme Corp",
1333  "created_at": "2023-04-08 00:11:13.110779+00"
1334}"#;
1335
1336        let res = serde_json::from_str::<ContactProperty>(contact_property);
1337        assert!(res.is_ok());
1338    }
1339
1340    #[test]
1341    fn deserialize_test2() {
1342        let contact = r#"{
1343          "object": "contact",
1344          "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
1345          "email": "steve.wozniak@gmail.com",
1346          "first_name": "Steve",
1347          "last_name": "Wozniak",
1348          "created_at": "2026-10-06 23:47:56.678+00",
1349          "unsubscribed": false,
1350          "properties": {
1351            "key": {
1352              "value": "value",
1353              "type": "string"
1354            }
1355          }
1356        }"#;
1357
1358        let res = serde_json::from_str::<Contact>(contact);
1359        assert!(res.is_ok());
1360    }
1361
1362    #[test]
1363    fn deserialize_test3() {
1364        let contact = r#"{
1365        "object": "contact",
1366        "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
1367        "email": "steve.wozniak@gmail.com",
1368        "first_name": null,
1369        "last_name": null,
1370        "created_at": "2026-10-06 23:47:56.678+00",
1371        "unsubscribed": false,
1372        "properties": {
1373          "key": {
1374            "value": "value",
1375            "type": "string"
1376          }
1377        }
1378      }"#;
1379
1380        let res = serde_json::from_str::<Contact>(contact);
1381        assert!(res.is_ok());
1382    }
1383
1384    #[test]
1385    #[allow(clippy::indexing_slicing)]
1386    fn serialize_create_contact_with_extras() {
1387        use crate::types::{SubscriptionType, UpdateContactTopicOptions};
1388
1389        let topic = UpdateContactTopicOptions::new("topic_123", SubscriptionType::OptIn);
1390        let mut properties = HashMap::new();
1391        let _old = properties.insert("company".to_owned(), "Acme Corp".to_owned());
1392
1393        let contact = CreateContactOptions::new("test@example.com")
1394            .with_first_name("John")
1395            .with_last_name("Doe")
1396            .with_property("department", "Sales")
1397            .with_properties(properties)
1398            .with_segment("segment_123")
1399            .with_segments(&["segment_456".to_string()])
1400            .with_topic(topic)
1401            .with_topics(&[UpdateContactTopicOptions::new(
1402                "topic_789",
1403                SubscriptionType::OptOut,
1404            )]);
1405
1406        let json = serde_json::to_value(&contact).expect("Failed to serialize");
1407
1408        // Verify structure
1409        assert_eq!(json["email"], "test@example.com");
1410        assert_eq!(json["first_name"], "John");
1411        assert_eq!(json["last_name"], "Doe");
1412
1413        // Verify properties
1414        assert!(json["properties"].is_object());
1415        let properties = json["properties"]
1416            .as_object()
1417            .expect("properties should be a map");
1418        assert_eq!(properties.len(), 2);
1419        assert!(properties.contains_key("department"));
1420        assert_eq!(
1421            properties.get("department"),
1422            Some(serde_json::Value::String("Sales".to_owned())).as_ref()
1423        );
1424        assert_eq!(
1425            properties.get("company"),
1426            Some(serde_json::Value::String("Acme Corp".to_owned())).as_ref()
1427        );
1428
1429        // Verify segments
1430        assert!(json["segments"].is_array());
1431        let segments = json["segments"]
1432            .as_array()
1433            .expect("segments should be an array");
1434        assert_eq!(segments.len(), 2);
1435        assert_eq!(segments[0], serde_json::json!({"id": "segment_123"}));
1436        assert_eq!(segments[1], serde_json::json!({"id": "segment_456"}));
1437
1438        // Verify topics
1439        assert!(json["topics"].is_array());
1440        let topics = json["topics"]
1441            .as_array()
1442            .expect("topics should be an array");
1443        assert_eq!(topics.len(), 2);
1444        assert_eq!(topics[0]["id"], "topic_123");
1445        assert_eq!(topics[0]["subscription"], "opt_in");
1446        assert_eq!(topics[1]["id"], "topic_789");
1447        assert_eq!(topics[1]["subscription"], "opt_out");
1448    }
1449}