use std::fmt;
use std::sync::Arc;
use reqwest::Method;
use crate::{
Config, Result,
contacts::types::ContactPropertyChanges,
list_opts::ListOptions,
types::{
AddContactSegmentResponse, ContactProperty, ContactTopic, CreateContactPropertyOptions,
CreateContactPropertyResponse, DeleteContactPropertyResponse, RemoveContactSegmentResponse,
Segment, UpdateContactPropertyResponse, UpdateContactTopicOptions,
},
};
use crate::{
list_opts::ListResponse,
types::{Contact, ContactChanges, ContactId, CreateContactOptions},
};
use self::types::UpdateContactResponse;
#[derive(Clone)]
pub struct ContactsSvc(pub(crate) Arc<Config>);
impl ContactsSvc {
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn create(&self, contact: CreateContactOptions) -> Result<ContactId> {
let path = contact.audience_id.as_ref().map_or_else(
|| "/contacts".to_string(),
|audience_id| format!("/audiences/{audience_id}/contacts"),
);
let request = self.0.build(Method::POST, &path);
let response = self.0.send(request.json(&contact)).await?;
let content = response.json::<types::CreateContactResponse>().await?;
Ok(content.id)
}
#[maybe_async::maybe_async]
pub async fn get(&self, contact_id_or_email: &str) -> Result<Contact> {
let path = format!("/contacts/{contact_id_or_email}");
let request = self.0.build(Method::GET, &path);
let response = self.0.send(request).await?;
let content = response.json::<Contact>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn update(
&self,
contact_id_or_email: &str,
update: ContactChanges,
) -> Result<UpdateContactResponse> {
let path = format!("/contacts/{contact_id_or_email}");
let request = self.0.build(Method::PATCH, &path);
let response = self.0.send(request.json(&update)).await?;
let content = response.json::<UpdateContactResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn delete(&self, contact_id_or_email: &str) -> Result<bool> {
let path = format!("/contacts/{contact_id_or_email}");
let request = self.0.build(Method::DELETE, &path);
let response = self.0.send(request).await?;
let content = response.json::<types::DeleteContactResponse>().await?;
Ok(content.deleted)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn list<T>(
&self,
audience: &str,
list_opts: ListOptions<T>,
) -> Result<ListResponse<Contact>> {
let path = format!("/audiences/{audience}/contacts");
let request = self.0.build(Method::GET, &path).query(&list_opts);
let response = self.0.send(request).await?;
let content = response.json::<ListResponse<Contact>>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn get_contact_topics<T>(
&self,
contact_id_or_email: &str,
list_opts: ListOptions<T>,
) -> Result<ListResponse<ContactTopic>> {
let path = format!("/contacts/{contact_id_or_email}/topics");
let request = self.0.build(Method::GET, &path).query(&list_opts);
let response = self.0.send(request).await?;
let content = response.json::<ListResponse<ContactTopic>>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn update_contact_topics(
&self,
contact_id_or_email: &str,
topics: impl Into<Vec<UpdateContactTopicOptions>>,
) -> Result<UpdateContactResponse> {
let path = format!("/contacts/{contact_id_or_email}/topics");
let request = self.0.build(Method::PATCH, &path);
let response = self.0.send(request.json(&topics.into())).await?;
let content = response.json::<UpdateContactResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn add_contact_segment(
&self,
contact_id_or_email: &str,
segment_id: &str,
) -> Result<AddContactSegmentResponse> {
let path = format!("/contacts/{contact_id_or_email}/segments/{segment_id}");
let request = self.0.build(Method::POST, &path);
let response = self.0.send(request).await?;
let content = response.json::<AddContactSegmentResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn delete_contact_segment(
&self,
contact_id_or_email: &str,
segment_id: &str,
) -> Result<RemoveContactSegmentResponse> {
let path = format!("/contacts/{contact_id_or_email}/segments/{segment_id}");
let request = self.0.build(Method::DELETE, &path);
let response = self.0.send(request).await?;
let content = response.json::<RemoveContactSegmentResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn list_contact_segment<T>(
&self,
contact_id_or_email: &str,
list_opts: ListOptions<T>,
) -> Result<ListResponse<Segment>> {
let path = format!("/contacts/{contact_id_or_email}/segments/");
let request = self.0.build(Method::GET, &path).query(&list_opts);
let response = self.0.send(request).await?;
let content = response.json::<ListResponse<Segment>>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn create_property(
&self,
contact_property: CreateContactPropertyOptions,
) -> Result<CreateContactPropertyResponse> {
let path = "/contact-properties";
let request = self.0.build(Method::POST, path);
let response = self.0.send(request.json(&contact_property)).await?;
let content = response.json::<CreateContactPropertyResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn get_property(&self, contact_property_id: &str) -> Result<ContactProperty> {
let path = format!("/contact-properties/{contact_property_id}");
let request = self.0.build(Method::GET, &path);
let response = self.0.send(request).await?;
let content = response.json::<ContactProperty>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn update_property(
&self,
contact_property_id: &str,
update: ContactPropertyChanges,
) -> Result<UpdateContactPropertyResponse> {
let path = format!("/contact-properties/{contact_property_id}");
let request = self.0.build(Method::PATCH, &path);
let response = self.0.send(request.json(&update)).await?;
let content = response.json::<UpdateContactPropertyResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
pub async fn delete_property(
&self,
contact_property_id: &str,
) -> Result<DeleteContactPropertyResponse> {
let path = format!("/contact-properties/{contact_property_id}");
let request = self.0.build(Method::DELETE, &path);
let response = self.0.send(request).await?;
let content = response.json::<DeleteContactPropertyResponse>().await?;
Ok(content)
}
#[maybe_async::maybe_async]
#[allow(clippy::needless_pass_by_value)]
pub async fn list_properties<T>(
&self,
list_opts: ListOptions<T>,
) -> Result<ListResponse<ContactProperty>> {
let path = "/contact-properties";
let request = self.0.build(Method::GET, path).query(&list_opts);
let response = self.0.send(request).await?;
let content = response.json::<ListResponse<ContactProperty>>().await?;
Ok(content)
}
}
impl fmt::Debug for ContactsSvc {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
#[allow(unreachable_pub)]
pub mod types {
use serde::{Deserialize, Serialize};
use crate::{
topics::types::TopicId,
types::{SegmentId, SubscriptionType},
};
crate::define_id_type!(ContactId);
crate::define_id_type!(ContactPropertyId);
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct CreateContactOptions {
email: String,
pub(crate) audience_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
first_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
unsubscribed: Option<bool>,
}
impl CreateContactOptions {
pub fn new(email: &str) -> Self {
Self {
email: email.to_owned(),
audience_id: None,
first_name: None,
last_name: None,
unsubscribed: None,
}
}
#[inline]
pub fn with_audience_id(mut self, audience_id: &str) -> Self {
self.audience_id = Some(audience_id.to_owned());
self
}
#[inline]
pub fn with_first_name(mut self, name: &str) -> Self {
self.first_name = Some(name.to_owned());
self
}
#[inline]
pub fn with_last_name(mut self, name: &str) -> Self {
self.last_name = Some(name.to_owned());
self
}
#[inline]
pub const fn with_unsubscribed(mut self, unsubscribed: bool) -> Self {
self.unsubscribed = Some(unsubscribed);
self
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateContactResponse {
pub id: ContactId,
}
#[must_use]
#[derive(Debug, Clone, Deserialize)]
pub struct Contact {
pub id: ContactId,
pub email: String,
pub first_name: String,
pub last_name: String,
pub unsubscribed: bool,
pub created_at: String,
}
#[must_use]
#[derive(Debug, Default, Clone, Serialize)]
pub struct ContactChanges {
#[serde(skip_serializing_if = "Option::is_none")]
first_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
unsubscribed: Option<bool>,
}
impl ContactChanges {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn with_first_name(mut self, name: &str) -> Self {
self.first_name = Some(name.to_owned());
self
}
#[inline]
pub fn with_last_name(mut self, name: &str) -> Self {
self.last_name = Some(name.to_owned());
self
}
#[inline]
pub const fn with_unsubscribed(mut self, unsubscribed: bool) -> Self {
self.unsubscribed = Some(unsubscribed);
self
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateContactResponse {
pub id: ContactId,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeleteContactResponse {
#[allow(dead_code)]
pub contact: ContactId,
pub deleted: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ContactTopic {
pub id: TopicId,
pub name: String,
pub description: Option<String>,
pub subscription: SubscriptionType,
pub created_at: String,
}
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct UpdateContactTopicOptions {
id: String,
subscription: SubscriptionType,
}
impl UpdateContactTopicOptions {
pub fn new(id: impl Into<String>, subscription: SubscriptionType) -> Self {
Self {
id: id.into(),
subscription,
}
}
}
#[must_use]
#[derive(Debug, Clone, Deserialize)]
pub struct AddContactSegmentResponse {
pub id: SegmentId,
}
#[must_use]
#[derive(Debug, Clone, Deserialize)]
pub struct RemoveContactSegmentResponse {
pub id: SegmentId,
pub deleted: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
#[must_use]
#[serde(rename_all = "snake_case")]
pub enum PropertyType {
String,
Number,
}
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct CreateContactPropertyOptions {
key: String,
#[serde(rename = "type")]
ttype: PropertyType,
fallback_value: Option<serde_json::Value>,
}
impl CreateContactPropertyOptions {
pub fn new(key: impl Into<String>, ttype: PropertyType) -> Self {
Self {
key: key.into(),
ttype,
fallback_value: None,
}
}
pub fn with_fallback(mut self, fallback: impl Into<serde_json::Value>) -> Self {
self.fallback_value = Some(fallback.into());
self
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateContactPropertyResponse {
pub id: ContactPropertyId,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ContactProperty {
pub id: ContactPropertyId,
pub created_at: String,
pub key: String,
#[serde(rename = "type")]
pub ttype: PropertyType,
pub fallback_value: Option<serde_json::Value>,
}
#[must_use]
#[derive(Debug, Default, Clone, Serialize)]
pub struct ContactPropertyChanges {
#[serde(skip_serializing_if = "Option::is_none")]
fallback_value: Option<serde_json::Value>,
}
impl ContactPropertyChanges {
pub fn with_fallback(mut self, fallback: impl Into<serde_json::Value>) -> Self {
self.fallback_value = Some(fallback.into());
self
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateContactPropertyResponse {
pub id: ContactPropertyId,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeleteContactPropertyResponse {
#[allow(dead_code)]
pub id: ContactPropertyId,
pub deleted: bool,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::needless_return)]
mod test {
use crate::list_opts::ListOptions;
use crate::test::{CLIENT, DebugResult};
use crate::types::{
ContactChanges, ContactProperty, CreateContactOptions, CreateTopicOptions,
SubscriptionType, UpdateContactTopicOptions,
};
#[tokio_shared_rt::test(shared = true)]
#[cfg(not(feature = "blocking"))]
async fn no_audience() -> DebugResult<()> {
let resend = &*CLIENT;
let contact = CreateContactOptions::new("steve.wozniak@gmail.com")
.with_first_name("Steve")
.with_last_name("Wozniak")
.with_unsubscribed(false);
let id = resend.contacts.create(contact).await?;
std::thread::sleep(std::time::Duration::from_secs(4));
let deleted = resend.contacts.delete(&id).await?;
assert!(deleted);
std::thread::sleep(std::time::Duration::from_secs(4));
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[cfg(not(feature = "blocking"))]
#[ignore = "Flaky backend"]
async fn all() -> DebugResult<()> {
let resend = &*CLIENT;
let audience = "test_contacts";
let audience = resend.segments.create(audience).await?;
let audience_id = audience.id;
std::thread::sleep(std::time::Duration::from_secs(4));
let contact = CreateContactOptions::new("antonios.barotsis@pm.me")
.with_first_name("Antonios")
.with_last_name("Barotsis")
.with_unsubscribed(false)
.with_audience_id(&audience_id);
let id = resend.contacts.create(contact).await?;
std::thread::sleep(std::time::Duration::from_secs(4));
let topics = resend
.contacts
.get_contact_topics(&id, ListOptions::default())
.await?;
assert!(topics.data.is_empty());
let topic = resend
.topics
.create(CreateTopicOptions::new(
"Weekly Newsletter",
SubscriptionType::OptIn,
))
.await?;
let topics = [UpdateContactTopicOptions::new(
topic.id.to_string(),
SubscriptionType::OptIn,
)];
let _topics = resend.contacts.update_contact_topics(&id, topics).await?;
std::thread::sleep(std::time::Duration::from_secs(4));
let topics = resend
.contacts
.get_contact_topics(&id, ListOptions::default())
.await?;
assert!(!topics.data.is_empty());
let changes = ContactChanges::new().with_unsubscribed(true);
let _contact = resend.contacts.update(&id, changes).await?;
std::thread::sleep(std::time::Duration::from_secs(4));
let contact = resend.contacts.get(&id).await?;
assert!(contact.unsubscribed);
let contact = resend.contacts.get("antonios.barotsis@pm.me").await?;
assert!(contact.unsubscribed);
let contacts = resend
.contacts
.list(&audience_id, ListOptions::default())
.await?;
assert_eq!(contacts.len(), 1);
let deleted = resend.contacts.delete(&id).await?;
assert!(deleted);
let deleted = resend.segments.delete(&audience_id.clone()).await?;
assert!(deleted);
std::thread::sleep(std::time::Duration::from_secs(1));
let deleted = resend.topics.delete(&topic.id).await?;
assert!(deleted.deleted);
let contacts = resend
.contacts
.list(&audience_id, ListOptions::default())
.await?;
assert!(contacts.is_empty());
std::thread::sleep(std::time::Duration::from_secs(4));
Ok(())
}
#[ignore = "Flaky backend"]
#[tokio_shared_rt::test(shared = true)]
#[cfg(not(feature = "blocking"))]
async fn segments() -> DebugResult<()> {
let resend = &*CLIENT;
let segment = resend.segments.create("registered users").await?;
std::thread::sleep(std::time::Duration::from_secs(2));
let contact = CreateContactOptions::new("antonios.barotsis@pm.me")
.with_first_name("Antonios")
.with_last_name("Barotsis");
let contact_id = resend.contacts.create(contact).await?;
std::thread::sleep(std::time::Duration::from_secs(2));
let _added = resend
.contacts
.add_contact_segment(&contact_id, &segment.id)
.await?;
std::thread::sleep(std::time::Duration::from_secs(4));
let list = resend
.contacts
.list_contact_segment(&contact_id, ListOptions::default())
.await?;
assert!(!list.data.is_empty());
let deleted = resend
.contacts
.delete_contact_segment(&contact_id, &segment.id)
.await?;
assert!(deleted.deleted);
std::thread::sleep(std::time::Duration::from_secs(2));
let deleted = resend.contacts.delete(&contact_id).await?;
assert!(deleted);
let deleted = resend.segments.delete(&segment.id).await?;
assert!(deleted);
std::thread::sleep(std::time::Duration::from_secs(4));
Ok(())
}
#[tokio_shared_rt::test(shared = true)]
#[cfg(not(feature = "blocking"))]
async fn properties() -> DebugResult<()> {
use crate::{
contacts::types::ContactPropertyChanges,
types::{CreateContactPropertyOptions, PropertyType},
};
let resend = &*CLIENT;
let contact_property =
CreateContactPropertyOptions::new("company_name", PropertyType::String)
.with_fallback("Acme Corp");
let contact_property = resend.contacts.create_property(contact_property).await?;
std::thread::sleep(std::time::Duration::from_secs(2));
let contact_property = resend.contacts.get_property(&contact_property.id).await?;
let update = ContactPropertyChanges::default().with_fallback("Example Company");
let contact_property = resend
.contacts
.update_property(&contact_property.id, update)
.await?;
let contact_properties = resend
.contacts
.list_properties(ListOptions::default())
.await?;
assert!(!contact_properties.is_empty());
let deleted = resend
.contacts
.delete_property(&contact_property.id)
.await?;
assert!(deleted.deleted);
std::thread::sleep(std::time::Duration::from_secs(4));
Ok(())
}
#[test]
fn deserialize_test() {
let contact_property = r#"{
"object": "contact_property",
"id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e",
"key": "company_name",
"type": "string",
"fallback_value": "Acme Corp",
"created_at": "2023-04-08T00:11:13.110779+00:00"
}"#;
let res = serde_json::from_str::<ContactProperty>(contact_property);
assert!(res.is_ok());
}
}