1use 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 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 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 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 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: 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 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#[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#[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 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}