@topy-ai/maggie 0.7.48 → 0.7.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +31 -4
- package/bin/maggie.js +17 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +8 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +9 -2
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +54 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check localized email templates and consent-aware lifecycle email plans.
|
|
3
|
+
|
|
4
|
+
Both commands are read-only validators. They never render with real customer
|
|
5
|
+
data, call a provider, or send email. ``email-matrix`` can write preview files
|
|
6
|
+
filled from declared fixtures for human review.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import html
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
EMAIL_SCHEMA = "maggie-email-localization.v1"
|
|
19
|
+
LIFECYCLE_SCHEMA = "maggie-lifecycle-email.v1"
|
|
20
|
+
|
|
21
|
+
LOCALE_SOURCES = ("user", "market", "account", "fallback")
|
|
22
|
+
UNSAFE_LOCALE_SOURCES = ("accept-language", "hard-coded")
|
|
23
|
+
FIELDS = ("subject", "html", "text")
|
|
24
|
+
CATEGORIES = ("transactional", "support", "marketing")
|
|
25
|
+
PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z_][\w.]*)\s*\}\}|\{([A-Za-z_][\w.]*)\}")
|
|
26
|
+
TAG = re.compile(r"<[^>]+>")
|
|
27
|
+
|
|
28
|
+
LIFECYCLE_STAGES = ("plan", "segment", "consent-check", "preview", "approve", "schedule", "send", "report")
|
|
29
|
+
LIBRARY_KINDS = (
|
|
30
|
+
"opt-in-confirmation", "welcome", "digest", "recommendation", "gift-follow-up",
|
|
31
|
+
"voucher-follow-up", "waitlist-follow-up", "merchant-lead-digest",
|
|
32
|
+
"campaign-announcement", "re-engagement",
|
|
33
|
+
)
|
|
34
|
+
CONSENT_BASES = ("opt-in", "double-opt-in", "soft-opt-in", "legitimate-interest-b2b")
|
|
35
|
+
# The confirmation request is sent before marketing consent exists, so it is
|
|
36
|
+
# transactional and needs no unsubscribe link, but it still has a frequency cap.
|
|
37
|
+
PRE_CONSENT_KINDS = ("opt-in-confirmation",)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def placeholders(value: str) -> set[str]:
|
|
41
|
+
return {a or b for a, b in PLACEHOLDER.findall(value or "")}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def visible_text(value: str) -> str:
|
|
45
|
+
stripped = PLACEHOLDER.sub("", TAG.sub(" ", value or ""))
|
|
46
|
+
return re.sub(r"\s+", " ", html.unescape(stripped)).strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def language(locale: str) -> str:
|
|
50
|
+
return (locale or "").split("-")[0].lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def route_ok(route: str) -> bool:
|
|
54
|
+
return isinstance(route, str) and (route.startswith("/") or route.startswith("https://"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_locale_resolution(template: dict, errors: list[str]) -> None:
|
|
58
|
+
tid = template.get("id")
|
|
59
|
+
order = template.get("localeResolution")
|
|
60
|
+
if not isinstance(order, list) or not order:
|
|
61
|
+
errors.append(f"{tid}: localeResolution must list the locale sources explicitly")
|
|
62
|
+
return
|
|
63
|
+
unsafe = [item for item in order if item in UNSAFE_LOCALE_SOURCES]
|
|
64
|
+
if unsafe:
|
|
65
|
+
errors.append(f"{tid}: localeResolution must not use {', '.join(unsafe)}; resolve from user, market, account, then fallback")
|
|
66
|
+
known = [item for item in order if item in LOCALE_SOURCES]
|
|
67
|
+
if known != [item for item in LOCALE_SOURCES if item in known]:
|
|
68
|
+
errors.append(f"{tid}: localeResolution precedence must be user > market > account > fallback")
|
|
69
|
+
if order[-1] != "fallback":
|
|
70
|
+
errors.append(f"{tid}: localeResolution must end with fallback")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_call_sites(manifest: dict, errors: list[str], warnings: list[str]) -> None:
|
|
74
|
+
templates = {item.get("id"): item for item in manifest.get("templates", []) if isinstance(item, dict)}
|
|
75
|
+
for site in manifest.get("callSites", []) or []:
|
|
76
|
+
source = site.get("localeSource")
|
|
77
|
+
tid = site.get("template")
|
|
78
|
+
label = f"call site {site.get('id') or tid}"
|
|
79
|
+
if tid not in templates:
|
|
80
|
+
errors.append(f"{label}: unknown template {tid}")
|
|
81
|
+
if source == "hard-coded":
|
|
82
|
+
errors.append(f"{label}: sends with a hard-coded locale; pass the resolved recipient locale")
|
|
83
|
+
elif source == "accept-language":
|
|
84
|
+
category = templates.get(tid, {}).get("category")
|
|
85
|
+
if site.get("path") == "fulfilment" or category == "transactional":
|
|
86
|
+
errors.append(f"{label}: fulfilment/transactional email must not use Accept-Language alone; resolve user > market > account > fallback")
|
|
87
|
+
else:
|
|
88
|
+
warnings.append(f"{label}: Accept-Language is only a hint; prefer a stored user or market locale")
|
|
89
|
+
elif source not in ("resolved",) + LOCALE_SOURCES:
|
|
90
|
+
errors.append(f"{label}: localeSource must be resolved, user, market, account or fallback")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_template(manifest: dict, template: dict, preview_dir: Path | None, errors: list[str], warnings: list[str]) -> list[dict]:
|
|
94
|
+
tid = template.get("id") or "<unnamed>"
|
|
95
|
+
source_locale = manifest.get("sourceLocale")
|
|
96
|
+
required = template.get("requiredLocales") or manifest.get("locales") or []
|
|
97
|
+
declared = set(template.get("variables") or [])
|
|
98
|
+
markets = manifest.get("markets") or {}
|
|
99
|
+
glossary = manifest.get("glossary") or []
|
|
100
|
+
if template.get("category") not in CATEGORIES:
|
|
101
|
+
errors.append(f"{tid}: category must be one of {', '.join(CATEGORIES)}")
|
|
102
|
+
check_locale_resolution(template, errors)
|
|
103
|
+
locales = template.get("locales") or {}
|
|
104
|
+
source = locales.get(source_locale) or {}
|
|
105
|
+
if not source:
|
|
106
|
+
errors.append(f"{tid}: missing source locale {source_locale}")
|
|
107
|
+
rows = []
|
|
108
|
+
for locale in required:
|
|
109
|
+
entry = locales.get(locale)
|
|
110
|
+
if not isinstance(entry, dict):
|
|
111
|
+
errors.append(f"{tid}: missing locale {locale}")
|
|
112
|
+
continue
|
|
113
|
+
for field in FIELDS:
|
|
114
|
+
if not isinstance(entry.get(field), str) or not entry[field].strip():
|
|
115
|
+
errors.append(f"{tid}/{locale}: missing {field}")
|
|
116
|
+
used = set()
|
|
117
|
+
for field in FIELDS:
|
|
118
|
+
value = entry.get(field) or ""
|
|
119
|
+
found = placeholders(value)
|
|
120
|
+
used |= found
|
|
121
|
+
undeclared = found - declared
|
|
122
|
+
if undeclared:
|
|
123
|
+
errors.append(f"{tid}/{locale}: {field} uses undeclared variables {', '.join(sorted(undeclared))}")
|
|
124
|
+
if locale != source_locale and source.get(field):
|
|
125
|
+
expected = placeholders(source[field])
|
|
126
|
+
if found != expected:
|
|
127
|
+
missing, extra = sorted(expected - found), sorted(found - expected)
|
|
128
|
+
errors.append(f"{tid}/{locale}: {field} variables changed from source (missing {missing or '[]'}, extra {extra or '[]'})")
|
|
129
|
+
if language(locale) != language(source_locale) and visible_text(value) and visible_text(value) == visible_text(source[field]):
|
|
130
|
+
errors.append(f"{tid}/{locale}: {field} is identical to the source text (untranslated)")
|
|
131
|
+
if placeholders(entry.get("html", "")) != placeholders(entry.get("text", "")):
|
|
132
|
+
errors.append(f"{tid}/{locale}: HTML and plain-text bodies use different variables")
|
|
133
|
+
route = entry.get("ctaRoute", template.get("cta", {}).get("route"))
|
|
134
|
+
if template.get("cta") or entry.get("ctaRoute"):
|
|
135
|
+
if not route_ok(route):
|
|
136
|
+
errors.append(f"{tid}/{locale}: CTA route must be a site path or https URL")
|
|
137
|
+
else:
|
|
138
|
+
market = entry.get("market")
|
|
139
|
+
prefix = (markets.get(market) or {}).get("routePrefix") if market else None
|
|
140
|
+
if market and market not in markets:
|
|
141
|
+
errors.append(f"{tid}/{locale}: unknown market {market}")
|
|
142
|
+
elif prefix and route.startswith("/") and not (route == prefix or route.startswith(prefix.rstrip("/") + "/")):
|
|
143
|
+
errors.append(f"{tid}/{locale}: CTA route {route} does not use market prefix {prefix}")
|
|
144
|
+
if market and locale not in ((markets.get(market) or {}).get("locales") or [locale]):
|
|
145
|
+
errors.append(f"{tid}/{locale}: market {market} does not serve locale {locale}")
|
|
146
|
+
base = template.get("cta", {}).get("route") or ""
|
|
147
|
+
if placeholders(route) != placeholders(base) and base:
|
|
148
|
+
errors.append(f"{tid}/{locale}: CTA route parameters differ from the template route")
|
|
149
|
+
for field in ("html", "text"):
|
|
150
|
+
body = entry.get(field) or ""
|
|
151
|
+
if route.split("{")[0] and route.split("{")[0] not in body and "{{ctaUrl}}" not in body and "{ctaUrl}" not in body:
|
|
152
|
+
warnings.append(f"{tid}/{locale}: {field} body does not include the CTA route or {{{{ctaUrl}}}}")
|
|
153
|
+
if locale != source_locale:
|
|
154
|
+
for term in glossary:
|
|
155
|
+
if term.get("locale") != locale:
|
|
156
|
+
continue
|
|
157
|
+
src_term, target = str(term.get("term", "")), str(term.get("translation", ""))
|
|
158
|
+
if src_term and src_term.lower() in visible_text(source.get("text", "")).lower() and target and target not in (entry.get("text") or ""):
|
|
159
|
+
warnings.append(f"{tid}/{locale}: glossary term '{src_term}' is not rendered as '{target}'")
|
|
160
|
+
approval = entry.get("approval") or {}
|
|
161
|
+
approved = approval.get("status") == "approved" and bool(approval.get("reviewer"))
|
|
162
|
+
if not approved:
|
|
163
|
+
warnings.append(f"{tid}/{locale}: not approved; not publishable")
|
|
164
|
+
rows.append({"template": tid, "locale": locale, "market": entry.get("market"), "approved": approved})
|
|
165
|
+
if preview_dir is not None:
|
|
166
|
+
write_preview(preview_dir, tid, locale, entry, template.get("fixtures") or {}, route)
|
|
167
|
+
unused = declared - set().union(*(placeholders((locales.get(l) or {}).get(f, "")) for l in required for f in FIELDS)) if required else set()
|
|
168
|
+
if unused:
|
|
169
|
+
warnings.append(f"{tid}: declared variables never used: {', '.join(sorted(unused))}")
|
|
170
|
+
return rows
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def fill(value: str, fixtures: dict, escape: bool) -> str:
|
|
174
|
+
def replace(match: re.Match) -> str:
|
|
175
|
+
key = match.group(1) or match.group(2)
|
|
176
|
+
raw = str(fixtures.get(key, f"[{key}]"))
|
|
177
|
+
return html.escape(raw) if escape else raw
|
|
178
|
+
return PLACEHOLDER.sub(replace, value or "")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def safe_name(value: str) -> str:
|
|
182
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "template"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def write_preview(directory: Path, tid: str, locale: str, entry: dict, fixtures: dict, route: str | None) -> None:
|
|
186
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
stem = f"{safe_name(tid)}.{safe_name(locale)}"
|
|
188
|
+
subject = fill(entry.get("subject", ""), fixtures, escape=False)
|
|
189
|
+
banner = f"<p data-maggie-preview>Preview only — fixture data, not sent. Subject: {html.escape(subject)}. CTA: {html.escape(str(route or ''))}</p>"
|
|
190
|
+
(directory / f"{stem}.html").write_text(banner + fill(entry.get("html", ""), fixtures, escape=True), encoding="utf-8")
|
|
191
|
+
(directory / f"{stem}.txt").write_text(f"Subject: {subject}\n\n" + fill(entry.get("text", ""), fixtures, escape=False), encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def email_matrix(manifest: dict, preview_dir: Path | None = None, require_approved: bool = False) -> dict:
|
|
195
|
+
errors: list[str] = []
|
|
196
|
+
warnings: list[str] = []
|
|
197
|
+
if manifest.get("schemaVersion") != EMAIL_SCHEMA:
|
|
198
|
+
errors.append(f"schemaVersion must be {EMAIL_SCHEMA}")
|
|
199
|
+
if not manifest.get("sourceLocale"):
|
|
200
|
+
errors.append("sourceLocale is required")
|
|
201
|
+
templates = manifest.get("templates")
|
|
202
|
+
if not isinstance(templates, list) or not templates:
|
|
203
|
+
errors.append("templates must be a non-empty list")
|
|
204
|
+
templates = []
|
|
205
|
+
seen: set[str] = set()
|
|
206
|
+
rows: list[dict] = []
|
|
207
|
+
for template in templates:
|
|
208
|
+
if not isinstance(template, dict):
|
|
209
|
+
errors.append("template entries must be objects")
|
|
210
|
+
continue
|
|
211
|
+
if template.get("id") in seen:
|
|
212
|
+
errors.append(f"duplicate template id {template.get('id')}")
|
|
213
|
+
seen.add(template.get("id"))
|
|
214
|
+
rows.extend(check_template(manifest, template, preview_dir, errors, warnings))
|
|
215
|
+
check_call_sites(manifest, errors, warnings)
|
|
216
|
+
if require_approved:
|
|
217
|
+
errors.extend(f"{row['template']}/{row['locale']}: approval required" for row in rows if not row["approved"])
|
|
218
|
+
return {
|
|
219
|
+
"schemaVersion": "maggie-email-localization-report.v1",
|
|
220
|
+
"status": "failed" if errors else "passed",
|
|
221
|
+
"templates": len(seen),
|
|
222
|
+
"matrix": rows,
|
|
223
|
+
"publishable": not errors and all(row["approved"] for row in rows),
|
|
224
|
+
"errors": errors,
|
|
225
|
+
"warnings": warnings,
|
|
226
|
+
"previewDir": str(preview_dir) if preview_dir else None,
|
|
227
|
+
"sends": False,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def approved(record: dict | None) -> bool:
|
|
232
|
+
return isinstance(record, dict) and record.get("status") == "approved" and bool(record.get("reviewer")) and bool(record.get("approvedAt"))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def lifecycle_validate(plan: dict) -> dict:
|
|
236
|
+
errors: list[str] = []
|
|
237
|
+
warnings: list[str] = []
|
|
238
|
+
if plan.get("schemaVersion") != LIFECYCLE_SCHEMA:
|
|
239
|
+
errors.append(f"schemaVersion must be {LIFECYCLE_SCHEMA}")
|
|
240
|
+
stages = plan.get("stages")
|
|
241
|
+
if stages != list(LIFECYCLE_STAGES):
|
|
242
|
+
errors.append(f"stages must be exactly {', '.join(LIFECYCLE_STAGES)} in order")
|
|
243
|
+
if (plan.get("defaults") or {}).get("send") != "disabled":
|
|
244
|
+
errors.append("defaults.send must be 'disabled' (no send until approved)")
|
|
245
|
+
templates = plan.get("templates") or []
|
|
246
|
+
ids: dict[str, dict] = {}
|
|
247
|
+
for template in templates:
|
|
248
|
+
tid = template.get("id") or "<unnamed>"
|
|
249
|
+
if tid in ids:
|
|
250
|
+
errors.append(f"duplicate template id {tid}")
|
|
251
|
+
ids[tid] = template
|
|
252
|
+
kind = template.get("kind")
|
|
253
|
+
category = template.get("category")
|
|
254
|
+
if kind not in LIBRARY_KINDS:
|
|
255
|
+
errors.append(f"{tid}: kind must be one of {', '.join(LIBRARY_KINDS)}")
|
|
256
|
+
if category not in CATEGORIES:
|
|
257
|
+
errors.append(f"{tid}: category must be one of {', '.join(CATEGORIES)}")
|
|
258
|
+
if kind in PRE_CONSENT_KINDS:
|
|
259
|
+
if category != "transactional":
|
|
260
|
+
errors.append(f"{tid}: opt-in confirmation is sent before consent and must be transactional, with no promotional content")
|
|
261
|
+
elif category == "marketing":
|
|
262
|
+
consent = template.get("consent") or {}
|
|
263
|
+
if consent.get("basis") not in CONSENT_BASES:
|
|
264
|
+
errors.append(f"{tid}: marketing template needs consent.basis ({', '.join(CONSENT_BASES)})")
|
|
265
|
+
if not consent.get("category"):
|
|
266
|
+
errors.append(f"{tid}: marketing template needs consent.category (preference-centre category)")
|
|
267
|
+
if not route_ok((template.get("unsubscribe") or {}).get("path")):
|
|
268
|
+
errors.append(f"{tid}: marketing template needs an unsubscribe path")
|
|
269
|
+
if not route_ok(template.get("preferenceCentre")):
|
|
270
|
+
errors.append(f"{tid}: marketing template needs a preference-centre path")
|
|
271
|
+
attribution = (template.get("attribution") or {}).get("fields") or []
|
|
272
|
+
if not attribution:
|
|
273
|
+
errors.append(f"{tid}: marketing template needs attribution fields (e.g. source, utm_campaign)")
|
|
274
|
+
elif category == "transactional" and kind not in PRE_CONSENT_KINDS:
|
|
275
|
+
warnings.append(f"{tid}: {kind} is normally marketing; confirm it carries no promotional content")
|
|
276
|
+
frequency = template.get("frequency") or {}
|
|
277
|
+
if not (isinstance(frequency.get("maxPerWindow"), int) and frequency["maxPerWindow"] >= 1 and frequency.get("window")):
|
|
278
|
+
errors.append(f"{tid}: frequency needs maxPerWindow >= 1 and a window (e.g. P7D)")
|
|
279
|
+
if template.get("sendable") and not approved(template.get("approval")):
|
|
280
|
+
errors.append(f"{tid}: sendable without an approved approval record (status, reviewer, approvedAt)")
|
|
281
|
+
for campaign in plan.get("campaigns") or []:
|
|
282
|
+
cid = campaign.get("id") or "<unnamed>"
|
|
283
|
+
for tid in campaign.get("templates") or []:
|
|
284
|
+
if tid not in ids:
|
|
285
|
+
errors.append(f"campaign {cid}: unknown template {tid}")
|
|
286
|
+
elif campaign.get("sendable") and not ids[tid].get("sendable"):
|
|
287
|
+
errors.append(f"campaign {cid}: template {tid} is not approved for sending")
|
|
288
|
+
audience = campaign.get("audience") or {}
|
|
289
|
+
uses_marketing = any(ids.get(tid, {}).get("category") == "marketing" for tid in campaign.get("templates") or [])
|
|
290
|
+
if uses_marketing and not audience.get("requiresConsent"):
|
|
291
|
+
errors.append(f"campaign {cid}: marketing audience must require consent")
|
|
292
|
+
if not audience.get("suppression"):
|
|
293
|
+
errors.append(f"campaign {cid}: audience must honour suppression")
|
|
294
|
+
if not audience.get("dedupe"):
|
|
295
|
+
errors.append(f"campaign {cid}: audience must deduplicate contacts")
|
|
296
|
+
if campaign.get("sendable") and not approved(campaign.get("approval")):
|
|
297
|
+
errors.append(f"campaign {cid}: sendable without an approved approval record")
|
|
298
|
+
if campaign.get("sendable") and not campaign.get("schedule"):
|
|
299
|
+
errors.append(f"campaign {cid}: sendable campaign needs a schedule")
|
|
300
|
+
if not (campaign.get("report") or {}).get("metrics"):
|
|
301
|
+
warnings.append(f"campaign {cid}: no aggregate report metrics declared")
|
|
302
|
+
missing = [kind for kind in LIBRARY_KINDS if kind not in {t.get("kind") for t in templates}]
|
|
303
|
+
return {
|
|
304
|
+
"schemaVersion": "maggie-lifecycle-email-report.v1",
|
|
305
|
+
"status": "failed" if errors else "passed",
|
|
306
|
+
"templates": len(ids),
|
|
307
|
+
"sendable": sorted(tid for tid, item in ids.items() if item.get("sendable")),
|
|
308
|
+
"libraryCoverage": {"missing": missing},
|
|
309
|
+
"errors": errors,
|
|
310
|
+
"warnings": warnings,
|
|
311
|
+
"sends": False,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def library() -> dict:
|
|
316
|
+
"""A starter plan: every template drafted, nothing sendable."""
|
|
317
|
+
templates = []
|
|
318
|
+
for kind in LIBRARY_KINDS:
|
|
319
|
+
pre = kind in PRE_CONSENT_KINDS
|
|
320
|
+
entry = {
|
|
321
|
+
"id": kind,
|
|
322
|
+
"kind": kind,
|
|
323
|
+
"category": "transactional" if pre else "marketing",
|
|
324
|
+
"frequency": {"maxPerWindow": 1, "window": "P1D" if pre else "P7D"},
|
|
325
|
+
"approval": {"status": "pending"},
|
|
326
|
+
"sendable": False,
|
|
327
|
+
}
|
|
328
|
+
if not pre:
|
|
329
|
+
entry.update({
|
|
330
|
+
"consent": {"basis": "opt-in", "category": "marketing.updates"},
|
|
331
|
+
"unsubscribe": {"path": "/email/unsubscribe/{token}"},
|
|
332
|
+
"preferenceCentre": "/account/notifications",
|
|
333
|
+
"attribution": {"fields": ["source", "utm_source", "utm_medium", "utm_campaign"]},
|
|
334
|
+
})
|
|
335
|
+
templates.append(entry)
|
|
336
|
+
return {"schemaVersion": LIFECYCLE_SCHEMA, "stages": list(LIFECYCLE_STAGES), "defaults": {"send": "disabled"}, "templates": templates, "campaigns": []}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def load(path: Path) -> dict:
|
|
340
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
341
|
+
if not isinstance(data, dict):
|
|
342
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
343
|
+
return data
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def add_parsers(sub: argparse._SubParsersAction) -> None:
|
|
347
|
+
matrix = sub.add_parser("email-matrix", help="Check localized email templates (locales, variables, routes, parity, approval)")
|
|
348
|
+
matrix.add_argument("manifest", type=Path)
|
|
349
|
+
matrix.add_argument("--preview-dir", type=Path, help="write fixture-filled HTML/text previews for review")
|
|
350
|
+
matrix.add_argument("--require-approved", action="store_true", help="fail when any template locale is not approved")
|
|
351
|
+
lifecycle = sub.add_parser("lifecycle", help="Validate a consent-aware lifecycle/marketing email plan; never sends")
|
|
352
|
+
lifecycle.add_argument("plan", type=Path, nargs="?")
|
|
353
|
+
lifecycle.add_argument("--library", action="store_true", help="print the starter template library (nothing sendable)")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run(args: argparse.Namespace) -> int:
|
|
357
|
+
if args.command == "email-matrix":
|
|
358
|
+
result = email_matrix(load(args.manifest), args.preview_dir, args.require_approved)
|
|
359
|
+
else:
|
|
360
|
+
if args.library:
|
|
361
|
+
print(json.dumps(library(), indent=2))
|
|
362
|
+
return 0
|
|
363
|
+
if not args.plan:
|
|
364
|
+
raise ValueError("lifecycle needs a plan file or --library")
|
|
365
|
+
result = lifecycle_validate(load(args.plan))
|
|
366
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
367
|
+
return 0 if result["status"] == "passed" else 1
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main() -> int:
|
|
371
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
372
|
+
add_parsers(parser.add_subparsers(dest="command", required=True))
|
|
373
|
+
args = parser.parse_args()
|
|
374
|
+
try:
|
|
375
|
+
return run(args)
|
|
376
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
377
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
378
|
+
return 1
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
raise SystemExit(main())
|
|
@@ -382,7 +382,14 @@ def list_feedback(args: argparse.Namespace) -> int:
|
|
|
382
382
|
|
|
383
383
|
def collect_batch(args: argparse.Namespace) -> int:
|
|
384
384
|
"""Create one privacy-safe draft per item while retaining shared batch context."""
|
|
385
|
-
|
|
385
|
+
if args.batch_file == "-":
|
|
386
|
+
# Reading JSON from stdin lets a quoted heredoc carry multiline text and
|
|
387
|
+
# backticks without shell interpolation.
|
|
388
|
+
manifest = json.loads(sys.stdin.read())
|
|
389
|
+
if not isinstance(manifest, dict):
|
|
390
|
+
raise ValueError("batch input must be a JSON object")
|
|
391
|
+
else:
|
|
392
|
+
manifest = load_json(Path(args.batch_file).expanduser().resolve())
|
|
386
393
|
batch_id = safe_text(manifest.get("batchId"))
|
|
387
394
|
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
388
395
|
raise ValueError("batch file requires a safe batchId")
|
|
@@ -655,7 +662,7 @@ def main() -> int:
|
|
|
655
662
|
collect_parser.add_argument("--relationship", dest="relationships", action="append", default=[], help="safe relation=target pointer")
|
|
656
663
|
batch_parser = sub.add_parser("batch")
|
|
657
664
|
batch_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
658
|
-
batch_parser.add_argument("--batch-file", required=True)
|
|
665
|
+
batch_parser.add_argument("--batch-file", required=True, help="batch JSON file, or - to read JSON from stdin")
|
|
659
666
|
summary_parser = sub.add_parser("session-summary", help="draft batch items from git commit subjects for review")
|
|
660
667
|
summary_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
661
668
|
summary_parser.add_argument("--since", required=True, help="git revision; commits after it are summarized")
|