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