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