Skip to main content

resend_rs/
topics.rs

1use std::sync::Arc;
2
3use reqwest::Method;
4
5use crate::{
6    Config, Result,
7    list_opts::{ListOptions, ListResponse},
8    types::{
9        CreateTopicOptions, CreateTopicResponse, DeleteTopicResponse, Topic, UpdateTopicOptions,
10        UpdateTopicResponse,
11    },
12};
13
14/// `Resend` APIs for `/topics` endpoints.
15#[derive(Clone, Debug)]
16pub struct TopicsSvc(pub(crate) Arc<Config>);
17
18impl TopicsSvc {
19    /// Create and email topics to segment your audience.
20    ///
21    /// <https://resend.com/docs/api-reference/topics/create-topic>
22    #[maybe_async::maybe_async]
23    pub async fn create(&self, topic: CreateTopicOptions) -> Result<CreateTopicResponse> {
24        let request = self.0.build(Method::POST, "/topics");
25        let response = self.0.send(request.json(&topic)).await?;
26        let content = response.json::<CreateTopicResponse>().await?;
27
28        Ok(content)
29    }
30
31    /// Retrieve a topic by its ID.
32    ///
33    /// <https://resend.com/docs/api-reference/topics/get-topic>
34    #[maybe_async::maybe_async]
35    pub async fn get(&self, topic_id: &str) -> Result<Topic> {
36        let path = format!("/topics/{topic_id}");
37
38        let request = self.0.build(Method::GET, &path);
39        let response = self.0.send(request).await?;
40        let content = response.json::<Topic>().await?;
41
42        Ok(content)
43    }
44
45    /// Update an existing topic.
46    ///
47    /// <https://resend.com/docs/api-reference/topics/update-topic>
48    #[maybe_async::maybe_async]
49    pub async fn update(
50        &self,
51        topic_id: &str,
52        update: UpdateTopicOptions,
53    ) -> Result<UpdateTopicResponse> {
54        let path = format!("/topics/{topic_id}");
55
56        let request = self.0.build(Method::PATCH, &path);
57        let response = self.0.send(request.json(&update)).await?;
58        let content = response.json::<UpdateTopicResponse>().await?;
59
60        Ok(content)
61    }
62
63    /// Remove an existing topic.
64    ///
65    /// <https://resend.com/docs/api-reference/topics/delete-topic>
66    #[maybe_async::maybe_async]
67    pub async fn delete(&self, topic_id: &str) -> Result<DeleteTopicResponse> {
68        let path = format!("/topics/{topic_id}");
69
70        let request = self.0.build(Method::DELETE, &path);
71        let response = self.0.send(request).await?;
72        let content = response.json::<DeleteTopicResponse>().await?;
73
74        Ok(content)
75    }
76
77    /// Retrieve a list of topics for the authenticated user.
78    ///
79    /// - Default limit: 20
80    ///
81    /// <https://resend.com/docs/api-reference/topics/list-topics>
82    #[maybe_async::maybe_async]
83    pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Topic>> {
84        let request = self.0.build(Method::GET, "/topics").query(&list_opts);
85        let response = self.0.send(request).await?;
86        let content = response.json::<ListResponse<Topic>>().await?;
87
88        Ok(content)
89    }
90}
91
92#[allow(unreachable_pub)]
93pub mod types {
94    use serde::{Deserialize, Serialize};
95
96    crate::define_id_type!(TopicId);
97
98    /// See [relevant docs].
99    ///
100    /// [relevant docs]: <https://resend.com/docs/api-reference/topics/create-topic#body-parameters>
101    #[must_use]
102    #[derive(Debug, Clone, Serialize)]
103    pub struct CreateTopicOptions {
104        name: String,
105        default_subscription: SubscriptionType,
106        #[serde(skip_serializing_if = "Option::is_none")]
107        description: Option<String>,
108        #[serde(skip_serializing_if = "Option::is_none")]
109        visibility: Option<TopicVisibility>,
110    }
111
112    #[must_use]
113    #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
114    #[serde(rename_all = "snake_case")]
115    pub enum SubscriptionType {
116        OptIn,
117        OptOut,
118    }
119
120    impl CreateTopicOptions {
121        /// Creates a new [`CreateTopicOptions`].
122        ///
123        /// - `name`: The topic name. Max length is `50` characters.
124        /// - `default_subscription`: The default subscription preference for new contacts.
125        pub fn new(name: impl Into<String>, default_subscription: SubscriptionType) -> Self {
126            Self {
127                name: name.into(),
128                default_subscription,
129                description: None,
130                visibility: None,
131            }
132        }
133
134        /// The topic description. Max length is `200` characters.
135        #[inline]
136        pub fn with_description(mut self, description: String) -> Self {
137            self.description = Some(description);
138            self
139        }
140
141        /// The visibility of the topic on the unsubscribe page.
142        #[inline]
143        pub const fn with_visibility(mut self, visibility: TopicVisibility) -> Self {
144            self.visibility = Some(visibility);
145            self
146        }
147    }
148
149    #[must_use]
150    #[derive(Debug, Clone, Serialize, Deserialize)]
151    pub struct CreateTopicResponse {
152        /// Unique identifier for the published topic.
153        pub id: TopicId,
154    }
155
156    /// Received Topic.
157    #[must_use]
158    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159    pub struct Topic {
160        pub id: TopicId,
161        pub name: String,
162        pub description: Option<String>,
163        pub default_subscription: SubscriptionType,
164        pub visibility: TopicVisibility,
165        pub created_at: String,
166    }
167
168    #[must_use]
169    #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
170    #[serde(rename_all = "kebab-case")]
171    pub enum TopicVisibility {
172        /// Only contacts who are opted in to the topic can see it on the unsubscribe page.
173        Public,
174        /// All contacts can see the topic on the unsubscribe page.
175        Private,
176    }
177
178    /// List of changes to apply to a [`Topic`].
179    #[must_use]
180    #[derive(Debug, Default, Clone, Serialize)]
181    pub struct UpdateTopicOptions {
182        name: Option<String>,
183        #[serde(skip_serializing_if = "Option::is_none")]
184        description: Option<String>,
185        #[serde(skip_serializing_if = "Option::is_none")]
186        visibility: Option<TopicVisibility>,
187    }
188
189    impl UpdateTopicOptions {
190        pub const fn new() -> Self {
191            Self {
192                name: None,
193                description: None,
194                visibility: None,
195            }
196        }
197
198        /// The topic name. Max length is `50` characters.
199        #[inline]
200        pub fn with_name(mut self, name: impl Into<String>) -> Self {
201            self.name = Some(name.into());
202            self
203        }
204
205        /// The topic description. Max length is `200` character
206        #[inline]
207        pub fn with_description(mut self, description: impl Into<String>) -> Self {
208            self.description = Some(description.into());
209            self
210        }
211
212        /// The visibility of the topic on the unsubscribe page.
213        #[inline]
214        pub const fn with_visibility(mut self, visibility: TopicVisibility) -> Self {
215            self.visibility = Some(visibility);
216            self
217        }
218    }
219
220    #[derive(Debug, Clone, Serialize, Deserialize)]
221    pub struct UpdateTopicResponse {
222        /// Unique identifier for the updated topic.
223        pub id: TopicId,
224    }
225
226    #[derive(Debug, Clone, Serialize, Deserialize)]
227    pub struct DeleteTopicResponse {
228        /// Unique identifier for the topic.
229        pub id: TopicId,
230        /// Indicates whether the topic was deleted successfully.
231        pub deleted: bool,
232    }
233}
234
235#[cfg(test)]
236#[allow(clippy::unwrap_used)]
237#[allow(clippy::needless_return)]
238mod test {
239    use crate::types::Topic;
240    #[cfg(not(feature = "blocking"))]
241    use crate::{
242        list_opts::ListOptions,
243        test::{CLIENT, DebugResult},
244        types::{CreateTopicOptions, SubscriptionType, TopicVisibility, UpdateTopicOptions},
245    };
246
247    #[tokio_shared_rt::test(shared = true)]
248    #[serial_test::serial]
249    #[cfg(not(feature = "blocking"))]
250    async fn all() -> DebugResult<()> {
251        let resend = &*CLIENT;
252
253        // Create
254        let topic = CreateTopicOptions::new("Weekly Newsletter", SubscriptionType::OptIn)
255            .with_visibility(TopicVisibility::Public);
256        let topic = resend.topics.create(topic).await?;
257        std::thread::sleep(std::time::Duration::from_secs(1));
258
259        // Get
260        let topic = resend.topics.get(&topic.id).await?;
261        assert_eq!(topic.visibility, TopicVisibility::Public);
262
263        // Update
264        let update = UpdateTopicOptions::new()
265            .with_name("Weekly Newsletter")
266            .with_description("Weekly newsletter for our subscribers")
267            .with_visibility(TopicVisibility::Private);
268        let topic = resend.topics.update(&topic.id, update).await?;
269        std::thread::sleep(std::time::Duration::from_secs(4));
270
271        // List
272        let topics = resend.topics.list(ListOptions::default()).await?;
273        assert!(topics.len() == 1, "{}", format!("Was {}", topics.len()));
274        assert_eq!(
275            topics.data.first().unwrap().visibility,
276            TopicVisibility::Private
277        );
278
279        // Delete
280        let deleted = resend.topics.delete(&topic.id).await?;
281        assert!(deleted.deleted);
282
283        std::thread::sleep(std::time::Duration::from_secs(4));
284
285        let topics = resend.topics.list(ListOptions::default()).await?;
286        assert!(topics.is_empty());
287
288        Ok(())
289    }
290
291    #[test]
292    fn deserialize_test() {
293        let topic = r#"{
294  "id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e",
295  "name": "Weekly Newsletter",
296  "description": "Weekly newsletter for our subscribers",
297  "default_subscription": "opt_in",
298  "visibility": "public",
299  "created_at": "2023-04-08 00:11:13.110779+00"
300}"#;
301
302        let res = serde_json::from_str::<Topic>(topic);
303        assert!(res.is_ok());
304    }
305}