1use crate::resource::{ContentBlock, EmailTemplate};
4use crate::values::braze_managed::prepare_field;
5use crate::values::placeholder::ResolutionError;
6use crate::values::templatize::FieldKind;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ResolutionFailure {
12 pub resource_kind: &'static str,
13 pub resource_name: String,
14 pub field: Option<&'static str>,
16 pub errors: Vec<ResolutionError>,
17}
18
19pub fn resolve_content_block_with_remote(
25 cb: &mut ContentBlock,
26 remote: Option<&ContentBlock>,
27) -> std::result::Result<(), ResolutionFailure> {
28 if !needs_resolve(&cb.content) {
29 return Ok(());
30 }
31 let prep = prepare_field(
32 &cb.content,
33 remote.map(|r| r.content.as_str()),
34 FieldKind::ContentBlock,
35 );
36 emit_prep_warnings("content_block", &cb.name, None, &prep.warnings);
37 if !prep.errors.is_empty() {
38 return Err(ResolutionFailure {
39 resource_kind: "content_block",
40 resource_name: cb.name.clone(),
41 field: None,
42 errors: prep.errors,
43 });
44 }
45 cb.content = prep.body;
46 Ok(())
47}
48
49pub fn resolve_email_template_with_remote(
51 et: &mut EmailTemplate,
52 remote: Option<&EmailTemplate>,
53) -> std::result::Result<(), Vec<ResolutionFailure>> {
54 let mut failures: Vec<ResolutionFailure> = Vec::new();
55
56 macro_rules! resolve_field {
57 ($field_name:expr, $field_kind:expr, $accessor:expr, $remote_accessor:expr) => {{
58 let body: &str = $accessor;
59 if needs_resolve(body) {
60 let prep = prepare_field(body, $remote_accessor, $field_kind);
61 emit_prep_warnings(
62 "email_template",
63 &et.name,
64 Some($field_name),
65 &prep.warnings,
66 );
67 if !prep.errors.is_empty() {
68 failures.push(ResolutionFailure {
69 resource_kind: "email_template",
70 resource_name: et.name.clone(),
71 field: Some($field_name),
72 errors: prep.errors,
73 });
74 None
75 } else {
76 Some(prep.body)
77 }
78 } else {
79 None
80 }
81 }};
82 }
83
84 let new_subject = resolve_field!(
85 "subject",
86 FieldKind::EmailSubject,
87 et.subject.as_str(),
88 remote.map(|r| r.subject.as_str())
89 );
90 let new_body_html = resolve_field!(
91 "body_html",
92 FieldKind::EmailHtmlBody,
93 et.body_html.as_str(),
94 remote.map(|r| r.body_html.as_str())
95 );
96 let new_body_plaintext = resolve_field!(
97 "body_plaintext",
98 FieldKind::EmailPlainBody,
99 et.body_plaintext.as_str(),
100 remote.map(|r| r.body_plaintext.as_str())
101 );
102 let new_preheader = match et.preheader.as_deref() {
103 Some(s) => resolve_field!(
104 "preheader",
105 FieldKind::EmailPreheader,
106 s,
107 remote.and_then(|r| r.preheader.as_deref())
108 ),
109 None => None,
110 };
111
112 if !failures.is_empty() {
113 return Err(failures);
114 }
115
116 if let Some(v) = new_subject {
117 et.subject = v;
118 }
119 if let Some(v) = new_body_html {
120 et.body_html = v;
121 }
122 if let Some(v) = new_body_plaintext {
123 et.body_plaintext = v;
124 }
125 if let Some(v) = new_preheader {
126 et.preheader = Some(v);
127 }
128 Ok(())
129}
130
131fn needs_resolve(body: &str) -> bool {
134 body.contains("__BRAZESYNC") || body.contains("__BRAZSYNC")
135}
136
137fn emit_prep_warnings(
138 kind: &'static str,
139 name: &str,
140 field: Option<&'static str>,
141 warnings: &[String],
142) {
143 if warnings.is_empty() {
144 return;
145 }
146 let scope = match field {
147 Some(f) => format!("{kind} '{name}' ({f})"),
148 None => format!("{kind} '{name}'"),
149 };
150 for w in warnings {
151 eprintln!("warning: {scope}: {w}");
152 }
153}
154
155pub fn format_failures(failures: &[ResolutionFailure]) -> crate::error::Error {
157 let mut msg = String::new();
158 msg.push_str(&format!(
159 "Cannot continue: {} placeholder resolution failure(s)\n",
160 failures.iter().map(|f| f.errors.len()).sum::<usize>(),
161 ));
162 for f in failures {
163 let scope = match f.field {
164 Some(field) => format!(" {} '{}' ({}):", f.resource_kind, f.resource_name, field),
165 None => format!(" {} '{}':", f.resource_kind, f.resource_name),
166 };
167 msg.push_str(&scope);
168 msg.push('\n');
169 for e in &f.errors {
170 match e {
171 ResolutionError::UnresolvedLid { start, anchor } => {
172 let where_ = anchor
173 .as_deref()
174 .map(|u| format!("URL '{u}'"))
175 .unwrap_or_else(|| "no URL anchor".to_string());
176 msg.push_str(&format!(
177 " - offset {start}: lid `__BRAZESYNC__` ({where_}) — no anchor match in remote body\n",
178 ));
179 }
180 ResolutionError::UnresolvedCbId { start, name } => {
181 let n = name.as_deref().unwrap_or("<unknown>");
182 msg.push_str(&format!(
183 " - offset {start}: cb_id `__BRAZESYNC__` (`${{{n}}}`) — no `${{{n}}}` include in remote body\n",
184 ));
185 }
186 ResolutionError::UnknownContext { start } => {
187 msg.push_str(&format!(
188 " - offset {start}: `__BRAZESYNC__` outside `| lid:` / `| id:` argument — cannot infer type\n",
189 ));
190 }
191 ResolutionError::RetiredNamespace { token } => {
192 msg.push_str(&format!(
193 " - {token}: retired placeholder syntax \
194 (v0.15 `__BRAZESYNC.<type>.<key>__` was removed in v0.16; \
195 re-run `braze-sync templatize` to regenerate)\n",
196 ));
197 }
198 }
199 }
200 }
201 crate::error::Error::Config(msg)
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::resource::content_block::ContentBlockState;
208
209 fn cb(name: &str, content: &str) -> ContentBlock {
210 ContentBlock {
211 name: name.into(),
212 description: None,
213 content: content.into(),
214 tags: Vec::new(),
215 state: ContentBlockState::Active,
216 }
217 }
218
219 fn et(name: &str) -> EmailTemplate {
220 EmailTemplate {
221 name: name.into(),
222 subject: String::new(),
223 body_html: String::new(),
224 body_plaintext: String::new(),
225 description: None,
226 preheader: None,
227 should_inline_css: None,
228 tags: Vec::new(),
229 }
230 }
231
232 #[test]
233 fn no_placeholders_skips_resolution() {
234 let mut block = cb("plain", "<p>hi there</p>");
235 resolve_content_block_with_remote(&mut block, None).unwrap();
236 assert_eq!(block.content, "<p>hi there</p>");
237 }
238
239 #[test]
240 fn content_block_resolves_lid_from_remote() {
241 let mut block = cb(
242 "promo",
243 r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
244 );
245 let remote = cb(
246 "promo",
247 r#"<a href="https://x.com/cta">{{x | lid: 'newlidvalue1'}}</a>"#,
248 );
249 resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap();
250 assert!(block.content.contains("'newlidvalue1'"));
251 }
252
253 #[test]
254 fn new_resource_lid_uses_url_slug() {
255 let mut block = cb(
256 "promo",
257 r#"<a href="https://x.com/spring-sale">{{x | lid: '__BRAZESYNC__'}}</a>"#,
258 );
259 resolve_content_block_with_remote(&mut block, None).unwrap();
260 assert!(
261 block.content.contains("'spring_sale'"),
262 "got: {}",
263 block.content
264 );
265 }
266
267 #[test]
268 fn new_resource_cb_id_filter_is_stripped() {
269 let mut block = cb("page", "{{content_blocks.${promo} | id: '__BRAZESYNC__'}}");
270 resolve_content_block_with_remote(&mut block, None).unwrap();
271 assert_eq!(block.content, "{{content_blocks.${promo}}}");
272 }
273
274 #[test]
275 fn email_template_resolves_per_field() {
276 let mut t = et("welcome");
277 t.body_html = r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#.into();
278 let mut remote = et("welcome");
279 remote.body_html = r#"<a href="https://x.com/cta">{{x | lid: 'newhtmllidx'}}</a>"#.into();
280 resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
281 assert!(t.body_html.contains("'newhtmllidx'"));
282 }
283
284 #[test]
285 fn missing_remote_anchor_surfaces_as_failure() {
286 let mut block = cb(
287 "promo",
288 r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
289 );
290 let remote = cb("promo", "<p>no anchor here</p>");
291 let err = resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap_err();
292 assert_eq!(err.errors.len(), 1);
293 assert!(matches!(
294 err.errors[0],
295 ResolutionError::UnresolvedLid { .. }
296 ));
297 }
298
299 #[test]
300 fn subject_lid_resolves_positionally_from_remote() {
301 let mut t = et("promo");
302 t.subject = "Spring sale {{x | lid: '__BRAZESYNC__'}}".into();
303 let mut remote = et("promo");
304 remote.subject = "Spring sale {{x | lid: 'subjectlid1'}}".into();
305 resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
306 assert!(t.subject.contains("'subjectlid1'"));
307 }
308
309 #[test]
310 fn retired_v015_envelope_is_fatal() {
311 let mut block = cb("legacy", "hello __BRAZESYNC.lid.foo__ world");
312 let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
313 assert!(err
314 .errors
315 .iter()
316 .any(|e| matches!(e, ResolutionError::RetiredNamespace { .. })));
317 }
318
319 #[test]
320 fn typo_suffixed_token_is_detected() {
321 let mut block = cb("typo", "hello __BRAZESYNCTEST__ world");
322 let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
323 assert!(
324 !err.errors.is_empty(),
325 "typo-suffixed token must not pass silently"
326 );
327 }
328}