Skip to main content

braze_sync/diff/
email_template.rs

1//! Email Template diff.
2//!
3//! ### Why `description` is excluded from the syncable comparison
4//!
5//! Braze's `/templates/email/info` returns `description` but the
6//! create and update endpoints cannot set it. Including it in
7//! `syncable_eq` would make any local file with `description: "..."` diff
8//! as Modified forever — the same "infinite drift" mode the Content Block
9//! `state` exclusion prevents. See PHASE_B1_NOTES.md §3 / §6.
10
11use crate::diff::{compute_text_diff, opt_str_eq, tags_eq_unordered, DiffOp, TextDiffSummary};
12use crate::resource::EmailTemplate;
13use std::collections::BTreeMap;
14
15/// Name → Braze `email_template_id`. Built during diff, consumed by
16/// apply to translate per-name plan entries into the id the update
17/// endpoint requires.
18pub type EmailTemplateIdIndex = BTreeMap<String, String>;
19
20#[derive(Debug, Clone)]
21pub struct EmailTemplateDiff {
22    pub name: String,
23    pub op: DiffOp<EmailTemplate>,
24    pub subject_changed: bool,
25    pub body_html_diff: Option<TextDiffSummary>,
26    pub body_plaintext_diff: Option<TextDiffSummary>,
27    pub metadata_changed: bool,
28    /// True when present in Braze but missing from Git.
29    pub orphan: bool,
30}
31
32impl EmailTemplateDiff {
33    pub fn has_changes(&self) -> bool {
34        self.op.is_change()
35            || self.subject_changed
36            || self.body_html_diff.is_some()
37            || self.body_plaintext_diff.is_some()
38            || self.metadata_changed
39            || self.orphan
40    }
41
42    pub fn is_orphan(&self) -> bool {
43        self.orphan
44    }
45
46    /// Canonical constructor for the remote-only / orphan shape. No
47    /// DELETE API → `op` stays `Unchanged`; callers branch on the
48    /// `orphan` flag instead.
49    pub fn orphan(name: impl Into<String>) -> Self {
50        Self {
51            name: name.into(),
52            op: DiffOp::Unchanged,
53            subject_changed: false,
54            body_html_diff: None,
55            body_plaintext_diff: None,
56            metadata_changed: false,
57            orphan: true,
58        }
59    }
60}
61
62/// Returns `None` only when both sides are absent. Local is desired
63/// state, remote is current Braze state.
64pub fn diff(
65    local: Option<&EmailTemplate>,
66    remote: Option<&EmailTemplate>,
67) -> Option<EmailTemplateDiff> {
68    match (local, remote) {
69        (None, None) => None,
70        (Some(l), None) => Some(EmailTemplateDiff {
71            name: l.name.clone(),
72            op: DiffOp::Added(l.clone()),
73            subject_changed: false,
74            body_html_diff: None,
75            body_plaintext_diff: None,
76            metadata_changed: false,
77            orphan: false,
78        }),
79        (None, Some(r)) => Some(EmailTemplateDiff::orphan(&r.name)),
80        (Some(l), Some(r)) => {
81            if syncable_eq(l, r) {
82                Some(EmailTemplateDiff {
83                    name: l.name.clone(),
84                    op: DiffOp::Unchanged,
85                    subject_changed: false,
86                    body_html_diff: None,
87                    body_plaintext_diff: None,
88                    metadata_changed: false,
89                    orphan: false,
90                })
91            } else {
92                let subject_changed = l.subject != r.subject;
93                let body_html_diff = if l.body_html != r.body_html {
94                    Some(compute_text_diff(&r.body_html, &l.body_html))
95                } else {
96                    None
97                };
98                let body_plaintext_diff = if l.body_plaintext != r.body_plaintext {
99                    Some(compute_text_diff(&r.body_plaintext, &l.body_plaintext))
100                } else {
101                    None
102                };
103                let metadata_changed = !metadata_eq(l, r);
104                Some(EmailTemplateDiff {
105                    name: l.name.clone(),
106                    op: DiffOp::Modified {
107                        from: r.clone(),
108                        to: l.clone(),
109                    },
110                    subject_changed,
111                    body_html_diff,
112                    body_plaintext_diff,
113                    metadata_changed,
114                    orphan: false,
115                })
116            }
117        }
118    }
119}
120
121/// Equality for the fields braze-sync can actually push to Braze.
122/// Excludes `description` — see the module docs. Tags are compared as a
123/// multiset because Braze's APIs don't document tag-order stability.
124fn syncable_eq(a: &EmailTemplate, b: &EmailTemplate) -> bool {
125    a.name == b.name
126        && a.subject == b.subject
127        && a.body_html == b.body_html
128        && a.body_plaintext == b.body_plaintext
129        && opt_str_eq(&a.preheader, &b.preheader)
130        && a.should_inline_css == b.should_inline_css
131        && tags_eq_unordered(&a.tags, &b.tags)
132    // description excluded (read-only, like ContentBlock state)
133}
134
135/// Metadata equality (everything except subject, body_html, body_plaintext,
136/// and description). Used to set the `metadata_changed` flag.
137fn metadata_eq(a: &EmailTemplate, b: &EmailTemplate) -> bool {
138    opt_str_eq(&a.preheader, &b.preheader)
139        && a.should_inline_css == b.should_inline_css
140        && tags_eq_unordered(&a.tags, &b.tags)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn et(name: &str, html: &str) -> EmailTemplate {
148        EmailTemplate {
149            name: name.into(),
150            subject: format!("Subject {name}"),
151            body_html: html.into(),
152            body_plaintext: format!("plain {name}"),
153            description: Some(format!("{name} desc")),
154            preheader: Some("preview".into()),
155            should_inline_css: Some(true),
156            tags: vec!["tag".into()],
157        }
158    }
159
160    #[test]
161    fn both_absent_returns_none() {
162        assert!(diff(None, None).is_none());
163    }
164
165    #[test]
166    fn local_only_is_added() {
167        let l = et("welcome", "<p>Hi</p>");
168        let d = diff(Some(&l), None).unwrap();
169        assert!(matches!(d.op, DiffOp::Added(_)));
170        assert!(!d.orphan);
171        assert!(d.has_changes());
172    }
173
174    #[test]
175    fn remote_only_is_orphan_not_removed() {
176        let r = et("legacy", "<p>old</p>");
177        let d = diff(None, Some(&r)).unwrap();
178        assert!(matches!(d.op, DiffOp::Unchanged));
179        assert!(d.orphan);
180        assert!(d.is_orphan());
181        assert!(d.has_changes());
182    }
183
184    #[test]
185    fn equal_templates_are_unchanged() {
186        let l = et("same", "<p>body</p>\n");
187        let r = et("same", "<p>body</p>\n");
188        let d = diff(Some(&l), Some(&r)).unwrap();
189        assert!(matches!(d.op, DiffOp::Unchanged));
190        assert!(!d.orphan);
191        assert!(!d.has_changes());
192    }
193
194    #[test]
195    fn subject_change_is_modified() {
196        let mut l = et("sub", "<p>body</p>\n");
197        let r = et("sub", "<p>body</p>\n");
198        l.subject = "New subject".into();
199        let d = diff(Some(&l), Some(&r)).unwrap();
200        assert!(matches!(d.op, DiffOp::Modified { .. }));
201        assert!(d.subject_changed);
202        assert!(d.body_html_diff.is_none());
203        assert!(d.body_plaintext_diff.is_none());
204    }
205
206    #[test]
207    fn body_html_change_produces_text_diff() {
208        let l = et("html", "line a\nline b\nline c\n");
209        let mut r = et("html", "line a\nold b\nline c\n");
210        r.body_html = "line a\nold b\nline c\n".into();
211        // sync the plaintext so only html differs
212        r.body_plaintext = l.body_plaintext.clone();
213        let d = diff(Some(&l), Some(&r)).unwrap();
214        assert!(matches!(d.op, DiffOp::Modified { .. }));
215        let td = d.body_html_diff.expect("html diff present");
216        assert_eq!(td.additions, 1);
217        assert_eq!(td.deletions, 1);
218        assert!(d.body_plaintext_diff.is_none());
219    }
220
221    #[test]
222    fn body_plaintext_change_produces_text_diff() {
223        let l = et("txt", "<p>same</p>");
224        let mut r = et("txt", "<p>same</p>");
225        r.body_plaintext = "different plain".into();
226        let d = diff(Some(&l), Some(&r)).unwrap();
227        assert!(matches!(d.op, DiffOp::Modified { .. }));
228        assert!(d.body_html_diff.is_none());
229        assert!(d.body_plaintext_diff.is_some());
230    }
231
232    #[test]
233    fn description_difference_alone_is_not_drift() {
234        let mut l = et("desc", "<p>body</p>");
235        let r = et("desc", "<p>body</p>");
236        l.description = Some("new desc".into());
237        // Make sure description is the ONLY difference
238        let d = diff(Some(&l), Some(&r)).unwrap();
239        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
240        assert!(!d.has_changes());
241    }
242
243    #[test]
244    fn tag_reorder_alone_is_not_drift() {
245        let mut l = et("tags", "<p>body</p>");
246        let mut r = et("tags", "<p>body</p>");
247        l.tags = vec!["alpha".into(), "beta".into(), "gamma".into()];
248        r.tags = vec!["gamma".into(), "alpha".into(), "beta".into()];
249        let d = diff(Some(&l), Some(&r)).unwrap();
250        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
251        assert!(!d.has_changes());
252    }
253
254    #[test]
255    fn tag_change_is_modified_with_metadata_flag() {
256        let mut l = et("tags2", "<p>body</p>");
257        let r = et("tags2", "<p>body</p>");
258        l.tags = vec!["a".into(), "b".into()];
259        let d = diff(Some(&l), Some(&r)).unwrap();
260        assert!(matches!(d.op, DiffOp::Modified { .. }));
261        assert!(d.metadata_changed);
262        assert!(!d.subject_changed);
263    }
264
265    #[test]
266    fn preheader_none_equals_empty() {
267        let mut l = et("pre", "<p>body</p>");
268        let mut r = et("pre", "<p>body</p>");
269        l.preheader = Some(String::new());
270        r.preheader = None;
271        let d = diff(Some(&l), Some(&r)).unwrap();
272        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
273        assert!(!d.has_changes());
274    }
275
276    #[test]
277    fn should_inline_css_change_is_metadata_modified() {
278        let mut l = et("css", "<p>body</p>");
279        let r = et("css", "<p>body</p>");
280        l.should_inline_css = Some(false);
281        let d = diff(Some(&l), Some(&r)).unwrap();
282        assert!(matches!(d.op, DiffOp::Modified { .. }));
283        assert!(d.metadata_changed);
284    }
285
286    #[test]
287    fn destructive_count_is_never_set_on_email_templates() {
288        let r = et("ghost", "<p>x</p>");
289        let orphan = diff(None, Some(&r)).unwrap();
290        assert!(!orphan.op.is_destructive());
291
292        let l2 = et("changed", "<p>new</p>");
293        let r2 = et("changed", "<p>old</p>");
294        let modified = diff(Some(&l2), Some(&r2)).unwrap();
295        assert!(!modified.op.is_destructive());
296    }
297}