Skip to main content

braze_sync/braze/
email_template.rs

1//! Email Template endpoints.
2//!
3//! Braze identifies email templates by `email_template_id` but uses
4//! `template_name` as the human-readable identifier. There is no DELETE
5//! endpoint, which is why remote-only templates are surfaced as orphans
6//! rather than `Removed` diffs — same pattern as Content Block (§11.6).
7//!
8//! API verification (2026-04-12):
9//! - Field name mapping: `template_name`↔`name`, `body`↔`body_html`,
10//!   `plaintext_body`↔`body_plaintext`
11//! - `description` is returned by /info but NOT settable via create/update
12//! - `from_address`/`from_display_name`/`reply_to` do NOT exist in the API
13//! - Pagination: `limit` (default 100, max 1000) + `offset`
14
15use crate::braze::error::BrazeApiError;
16use crate::braze::{
17    check_duplicate_names, check_pagination, classify_info_message, BrazeClient, InfoMessageClass,
18};
19use crate::resource::EmailTemplate;
20use serde::{Deserialize, Serialize};
21
22const LIST_LIMIT: u32 = 100;
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct EmailTemplateSummary {
26    pub email_template_id: String,
27    pub name: String,
28}
29
30impl BrazeClient {
31    /// Returns one page of up to [`LIST_LIMIT`] summaries. Hard-errors
32    /// rather than silently truncating: see `PaginationNotImplemented`.
33    pub async fn list_email_templates(&self) -> Result<Vec<EmailTemplateSummary>, BrazeApiError> {
34        let req = self
35            .get(&["templates", "email", "list"])
36            .query(&[("limit", LIST_LIMIT.to_string())]);
37        let resp: EmailTemplateListResponse = self.send_json(req).await?;
38        let returned = resp.templates.len();
39
40        // Fail closed when the page is or might be truncated.
41        check_pagination(
42            resp.count,
43            returned,
44            LIST_LIMIT as usize,
45            "/templates/email/list",
46        )?;
47
48        check_duplicate_names(
49            resp.templates.iter().map(|e| e.template_name.as_str()),
50            resp.templates.len(),
51            "/templates/email/list",
52        )?;
53
54        Ok(resp
55            .templates
56            .into_iter()
57            .map(|w| EmailTemplateSummary {
58                email_template_id: w.email_template_id,
59                name: w.template_name,
60            })
61            .collect())
62    }
63
64    /// Fetch full template details by id. Uses the same 200+message
65    /// classifier pattern as content_block::get_content_block.
66    pub async fn get_email_template(&self, id: &str) -> Result<EmailTemplate, BrazeApiError> {
67        let req = self
68            .get(&["templates", "email", "info"])
69            .query(&[("email_template_id", id)]);
70        let wire: EmailTemplateInfoResponse = self.send_json(req).await?;
71        match classify_info_message(wire.message.as_deref(), "no email template") {
72            InfoMessageClass::Success => {}
73            InfoMessageClass::NotFound => {
74                return Err(BrazeApiError::NotFound {
75                    resource: format!("email_template id '{id}'"),
76                });
77            }
78            InfoMessageClass::Unexpected(message) => {
79                return Err(BrazeApiError::UnexpectedApiMessage {
80                    endpoint: "/templates/email/info",
81                    message,
82                });
83            }
84        }
85        Ok(EmailTemplate {
86            name: wire.template_name,
87            subject: wire.subject.unwrap_or_default(),
88            body_html: wire.body.unwrap_or_default(),
89            body_plaintext: wire.plaintext_body.unwrap_or_default(),
90            // description is read-only — returned by /info but not
91            // settable via create/update.
92            description: wire.description,
93            preheader: wire.preheader,
94            should_inline_css: wire.should_inline_css,
95            tags: wire.tags.unwrap_or_default(),
96        })
97    }
98
99    pub async fn create_email_template(&self, et: &EmailTemplate) -> Result<String, BrazeApiError> {
100        let body = EmailTemplateWriteBody {
101            email_template_id: None,
102            template_name: &et.name,
103            subject: &et.subject,
104            body: &et.body_html,
105            plaintext_body: &et.body_plaintext,
106            preheader: et.preheader.as_deref(),
107            should_inline_css: et.should_inline_css,
108            tags: &et.tags,
109        };
110        let req = self.post(&["templates", "email", "create"]).json(&body);
111        let resp: EmailTemplateCreateResponse = self.send_json(req).await?;
112        Ok(resp.email_template_id)
113    }
114
115    /// Update an existing email template. `description` is intentionally
116    /// omitted — Braze /info returns it but create/update cannot set it.
117    pub async fn update_email_template(
118        &self,
119        id: &str,
120        et: &EmailTemplate,
121    ) -> Result<(), BrazeApiError> {
122        let body = EmailTemplateWriteBody {
123            email_template_id: Some(id),
124            template_name: &et.name,
125            subject: &et.subject,
126            body: &et.body_html,
127            plaintext_body: &et.body_plaintext,
128            preheader: et.preheader.as_deref(),
129            should_inline_css: et.should_inline_css,
130            tags: &et.tags,
131        };
132        let req = self.post(&["templates", "email", "update"]).json(&body);
133        self.send_ok(req).await
134    }
135}
136
137// ===================================================================
138// Wire types
139// ===================================================================
140
141#[derive(Debug, Deserialize)]
142struct EmailTemplateListResponse {
143    #[serde(default)]
144    templates: Vec<EmailTemplateListEntry>,
145    #[serde(default)]
146    count: Option<usize>,
147}
148
149#[derive(Debug, Deserialize)]
150struct EmailTemplateListEntry {
151    email_template_id: String,
152    template_name: String,
153}
154
155#[derive(Debug, Deserialize)]
156struct EmailTemplateInfoResponse {
157    #[serde(default)]
158    template_name: String,
159    #[serde(default)]
160    subject: Option<String>,
161    #[serde(default)]
162    body: Option<String>,
163    #[serde(default)]
164    plaintext_body: Option<String>,
165    #[serde(default)]
166    description: Option<String>,
167    #[serde(default)]
168    preheader: Option<String>,
169    #[serde(default)]
170    should_inline_css: Option<bool>,
171    #[serde(default)]
172    tags: Option<Vec<String>>,
173    #[serde(default)]
174    message: Option<String>,
175}
176
177/// Wire body shared by create and update. `description` is intentionally
178/// absent — Braze /info returns it but create/update cannot set it.
179#[derive(Serialize)]
180struct EmailTemplateWriteBody<'a> {
181    #[serde(skip_serializing_if = "Option::is_none")]
182    email_template_id: Option<&'a str>,
183    template_name: &'a str,
184    subject: &'a str,
185    body: &'a str,
186    plaintext_body: &'a str,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    preheader: Option<&'a str>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    should_inline_css: Option<bool>,
191    tags: &'a [String],
192}
193
194#[derive(Debug, Deserialize)]
195struct EmailTemplateCreateResponse {
196    email_template_id: String,
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::braze::test_client as make_client;
203    use serde_json::json;
204    use wiremock::matchers::{body_json, header, method, path, query_param};
205    use wiremock::{Mock, MockServer, ResponseTemplate};
206
207    #[tokio::test]
208    async fn list_happy_path() {
209        let server = MockServer::start().await;
210        Mock::given(method("GET"))
211            .and(path("/templates/email/list"))
212            .and(header("authorization", "Bearer test-key"))
213            .and(query_param("limit", "100"))
214            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
215                "count": 2,
216                "templates": [
217                    {"email_template_id": "id-1", "template_name": "welcome"},
218                    {"email_template_id": "id-2", "template_name": "password_reset"}
219                ],
220                "message": "success"
221            })))
222            .mount(&server)
223            .await;
224
225        let client = make_client(&server);
226        let summaries = client.list_email_templates().await.unwrap();
227        assert_eq!(summaries.len(), 2);
228        assert_eq!(summaries[0].email_template_id, "id-1");
229        assert_eq!(summaries[0].name, "welcome");
230    }
231
232    #[tokio::test]
233    async fn list_empty() {
234        let server = MockServer::start().await;
235        Mock::given(method("GET"))
236            .and(path("/templates/email/list"))
237            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"templates": []})))
238            .mount(&server)
239            .await;
240        let client = make_client(&server);
241        assert!(client.list_email_templates().await.unwrap().is_empty());
242    }
243
244    #[tokio::test]
245    async fn list_ignores_unknown_fields() {
246        let server = MockServer::start().await;
247        Mock::given(method("GET"))
248            .and(path("/templates/email/list"))
249            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
250                "templates": [{
251                    "email_template_id": "id-1",
252                    "template_name": "welcome",
253                    "updated_at": "2026-04-12T00:00:00Z",
254                    "created_at": "2026-01-01T00:00:00Z",
255                    "tags": ["onboarding"],
256                    "future_field": true
257                }]
258            })))
259            .mount(&server)
260            .await;
261        let client = make_client(&server);
262        let summaries = client.list_email_templates().await.unwrap();
263        assert_eq!(summaries.len(), 1);
264        assert_eq!(summaries[0].name, "welcome");
265    }
266
267    #[tokio::test]
268    async fn list_unauthorized() {
269        let server = MockServer::start().await;
270        Mock::given(method("GET"))
271            .and(path("/templates/email/list"))
272            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
273            .mount(&server)
274            .await;
275        let client = make_client(&server);
276        let err = client.list_email_templates().await.unwrap_err();
277        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
278    }
279
280    #[tokio::test]
281    async fn info_happy_path() {
282        let server = MockServer::start().await;
283        Mock::given(method("GET"))
284            .and(path("/templates/email/info"))
285            .and(query_param("email_template_id", "id-1"))
286            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
287                "email_template_id": "id-1",
288                "template_name": "welcome",
289                "description": "Welcome email",
290                "subject": "Welcome to our service",
291                "body": "<p>Hello</p>",
292                "plaintext_body": "Hello",
293                "preheader": "Get started",
294                "should_inline_css": true,
295                "tags": ["onboarding", "email"],
296                "created_at": "2026-01-01T00:00:00Z",
297                "updated_at": "2026-04-12T00:00:00Z",
298                "message": "success"
299            })))
300            .mount(&server)
301            .await;
302
303        let client = make_client(&server);
304        let et = client.get_email_template("id-1").await.unwrap();
305        assert_eq!(et.name, "welcome");
306        assert_eq!(et.subject, "Welcome to our service");
307        assert_eq!(et.body_html, "<p>Hello</p>");
308        assert_eq!(et.body_plaintext, "Hello");
309        assert_eq!(et.description.as_deref(), Some("Welcome email"));
310        assert_eq!(et.preheader.as_deref(), Some("Get started"));
311        assert_eq!(et.should_inline_css, Some(true));
312        assert_eq!(et.tags, vec!["onboarding", "email"]);
313    }
314
315    #[tokio::test]
316    async fn info_missing_optional_fields_default() {
317        let server = MockServer::start().await;
318        Mock::given(method("GET"))
319            .and(path("/templates/email/info"))
320            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
321                "template_name": "minimal",
322                "message": "success"
323            })))
324            .mount(&server)
325            .await;
326        let client = make_client(&server);
327        let et = client.get_email_template("id-x").await.unwrap();
328        assert_eq!(et.name, "minimal");
329        assert_eq!(et.subject, "");
330        assert_eq!(et.body_html, "");
331        assert_eq!(et.body_plaintext, "");
332        assert!(et.description.is_none());
333        assert!(et.preheader.is_none());
334        assert!(et.should_inline_css.is_none());
335        assert!(et.tags.is_empty());
336    }
337
338    #[tokio::test]
339    async fn info_not_found_message() {
340        let server = MockServer::start().await;
341        Mock::given(method("GET"))
342            .and(path("/templates/email/info"))
343            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
344                "message": "No email template with id 'missing' found"
345            })))
346            .mount(&server)
347            .await;
348        let client = make_client(&server);
349        let err = client.get_email_template("missing").await.unwrap_err();
350        match err {
351            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
352            other => panic!("expected NotFound, got {other:?}"),
353        }
354    }
355
356    #[tokio::test]
357    async fn info_unexpected_message_surfaces_verbatim() {
358        let server = MockServer::start().await;
359        Mock::given(method("GET"))
360            .and(path("/templates/email/info"))
361            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
362                "message": "Internal server hiccup, please retry"
363            })))
364            .mount(&server)
365            .await;
366        let client = make_client(&server);
367        let err = client.get_email_template("some-id").await.unwrap_err();
368        match err {
369            BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
370                assert_eq!(endpoint, "/templates/email/info");
371                assert!(message.contains("Internal server hiccup"));
372            }
373            other => panic!("expected UnexpectedApiMessage, got {other:?}"),
374        }
375    }
376
377    #[tokio::test]
378    async fn create_sends_correct_body_and_returns_id() {
379        let server = MockServer::start().await;
380        Mock::given(method("POST"))
381            .and(path("/templates/email/create"))
382            .and(header("authorization", "Bearer test-key"))
383            .and(body_json(json!({
384                "template_name": "welcome",
385                "subject": "Welcome",
386                "body": "<p>Hi</p>",
387                "plaintext_body": "Hi",
388                "preheader": "Get started",
389                "should_inline_css": true,
390                "tags": ["onboarding"]
391            })))
392            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
393                "email_template_id": "new-id-123",
394                "message": "success"
395            })))
396            .mount(&server)
397            .await;
398
399        let client = make_client(&server);
400        let et = EmailTemplate {
401            name: "welcome".into(),
402            subject: "Welcome".into(),
403            body_html: "<p>Hi</p>".into(),
404            body_plaintext: "Hi".into(),
405            description: Some("should not be sent".into()),
406            preheader: Some("Get started".into()),
407            should_inline_css: Some(true),
408            tags: vec!["onboarding".into()],
409        };
410        let id = client.create_email_template(&et).await.unwrap();
411        assert_eq!(id, "new-id-123");
412    }
413
414    #[tokio::test]
415    async fn create_minimal_omits_optional_fields() {
416        let server = MockServer::start().await;
417        Mock::given(method("POST"))
418            .and(path("/templates/email/create"))
419            .and(body_json(json!({
420                "template_name": "minimal",
421                "subject": "x",
422                "body": "",
423                "plaintext_body": "",
424                "tags": []
425            })))
426            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
427                "email_template_id": "id-min"
428            })))
429            .mount(&server)
430            .await;
431        let client = make_client(&server);
432        let et = EmailTemplate {
433            name: "minimal".into(),
434            subject: "x".into(),
435            body_html: String::new(),
436            body_plaintext: String::new(),
437            description: None,
438            preheader: None,
439            should_inline_css: None,
440            tags: vec![],
441        };
442        client.create_email_template(&et).await.unwrap();
443    }
444
445    #[tokio::test]
446    async fn update_sends_id_and_omits_description() {
447        // Pins that description is NOT sent — it's read-only.
448        let server = MockServer::start().await;
449        Mock::given(method("POST"))
450            .and(path("/templates/email/update"))
451            .and(body_json(json!({
452                "email_template_id": "id-1",
453                "template_name": "welcome",
454                "subject": "Updated",
455                "body": "<p>New</p>",
456                "plaintext_body": "New",
457                "tags": []
458            })))
459            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
460            .mount(&server)
461            .await;
462
463        let client = make_client(&server);
464        let et = EmailTemplate {
465            name: "welcome".into(),
466            subject: "Updated".into(),
467            body_html: "<p>New</p>".into(),
468            body_plaintext: "New".into(),
469            description: Some("this should not appear in wire body".into()),
470            preheader: None,
471            should_inline_css: None,
472            tags: vec![],
473        };
474        client.update_email_template("id-1", &et).await.unwrap();
475    }
476
477    #[tokio::test]
478    async fn update_unauthorized_propagates() {
479        let server = MockServer::start().await;
480        Mock::given(method("POST"))
481            .and(path("/templates/email/update"))
482            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
483            .mount(&server)
484            .await;
485        let client = make_client(&server);
486        let et = EmailTemplate {
487            name: "x".into(),
488            subject: "x".into(),
489            body_html: String::new(),
490            body_plaintext: String::new(),
491            description: None,
492            preheader: None,
493            should_inline_css: None,
494            tags: vec![],
495        };
496        let err = client.update_email_template("id", &et).await.unwrap_err();
497        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
498    }
499
500    #[tokio::test]
501    async fn list_errors_when_count_exceeds_returned() {
502        let server = MockServer::start().await;
503        let entries: Vec<serde_json::Value> = (0..100)
504            .map(|i| {
505                json!({
506                    "email_template_id": format!("id-{i}"),
507                    "template_name": format!("tpl-{i}")
508                })
509            })
510            .collect();
511        Mock::given(method("GET"))
512            .and(path("/templates/email/list"))
513            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
514                "count": 250,
515                "templates": entries,
516                "message": "success"
517            })))
518            .mount(&server)
519            .await;
520        let client = make_client(&server);
521        let err = client.list_email_templates().await.unwrap_err();
522        match err {
523            BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
524                assert_eq!(endpoint, "/templates/email/list");
525                assert!(detail.contains("100"), "detail: {detail}");
526                assert!(detail.contains("250"), "detail: {detail}");
527            }
528            other => panic!("expected PaginationNotImplemented, got {other:?}"),
529        }
530    }
531
532    #[tokio::test]
533    async fn list_errors_on_full_page_with_no_count_field() {
534        let server = MockServer::start().await;
535        let entries: Vec<serde_json::Value> = (0..100)
536            .map(|i| {
537                json!({
538                    "email_template_id": format!("id-{i}"),
539                    "template_name": format!("tpl-{i}")
540                })
541            })
542            .collect();
543        Mock::given(method("GET"))
544            .and(path("/templates/email/list"))
545            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "templates": entries })))
546            .mount(&server)
547            .await;
548        let client = make_client(&server);
549        let err = client.list_email_templates().await.unwrap_err();
550        assert!(
551            matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
552            "got {err:?}"
553        );
554    }
555
556    #[tokio::test]
557    async fn list_errors_on_duplicate_name() {
558        let server = MockServer::start().await;
559        Mock::given(method("GET"))
560            .and(path("/templates/email/list"))
561            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
562                "count": 2,
563                "templates": [
564                    {"email_template_id": "id-a", "template_name": "dup"},
565                    {"email_template_id": "id-b", "template_name": "dup"}
566                ]
567            })))
568            .mount(&server)
569            .await;
570        let client = make_client(&server);
571        let err = client.list_email_templates().await.unwrap_err();
572        match err {
573            BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
574                assert_eq!(endpoint, "/templates/email/list");
575                assert_eq!(name, "dup");
576            }
577            other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
578        }
579    }
580}