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