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