@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
@@ -63,16 +63,16 @@ const TEMPLATE_VOLUME_KEY = "$dms_mailing.metrics.template_volume";
63
63
  const FIRST_ROW_OFFSET = 0;
64
64
  const SINGLE_ROW = 1;
65
65
 
66
- const KPI_EXTRACTORS: Record<string, TotalsExtractor> = {
67
- sends: (totals) => totals.sends,
68
- deliverability: (totals) => totals.deliverability,
69
- "open-rate": (totals) => totals.openRate,
70
- "click-rate": (totals) => totals.clickRate,
71
- bounces: (totals) => totals.bounced,
72
- unsubscribes: (totals) => totals.unsubscribed,
73
- queued: (totals) => totals.queued,
74
- latency: (totals) => totals.latencyMedian,
75
- };
66
+ const KPI_EXTRACTORS = new Map<string, TotalsExtractor>([
67
+ ["sends", (totals) => totals.sends],
68
+ ["deliverability", (totals) => totals.deliverability],
69
+ ["open-rate", (totals) => totals.openRate],
70
+ ["click-rate", (totals) => totals.clickRate],
71
+ ["bounces", (totals) => totals.bounced],
72
+ ["unsubscribes", (totals) => totals.unsubscribed],
73
+ ["queued", (totals) => totals.queued],
74
+ ["latency", (totals) => totals.latencyMedian],
75
+ ]);
76
76
 
77
77
  /** The KPIs read from delivery events: unmeasured when nothing reports them. */
78
78
  const DELIVERY_METRICS = new Set(["deliverability", "open-rate", "click-rate"]);
@@ -83,18 +83,23 @@ export interface TopMetric {
83
83
  minDeliveries: number;
84
84
  }
85
85
 
