@antelopejs/dms-mailing 0.7.1 → 0.9.0

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 (255) hide show
  1. package/README.md +46 -31
  2. package/dist/automation/email-event-trigger.d.ts +2 -2
  3. package/dist/automation/send-template-action.d.ts +11 -3
  4. package/dist/automation/send-template-action.js +1 -1
  5. package/dist/automation/send-template-action.js.map +1 -1
  6. package/dist/crons/jobs.d.ts +1 -1
  7. package/dist/data/body.d.ts +2 -1
  8. package/dist/data/body.js +4 -2
  9. package/dist/data/body.js.map +1 -1
  10. package/dist/data/read-only-routes.d.ts +4 -4
  11. package/dist/data/sends-table.d.ts +4 -4
  12. package/dist/data/suppressions-table.d.ts +4 -4
  13. package/dist/data/templates-table.d.ts +35 -12
  14. package/dist/data/templates-table.js +30 -24
  15. package/dist/data/templates-table.js.map +1 -1
  16. package/dist/data-types/categories-type.d.ts +1 -2
  17. package/dist/data-types/categories-type.js +3 -5
  18. package/dist/data-types/categories-type.js.map +1 -1
  19. package/dist/data-types/category-type.d.ts +1 -2
  20. package/dist/data-types/category-type.js +3 -5
  21. package/dist/data-types/category-type.js.map +1 -1
  22. package/dist/db/models/send-events.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.d.ts +7 -5
  24. package/dist/db/models/sends.model.js.map +1 -1
  25. package/dist/db/models/settings.model.d.ts +7 -5
  26. package/dist/db/models/suppressions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.d.ts +7 -5
  28. package/dist/db/models/template-versions.model.js +4 -3
  29. package/dist/db/models/template-versions.model.js.map +1 -1
  30. package/dist/db/models/templates.model.d.ts +7 -5
  31. package/dist/db/models/templates.model.js +2 -1
  32. package/dist/db/models/templates.model.js.map +1 -1
  33. package/dist/db/models/webhook-status.model.d.ts +7 -5
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -18
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +5 -5
  39. package/dist/engine/conditions.js.map +1 -1
  40. package/dist/engine/coverage.d.ts +2 -1
  41. package/dist/engine/coverage.js.map +1 -1
  42. package/dist/engine/fallback-html.js +8 -8
  43. package/dist/engine/fallback-html.js.map +1 -1
  44. package/dist/engine/formats.d.ts +4 -6
  45. package/dist/engine/formats.js +22 -8
  46. package/dist/engine/formats.js.map +1 -1
  47. package/dist/engine/paths.d.ts +4 -2
  48. package/dist/engine/paths.js +16 -5
  49. package/dist/engine/paths.js.map +1 -1
  50. package/dist/engine/plain-text.js +2 -4
  51. package/dist/engine/plain-text.js.map +1 -1
  52. package/dist/engine/resolve.d.ts +2 -1
  53. package/dist/engine/resolve.js +1 -1
  54. package/dist/engine/resolve.js.map +1 -1
  55. package/dist/engine/system-variables.d.ts +2 -1
  56. package/dist/engine/system-variables.js +1 -1
  57. package/dist/engine/system-variables.js.map +1 -1
  58. package/dist/engine/tokens.d.ts +2 -1
  59. package/dist/engine/tokens.js.map +1 -1
  60. package/dist/hooks/tenant-export.js +10 -9
  61. package/dist/hooks/tenant-export.js.map +1 -1
  62. package/dist/pages/sends.js.map +1 -1
  63. package/dist/pages/templates.js +3 -1
  64. package/dist/pages/templates.js.map +1 -1
  65. package/dist/pages/toolbar.js.map +1 -1
  66. package/dist/routes/events.js +6 -4
  67. package/dist/routes/events.js.map +1 -1
  68. package/dist/routes/index.d.ts +1 -0
  69. package/dist/routes/index.js +1 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/metrics.js +31 -31
  72. package/dist/routes/metrics.js.map +1 -1
  73. package/dist/routes/period.js +5 -5
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +4 -5
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +1 -1
  78. package/dist/routes/settings.js +16 -14
  79. package/dist/routes/settings.js.map +1 -1
  80. package/dist/routes/suppressions.js +9 -6
  81. package/dist/routes/suppressions.js.map +1 -1
  82. package/dist/routes/template-content.d.ts +27 -0
  83. package/dist/routes/template-content.js +3 -0
  84. package/dist/routes/template-content.js.map +1 -0
  85. package/dist/routes/templates.d.ts +7 -30
  86. package/dist/routes/templates.js +7 -6
  87. package/dist/routes/templates.js.map +1 -1
  88. package/dist/routes/zod-json.d.ts +3 -0
  89. package/dist/routes/zod-json.js +21 -0
  90. package/dist/routes/zod-json.js.map +1 -0
  91. package/dist/services/attention.js +16 -8
  92. package/dist/services/attention.js.map +1 -1
  93. package/dist/services/catalog.js +4 -2
  94. package/dist/services/catalog.js.map +1 -1
  95. package/dist/services/categories.js +6 -6
  96. package/dist/services/categories.js.map +1 -1
  97. package/dist/services/checked-send.js +16 -9
  98. package/dist/services/checked-send.js.map +1 -1
  99. package/dist/services/declaration-error.d.ts +1 -1
  100. package/dist/services/declaration-error.js.map +1 -1
  101. package/dist/services/declarations.js +4 -1
  102. package/dist/services/declarations.js.map +1 -1
  103. package/dist/services/events.js +13 -7
  104. package/dist/services/events.js.map +1 -1
  105. package/dist/services/localized-text.d.ts +2 -1
  106. package/dist/services/localized-text.js +2 -1
  107. package/dist/services/localized-text.js.map +1 -1
  108. package/dist/services/metrics.js +2 -5
  109. package/dist/services/metrics.js.map +1 -1
  110. package/dist/services/render.js.map +1 -1
  111. package/dist/services/send-import.js +7 -3
  112. package/dist/services/send-import.js.map +1 -1
  113. package/dist/services/send.js +7 -7
  114. package/dist/services/send.js.map +1 -1
  115. package/dist/services/settings.d.ts +4 -2
  116. package/dist/services/settings.js +5 -4
  117. package/dist/services/settings.js.map +1 -1
  118. package/dist/services/starters.d.ts +3 -2
  119. package/dist/services/starters.js +5 -5
  120. package/dist/services/starters.js.map +1 -1
  121. package/dist/services/template-stats.js +5 -2
  122. package/dist/services/template-stats.js.map +1 -1
  123. package/dist/services/templates.d.ts +2 -1
  124. package/dist/services/templates.js +8 -4
  125. package/dist/services/templates.js.map +1 -1
  126. package/dist/services/webhook-status.d.ts +2 -2
  127. package/dist/services/webhook-status.js +6 -4
  128. package/dist/services/webhook-status.js.map +1 -1
  129. package/dist/types/json.d.ts +1 -0
  130. package/dist/types/json.js +3 -0
  131. package/dist/types/json.js.map +1 -0
  132. package/dist/types/settings.d.ts +2 -2
  133. package/dist/validation/declarations.schema.d.ts +44 -44
  134. package/dist/validation/declarations.schema.js +6 -5
  135. package/dist/validation/declarations.schema.js.map +1 -1
  136. package/dist/validation/guards.d.ts +3 -0
  137. package/dist/validation/guards.js +15 -0
  138. package/dist/validation/guards.js.map +1 -0
  139. package/dist/validation/json.d.ts +5 -0
  140. package/dist/validation/json.js +54 -0
  141. package/dist/validation/json.js.map +1 -0
  142. package/dist/validation/json.schema.d.ts +2 -0
  143. package/dist/validation/json.schema.js +9 -0
  144. package/dist/validation/json.schema.js.map +1 -0
  145. package/dist/validation/send-event-type.schema.d.ts +2 -0
  146. package/dist/validation/send-event-type.schema.js +11 -0
  147. package/dist/validation/send-event-type.schema.js.map +1 -0
  148. package/dist/validation/settings.schema.js +1 -1
  149. package/dist/validation/settings.schema.js.map +1 -1
  150. package/dist/validation/templates.schema.d.ts +44 -42
  151. package/dist/validation/templates.schema.js +4 -3
  152. package/dist/validation/templates.schema.js.map +1 -1
  153. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  154. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  155. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  156. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  157. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  158. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  159. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  160. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  161. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  162. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  163. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  164. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  165. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  166. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  167. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  168. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  169. package/frontend-vue/build/backend-boundary.ts +122 -0
  170. package/frontend-vue/dms.frontend.build.ts +7 -0
  171. package/frontend-vue/eslint.config.mjs +13 -0
  172. package/frontend-vue/package.json +4 -2
  173. package/frontend-vue/pnpm-lock.yaml +250 -61
  174. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  175. package/frontend-vue/tests/dnd.test.ts +1 -2
  176. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  177. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  178. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  179. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  180. package/frontend-vue/tests/page-reads.test.ts +26 -6
  181. package/frontend-vue/tests/send-actions.test.ts +0 -1
  182. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  183. package/frontend-vue/tests/send-modals.test.ts +8 -5
  184. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  185. package/frontend-vue/tests/suppression.test.ts +1 -2
  186. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  187. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  188. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  189. package/package.json +16 -20
  190. package/skills/dms-mailing/SKILL.md +1 -1
  191. package/skills/dms-mailing/cases.json +49 -0
  192. package/src/automation/email-event-trigger.ts +2 -2
  193. package/src/automation/send-template-action.ts +33 -14
  194. package/src/crons/jobs.ts +1 -1
  195. package/src/data/body.ts +7 -3
  196. package/src/data/templates-table.ts +55 -56
  197. package/src/data-types/categories-type.ts +2 -2
  198. package/src/data-types/category-type.ts +2 -2
  199. package/src/db/models/sends.model.ts +2 -2
  200. package/src/db/models/template-versions.model.ts +10 -4
  201. package/src/db/models/templates.model.ts +6 -3
  202. package/src/db/tables/templates.table.ts +1 -1
  203. package/src/engine/changes.ts +11 -28
  204. package/src/engine/conditions.ts +14 -8
  205. package/src/engine/coverage.ts +2 -1
  206. package/src/engine/fallback-html.ts +13 -13
  207. package/src/engine/formats.ts +35 -21
  208. package/src/engine/paths.ts +41 -7
  209. package/src/engine/plain-text.ts +2 -4
  210. package/src/engine/resolve.ts +12 -4
  211. package/src/engine/system-variables.ts +6 -4
  212. package/src/engine/tokens.ts +3 -3
  213. package/src/hooks/tenant-export.ts +11 -13
  214. package/src/pages/sends.ts +5 -3
  215. package/src/pages/templates.ts +4 -2
  216. package/src/pages/toolbar.ts +4 -2
  217. package/src/routes/events.ts +10 -10
  218. package/src/routes/index.ts +1 -0
  219. package/src/routes/metrics.ts +35 -33
  220. package/src/routes/period.ts +5 -5
  221. package/src/routes/sends.ts +23 -26
  222. package/src/routes/settings.ts +21 -17
  223. package/src/routes/suppressions.ts +12 -8
  224. package/src/routes/template-content.ts +45 -0
  225. package/src/routes/templates.ts +16 -49
  226. package/src/routes/zod-json.ts +28 -0
  227. package/src/services/attention.ts +20 -18
  228. package/src/services/catalog.ts +4 -8
  229. package/src/services/categories.ts +6 -6
  230. package/src/services/checked-send.ts +17 -19
  231. package/src/services/declaration-error.ts +1 -1
  232. package/src/services/declarations.ts +7 -6
  233. package/src/services/events.ts +18 -13
  234. package/src/services/localized-text.ts +4 -2
  235. package/src/services/metrics.ts +5 -7
  236. package/src/services/notify.ts +1 -1
  237. package/src/services/render.ts +5 -1
  238. package/src/services/send-import.ts +16 -11
  239. package/src/services/send.ts +10 -13
  240. package/src/services/settings.ts +17 -16
  241. package/src/services/starters.ts +12 -8
  242. package/src/services/template-stats.ts +11 -4
  243. package/src/services/templates.ts +24 -15
  244. package/src/services/webhook-status.ts +13 -8
  245. package/src/types/json.ts +1 -0
  246. package/src/types/settings.ts +2 -2
  247. package/src/validation/declarations.schema.ts +6 -5
  248. package/src/validation/guards.ts +17 -0
  249. package/src/validation/json.schema.ts +7 -0
  250. package/src/validation/json.ts +66 -0
  251. package/src/validation/send-event-type.schema.ts +10 -0
  252. package/src/validation/settings.schema.ts +7 -2
  253. package/src/validation/templates.schema.ts +9 -4
  254. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  255. package/frontend-vue/tests/i18n.test.ts +0 -138
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import type {
2
3
  Block,
3
4
  BlockType,
@@ -40,36 +41,21 @@ export const CONTRACT_LOCALE = "";
40
41
 
41
42
  interface BlockSnapshot {
42
43
  type: BlockType;
43
- signature: string;
44
+ fields: object;
44
45
  }
45
46
 
46
47
  const NESTED_KEYS = new Set(["children", "elseChildren"]);
47
48
 
48
- /**
49
- * A value with its object keys sorted at every depth, so two blocks equal but
50
- * for the order their fields were written in read the same.
51
- */
52
- function canonical(value: unknown): unknown {
53
- if (Array.isArray(value)) return value.map(canonical);
54
- if (!value || typeof value !== "object") return value;
49
+ function ownFields(block: Block): object {
55
50
  return Object.fromEntries(
56
- Object.keys(value)
57
- .sort()
58
- .map((key) => [key, canonical((value as Record<string, unknown>)[key])]),
59
- );
60
- }
61
-
62
- function ownFields(block: Block): string {
63
- const entries = Object.entries(block).filter(
64
- ([key]) => !NESTED_KEYS.has(key),
51
+ Object.entries(block).filter(([key]) => !NESTED_KEYS.has(key)),
65
52
  );
66
- return JSON.stringify(canonical(Object.fromEntries(entries)));
67
53
  }
68
54
 
69
55
  function snapshotBlocks(blocks: Block[]): Map<string, BlockSnapshot> {
70
56
  const snapshots = new Map<string, BlockSnapshot>();
71
57
  walkBlocks(blocks, (block) => {
72
- snapshots.set(block.id, { type: block.type, signature: ownFields(block) });
58
+ snapshots.set(block.id, { type: block.type, fields: ownFields(block) });
73
59
  });
74
60
  return snapshots;
75
61
  }
@@ -93,7 +79,7 @@ function blockChanges(
93
79
  const touched = [...after].flatMap(([id, snapshot]) => {
94
80
  const previous = before.get(id);
95
81
  if (!previous) return [blockChange(locale, "block_added", id, snapshot)];
96
- if (previous.signature === snapshot.signature) return [];
82
+ if (isDeepStrictEqual(previous.fields, snapshot.fields)) return [];
97
83
  return [blockChange(locale, "block_changed", id, snapshot)];
98
84
  });
99
85
  return [...touched, ...removed];
@@ -161,13 +147,8 @@ export function diffContents(
161
147
 
162
148
  function declarationsByPath(
163
149
  variables: VariableDefinition[],
164
- ): Map<string, string> {
165
- return new Map(
166
- variables.map((variable) => [
167
- variable.path,
168
- JSON.stringify(canonical(variable)),
169
- ]),
170
- );
150
+ ): Map<string, VariableDefinition> {
151
+ return new Map(variables.map((variable) => [variable.path, variable]));
171
152
  }
172
153
 
173
154
  function variableChange(
@@ -193,6 +174,8 @@ export function diffVariables(
193
174
  const next = after.get(path);
194
175
  if (previous === undefined) return [variableChange("variable_added", path)];
195
176
  if (next === undefined) return [variableChange("variable_removed", path)];
196
- return previous === next ? [] : [variableChange("variable_changed", path)];
177
+ return isDeepStrictEqual(previous, next)
178
+ ? []
179
+ : [variableChange("variable_changed", path)];
197
180
  });
198
181
  }
@@ -1,23 +1,29 @@
1
+ import type {
2
+ TemplateVariables,
3
+ TemplateVariableValue,
4
+ } from "@antelopejs/interface-dms-mailing";
1
5
  import type { Condition, ConditionOperator } from "../types";
6
+ import { plainText } from "./formats";
2
7
  import { getPath } from "./paths";
3
8
 
4
- type Evaluator = (actual: unknown, expected: string) => boolean;
5
-
6
- const asNumber = (value: unknown): number => Number(value);
9
+ type Evaluator = (
10
+ actual: TemplateVariableValue | undefined,
11
+ expected: string,
12
+ ) => boolean;
7
13
 
8
14
  const EVALUATORS: Record<ConditionOperator, Evaluator> = {
9
15
  truthy: (actual) => Boolean(actual),
10
16
  falsy: (actual) => !actual,
11
- eq: (actual, expected) => String(actual) === expected,
12
- ne: (actual, expected) => String(actual) !== expected,
13
- gt: (actual, expected) => asNumber(actual) > asNumber(expected),
14
- lt: (actual, expected) => asNumber(actual) < asNumber(expected),
17
+ eq: (actual, expected) => plainText(actual) === expected,
18
+ ne: (actual, expected) => plainText(actual) !== expected,
19
+ gt: (actual, expected) => Number(actual) > Number(expected),
20
+ lt: (actual, expected) => Number(actual) < Number(expected),
15
21
  };
16
22
 
17
23
  /** Whether `data` passes the condition a block is shown under. */
18
24
  export function evaluateCondition(
19
25
  condition: Condition,
20
- data: Record<string, unknown>,
26
+ data: TemplateVariables,
21
27
  ): boolean {
22
28
  const evaluate = EVALUATORS[condition.operator];
23
29
  return evaluate(getPath(data, condition.path), condition.value);
@@ -1,3 +1,4 @@
1
+ import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
1
2
  import { SYSTEM_VARIABLE_PATHS } from "../constants";
2
3
  import type {
3
4
  Block,
@@ -63,7 +64,7 @@ export function collectContentVariablePaths(
63
64
 
64
65
  export function missingRequiredVariables(
65
66
  declared: VariableDefinition[],
66
- data: Record<string, unknown>,
67
+ data: TemplateVariables,
67
68
  ): string[] {
68
69
  return declared
69
70
  .filter((variable) => variable.required)
@@ -1,15 +1,15 @@
1
1
  import type { ResolvedBlock, ResolvedEmail } from "../types";
2
2
 
3
- const ESCAPES: Record<string, string> = {
4
- "&": "&amp;",
5
- "<": "&lt;",
6
- ">": "&gt;",
7
- '"': "&quot;",
8
- "'": "&#39;",
9
- };
3
+ const ESCAPES = new Map([
4
+ ["&", "&amp;"],
5
+ ["<", "&lt;"],
6
+ [">", "&gt;"],
7
+ ['"', "&quot;"],
8
+ ["'", "&#39;"],
9
+ ]);
10
10
 
11
11
  export const escapeHtml = (value: string): string =>
12
- value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
12
+ value.replace(/[&<>"']/g, (char) => ESCAPES.get(char) ?? char);
13
13
 
14
14
  const LINE_BREAK = /\r?\n/g;
15
15
 
@@ -77,7 +77,7 @@ const row = (label: string, value: string, weight: string): string =>
77
77
  const table = (rows: string): string =>
78
78
  `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BLOCK_SPACING}">${rows}</table>`;
79
79
 
80
- const TEXT_RENDERERS: Record<string, BlockHtml> = {
80
+ const TEXT_RENDERERS = {
81
81
  heading: (block) =>
82
82
  block.type === "heading"
83
83
  ? `<h1 style="${FONT};font-size:${block.size}px;font-weight:600;color:${HEADING_COLOR};text-align:${block.align};margin:0 0 24px;${WRAP}">${escapeLines(block.text)}</h1>`
@@ -94,7 +94,7 @@ const TEXT_RENDERERS: Record<string, BlockHtml> = {
94
94
  block.type === "button"
95
95
  ? `<div style="text-align:${block.align};margin:24px 0"><a href="${escapeHtml(safeUrl(block.href))}" style="${FONT};${BUTTON_STYLE}">${escapeHtml(block.text)}</a></div>`
96
96
  : "",
97
- };
97
+ } satisfies Record<string, BlockHtml>;
98
98
 
99
99
  const footerHtml = (block: ResolvedBlock): string => {
100
100
  if (block.type !== "footer") return "";
@@ -109,7 +109,7 @@ const footerHtml = (block: ResolvedBlock): string => {
109
109
  return `${DIVIDER}<p style="${FOOTER_STYLE}">${escapeLines(block.text)}${linkLine}</p>`;
110
110
  };
111
111
 
112
- const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
112
+ const LAYOUT_RENDERERS = {
113
113
  hero: (block) =>
114
114
  block.type === "hero" && block.imageUrl
115
115
  ? `<img src="${escapeHtml(safeUrl(block.imageUrl))}" alt="${escapeHtml(block.alt)}" style="display:block;width:100%;border-radius:8px;margin:0 0 20px">`
@@ -124,12 +124,12 @@ const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
124
124
  block.type === "total" ? table(row(block.label, block.value, "600")) : "",
125
125
  divider: () => DIVIDER,
126
126
  footer: footerHtml,
127
- };
127
+ } satisfies Record<string, BlockHtml>;
128
128
 
129
129
  const RENDERERS: Record<ResolvedBlock["type"], BlockHtml> = {
130
130
  ...TEXT_RENDERERS,
131
131
  ...LAYOUT_RENDERERS,
132
- } as Record<ResolvedBlock["type"], BlockHtml>;
132
+ };
133
133
 
134
134
  const FOOTER_TYPE = "footer";
135
135
 
@@ -1,15 +1,10 @@
1
+ import type {
2
+ MoneyValue,
3
+ TemplateVariableValue,
4
+ } from "@antelopejs/interface-dms-mailing";
1
5
  import type { VariableDefinition } from "../types";
2
-
3
- /**
4
- * An amount in the minor unit of its currency (cents for `EUR`), like the
5
- * DMS's composed-text `money` parameter: `{ value: 987420, currency: "EUR" }`
6
- * reads "€9,874.20" in English, "9 874,20 €" in French.
7
- */
8
- export interface MoneyValue {
9
- value: number;
10
- /** ISO 4217 code. An unknown code writes the amount as a plain number. */
11
- currency: string;
12
- }
6
+ import { isText } from "../validation/guards";
7
+ import { isPresent, isRecord } from "./paths";
13
8
 
14
9
  /** How a send writes its typed values: its locale, and the paths typed `date`. */
15
10
  export interface ValueFormat {
@@ -54,13 +49,14 @@ export function valueFormatOf(
54
49
  };
55
50
  }
56
51
 
57
- export function isMoneyValue(value: unknown): value is MoneyValue {
58
- if (!value || typeof value !== "object") return false;
59
- const candidate = value as Partial<MoneyValue>;
52
+ export function isMoneyValue(
53
+ value: TemplateVariableValue | undefined,
54
+ ): value is MoneyValue {
55
+ if (!isRecord(value)) return false;
60
56
  return (
61
- typeof candidate.value === "number" &&
62
- Number.isFinite(candidate.value) &&
63
- typeof candidate.currency === "string"
57
+ typeof value.value === "number" &&
58
+ Number.isFinite(value.value) &&
59
+ typeof value.currency === "string"
64
60
  );
65
61
  }
66
62
 
@@ -101,6 +97,24 @@ function formatIsoDate(value: string, locale: string): string | null {
101
97
  : formatDate(date, options, locale);
102
98
  }
103
99
 
100
+ /** What `String()` writes for a record, which has no text of its own. */
101
+ const RECORD_TEXT = "[object Object]";
102
+
103
+ /**
104
+ * `String(value)` spelled out for each kind a variable can be: a list joins
105
+ * its items with commas, a missing item writing nothing, and a record writes
106
+ * {@link RECORD_TEXT}.
107
+ */
108
+ export function plainText(value: TemplateVariableValue | undefined): string {
109
+ if (Array.isArray(value))
110
+ return value
111
+ .map((item) => (isPresent(item) ? plainText(item) : ""))
112
+ .join(",");
113
+ if (value instanceof Date) return value.toString();
114
+ if (isRecord(value)) return RECORD_TEXT;
115
+ return String(value);
116
+ }
117
+
104
118
  /**
105
119
  * A value as a recipient reads it. A money value and a `Date` format
106
120
  * themselves in the send's locale wherever they appear; an ISO string is
@@ -108,7 +122,7 @@ function formatIsoDate(value: string, locale: string): string | null {
108
122
  * else, a string already formatted by the caller among them, as given.
109
123
  */
110
124
  export function formatValue(
111
- value: unknown,
125
+ value: TemplateVariableValue | undefined,
112
126
  path = "",
113
127
  format?: ValueFormat,
114
128
  ): string {
@@ -116,9 +130,9 @@ export function formatValue(
116
130
  if (isMoneyValue(value)) return formatMoney(value, locale);
117
131
  if (value instanceof Date)
118
132
  return Number.isNaN(value.getTime())
119
- ? String(value)
133
+ ? value.toString()
120
134
  : formatDate(value, DATE_STYLE, locale);
121
- if (typeof value === "string" && format?.datePaths.has(path))
135
+ if (isText(value) && format?.datePaths.has(path))
122
136
  return formatIsoDate(value, locale) ?? value;
123
- return String(value);
137
+ return plainText(value);
124
138
  }
@@ -1,5 +1,13 @@
1
+ import type {
2
+ TemplateVariables,
3
+ TemplateVariableValue,
4
+ } from "@antelopejs/interface-dms-mailing";
5
+ import { isObject } from "../validation/guards";
6
+
1
7
  const PATH_SEPARATOR = ".";
2
8
  const ARRAY_SUFFIX = "[]";
9
+ const ARRAY_LENGTH = "length";
10
+ const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
3
11
 
4
12
  export function normalizePath(path: string): string {
5
13
  const trimmed = path.trim();
@@ -15,19 +23,45 @@ export function normalizePath(path: string): string {
15
23
  */
16
24
  const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
17
25
 
18
- function readSegment(current: unknown, segment: string): unknown {
19
- if (current === null || current === undefined) return undefined;
20
- if (typeof current !== "object") return undefined;
26
+ /** Whether a path can step into `value`: a record or a money value, not a list or a `Date`. */
27
+ export function isRecord(
28
+ value: TemplateVariableValue | undefined,
29
+ ): value is TemplateVariables {
30
+ return isObject(value) && !Array.isArray(value) && !(value instanceof Date);
31
+ }
32
+
33
+ function readKey(record: TemplateVariables, key: string) {
34
+ return Object.hasOwn(record, key) ? record[key] : undefined;
35
+ }
36
+
37
+ function readSegment(
38
+ current: TemplateVariableValue | undefined,
39
+ segment: string,
40
+ ): TemplateVariableValue | undefined {
21
41
  if (RESERVED_SEGMENTS.includes(segment)) return undefined;
22
- return (current as Record<string, unknown>)[segment];
42
+ if (Array.isArray(current)) {
43
+ if (segment === ARRAY_LENGTH) return current.length;
44
+ return ARRAY_INDEX.test(segment) ? current[Number(segment)] : undefined;
45
+ }
46
+ return isRecord(current) ? readKey(current, segment) : undefined;
23
47
  }
24
48
 
25
- export function getPath(data: unknown, path: string): unknown {
49
+ /**
50
+ * The value `path` names in `data`: a key a record owns, a list's element by
51
+ * index or its length. `undefined` when the path leaves the data.
52
+ */
53
+ export function getPath(
54
+ data: TemplateVariableValue | undefined,
55
+ path: string,
56
+ ): TemplateVariableValue | undefined {
26
57
  return normalizePath(path)
27
58
  .split(PATH_SEPARATOR)
28
- .reduce<unknown>(readSegment, data);
59
+ .reduce<TemplateVariableValue | undefined>(readSegment, data);
29
60
  }
30
61
 
31
- export function isPresent(value: unknown): boolean {
62
+ /** Whether `value` is neither missing nor `null`. */
63
+ export function isPresent(
64
+ value: TemplateVariableValue | undefined,
65
+ ): value is Exclude<TemplateVariableValue, null> {
32
66
  return value !== undefined && value !== null;
33
67
  }
@@ -13,14 +13,12 @@ const FOOTER_TYPE = "footer";
13
13
  * Languages whose typography spaces a colon off its label, with a no-break
14
14
  * space so the colon never starts a line ("Total : 64,00 €").
15
15
  */
16
- const LABEL_SEPARATORS: Record<string, string> = {
17
- fr: " : ",
18
- };
16
+ const LABEL_SEPARATORS = new Map([["fr", " : "]]);
19
17
 
20
18
  /** What goes between a label and its value in the e-mail's language. */
21
19
  export function labelSeparatorOf(locale = ""): string {
22
20
  const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
23
- return LABEL_SEPARATORS[language] ?? DEFAULT_LABEL_SEPARATOR;
21
+ return LABEL_SEPARATORS.get(language) ?? DEFAULT_LABEL_SEPARATOR;
24
22
  }
25
23
 
26
24
  const linked = (label: string, href: string, separator: string): string =>
@@ -1,3 +1,7 @@
1
+ import type {
2
+ TemplateVariables,
3
+ TemplateVariableValue,
4
+ } from "@antelopejs/interface-dms-mailing";
1
5
  import {
2
6
  PREFERENCES_URL_PATH,
3
7
  SYSTEM_VARIABLE_PATHS,
@@ -15,12 +19,12 @@ import type {
15
19
  } from "../types";
16
20
  import { evaluateCondition } from "./conditions";
17
21
  import { safeUrl } from "./fallback-html";
18
- import { formatValue, type ValueFormat } from "./formats";
22
+ import { formatValue, plainText, type ValueFormat } from "./formats";
19
23
  import { getPath, isPresent } from "./paths";
20
24
  import { footerLinkPaths } from "./system-variables";
21
25
  import { type InterpolationMode, interpolate } from "./tokens";
22
26
 
23
- type Data = Record<string, unknown>;
27
+ type Data = TemplateVariables;
24
28
 
25
29
  interface ResolveState {
26
30
  data: Data;
@@ -60,11 +64,15 @@ function systemUrl(
60
64
  ): string {
61
65
  if (!footerLinkPaths(block).includes(path)) return "";
62
66
  const value = getPath(state.data, path);
63
- return isPresent(value) ? safeUrl(String(value)) : "";
67
+ return isPresent(value) ? safeUrl(plainText(value)) : "";
64
68
  }
65
69
 
66
70
  /** A list cell: a money value or a date in it is written for the locale. */
67
- function cell(item: unknown, path: string, state: ResolveState): string {
71
+ function cell(
72
+ item: TemplateVariableValue,
73
+ path: string,
74
+ state: ResolveState,
75
+ ): string {
68
76
  const value = getPath(item, path);
69
77
  return isPresent(value) ? formatValue(value, "", state.format) : "";
70
78
  }
@@ -1,3 +1,4 @@
1
+ import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
1
2
  import {
2
3
  PREFERENCES_URL_PATH,
3
4
  SYSTEM_VARIABLE_PATHS,
@@ -22,7 +23,10 @@ export const MARKETING_CATEGORY = "marketing";
22
23
 
23
24
  const SYSTEM_VARIABLE_TYPE = "url";
24
25
 
25
- type FooterLabelKey = "unsubscribeLabel" | "preferencesLabel";
26
+ /** The footer fields that label a link, in the order the links show. */
27
+ const LABEL_KEYS = ["unsubscribeLabel", "preferencesLabel"] as const;
28
+
29
+ type FooterLabelKey = (typeof LABEL_KEYS)[number];
26
30
 
27
31
  /** Each footer link: the label that shows it and the URL the caller passes. */
28
32
  const FOOTER_LINKS: Record<FooterLabelKey, string> = {
@@ -30,8 +34,6 @@ const FOOTER_LINKS: Record<FooterLabelKey, string> = {
30
34
  preferencesLabel: PREFERENCES_URL_PATH,
31
35
  };
32
36
 
33
- const LABEL_KEYS = Object.keys(FOOTER_LINKS) as FooterLabelKey[];
34
-
35
37
  export function isMarketingCategory(category?: string | null): boolean {
36
38
  return category === MARKETING_CATEGORY;
37
39
  }
@@ -92,7 +94,7 @@ export function systemVariables(
92
94
  export function missingSystemVariables(
93
95
  content: LocaleContent,
94
96
  category: string | null | undefined,
95
- data: Record<string, unknown>,
97
+ data: TemplateVariables,
96
98
  ): string[] {
97
99
  return systemVariables(localeSystemPaths(content), category)
98
100
  .filter((variable) => variable.required)
@@ -1,3 +1,4 @@
1
+ import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
1
2
  import { formatValue, type ValueFormat } from "./formats";
2
3
  import { getPath, isPresent } from "./paths";
3
4
 
@@ -21,8 +22,7 @@ const MISSING_TOKEN: Record<InterpolationMode, (token: string) => string> = {
21
22
 
22
23
  export function extractTokens(text: string): string[] {
23
24
  const found = new Set<string>();
24
- for (const match of text.matchAll(TOKEN_PATTERN))
25
- found.add(match[1] as string);
25
+ for (const match of text.matchAll(TOKEN_PATTERN)) found.add(match[1]);
26
26
  return [...found];
27
27
  }
28
28
 
@@ -32,7 +32,7 @@ export function extractTokens(text: string): string[] {
32
32
  */
33
33
  export function interpolate(
34
34
  text: string,
35
- data: Record<string, unknown>,
35
+ data: TemplateVariables,
36
36
  mode: InterpolationMode = "preview",
37
37
  format?: ValueFormat,
38
38
  ): InterpolationResult {
@@ -11,11 +11,11 @@ import {
11
11
  import { MODULE_ID } from "../constants";
12
12
  import {
13
13
  type MailingTemplate,
14
- SettingsModel,
15
14
  SuppressionModel,
16
15
  TemplateModel,
17
16
  TemplateVersionModel,
18
17
  } from "../db";
18
+ import { findSettings } from "../services/settings";
19
19
  import type { MailingSettingsValues } from "../types";
20
20
 
21
21
  export type ExportedSettings = Omit<MailingSettingsValues, "webhookSecret">;
@@ -26,7 +26,7 @@ export type ExportPageLoader<T> = (
26
26
  ) => Promise<T[]>;
27
27
 
28
28
  interface RowCodec<T> {
29
- fromDatabase(row: unknown): T | undefined;
29
+ fromDatabase(row: T): T | undefined;
30
30
  }
31
31
 
32
32
  export interface ExportedCollection {
@@ -72,18 +72,16 @@ export async function* jsonArrayChunks<T>(
72
72
  async function exportedSettings(
73
73
  tenantId: string,
74
74
  ): Promise<ExportedSettings | null> {
75
- const row = await GetModel(SettingsModel, tenantId).get(tenantId);
76
- if (!row) return null;
75
+ const settings = await findSettings(tenantId);
76
+ if (!settings) return null;
77
77
  return {
78
- fallbackLocale: row.fallbackLocale,
79
- logRetentionDays: row.logRetentionDays,
80
- blockOnMissingVariables: row.blockOnMissingVariables,
81
- senderName: row.senderName,
82
- senderEmail: row.senderEmail,
83
- replyTo: row.replyTo,
84
- categories: JSON.parse(
85
- row.json_categories,
86
- ) as ExportedSettings["categories"],
78
+ fallbackLocale: settings.fallbackLocale,
79
+ logRetentionDays: settings.logRetentionDays,
80
+ blockOnMissingVariables: settings.blockOnMissingVariables,
81
+ senderName: settings.senderName,
82
+ senderEmail: settings.senderEmail,
83
+ replyTo: settings.replyTo,
84
+ categories: settings.categories,
87
85
  };
88
86
  }
89
87
 
@@ -313,11 +313,13 @@ export class SendsPageController extends PageController(
313
313
  permissionMeta("send_drawer", "i-ph-info"),
314
314
  ),
315
315
  title: "$dms_mailing.sends.actions.details",
316
- // Names the open send: its recipient and the template it used.
316
+ /** Names the open send: its recipient and the template it used. */
317
317
  rowTitle: "$dms_mailing.sends.drawer.heading",
318
318
  positionLabel: "$dms_mailing.sends.drawer.position",
319
- // A side panel keeps the open send in the URL and words a missing
320
- // one by default; a drawer does both only when asked.
319
+ /**
320
+ * A side panel keeps the open send in the URL and words a missing
321
+ * one by default; a drawer does both only when asked.
322
+ */
321
323
  deepLink: true,
322
324
  notFound: {},
323
325
  },
@@ -109,7 +109,9 @@ export class TemplatesPageController extends PageController(
109
109
  },
110
110
  async edit(ctx, { id, body, current }) {
111
111
  await assertEditable(getRequestTenantId(ctx), body, current);
112
- return afterEdit(this.model, id, body.name, localeOfRequest(ctx));
112
+ return body.name === undefined
113
+ ? undefined
114
+ : afterEdit(this.model, id, localeOfRequest(ctx));
113
115
  },
114
116
  },
115
117
  rowActions: {
@@ -146,7 +148,7 @@ export class TemplatesPageController extends PageController(
146
148
  deepLink: true,
147
149
  target: {
148
150
  type: "drawer",
149
- // On the right, as the gallery opens it (useTemplateDrawer).
151
+ /** On the right, as the gallery opens it (`useTemplateDrawer`). */
150
152
  direction: "right",
151
153
  component: templateDrawer,
152
154
  title: "$dms_mailing.templates.actions.details",
@@ -65,8 +65,10 @@ export function mailingToolbar(
65
65
  id: period.scope,
66
66
  presets: period.presets,
67
67
  defaultPreset: period.defaultPreset,
68
- // More than one comparison: pageToolbar() draws them as a "vs …" menu
69
- // on the first one, the previous period.
68
+ /**
69
+ * More than one comparison: `pageToolbar()` draws them as a "vs …" menu
70
+ * on the first one, the previous period.
71
+ */
70
72
  comparisons: MAILING_COMPARISONS,
71
73
  },
72
74
  }).meta({
@@ -17,17 +17,17 @@ import {
17
17
  secretRefusal,
18
18
  WEBHOOK_SECRET_HEADER,
19
19
  } from "../services/webhook-status";
20
- import {
21
- SEND_EVENT_TYPES,
22
- type SendEventType,
23
- type WebhookRefusal,
24
- } from "../types";
20
+ import type { WebhookRefusal } from "../types";
21
+ import type { JsonValue } from "../types/json";
22
+ import { parseJson } from "../validation/json";
23
+ import { jsonObjectSchema } from "../validation/json.schema";
24
+ import { sendEventTypeSchema } from "../validation/send-event-type.schema";
25
25
 
26
26
  const eventSchema = z.object({
27
27
  messageId: z.string().min(1),
28
- type: z.enum(SEND_EVENT_TYPES as [SendEventType, ...SendEventType[]]),
28
+ type: sendEventTypeSchema,
29
29
  at: z.coerce.date().optional(),
30
- details: z.record(z.unknown()).optional(),
30
+ details: jsonObjectSchema.optional(),
31
31
  });
32
32
 
33
33
  type ParsedEvent = z.infer<typeof eventSchema>;
@@ -41,7 +41,7 @@ const STATUS_OF_REFUSAL: Record<WebhookRefusal, number> = {
41
41
  interface ReceivedEvent {
42
42
  provider: string;
43
43
  secret: string | undefined;
44
- body: unknown;
44
+ body: JsonValue | undefined;
45
45
  /** The tenant the URL names. */
46
46
  tenantId: string;
47
47
  }
@@ -57,9 +57,9 @@ export interface WebhookResponse {
57
57
  * The body as JSON, `undefined` when it is not: a malformed body is refused
58
58
  * as an invalid event, after the secret, never answered with a parser error.
59
59
  */
60
- function parseBody(raw: Buffer): unknown {
60
+ function parseBody(raw: Buffer): JsonValue | undefined {
61
61
  try {
62
- return JSON.parse(raw.toString("utf8"));
62
+ return parseJson(raw.toString("utf8"));
63
63
  } catch {
64
64
  return undefined;
65
65
  }
@@ -5,5 +5,6 @@ export * from "./provider";
5
5
  export * from "./sends";
6
6
  export * from "./settings";
7
7
  export * from "./suppressions";
8
+ export * from "./template-content";
8
9
  export * from "./template-versions";
9
10
  export * from "./templates";