1use std::collections::BTreeMap;
4
5use crate::resource::{ContentBlock, EmailTemplate};
6use crate::values::braze_managed::prepare_field;
7use crate::values::placeholder::{
8 find_suspicious_placeholders, resolve_placeholders, LookupKey, ResolutionError,
9};
10use crate::values::templatize::FieldKind;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ResolutionFailure {
16 pub resource_kind: &'static str,
17 pub resource_name: String,
18 pub field: Option<&'static str>,
20 pub errors: Vec<ResolutionError>,
21}
22
23pub fn resolve_content_block_with_remote(
29 cb: &mut ContentBlock,
30 remote: Option<&ContentBlock>,
31) -> std::result::Result<(), ResolutionFailure> {
32 warn_suspicious("content_block", &cb.name, None, &cb.content)?;
33 if !body_has_placeholders(&cb.content) {
34 return Ok(());
35 }
36 let prep = prepare_field(
37 &cb.content,
38 remote.map(|r| r.content.as_str()),
39 FieldKind::ContentBlock,
40 );
41 emit_prep_warnings("content_block", &cb.name, None, &prep.warnings);
42 let lookup: BTreeMap<LookupKey, String> = prep.additions;
43 match resolve_placeholders(&prep.body, &lookup) {
44 Ok(resolved) => {
45 cb.content = resolved;
46 Ok(())
47 }
48 Err(errors) => Err(ResolutionFailure {
49 resource_kind: "content_block",
50 resource_name: cb.name.clone(),
51 field: None,
52 errors,
53 }),
54 }
55}
56
57pub fn resolve_email_template_with_remote(
59 et: &mut EmailTemplate,
60 remote: Option<&EmailTemplate>,
61) -> std::result::Result<(), Vec<ResolutionFailure>> {
62 let mut failures: Vec<ResolutionFailure> = Vec::new();
63
64 macro_rules! resolve_field {
65 ($field_name:expr, $field_kind:expr, $accessor:expr, $remote_accessor:expr) => {{
66 let body: &str = $accessor;
67 if let Err(f) = warn_suspicious("email_template", &et.name, Some($field_name), body) {
68 failures.push(f);
69 }
70 if body_has_placeholders(body) {
71 let prep = prepare_field(body, $remote_accessor, $field_kind);
72 emit_prep_warnings(
73 "email_template",
74 &et.name,
75 Some($field_name),
76 &prep.warnings,
77 );
78 match resolve_placeholders(&prep.body, &prep.additions) {
79 Ok(resolved) => Some(resolved),
80 Err(errors) => {
81 failures.push(ResolutionFailure {
82 resource_kind: "email_template",
83 resource_name: et.name.clone(),
84 field: Some($field_name),
85 errors,
86 });
87 None
88 }
89 }
90 } else {
91 None
92 }
93 }};
94 }
95
96 let new_subject = resolve_field!(
97 "subject",
98 FieldKind::EmailSubject,
99 et.subject.as_str(),
100 remote.map(|r| r.subject.as_str())
101 );
102 let new_body_html = resolve_field!(
103 "body_html",
104 FieldKind::EmailHtmlBody,
105 et.body_html.as_str(),
106 remote.map(|r| r.body_html.as_str())
107 );
108 let new_body_plaintext = resolve_field!(
109 "body_plaintext",
110 FieldKind::EmailPlainBody,
111 et.body_plaintext.as_str(),
112 remote.map(|r| r.body_plaintext.as_str())
113 );
114 let new_preheader = match et.preheader.as_deref() {
115 Some(s) => resolve_field!(
116 "preheader",
117 FieldKind::EmailPreheader,
118 s,
119 remote.and_then(|r| r.preheader.as_deref())
120 ),
121 None => None,
122 };
123
124 if !failures.is_empty() {
125 return Err(failures);
126 }
127
128 if let Some(v) = new_subject {
129 et.subject = v;
130 }
131 if let Some(v) = new_body_html {
132 et.body_html = v;
133 }
134 if let Some(v) = new_body_plaintext {
135 et.body_plaintext = v;
136 }
137 if let Some(v) = new_preheader {
138 et.preheader = Some(v);
139 }
140 Ok(())
141}
142
143fn body_has_placeholders(body: &str) -> bool {
144 body.contains("__BRAZESYNC.")
145}
146
147fn emit_prep_warnings(
153 kind: &'static str,
154 name: &str,
155 field: Option<&'static str>,
156 warnings: &[String],
157) {
158 if warnings.is_empty() {
159 return;
160 }
161 let scope = match field {
162 Some(f) => format!("{kind} '{name}' ({f})"),
163 None => format!("{kind} '{name}'"),
164 };
165 for w in warnings {
166 eprintln!("warning: {scope}: {w}");
167 }
168}
169
170fn warn_suspicious(
171 kind: &'static str,
172 name: &str,
173 field: Option<&'static str>,
174 body: &str,
175) -> Result<(), ResolutionFailure> {
176 if !body.contains("__") {
177 return Ok(());
178 }
179 let suspects = find_suspicious_placeholders(body);
180 if suspects.is_empty() {
181 return Ok(());
182 }
183 let scope = match field {
184 Some(f) => format!("{kind} '{name}' ({f})"),
185 None => format!("{kind} '{name}'"),
186 };
187 let mut retired: Vec<ResolutionError> = Vec::new();
188 for s in &suspects {
189 if s.starts_with("__BRAZESYNC.") {
190 retired.push(ResolutionError::RetiredNamespace { token: s.clone() });
191 } else {
192 eprintln!("warning: {scope}: suspicious placeholder-like token {s}");
193 }
194 }
195 if retired.is_empty() {
196 Ok(())
197 } else {
198 Err(ResolutionFailure {
199 resource_kind: kind,
200 resource_name: name.to_string(),
201 field,
202 errors: retired,
203 })
204 }
205}
206
207pub fn format_failures(failures: &[ResolutionFailure]) -> crate::error::Error {
209 let mut msg = String::new();
210 msg.push_str(&format!(
211 "Cannot continue: {} placeholder resolution failure(s)\n",
212 failures.iter().map(|f| f.errors.len()).sum::<usize>(),
213 ));
214 for f in failures {
215 let scope = match f.field {
216 Some(field) => format!(" {} '{}' ({}):", f.resource_kind, f.resource_name, field),
217 None => format!(" {} '{}':", f.resource_kind, f.resource_name),
218 };
219 msg.push_str(&scope);
220 msg.push('\n');
221 for e in &f.errors {
222 match e {
223 ResolutionError::UnknownKey { ty, key, start } => {
224 msg.push_str(&format!(
225 " - offset {}: __BRAZESYNC.{}.{}__ (no anchor match in remote body)\n",
226 start,
227 ty.as_str(),
228 key,
229 ));
230 }
231 ResolutionError::DuplicateLidKey { key, occurrences } => {
232 let offsets = occurrences
233 .iter()
234 .map(|o| o.to_string())
235 .collect::<Vec<_>>()
236 .join(", ");
237 msg.push_str(&format!(
238 " - __BRAZESYNC.lid.{key}__ referenced {} times (offsets {offsets}); \
239 lid IDs are per-click-context — use a distinct key per occurrence\n",
240 occurrences.len(),
241 ));
242 }
243 ResolutionError::RetiredNamespace { token } => {
244 msg.push_str(&format!(
245 " - {token}: retired placeholder namespace \
246 (custom/global removed in v0.15; use literal values)\n",
247 ));
248 }
249 }
250 }
251 }
252 crate::error::Error::Config(msg)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::resource::content_block::ContentBlockState;
259
260 fn cb(name: &str, content: &str) -> ContentBlock {
261 ContentBlock {
262 name: name.into(),
263 description: None,
264 content: content.into(),
265 tags: Vec::new(),
266 state: ContentBlockState::Active,
267 }
268 }
269
270 fn et(name: &str) -> EmailTemplate {
271 EmailTemplate {
272 name: name.into(),
273 subject: String::new(),
274 body_html: String::new(),
275 body_plaintext: String::new(),
276 description: None,
277 preheader: None,
278 should_inline_css: None,
279 tags: Vec::new(),
280 }
281 }
282
283 #[test]
284 fn no_placeholders_skips_resolution() {
285 let mut block = cb("plain", "<p>hi there</p>");
286 resolve_content_block_with_remote(&mut block, None).unwrap();
287 assert_eq!(block.content, "<p>hi there</p>");
288 }
289
290 #[test]
291 fn content_block_resolves_lid_from_remote() {
292 let mut block = cb(
293 "promo",
294 r#"<a href="https://x.com/cta">__BRAZESYNC.lid.cta__</a>"#,
295 );
296 let remote = cb(
297 "promo",
298 r#"<a href="https://x.com/cta">{{x | lid: 'newlidvalue1'}}</a>"#,
299 );
300 resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap();
301 assert!(block.content.contains(">newlidvalue1<"));
302 }
303
304 #[test]
305 fn new_resource_lid_fallback_uses_placeholder_key() {
306 let mut block = cb(
307 "promo",
308 r#"<a href="https://x.com/spring">__BRAZESYNC.lid.spring_sale__</a>"#,
309 );
310 resolve_content_block_with_remote(&mut block, None).unwrap();
311 assert!(block.content.contains(">spring_sale<"));
312 }
313
314 #[test]
315 fn new_resource_cb_id_filter_is_stripped() {
316 let mut block = cb(
317 "page",
318 "{{content_blocks.${promo} | id: '__BRAZESYNC.cb_id.promo__'}}",
319 );
320 resolve_content_block_with_remote(&mut block, None).unwrap();
321 assert_eq!(block.content, "{{content_blocks.${promo}}}");
322 }
323
324 #[test]
325 fn email_template_resolves_per_field() {
326 let mut t = et("welcome");
327 t.body_html = r#"<a href="https://x.com/cta">__BRAZESYNC.lid.cta__</a>"#.into();
328 let mut remote = et("welcome");
329 remote.body_html = r#"<a href="https://x.com/cta">{{x | lid: 'newhtmllidx'}}</a>"#.into();
330 resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
331 assert!(t.body_html.contains(">newhtmllidx<"));
332 }
333
334 #[test]
335 fn missing_remote_anchor_surfaces_as_failure() {
336 let mut block = cb(
337 "promo",
338 r#"<a href="https://x.com/cta">__BRAZESYNC.lid.cta__</a>"#,
339 );
340 let remote = cb("promo", "<p>no anchor here</p>");
341 let err = resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap_err();
342 assert_eq!(err.errors.len(), 1);
343 }
344
345 #[test]
346 fn subject_lid_resolves_positionally_from_remote() {
347 let mut t = et("promo");
348 t.subject = "Spring sale {{x | lid: '__BRAZESYNC.lid.subject_lid__'}}".into();
349 let mut remote = et("promo");
350 remote.subject = "Spring sale {{x | lid: 'subjectlid1'}}".into();
351 resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
352 assert!(t.subject.contains("'subjectlid1'"));
353 }
354
355 #[test]
356 fn subject_lid_count_mismatch_still_resolves_overlap() {
357 let mut t = et("promo");
358 t.subject = "{{x | lid: '__BRAZESYNC.lid.a__'}} {{y | lid: '__BRAZESYNC.lid.b__'}}".into();
359 let mut remote = et("promo");
360 remote.subject = "{{x | lid: 'firstvalue'}}".into();
361 let err = resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap_err();
364 assert_eq!(err.len(), 1);
365 assert_eq!(err[0].field, Some("subject"));
366 }
367
368 #[test]
369 fn retired_namespace_is_fatal() {
370 let mut block = cb("legacy", "hello __BRAZESYNC.custom.foo__ world");
371 let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
372 assert!(err.errors.iter().any(|e| matches!(
373 e,
374 ResolutionError::RetiredNamespace { token }
375 if token.contains("custom.foo")
376 )));
377 }
378
379 #[test]
380 fn typo_namespace_is_warning_not_fatal() {
381 let mut block = cb("typo", "hello __BRAZSYNC.lid.foo__ world");
382 resolve_content_block_with_remote(&mut block, None).unwrap();
383 }
384
385 #[test]
386 fn double_quoted_cb_id_filter_stripped_on_new_resource() {
387 let mut block = cb(
388 "page",
389 r#"{{content_blocks.${promo} | id: "__BRAZESYNC.cb_id.promo__"}}"#,
390 );
391 resolve_content_block_with_remote(&mut block, None).unwrap();
392 assert_eq!(block.content, "{{content_blocks.${promo}}}");
393 }
394}