86
- const TOP_METRICS: Record<string, TopMetric> = {
87
- "open-rate": {
88
- extract: (totals) => totals.openRate,
89
- minDeliveries: MIN_RANKED_DELIVERIES,
90
- },
91
- "click-rate": {
92
- extract: (totals) => totals.clickRate,
93
- minDeliveries: MIN_RANKED_DELIVERIES,
94
- },
95
- sends: { extract: (totals) => totals.sends, minDeliveries: 0 },
86
+ const OPEN_RATE_METRIC: TopMetric = {
87
+ extract: (totals) => totals.openRate,
88
+ minDeliveries: MIN_RANKED_DELIVERIES,
96
89
  };
97
- const DEFAULT_TOP_METRIC = TOP_METRICS["open-rate"] as TopMetric;
90
+
91
+ const TOP_METRICS = new Map<string, TopMetric>([
92
+ ["open-rate", OPEN_RATE_METRIC],
93
+ [
94
+ "click-rate",
95
+ {
96
+ extract: (totals) => totals.clickRate,
97
+ minDeliveries: MIN_RANKED_DELIVERIES,
98
+ },
99
+ ],
100
+ ["sends", { extract: (totals) => totals.sends, minDeliveries: 0 }],
101
+ ]);
102
+ const DEFAULT_TOP_METRIC = OPEN_RATE_METRIC;
98
103
 
99
104
  interface FunnelStepDefinition {
100
105
  id: string;
@@ -223,7 +228,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
223
228
  async kpi(
224
229
  @Parameter("metric", "param") metric: string,
225
230
  ): Promise<KpiCardData> {
226
- const extract = KPI_EXTRACTORS[metric];
231
+ const extract = KPI_EXTRACTORS.get(metric);
227
232
  assert(extract, HTTP_NOT_FOUND, UNKNOWN_METRIC);
228
233
  const range = currentRange(this.ctx);
229
234
  const [current, previous, tracking] = await Promise.all([
@@ -252,7 +257,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
252
257
  @Get("/top-templates")
253
258
  async topTemplates() {
254
259
  const metric =
255
- TOP_METRICS[this.ctx.url.searchParams.get("metric") ?? ""] ??
260
+ TOP_METRICS.get(this.ctx.url.searchParams.get("metric") ?? "") ??
256
261
  DEFAULT_TOP_METRIC;
257
262
  const range = currentRange(this.ctx);
258
263
  const [sends, previous, templates] = await Promise.all([
@@ -467,22 +472,19 @@ export function comparedSteps(
467
472
  /** Which sends the funnel counts: all, or one side of the marketing split. */
468
473
  export type FunnelKind = "all" | "marketing" | "transactional";
469
474
 
470
- const FUNNEL_KINDS: Record<string, FunnelKind> = {
471
- all: "all",
472
- marketing: "marketing",
473
- transactional: "transactional",
474
- };
475
-
476
- export function readFunnelKind(raw: string | null): FunnelKind {
477
- return FUNNEL_KINDS[raw ?? ""] ?? "all";
478
- }
479
-
480
475
  const KIND_KEEPS: Record<FunnelKind, (category: string) => boolean> = {
481
476
  all: () => true,
482
477
  marketing: (category) => isMarketingCategory(category),
483
478
  transactional: (category) => !isMarketingCategory(category),
484
479
  };
485
480
 
481
+ const isFunnelKind = (raw: string): raw is FunnelKind =>
482
+ Object.hasOwn(KIND_KEEPS, raw);
483
+
484
+ export function readFunnelKind(raw: string | null): FunnelKind {
485
+ return raw !== null && isFunnelKind(raw) ? raw : "all";
486
+ }
487
+
486
488
  /**
487
489
  * Sends of the templates on one side of the split. A template's category
488
490
  * decides: "marketing" is the campaign side, everything else transactional.
@@ -18,10 +18,10 @@ const PREVIOUS_YEAR = "previous-year";
18
18
  * What the comparison series is called, by the selector's `comparison`. A
19
19
  * custom window, or a caller naming none, is neither of the two.
20
20
  */
21
- const COMPARISON_SERIES_LABELS: Record<string, string> = {
22
- "previous-period": "$dms_mailing.metrics.previous",
23
- [PREVIOUS_YEAR]: "$dms_mailing.metrics.previous_year",
24
- };
21
+ const COMPARISON_SERIES_LABELS = new Map([
22
+ ["previous-period", "$dms_mailing.metrics.previous"],
23
+ [PREVIOUS_YEAR, "$dms_mailing.metrics.previous_year"],
24
+ ]);
25
25
  const COMPARED_SERIES_LABEL = "$dms_mailing.metrics.compared";
26
26
 
27
27
  function readQuery(ctx: RangeContext, key: string): string | undefined {
@@ -67,7 +67,7 @@ export function isComparisonWanted(ctx: RangeContext): boolean {
67
67
  /** The label of the comparison series, for the comparison the caller asks. */
68
68
  export function comparisonLabel(ctx: RangeContext): string {
69
69
  const comparison = readQuery(ctx, "comparison") ?? "previous-period";
70
- return COMPARISON_SERIES_LABELS[comparison] ?? COMPARED_SERIES_LABEL;
70
+ return COMPARISON_SERIES_LABELS.get(comparison) ?? COMPARED_SERIES_LABEL;
71
71
  }
72
72
 
73
73
  function yearBefore(date: Date): Date {
@@ -10,7 +10,7 @@ import {
10
10
  } from "@antelopejs/interface-api";
11
11
  import { assert, assertValidation } from "@antelopejs/interface-api-util";
12
12
  import { AuthUserWithPermission } from "@antelopejs/interface-dms/guards";
13
- import { ZodError } from "zod";
13
+ import { ZodError, type z } from "zod";
14
14
  import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
15
15
  import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
16
16
  import type { User } from "@antelopejs/interface-dms/auth/db";
@@ -68,13 +68,10 @@ import {
68
68
  requireTemplate,
69
69
  snapshotOfVersion,
70
70
  } from "../services/templates";
71
- import type {
72
- SendStatus,
73
- SuppressionSummary,
74
- TemplateContent,
75
- TemplateStatus,
76
- } from "../types";
71
+ import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
72
+ import type { JsonObject } from "../types/json";
77
73
  import {
74
+ type previewVersionSchema,
78
75
  realSendSchema,
79
76
  replayFailedSchema,
80
77
  replaySchema,
@@ -98,12 +95,6 @@ const DRAFT_VERSION = 0;
98
95
  /** Renders as the recipient got it, not as the editor previews it. */
99
96
  const SEND_MODE: InterpolationMode = "send";
100
97
 
101
- interface TestSendInput {
102
- locale: string;
103
- content?: TemplateContent;
104
- data?: Record<string, unknown>;
105
- }
106
-
107
98
  export interface SendTemplateSummary {
108
99
  id: string;
109
100
  name: string;
@@ -194,10 +185,10 @@ const sameAddress = (left: string, right: string): boolean =>
194
185
  normalizeAddress(left) === normalizeAddress(right);
195
186
 
196
187
  /** A bad address gets its own message; any other invalid field is the content's. */
197
- function sendInputError(error: unknown): string {
198
- const isRecipient =
199
- error instanceof ZodError &&
200
- error.issues.some((issue) => issue.path[0] === RECIPIENT_FIELD);
188
+ function sendInputError(error: ZodError): string {
189
+ const isRecipient = error.issues.some(
190
+ (issue) => issue.path[0] === RECIPIENT_FIELD,
191
+ );
201
192
  return isRecipient ? INVALID_RECIPIENT : INVALID_CONTENT;
202
193
  }
203
194
 
@@ -265,7 +256,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
265
256
  const input = assertValidation(
266
257
  body,
267
258
  (value) => testSendSchema.parse(value),
268
- sendInputError,
259
+ (error) =>
260
+ error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
269
261
  );
270
262
  const template = await requireTemplate(this.templates, id);
271
263
  const ownAddressSuppressed = await this.ownSuppression(input.to);
@@ -274,9 +266,9 @@ export class SendsController extends Controller(API_BASE_PATH) {
274
266
  tenantId: this.tenantId,
275
267
  to: input.to,
276
268
  allowSuppressed: [this.user.email],
277
- locale: (input as TestSendInput).locale,
278
- variables: (input as TestSendInput).data,
279
- content: (input as TestSendInput).content,
269
+ locale: input.locale,
270
+ variables: input.data,
271
+ content: input.content,
280
272
  version: requestedVersionOf(input.version),
281
273
  isTest: true,
282
274
  source: TEST_SEND_SOURCE,
@@ -311,7 +303,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
311
303
  const input = assertValidation(
312
304
  body,
313
305
  (value) => realSendSchema.parse(value),
314
- sendInputError,
306
+ (error) =>
307
+ error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
315
308
  );
316
309
  const template = await requireTemplate(this.templates, id);
317
310
  const result = await this.send(
@@ -578,12 +571,16 @@ export class SendsController extends Controller(API_BASE_PATH) {
578
571
  * The version a test of a published version records: a number names one,
579
572
  * "draft" or "published" let the send decide.
580
573
  */
581
- function requestedVersionOf(version: unknown): number | undefined {
582
- return typeof version === "number" ? version : undefined;
574
+ function requestedVersionOf(
575
+ version: z.infer<typeof previewVersionSchema> | undefined,
576
+ ): number | undefined {
577
+ return version === "draft" || version === "published" ? undefined : version;
583
578
  }
584
579
 
585
- function variablesOf(send: MailingSend): Record<string, unknown> {
586
- return JSON.parse(send.json_variables || "{}") as Record<string, unknown>;
580
+ function variablesOf(send: MailingSend): JsonObject {
581
+ // SAFETY: json_variables is written only as JSON.stringify(params.variables ?? {}) (services/send.ts) or
582
+ // JSON.stringify(send.variables) (services/send-import.ts), both a JsonObject.
583
+ return JSON.parse(send.json_variables || "{}") as JsonObject;
587
584
  }
588
585
 
589
586
  function summarize(template: MailingTemplate): SendTemplateSummary {
@@ -48,6 +48,8 @@ import {
48
48
  settingsPatchSchema,
49
49
  } from "../validation/settings.schema";
50
50
  import { retentionLimit } from "../crons/retention";
51
+ import { isJsonObject } from "../validation/json";
52
+ import { zodIssuesJson } from "./zod-json";
51
53
 
52
54
  const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
53
55
  const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
@@ -61,9 +63,9 @@ const NEXT_RUN_MINUTE = 15;
61
63
  const RETENTION_DAYS_PARAM = "days";
62
64
 
63
65
  /** A settings field the form shows under another name. */
64
- const FORM_FIELD_OF: Record<string, string> = {
65
- blockOnMissingVariables: "missingVariables",
66
- };
66
+ const FORM_FIELD_OF = new Map([
67
+ ["blockOnMissingVariables", "missingVariables"],
68
+ ]);
67
69
 
68
70
  const retentionDaysSchema = z.coerce
69
71
  .number()
@@ -89,7 +91,7 @@ export interface MailingSettingsResponse extends MailingSettingsValues {
89
91
  export interface SettingsNotice {
90
92
  title: string;
91
93
  color?: string;
92
- params?: Record<string, unknown>;
94
+ params?: Record<string, number | string>;
93
95
  }
94
96
 
95
97
  export interface SettingsWriteResponse extends MailingSettingsValues {
@@ -141,14 +143,16 @@ function webhookUrl(
141
143
  /** The issue of a refused write, under the form field it belongs to. */
142
144
  function formIssue(issue: z.ZodIssue): z.ZodIssue {
143
145
  const [field, ...rest] = issue.path;
144
- const formField = FORM_FIELD_OF[String(field)];
146
+ const formField = FORM_FIELD_OF.get(String(field));
145
147
  return formField ? { ...issue, path: [formField, ...rest] } : issue;
146
148
  }
147
149
 
148
150
  /** A refused write: the generic message, and each issue for its field. */
149
- function invalidSettings(error: unknown): unknown {
150
- if (!(error instanceof z.ZodError)) return INVALID_SETTINGS;
151
- return { message: INVALID_SETTINGS, issues: error.issues.map(formIssue) };
151
+ function invalidSettings(error: z.ZodError) {
152
+ return {
153
+ message: INVALID_SETTINGS,
154
+ issues: zodIssuesJson(error.issues.map(formIssue)),
155
+ };
152
156
  }
153
157
 
154
158
  function senderNotice(values: MailingSettingsValues): SettingsNotice[] {
@@ -236,13 +240,12 @@ export class MailingSettingsController extends Controller(
236
240
  /** The body as a checked patch; 400 for anything else. */
237
241
  private readPatch(raw: Buffer): SettingsPatch {
238
242
  const body: unknown = parseBody(raw);
239
- const isPatch =
240
- typeof body === "object" && body !== null && !Array.isArray(body);
241
- assert(isPatch, HTTP_BAD_REQUEST, INVALID_SETTINGS);
243
+ assert(isJsonObject(body), HTTP_BAD_REQUEST, INVALID_SETTINGS);
242
244
  return assertValidation(
243
- clearedToEmpty(body as Record<string, unknown>),
245
+ clearedToEmpty(body),
244
246
  (value) => settingsPatchSchema.parse(value),
245
- invalidSettings,
247
+ (error) =>
248
+ error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
246
249
  );
247
250
  }
248
251
 
@@ -296,7 +299,8 @@ export class MailingSettingsController extends Controller(
296
299
  return assertValidation(
297
300
  raw,
298
301
  (value) => retentionDaysSchema.parse(value),
299
- invalidSettings,
302
+ (error) =>
303
+ error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
300
304
  );
301
305
  }
302
306
 
@@ -307,12 +311,12 @@ export class MailingSettingsController extends Controller(
307
311
  @Get("/category-usage")
308
312
  async categoryUsage(): Promise<{ counts: Record<string, number> }> {
309
313
  const templates = await this.templates.getAll();
310
- const counts: Record<string, number> = {};
314
+ const counts = new Map<string, number>();
311
315
  for (const template of templates) {
312
316
  if (!template.category || template.status === ARCHIVED_STATUS) continue;
313
- counts[template.category] = (counts[template.category] ?? 0) + 1;
317
+ counts.set(template.category, (counts.get(template.category) ?? 0) + 1);
314
318
  }
315
- return { counts };
319
+ return { counts: Object.fromEntries(counts) };
316
320
  }
317
321
 
318
322
  /**
@@ -18,6 +18,7 @@ import { API_BASE_PATH, HTTP_CONFLICT, HTTP_NOT_FOUND } from "../constants";
18
18
  import { SuppressionModel } from "../db";
19
19
  import { SuppressionsPageController } from "../pages/suppressions";
20
20
  import { suppressAddress } from "../services/suppressions";
21
+ import { zodIssuesJson } from "./zod-json";
21
22
 
22
23
  const INVALID_SUPPRESSION = "$dms_mailing.suppressions.errors.invalid";
23
24
  const ALREADY_SUPPRESSED = "$dms_mailing.suppressions.errors.already";
@@ -32,12 +33,6 @@ const manualSuppressionSchema = z.object({
32
33
  note: z.string().trim().max(MAX_NOTE_LENGTH).optional(),
33
34
  });
34
35
 
35
- /** A refused entry: the generic message, and each issue under its field. */
36
- function invalidSuppression(error: unknown): unknown {
37
- if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
38
- return { message: INVALID_SUPPRESSION, issues: error.issues };
39
- }
40
-
41
36
  /** An address already on the list, told under the address field. */
42
37
  const alreadySuppressed = () => ({
43
38
  message: ALREADY_SUPPRESSED,
@@ -56,13 +51,22 @@ export class SuppressionsController extends Controller(
56
51
  @TenantScopedModel(SuppressionModel)
57
52
  declare suppressions: SuppressionModel;
58
53
 
59
- /** Puts an address on the list by hand: nothing is sent to it any more. */
54
+ /**
55
+ * Puts an address on the list by hand: nothing is sent to it any more. A
56
+ * refused entry answers the generic message, and each issue under its field.
57
+ */
60
58
  @Post("")
61
59
  async add(@JSONBody() body: unknown): Promise<{ added: true }> {
62
60
  const input = assertValidation(
63
61
  body ?? {},
64
62
  (value) => manualSuppressionSchema.parse(value),
65
- invalidSuppression,
63
+ (error) => {
64
+ if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
65
+ return {
66
+ message: INVALID_SUPPRESSION,
67
+ issues: zodIssuesJson(error.issues),
68
+ };
69
+ },
66
70
  );
67
71
  const isAdded = await suppressAddress(getRequestTenantId(this.ctx), {
68
72
  email: input.email,
@@ -0,0 +1,45 @@
1
+ import type { MailingTemplate } from "../db";
2
+ import type { TemplateChange } from "../engine";
3
+ import type {
4
+ CategoryInLocale,
5
+ TemplateContent,
6
+ VariableDefinition,
7
+ } from "../types";
8
+ import type { JsonObject } from "../types/json";
9
+
10
+ export interface TemplateSender {
11
+ name: string;
12
+ email: string;
13
+ replyTo: string;
14
+ }
15
+
16
+ export interface TemplateContentResponse {
17
+ /** The template, its name in the reader's language. */
18
+ template: MailingTemplate;
19
+ /** The content the editor works on: the draft, else the published one. */
20
+ content: TemplateContent;
21
+ /** What customers receive; `null` when the template was never published. */
22
+ publishedContent: TemplateContent | null;
23
+ hasDraft: boolean;
24
+ /** Version customers receive; 0 when never published. */
25
+ version: number;
26
+ /** The draft revision a save or a publish must carry back. */
27
+ revision: number;
28
+ changes: TemplateChange[];
29
+ /** The declarations the editor works on: the draft's, else the published. */
30
+ variables: VariableDefinition[];
31
+ /** Paths the working content references, across every locale. */
32
+ detectedVariables: string[];
33
+ /**
34
+ * The contract callers send against: the published declarations and the
35
+ * paths the published content references; `null` when never published.
36
+ */
37
+ publishedVariables: VariableDefinition[] | null;
38
+ publishedDetectedVariables: string[] | null;
39
+ testData: JsonObject;
40
+ fallbackLocale: string;
41
+ /** Whether a send missing a variable fails rather than goes out blank. */
42
+ blockOnMissingVariables: boolean;
43
+ categories: CategoryInLocale[];
44
+ sender: TemplateSender;
45
+ }
@@ -22,7 +22,6 @@ import {
22
22
  import {
23
23
  collectContentVariablePaths,
24
24
  resolveEmail,
25
- type TemplateChange,
26
25
  valueFormatOf,
27
26
  } from "../engine";
28
27
  import {
@@ -31,7 +30,7 @@ import {
31
30
  type TemplateFields,
32
31
  TemplateModel,
33
32
  } from "../db";
34
- import { saveDetails } from "../data/templates-table";
33
+ import { INVALID_TEMPLATE, saveDetails } from "../data/templates-table";
35
34
  import { TemplatesPageController } from "../pages/templates";
36
35
  import { forAudience, statsWindowStart } from "../services/metrics";
37
36
  import { refreshTemplateAttention } from "../services/template-attention";
@@ -75,18 +74,18 @@ import {
75
74
  import {
76
75
  BUSINESS_AUDIENCE,
77
76
  type CategoryInLocale,
78
- type TemplateContent,
79
77
  type TemplateStatus,
80
- type VariableDefinition,
81
78
  } from "../types";
82
79
  import {
83
80
  duplicateSchema,
84
81
  previewSchema,
85
82
  publishSchema,
86
83
  saveContentSchema,
84
+ templateDetailsSchema,
87
85
  testDataSchema,
88
86
  variablesSchema,
89
87
  } from "../validation/templates.schema";
88
+ import type { TemplateContentResponse } from "./template-content";
90
89
 
91
90
  const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
92
91
  const INVALID_CONTENT = "$dms_mailing.errors.invalid_content";
@@ -125,43 +124,6 @@ export interface CategoriesResponse {
125
124
  categories: CategoryInLocale[];
126
125
  }
127
126
 
128
- export interface TemplateSender {
129
- name: string;
130
- email: string;
131
- replyTo: string;
132
- }
133
-
134
- export interface TemplateContentResponse {
135
- /** The template, its name in the reader's language. */
136
- template: MailingTemplate;
137
- /** The content the editor works on: the draft, else the published one. */
138
- content: TemplateContent;
139
- /** What customers receive; `null` when the template was never published. */
140
- publishedContent: TemplateContent | null;
141
- hasDraft: boolean;
142
- /** Version customers receive; 0 when never published. */
143
- version: number;
144
- /** The draft revision a save or a publish must carry back. */
145
- revision: number;
146
- changes: TemplateChange[];
147
- /** The declarations the editor works on: the draft's, else the published. */
148
- variables: VariableDefinition[];
149
- /** Paths the working content references, across every locale. */
150
- detectedVariables: string[];
151
- /**
152
- * The contract callers send against: the published declarations and the
153
- * paths the published content references; `null` when never published.
154
- */
155
- publishedVariables: VariableDefinition[] | null;
156
- publishedDetectedVariables: string[] | null;
157
- testData: Record<string, unknown>;
158
- fallbackLocale: string;
159
- /** Whether a send missing a variable fails rather than goes out blank. */
160
- blockOnMissingVariables: boolean;
161
- categories: CategoryInLocale[];
162
- sender: TemplateSender;
163
- }
164
-
165
127
  export interface StarterSummary {
166
128
  id: string;
167
129
  name: string;
@@ -221,9 +183,9 @@ export class TemplatesController extends Controller(
221
183
  }
222
184
 
223
185
  @Get("/starters")
224
- starters(): { starters: StarterSummary[] } {
186
+ starters() {
225
187
  return {
226
- starters: STARTER_TEMPLATES.map((starter) => ({
188
+ starters: STARTER_TEMPLATES.map((starter): StarterSummary => ({
227
189
  id: starter.id,
228
190
  name: starter.name,
229
191
  slug: starter.slug,
@@ -352,7 +314,7 @@ export class TemplatesController extends Controller(
352
314
  (value) => saveContentSchema.parse(value),
353
315
  () => INVALID_CONTENT,
354
316
  );
355
- const content = input.content as TemplateContent;
317
+ const { content } = input;
356
318
  const template = await requireTemplate(this.templates, id);
357
319
  assertCurrentDraft(template, input.revision);
358
320
  const variables = input.variables ?? workingVariables(template);
@@ -514,7 +476,7 @@ export class TemplatesController extends Controller(
514
476
  const { content, variables } = input.content
515
477
  ? {
516
478
  ...workingSnapshot(template),
517
- content: input.content as TemplateContent,
479
+ content: input.content,
518
480
  }
519
481
  : await this.previewSnapshot(template, input.version);
520
482
  const settings = await getSettings(this.tenantId);
@@ -542,9 +504,9 @@ export class TemplatesController extends Controller(
542
504
  version: "draft" | "published" | number | undefined,
543
505
  ): Promise<TemplateDraft> | TemplateDraft {
544
506
  if (version === "published") return publishedSnapshot(template);
545
- if (typeof version === "number")
546
- return snapshotOfVersion(this.tenantId, template, version);
547
- return workingSnapshot(template);
507
+ if (version === undefined || version === "draft")
508
+ return workingSnapshot(template);
509
+ return snapshotOfVersion(this.tenantId, template, version);
548
510
  }
549
511
 
550
512
  /**
@@ -559,7 +521,12 @@ export class TemplatesController extends Controller(
559
521
  @JSONBody() body: unknown,
560
522
  ) {
561
523
  const template = await requireTemplate(this.templates, id);
562
- await saveDetails(this.ctx, template, body, this.author);
524
+ const details = assertValidation(
525
+ body,
526
+ (value) => templateDetailsSchema.parse(value),
527
+ () => INVALID_TEMPLATE,
528
+ );
529
+ await saveDetails(this.ctx, template, details, this.author);
563
530
  return { id };
564
531
  }
565
532
 
@@ -0,0 +1,28 @@
1
+ import type { HTTPBody } from "@antelopejs/interface-api";
2
+ import type { ZodIssue } from "zod";
3
+
4
+ function isJson(value: unknown): value is HTTPBody {
5
+ if (
6
+ value === null ||
7
+ typeof value === "string" ||
8
+ typeof value === "number" ||
9
+ typeof value === "boolean"
10
+ ) {
11
+ return true;
12
+ }
13
+ if (Array.isArray(value)) return value.every(isJson);
14
+ return typeof value === "object" && Object.values(value).every(isJson);
15
+ }
16
+
17
+ /**
18
+ * zod's issues as the JSON value `JSON.stringify` writes for them, so a body
19
+ * holding it serializes byte for byte as one holding the issues. A `ZodError`
20
+ * nested in an issue (`unionErrors`, `argumentsError`, `returnTypeError`)
21
+ * becomes its enumerable fields, `issues` and `name`.
22
+ */
23
+ export function zodIssuesJson(issues: readonly ZodIssue[]): HTTPBody {
24
+ const json: unknown = JSON.parse(JSON.stringify(issues));
25
+ if (!isJson(json))
26
+ throw new TypeError("zod issues did not serialize to JSON");
27
+ return json;
28
+ }
@@ -11,6 +11,7 @@ import {
11
11
  type TemplateAttentionReason,
12
12
  } from "../types";
13
13
  import { isDeliveryUntracked, type ProviderTracking } from "./metrics";
14
+ import { isNumber } from "../validation/guards";
14
15
 
15
16
  export type AttentionTone = "error" | "warning" | "neutral";
16
17
 
@@ -238,7 +239,7 @@ function namesOf(templates: MailingTemplate[]): ComposedTextParam {
238
239
  };
239
240
  }
240
241
 
241
- function problemBreakdown(problems: SendSummary[]): Record<string, number> {
242
+ function problemBreakdown(problems: SendSummary[]) {
242
243
  const countOf = (status: string) =>
243
244
  problems.filter((send) => send.status === status).length;
244
245
  return {
@@ -334,9 +335,9 @@ const COUNT_PARAM = "count";
334
335
  function feedParams(
335
336
  item: AttentionItem,
336
337
  ): Record<string, ComposedTextParam> | undefined {
337
- const params: Record<string, ComposedTextParam> = { ...item.params };
338
+ const params = { ...item.params };
338
339
  const count = item.count ?? item.params?.[COUNT_PARAM];
339
- if (typeof count === "number") {
340
+ if (isNumber(count)) {
340
341
  params[COUNT_PARAM] = { type: "count", value: count };
341
342
  }
342
343
  return Object.keys(params).length ? params : undefined;
@@ -374,20 +375,21 @@ type TemplateAttentionRule = (
374
375
  context: TemplateAttentionContext,
375
376
  ) => boolean;
376
377
 
377
- const TEMPLATE_ATTENTION_RULES: Record<
378
+ const TEMPLATE_ATTENTION_RULES = new Map<
378
379
  TemplateAttentionReason,
379
380
  TemplateAttentionRule
380
- > = {
381
- stale_draft: (template, context) => isStaleDraft(template, context.now),
382
- never_sent: (template, context) =>
383
- template.status === "live" && !context.sentSlugs.has(template.slug),
384
- missing_locale: (template, context) =>
385
- isMissingLocale(template, context.knownLocales),
386
- };
387
-
388
- const TEMPLATE_ATTENTION_REASONS = Object.keys(
389
- TEMPLATE_ATTENTION_RULES,
390
- ) as TemplateAttentionReason[];
381
+ >([
382
+ ["stale_draft", (template, context) => isStaleDraft(template, context.now)],
383
+ [
384
+ "never_sent",
385
+ (template, context) =>
386
+ template.status === "live" && !context.sentSlugs.has(template.slug),
387
+ ],
388
+ [
389
+ "missing_locale",
390
+ (template, context) => isMissingLocale(template, context.knownLocales),
391
+ ],
392
+ ]);
391
393
 
392
394
  /**
393
395
  * Why a template needs a look, in a stable order: a draft never published and
@@ -398,7 +400,7 @@ export function templateAttentionReasons(
398
400
  template: MailingTemplate,
399
401
  context: TemplateAttentionContext,
400
402
  ): TemplateAttentionReason[] {
401
- return TEMPLATE_ATTENTION_REASONS.filter((reason) =>
402
- TEMPLATE_ATTENTION_RULES[reason](template, context),
403
- );
403
+ return [...TEMPLATE_ATTENTION_RULES]
404
+ .filter(([, applies]) => applies(template, context))
405
+ .map(([reason]) => reason);
404
406
  }