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