use crate::resource::{ContentBlock, EmailTemplate};
use crate::values::braze_managed::prepare_field;
use crate::values::placeholder::ResolutionError;
use crate::values::templatize::FieldKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolutionFailure {
pub resource_kind: &'static str,
pub resource_name: String,
pub field: Option<&'static str>,
pub errors: Vec<ResolutionError>,
}
pub fn resolve_content_block_with_remote(
cb: &mut ContentBlock,
remote: Option<&ContentBlock>,
) -> std::result::Result<(), ResolutionFailure> {
if !needs_resolve(&cb.content) {
return Ok(());
}
let prep = prepare_field(
&cb.content,
remote.map(|r| r.content.as_str()),
FieldKind::ContentBlock,
);
emit_prep_warnings("content_block", &cb.name, None, &prep.warnings);
if !prep.errors.is_empty() {
return Err(ResolutionFailure {
resource_kind: "content_block",
resource_name: cb.name.clone(),
field: None,
errors: prep.errors,
});
}
cb.content = prep.body;
Ok(())
}
pub fn resolve_email_template_with_remote(
et: &mut EmailTemplate,
remote: Option<&EmailTemplate>,
) -> std::result::Result<(), Vec<ResolutionFailure>> {
let mut failures: Vec<ResolutionFailure> = Vec::new();
macro_rules! resolve_field {
($field_name:expr, $field_kind:expr, $accessor:expr, $remote_accessor:expr) => {{
let body: &str = $accessor;
if needs_resolve(body) {
let prep = prepare_field(body, $remote_accessor, $field_kind);
emit_prep_warnings(
"email_template",
&et.name,
Some($field_name),
&prep.warnings,
);
if !prep.errors.is_empty() {
failures.push(ResolutionFailure {
resource_kind: "email_template",
resource_name: et.name.clone(),
field: Some($field_name),
errors: prep.errors,
});
None
} else {
Some(prep.body)
}
} else {
None
}
}};
}
let new_subject = resolve_field!(
"subject",
FieldKind::EmailSubject,
et.subject.as_str(),
remote.map(|r| r.subject.as_str())
);
let new_body_html = resolve_field!(
"body_html",
FieldKind::EmailHtmlBody,
et.body_html.as_str(),
remote.map(|r| r.body_html.as_str())
);
let new_body_plaintext = resolve_field!(
"body_plaintext",
FieldKind::EmailPlainBody,
et.body_plaintext.as_str(),
remote.map(|r| r.body_plaintext.as_str())
);
let new_preheader = match et.preheader.as_deref() {
Some(s) => resolve_field!(
"preheader",
FieldKind::EmailPreheader,
s,
remote.and_then(|r| r.preheader.as_deref())
),
None => None,
};
if !failures.is_empty() {
return Err(failures);
}
if let Some(v) = new_subject {
et.subject = v;
}
if let Some(v) = new_body_html {
et.body_html = v;
}
if let Some(v) = new_body_plaintext {
et.body_plaintext = v;
}
if let Some(v) = new_preheader {
et.preheader = Some(v);
}
Ok(())
}
fn needs_resolve(body: &str) -> bool {
body.contains("__BRAZESYNC") || body.contains("__BRAZSYNC")
}
fn emit_prep_warnings(
kind: &'static str,
name: &str,
field: Option<&'static str>,
warnings: &[String],
) {
if warnings.is_empty() {
return;
}
let scope = match field {
Some(f) => format!("{kind} '{name}' ({f})"),
None => format!("{kind} '{name}'"),
};
for w in warnings {
eprintln!("warning: {scope}: {w}");
}
}
pub fn format_failures(failures: &[ResolutionFailure]) -> crate::error::Error {
let mut msg = String::new();
msg.push_str(&format!(
"Cannot continue: {} placeholder resolution failure(s)\n",
failures.iter().map(|f| f.errors.len()).sum::<usize>(),
));
for f in failures {
let scope = match f.field {
Some(field) => format!(" {} '{}' ({}):", f.resource_kind, f.resource_name, field),
None => format!(" {} '{}':", f.resource_kind, f.resource_name),
};
msg.push_str(&scope);
msg.push('\n');
for e in &f.errors {
match e {
ResolutionError::UnresolvedLid { start, anchor } => {
let where_ = anchor
.as_deref()
.map(|u| format!("URL '{u}'"))
.unwrap_or_else(|| "no URL anchor".to_string());
msg.push_str(&format!(
" - offset {start}: lid `__BRAZESYNC__` ({where_}) — no anchor match in remote body\n",
));
}
ResolutionError::UnresolvedCbId { start, name } => {
let n = name.as_deref().unwrap_or("<unknown>");
msg.push_str(&format!(
" - offset {start}: cb_id `__BRAZESYNC__` (`${{{n}}}`) — no `${{{n}}}` include in remote body\n",
));
}
ResolutionError::UnknownContext { start } => {
msg.push_str(&format!(
" - offset {start}: `__BRAZESYNC__` outside `| lid:` / `| id:` argument — cannot infer type\n",
));
}
ResolutionError::RetiredNamespace { token } => {
msg.push_str(&format!(
" - {token}: retired placeholder syntax \
(v0.15 `__BRAZESYNC.<type>.<key>__` was removed in v0.16; \
re-run `braze-sync templatize` to regenerate)\n",
));
}
}
}
}
crate::error::Error::Config(msg)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resource::content_block::ContentBlockState;
fn cb(name: &str, content: &str) -> ContentBlock {
ContentBlock {
name: name.into(),
description: None,
content: content.into(),
tags: Vec::new(),
state: ContentBlockState::Active,
}
}
fn et(name: &str) -> EmailTemplate {
EmailTemplate {
name: name.into(),
subject: String::new(),
body_html: String::new(),
body_plaintext: String::new(),
description: None,
preheader: None,
should_inline_css: None,
tags: Vec::new(),
}
}
#[test]
fn no_placeholders_skips_resolution() {
let mut block = cb("plain", "<p>hi there</p>");
resolve_content_block_with_remote(&mut block, None).unwrap();
assert_eq!(block.content, "<p>hi there</p>");
}
#[test]
fn content_block_resolves_lid_from_remote() {
let mut block = cb(
"promo",
r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
);
let remote = cb(
"promo",
r#"<a href="https://x.com/cta">{{x | lid: 'newlidvalue1'}}</a>"#,
);
resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap();
assert!(block.content.contains("'newlidvalue1'"));
}
#[test]
fn new_resource_lid_uses_url_slug() {
let mut block = cb(
"promo",
r#"<a href="https://x.com/spring-sale">{{x | lid: '__BRAZESYNC__'}}</a>"#,
);
resolve_content_block_with_remote(&mut block, None).unwrap();
assert!(
block.content.contains("'spring_sale'"),
"got: {}",
block.content
);
}
#[test]
fn new_resource_cb_id_filter_is_stripped() {
let mut block = cb("page", "{{content_blocks.${promo} | id: '__BRAZESYNC__'}}");
resolve_content_block_with_remote(&mut block, None).unwrap();
assert_eq!(block.content, "{{content_blocks.${promo}}}");
}
#[test]
fn email_template_resolves_per_field() {
let mut t = et("welcome");
t.body_html = r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#.into();
let mut remote = et("welcome");
remote.body_html = r#"<a href="https://x.com/cta">{{x | lid: 'newhtmllidx'}}</a>"#.into();
resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
assert!(t.body_html.contains("'newhtmllidx'"));
}
#[test]
fn missing_remote_anchor_surfaces_as_failure() {
let mut block = cb(
"promo",
r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
);
let remote = cb("promo", "<p>no anchor here</p>");
let err = resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap_err();
assert_eq!(err.errors.len(), 1);
assert!(matches!(
err.errors[0],
ResolutionError::UnresolvedLid { .. }
));
}
#[test]
fn subject_lid_resolves_positionally_from_remote() {
let mut t = et("promo");
t.subject = "Spring sale {{x | lid: '__BRAZESYNC__'}}".into();
let mut remote = et("promo");
remote.subject = "Spring sale {{x | lid: 'subjectlid1'}}".into();
resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
assert!(t.subject.contains("'subjectlid1'"));
}
#[test]
fn retired_v015_envelope_is_fatal() {
let mut block = cb("legacy", "hello __BRAZESYNC.lid.foo__ world");
let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
assert!(err
.errors
.iter()
.any(|e| matches!(e, ResolutionError::RetiredNamespace { .. })));
}
#[test]
fn typo_suffixed_token_is_detected() {
let mut block = cb("typo", "hello __BRAZESYNCTEST__ world");
let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
assert!(
!err.errors.is_empty(),
"typo-suffixed token must not pass silently"
);
}
}