resend_rs/
templates.rs

1use std::sync::Arc;
2
3use reqwest::Method;
4
5use crate::types::{
6    CreateTemplateOptions, CreateTemplateResponse, DeleteTemplateResponse,
7    DuplicateTemplateResponse, PublishTemplateResponse, Template, UpdateTemplateOptions,
8    UpdateTemplateResponse,
9};
10use crate::{
11    Config, Result,
12    list_opts::{ListOptions, ListResponse},
13};
14
15/// `Resend` APIs for `/templates` endpoints.
16#[derive(Clone, Debug)]
17pub struct TemplateSvc(pub(crate) Arc<Config>);
18
19impl TemplateSvc {
20    /// Create a new template.
21    ///
22    /// <https://resend.com/docs/api-reference/templates/create-template>
23    #[maybe_async::maybe_async]
24    #[allow(clippy::needless_pass_by_value)]
25    pub async fn create(&self, template: CreateTemplateOptions) -> Result<CreateTemplateResponse> {
26        let request = self.0.build(Method::POST, "/templates");
27        let response = self.0.send(request.json(&template)).await?;
28        let content = response.json::<CreateTemplateResponse>().await?;
29
30        Ok(content)
31    }
32
33    /// Get a template by ID
34    ///
35    /// <https://resend.com/docs/api-reference/templates/get-template>
36    #[maybe_async::maybe_async]
37    pub async fn get(&self, id_or_alias: &str) -> Result<Template> {
38        let path = format!("/templates/{id_or_alias}");
39
40        let request = self.0.build(Method::GET, &path);
41        let response = self.0.send(request).await?;
42        let content = response.json::<Template>().await?;
43
44        Ok(content)
45    }
46
47    /// Update a template.
48    ///
49    /// <https://resend.com/docs/api-reference/templates/update-template>
50    #[maybe_async::maybe_async]
51    #[allow(clippy::needless_pass_by_value)]
52    pub async fn update(
53        &self,
54        id_or_alias: &str,
55        update: UpdateTemplateOptions,
56    ) -> Result<UpdateTemplateResponse> {
57        let path = format!("/templates/{id_or_alias}");
58
59        let request = self.0.build(Method::PATCH, &path);
60        let response = self.0.send(request.json(&update)).await?;
61        let content = response.json::<UpdateTemplateResponse>().await?;
62
63        Ok(content)
64    }
65
66    /// Publish a template.
67    ///
68    /// <https://resend.com/docs/api-reference/templates/publish-template>
69    #[maybe_async::maybe_async]
70    pub async fn publish(&self, id_or_alias: &str) -> Result<PublishTemplateResponse> {
71        let path = format!("/templates/{id_or_alias}/publish");
72
73        let request = self.0.build(Method::POST, &path);
74        let response = self.0.send(request).await?;
75        let content = response.json::<PublishTemplateResponse>().await?;
76
77        Ok(content)
78    }
79
80    /// Duplicate a template.
81    ///
82    /// <https://resend.com/docs/api-reference/templates/duplicate-template>
83    #[maybe_async::maybe_async]
84    pub async fn duplicate(&self, id_or_alias: &str) -> Result<DuplicateTemplateResponse> {
85        let path = format!("/templates/{id_or_alias}/duplicate");
86
87        let request = self.0.build(Method::POST, &path);
88        let response = self.0.send(request).await?;
89        let content = response.json::<DuplicateTemplateResponse>().await?;
90
91        Ok(content)
92    }
93
94    /// Delete a template.
95    ///
96    /// <https://resend.com/docs/api-reference/templates/delete-template>
97    #[maybe_async::maybe_async]
98    pub async fn delete(&self, id_or_alias: &str) -> Result<DeleteTemplateResponse> {
99        let path = format!("/templates/{id_or_alias}");
100
101        let request = self.0.build(Method::DELETE, &path);
102        let response = self.0.send(request).await?;
103        let content = response.json::<DeleteTemplateResponse>().await?;
104
105        Ok(content)
106    }
107
108    /// Retrieve a list of templates.
109    ///
110    /// - Default limit: 20
111    ///
112    /// <https://resend.com/docs/api-reference/templates/list-templates>
113    #[maybe_async::maybe_async]
114    #[allow(clippy::needless_pass_by_value)]
115    pub async fn list<T>(&self, list_opts: ListOptions<T>) -> Result<ListResponse<Template>> {
116        let request = self.0.build(Method::GET, "/templates").query(&list_opts);
117        let response = self.0.send(request).await?;
118        let content = response.json::<ListResponse<Template>>().await?;
119
120        Ok(content)
121    }
122}
123
124#[allow(unreachable_pub)]
125pub mod types {
126    use serde::{Deserialize, Deserializer, Serialize};
127    crate::define_id_type!(TemplateId);
128
129    /// See [relevant docs].
130    ///
131    /// [relevant docs]: <https://resend.com/docs/api-reference/templates/create-template#body-parameters>
132    #[must_use]
133    #[derive(Debug, Clone, Serialize)]
134    pub struct CreateTemplateOptions {
135        name: String,
136        #[serde(skip_serializing_if = "Option::is_none")]
137        alias: Option<String>,
138        #[serde(skip_serializing_if = "Option::is_none")]
139        from: Option<String>,
140        #[serde(skip_serializing_if = "Option::is_none")]
141        subject: Option<String>,
142        #[serde(skip_serializing_if = "Option::is_none")]
143        reply_to: Option<Vec<String>>,
144        html: String,
145        #[serde(skip_serializing_if = "Option::is_none")]
146        text: Option<String>,
147        #[serde(skip_serializing_if = "Option::is_none")]
148        variables: Option<Vec<Variable>>,
149    }
150
151    /// See [relevant docs].
152    ///
153    /// [relevant docs]: <https://resend.com/docs/api-reference/templates/create-template#param-variables>
154    #[must_use]
155    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156    pub struct Variable {
157        key: String,
158        #[serde(rename = "type")]
159        ttype: VariableType,
160        fallback_value: Option<serde_json::Value>,
161    }
162
163    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
164    #[must_use]
165    #[serde(rename_all = "snake_case")]
166    pub enum VariableType {
167        String,
168        Number,
169    }
170
171    impl CreateTemplateOptions {
172        /// Creates a new [`CreateTemplateOptions`].
173        ///
174        /// - `name`: The name of the template.
175        /// - `html`: The HTML version of the template.
176        pub fn new(name: impl Into<String>, html: impl Into<String>) -> Self {
177            Self {
178                name: name.into(),
179                alias: None,
180                from: None,
181                subject: None,
182                reply_to: None,
183                html: html.into(),
184                text: None,
185                variables: None,
186            }
187        }
188
189        /// Adds or overwrites the alias version of the template.
190        #[inline]
191        pub fn with_alias(mut self, alias: &str) -> Self {
192            self.alias = Some(alias.to_owned());
193            self
194        }
195
196        /// Adds or overwrites the sender email address of the template.
197        ///
198        /// To include a friendly name, use the format `"Your Name <sender@domain.com>"`.
199        ///
200        /// If provided, this value can be overridden when sending an email using the template.
201        #[inline]
202        pub fn with_from(mut self, from: &str) -> Self {
203            self.from = Some(from.to_owned());
204            self
205        }
206
207        /// Adds or overwrites the sender email subject of the template.
208        ///
209        /// If provided, this value can be overridden when sending an email using the template.
210        #[inline]
211        pub fn with_subject(mut self, subject: &str) -> Self {
212            self.subject = Some(subject.to_owned());
213            self
214        }
215
216        /// Attaches reply-to email address.
217        ///
218        /// If provided, this value can be overridden when sending an email using the template.
219        #[inline]
220        pub fn with_reply_to(mut self, reply_to: &str) -> Self {
221            let reply_to_vec = self.reply_to.get_or_insert_with(Vec::new);
222            reply_to_vec.push(reply_to.to_owned());
223            self
224        }
225
226        /// Attaches reply-to email addresses.
227        ///
228        /// If provided, this value can be overridden when sending an email using the template.
229        #[inline]
230        pub fn with_reply_tos(mut self, reply_tos: &[String]) -> Self {
231            let reply_to_vec = self.reply_to.get_or_insert_with(Vec::new);
232            reply_to_vec.extend_from_slice(reply_tos);
233            self
234        }
235
236        /// Adds or overwrites the The plain text version of the message.
237        ///
238        /// If not provided, the HTML will be used to generate a plain text version. You can opt
239        /// out of this behavior by setting value to an empty string.
240        #[inline]
241        pub fn with_text(mut self, text: &str) -> Self {
242            self.text = Some(text.to_owned());
243            self
244        }
245
246        /// Attaches a variable.
247        ///
248        /// Each template may contain up to 20 variables.
249        #[inline]
250        #[allow(clippy::needless_pass_by_value)]
251        pub fn with_variable(mut self, variable: Variable) -> Self {
252            let variables = self.variables.get_or_insert_with(Vec::new);
253            variables.push(variable);
254            self
255        }
256
257        /// Attaches variables.
258        ///
259        /// Each template may contain up to 20 variables.
260        #[inline]
261        #[allow(clippy::needless_pass_by_value)]
262        pub fn with_variables(mut self, variables: &[Variable]) -> Self {
263            let variables_vec = self.variables.get_or_insert_with(Vec::new);
264            variables_vec.extend_from_slice(variables);
265            self
266        }
267    }
268
269    impl Variable {
270        /// Creates a new [`Variable`].
271        ///
272        /// - `key`: The key of the variable. We recommend capitalizing the key (e.g. `FIRST_NAME`).
273        /// - `ttype`: The type of the variable.
274        ///   Can be `string`, `number`, `boolean`, `object`, or `list`.
275        pub fn new(key: impl Into<String>, ttype: VariableType) -> Self {
276            Self {
277                key: key.into(),
278                ttype,
279                fallback_value: None,
280            }
281        }
282
283        /// Adds or overwrites the fallback value.
284        ///
285        /// The fallback value of the variable. The value must match the type of the variable.
286        ///
287        /// If no fallback value is provided, you must provide a value for the variable when
288        /// sending an email using the template.
289        ///
290        /// If `object` type is provided, you must include a fallback.
291        #[inline]
292        #[allow(clippy::needless_pass_by_value)]
293        pub fn with_fallback(mut self, fallback: impl Into<serde_json::Value>) -> Self {
294            self.fallback_value = Some(fallback.into());
295            self
296        }
297    }
298
299    #[derive(Debug, Clone, Deserialize)]
300    pub struct CreateTemplateResponse {
301        /// The ID of the created template.
302        pub id: TemplateId,
303    }
304
305    /// Received Template.
306    #[must_use]
307    #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
308    pub struct Template {
309        pub id: TemplateId,
310        pub alias: Option<String>,
311        pub name: String,
312        pub created_at: String,
313        pub updated_at: String,
314        pub status: TemplateEvent,
315        pub published_at: Option<String>,
316        pub from: Option<String>,
317        pub subject: Option<String>,
318        pub reply_to: Option<Vec<String>>,
319        pub html: Option<String>,
320        pub text: Option<String>,
321        #[serde(deserialize_with = "parse_nullable_vec")]
322        #[serde(default)]
323        pub variables: Vec<Variable>,
324    }
325
326    /// Turns:
327    /// - `null` -> `[]`
328    /// - `["text"]` -> `["text"]`
329    fn parse_nullable_vec<'de, D>(deserializer: D) -> Result<Vec<Variable>, D::Error>
330    where
331        D: Deserializer<'de>,
332    {
333        let opt = Option::deserialize(deserializer)?;
334        Ok(opt.unwrap_or_else(Vec::new))
335    }
336
337    /// Strongly typed `status`.
338    #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
339    #[serde(rename_all = "snake_case")]
340    pub enum TemplateEvent {
341        Draft,
342        Published,
343    }
344
345    /// List of changes to apply to a [`Template`].
346    #[must_use]
347    #[derive(Debug, Default, Clone, Serialize)]
348    pub struct UpdateTemplateOptions {
349        name: String,
350        #[serde(skip_serializing_if = "Option::is_none")]
351        alias: Option<String>,
352        #[serde(skip_serializing_if = "Option::is_none")]
353        from: Option<String>,
354        #[serde(skip_serializing_if = "Option::is_none")]
355        subject: Option<String>,
356        #[serde(skip_serializing_if = "Option::is_none")]
357        reply_to: Option<Vec<String>>,
358        html: String,
359        #[serde(skip_serializing_if = "Option::is_none")]
360        text: Option<String>,
361        #[serde(skip_serializing_if = "Option::is_none")]
362        variables: Option<Vec<Variable>>,
363    }
364
365    impl UpdateTemplateOptions {
366        pub fn new(name: impl Into<String>, html: impl Into<String>) -> Self {
367            Self {
368                name: name.into(),
369                alias: None,
370                from: None,
371                subject: None,
372                reply_to: None,
373                html: html.into(),
374                text: None,
375                variables: None,
376            }
377        }
378
379        #[inline]
380        pub fn with_alias(mut self, alias: &str) -> Self {
381            self.alias = Some(alias.to_owned());
382            self
383        }
384
385        #[inline]
386        pub fn with_from(mut self, from: &str) -> Self {
387            self.from = Some(from.to_owned());
388            self
389        }
390
391        #[inline]
392        pub fn with_subject(mut self, subject: &str) -> Self {
393            self.subject = Some(subject.to_owned());
394            self
395        }
396
397        #[inline]
398        pub fn with_reply_to(mut self, reply_to: &str) -> Self {
399            let reply_tos = self.reply_to.get_or_insert_with(Vec::new);
400            reply_tos.push(reply_to.to_owned());
401            self
402        }
403
404        #[inline]
405        pub fn with_reply_tos(mut self, reply_tos: &[String]) -> Self {
406            let reply_tos_vec = self.reply_to.get_or_insert_with(Vec::new);
407            reply_tos_vec.extend_from_slice(reply_tos);
408            self
409        }
410
411        #[inline]
412        pub fn with_text(mut self, text: &str) -> Self {
413            self.text = Some(text.to_owned());
414            self
415        }
416
417        #[inline]
418        #[allow(clippy::needless_pass_by_value)]
419        pub fn with_variable(mut self, variable: Variable) -> Self {
420            let variables_vec = self.variables.get_or_insert_with(Vec::new);
421            variables_vec.push(variable);
422            self
423        }
424
425        #[inline]
426        #[allow(clippy::needless_pass_by_value)]
427        pub fn with_variables(mut self, variables: &[Variable]) -> Self {
428            let variables_vec = self.variables.get_or_insert_with(Vec::new);
429            variables_vec.extend_from_slice(variables);
430            self
431        }
432    }
433
434    #[derive(Debug, Clone, Deserialize)]
435    pub struct UpdateTemplateResponse {
436        /// Unique identifier for the updated template.
437        pub id: TemplateId,
438    }
439
440    #[derive(Debug, Clone, Deserialize)]
441    pub struct PublishTemplateResponse {
442        /// Unique identifier for the published template.
443        pub id: TemplateId,
444    }
445
446    #[derive(Debug, Clone, Deserialize)]
447    pub struct DuplicateTemplateResponse {
448        /// Unique identifier for the duplicated template.
449        pub id: TemplateId,
450    }
451
452    #[derive(Debug, Clone, Deserialize)]
453    pub struct DeleteTemplateResponse {
454        /// Unique identifier for the template.
455        pub id: TemplateId,
456        /// Indicates whether the template was deleted successfully.
457        pub deleted: bool,
458    }
459}
460
461#[cfg(test)]
462#[allow(clippy::unwrap_used)]
463#[allow(clippy::needless_return)]
464mod test {
465    use crate::{
466        templates::Template,
467        test::{CLIENT, DebugResult},
468        types::CreateTemplateOptions,
469    };
470
471    #[tokio_shared_rt::test(shared = true)]
472    #[cfg(not(feature = "blocking"))]
473    async fn all() -> DebugResult<()> {
474        use crate::{list_opts::ListOptions, types::UpdateTemplateOptions};
475
476        let resend = &*CLIENT;
477
478        let name = "my template";
479        let html = "<p>hello</p>";
480        let alias = "alias";
481
482        // Create
483        let template = CreateTemplateOptions::new(name, html).with_alias(alias);
484
485        let template = resend.templates.create(template).await?;
486        let id = template.id;
487
488        std::thread::sleep(std::time::Duration::from_secs(1));
489
490        let get_alias = resend.templates.get(alias).await?;
491        let get_id = resend.templates.get(&id).await?;
492        assert_eq!(get_alias, get_id);
493
494        // Update
495        let alias = "alias updated";
496        let template = resend.templates.get(alias).await;
497        assert!(template.is_err());
498
499        let update = UpdateTemplateOptions::new(name, html).with_alias(alias);
500        let _update = resend.templates.update("alias", update).await?;
501        std::thread::sleep(std::time::Duration::from_secs(1));
502
503        // Get
504        let template = resend.templates.get(alias).await;
505        assert!(template.is_ok());
506
507        // Publish
508        let template = resend.templates.get(alias).await?;
509        assert!(template.published_at.is_none());
510
511        let template = resend.templates.publish(alias).await?;
512        std::thread::sleep(std::time::Duration::from_secs(1));
513
514        let template = resend.templates.get(&template.id).await?;
515        assert!(template.published_at.is_some());
516
517        // List
518        let templates = resend.templates.list(ListOptions::default()).await?;
519        assert!(templates.len() == 1);
520
521        // Duplicate
522        let duplicate = resend.templates.duplicate(alias).await?;
523        assert!(duplicate.id != template.id);
524        std::thread::sleep(std::time::Duration::from_secs(1));
525        let templates = resend.templates.list(ListOptions::default()).await?;
526        assert!(templates.len() == 2);
527
528        // Delete
529        let deleted = resend.templates.delete(alias).await?;
530        assert!(deleted.deleted);
531        let deleted = resend.templates.delete(&duplicate.id).await;
532        assert!(deleted.is_ok());
533        std::thread::sleep(std::time::Duration::from_secs(1));
534
535        let deleted = resend.templates.delete(&duplicate.id).await;
536        assert!(deleted.is_err());
537
538        Ok(())
539    }
540
541    #[test]
542    fn deserialize_test() {
543        let template = r#"{
544  "object": "template",
545  "id": "34a080c9-b17d-4187-ad80-5af20266e535",
546  "alias": "reset-password",
547  "name": "reset-password",
548  "created_at": "2023-10-06T23:47:56.678Z",
549  "updated_at": "2023-10-06T23:47:56.678Z",
550  "status": "published",
551  "published_at": "2023-10-06T23:47:56.678Z",
552  "from": "John Doe <john.doe@example.com>",
553  "subject": "Hello, world!",
554  "reply_to": null,
555  "html": "<h1>Hello, world!</h1>",
556  "text": "Hello, world!",
557  "variables": [
558    {
559      "id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
560      "key": "user_name",
561      "type": "string",
562      "fallback_value": "John Doe",
563      "created_at": "2023-10-06T23:47:56.678Z",
564      "updated_at": "2023-10-06T23:47:56.678Z"
565    }
566  ]
567}"#;
568
569        let res = serde_json::from_str::<Template>(template);
570        assert!(res.is_ok());
571
572        let res = res.unwrap();
573        assert!(!res.variables.is_empty());
574
575        let template = r#"{
576  "object": "template",
577  "id": "34a080c9-b17d-4187-ad80-5af20266e535",
578  "alias": "reset-password",
579  "name": "reset-password",
580  "created_at": "2023-10-06T23:47:56.678Z",
581  "updated_at": "2023-10-06T23:47:56.678Z",
582  "status": "published",
583  "published_at": "2023-10-06T23:47:56.678Z",
584  "from": "John Doe <john.doe@example.com>",
585  "subject": "Hello, world!",
586  "reply_to": null,
587  "html": "<h1>Hello, world!</h1>",
588  "text": "Hello, world!"
589}"#;
590
591        let res = serde_json::from_str::<Template>(template);
592        assert!(res.is_ok());
593
594        let res = res.unwrap();
595        assert!(res.variables.is_empty());
596    }
597}