@topy-ai/maggie 0.7.47 → 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.
Files changed (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. package/references/provider-adapter-contract.md +33 -0
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Audit project documentation for stale trackers and unreferenced files."""
2
+ """Audit project documentation for stale trackers, unreferenced files, and conflicting rollout status."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
@@ -126,16 +126,94 @@ def audit(project: Path, docs_dir: str = "docs") -> dict:
126
126
  }
127
127
 
128
128
 
129
+ HISTORICAL_RE = re.compile(r"documentation-status:\s*historical-snapshot", re.I)
130
+ ROLLOUT_STATES = ("planned", "in_progress", "blocked", "complete")
131
+ CLAIM_PATTERNS = (
132
+ ("complete", re.compile(r"(?i)^\s*[-*]\s*\[x\]|\b(?:complete|completed|done|shipped|live|resolved)\b")),
133
+ ("open", re.compile(r"(?i)^\s*[-*]\s*\[ \]|\b(?:in[ -]progress|pending|ongoing|planned|not started|blocked|todo|unchecked)\b")),
134
+ )
135
+
136
+
137
+ def _claim(line: str) -> str | None:
138
+ """Return complete/open for a status line, or None when absent or ambiguous."""
139
+ found = {state for state, pattern in CLAIM_PATTERNS if pattern.search(line)}
140
+ return found.pop() if len(found) == 1 else None
141
+
142
+
143
+ def rollout_status(project: Path, docs_dir: str = "docs", status_file: str = "docs/rollout-status.json", id_pattern: str = r"\b[A-Z][A-Z0-9]+-\d+\b") -> dict:
144
+ """Find documents whose rollout claims disagree with each other or with the canonical status file."""
145
+ root = project.resolve()
146
+ docs = (root / docs_dir).resolve()
147
+ errors: list[str] = []
148
+ canonical: dict[str, dict] = {}
149
+ canonical_path = root / status_file
150
+ if canonical_path.is_file():
151
+ data = json.loads(canonical_path.read_text(encoding="utf-8"))
152
+ if data.get("schemaVersion") != "maggie-rollout-status.v1" or not isinstance(data.get("items"), list):
153
+ errors.append(f"{status_file} must use schemaVersion maggie-rollout-status.v1 with an items list")
154
+ for item in data.get("items") or []:
155
+ item_id, state = str(item.get("id") or ""), item.get("status")
156
+ if not item_id or state not in ROLLOUT_STATES:
157
+ errors.append(f"{status_file}: item {item_id or '?'} needs an id and a status in {', '.join(ROLLOUT_STATES)}")
158
+ continue
159
+ if state == "complete" and not item.get("evidence"):
160
+ errors.append(f"{status_file}: {item_id} is complete without evidence")
161
+ canonical[item_id] = item
162
+ pattern = re.compile(id_pattern)
163
+ claims: dict[str, list[dict]] = {}
164
+ historical: list[str] = []
165
+ for path in _files(docs) if docs.is_dir() else []:
166
+ if path.suffix.casefold() not in {".md", ".mdx"}:
167
+ continue
168
+ text = path.read_text(encoding="utf-8", errors="replace")
169
+ relative = str(path.relative_to(root))
170
+ if HISTORICAL_RE.search(text):
171
+ historical.append(relative)
172
+ continue
173
+ for number, line in enumerate(text.splitlines(), 1):
174
+ ids = set(pattern.findall(line))
175
+ claim = _claim(line) if len(ids) == 1 else None
176
+ if claim:
177
+ claims.setdefault(ids.pop(), []).append({"path": relative, "line": number, "claim": claim})
178
+ conflicts = []
179
+ for item_id, found in sorted(claims.items()):
180
+ if item_id in canonical:
181
+ expected = "complete" if canonical[item_id]["status"] == "complete" else "open"
182
+ wrong = [claim for claim in found if claim["claim"] != expected]
183
+ if wrong:
184
+ conflicts.append({"id": item_id, "canonical": canonical[item_id]["status"], "claims": wrong})
185
+ elif len({claim["claim"] for claim in found}) > 1:
186
+ conflicts.append({"id": item_id, "canonical": None, "claims": found})
187
+ errors.extend(f"{item['id']}: conflicting rollout status in {', '.join(sorted({c['path'] for c in item['claims']}))}" for item in conflicts)
188
+ return {
189
+ "schemaVersion": "maggie-rollout-status-audit.v1",
190
+ "passed": not errors,
191
+ "canonical": str(canonical_path.relative_to(root)) if canonical_path.is_file() else None,
192
+ "itemCount": len(claims),
193
+ "historicalSnapshots": historical,
194
+ "conflicts": conflicts,
195
+ "errors": errors,
196
+ }
197
+
198
+
129
199
  def main() -> int:
130
200
  parser = argparse.ArgumentParser(description=__doc__)
131
- parser.add_argument("audit", nargs="?", choices=("audit",), default="audit")
201
+ parser.add_argument("command", nargs="?", choices=("audit", "rollout-status"), default="audit")
132
202
  parser.add_argument("--project", default=".")
133
203
  parser.add_argument("--docs-dir", default="docs")
134
204
  parser.add_argument("--fail-on-findings", action="store_true")
205
+ parser.add_argument("--status-file", default="docs/rollout-status.json", help="canonical maggie-rollout-status.v1 file (rollout-status)")
206
+ parser.add_argument("--id-pattern", default=r"\b[A-Z][A-Z0-9]+-\d+\b", help="regex for rollout item IDs such as MSG-14 (rollout-status)")
135
207
  parser.add_argument("--output")
136
208
  args = parser.parse_args()
137
- result = audit(Path(args.project), args.docs_dir)
138
- if args.fail_on_findings and result.get("findingCount"):
209
+ if args.command == "rollout-status":
210
+ try:
211
+ result = rollout_status(Path(args.project), args.docs_dir, args.status_file, args.id_pattern)
212
+ except (OSError, ValueError, json.JSONDecodeError, re.error) as error:
213
+ result = {"schemaVersion": "maggie-rollout-status-audit.v1", "passed": False, "errors": [str(error)]}
214
+ else:
215
+ result = audit(Path(args.project), args.docs_dir)
216
+ if args.command == "audit" and args.fail_on_findings and result.get("findingCount"):
139
217
  result["passed"] = False
140
218
  result.setdefault("errors", []).append("documentation findings require review")
141
219
  if args.output:
@@ -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())