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