@antelopejs/dms-mailing 0.8.0 → 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 (179) hide show
  1. package/README.md +41 -43
  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/data/body.d.ts +2 -1
  7. package/dist/data/body.js +2 -1
  8. package/dist/data/body.js.map +1 -1
  9. package/dist/data/read-only-routes.d.ts +4 -4
  10. package/dist/data/sends-table.d.ts +4 -4
  11. package/dist/data/suppressions-table.d.ts +4 -4
  12. package/dist/data/templates-table.d.ts +35 -12
  13. package/dist/data/templates-table.js +29 -24
  14. package/dist/data/templates-table.js.map +1 -1
  15. package/dist/data-types/categories-type.d.ts +1 -2
  16. package/dist/data-types/categories-type.js +3 -5
  17. package/dist/data-types/categories-type.js.map +1 -1
  18. package/dist/data-types/category-type.d.ts +1 -2
  19. package/dist/data-types/category-type.js +3 -5
  20. package/dist/data-types/category-type.js.map +1 -1
  21. package/dist/db/models/send-events.model.d.ts +7 -5
  22. package/dist/db/models/sends.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.js.map +1 -1
  24. package/dist/db/models/settings.model.d.ts +7 -5
  25. package/dist/db/models/suppressions.model.d.ts +7 -5
  26. package/dist/db/models/template-versions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.js +4 -3
  28. package/dist/db/models/template-versions.model.js.map +1 -1
  29. package/dist/db/models/templates.model.d.ts +7 -5
  30. package/dist/db/models/webhook-status.model.d.ts +7 -5
  31. package/dist/db/tables/templates.table.js.map +1 -1
  32. package/dist/engine/changes.js +8 -19
  33. package/dist/engine/changes.js.map +1 -1
  34. package/dist/engine/conditions.d.ts +2 -1
  35. package/dist/engine/conditions.js +3 -2
  36. package/dist/engine/conditions.js.map +1 -1
  37. package/dist/engine/coverage.d.ts +2 -1
  38. package/dist/engine/coverage.js.map +1 -1
  39. package/dist/engine/formats.d.ts +4 -6
  40. package/dist/engine/formats.js +17 -3
  41. package/dist/engine/formats.js.map +1 -1
  42. package/dist/engine/paths.d.ts +4 -2
  43. package/dist/engine/paths.js +15 -3
  44. package/dist/engine/paths.js.map +1 -1
  45. package/dist/engine/plain-text.js +2 -4
  46. package/dist/engine/plain-text.js.map +1 -1
  47. package/dist/engine/resolve.d.ts +2 -1
  48. package/dist/engine/resolve.js +1 -1
  49. package/dist/engine/resolve.js.map +1 -1
  50. package/dist/engine/system-variables.d.ts +2 -1
  51. package/dist/engine/system-variables.js.map +1 -1
  52. package/dist/engine/tokens.d.ts +2 -1
  53. package/dist/engine/tokens.js.map +1 -1
  54. package/dist/hooks/tenant-export.js +10 -9
  55. package/dist/hooks/tenant-export.js.map +1 -1
  56. package/dist/pages/templates.js +3 -1
  57. package/dist/pages/templates.js.map +1 -1
  58. package/dist/routes/events.js +4 -2
  59. package/dist/routes/events.js.map +1 -1
  60. package/dist/routes/index.d.ts +1 -0
  61. package/dist/routes/index.js +1 -0
  62. package/dist/routes/index.js.map +1 -1
  63. package/dist/routes/metrics.js +27 -28
  64. package/dist/routes/metrics.js.map +1 -1
  65. package/dist/routes/period.js +5 -5
  66. package/dist/routes/period.js.map +1 -1
  67. package/dist/routes/sends.js +3 -4
  68. package/dist/routes/sends.js.map +1 -1
  69. package/dist/routes/settings.d.ts +1 -1
  70. package/dist/routes/settings.js +12 -10
  71. package/dist/routes/settings.js.map +1 -1
  72. package/dist/routes/suppressions.js +9 -6
  73. package/dist/routes/suppressions.js.map +1 -1
  74. package/dist/routes/template-content.d.ts +27 -0
  75. package/dist/routes/template-content.js +3 -0
  76. package/dist/routes/template-content.js.map +1 -0
  77. package/dist/routes/templates.d.ts +7 -30
  78. package/dist/routes/templates.js +3 -2
  79. package/dist/routes/templates.js.map +1 -1
  80. package/dist/routes/zod-json.d.ts +3 -0
  81. package/dist/routes/zod-json.js +21 -0
  82. package/dist/routes/zod-json.js.map +1 -0
  83. package/dist/services/attention.js +14 -7
  84. package/dist/services/attention.js.map +1 -1
  85. package/dist/services/catalog.js +4 -2
  86. package/dist/services/catalog.js.map +1 -1
  87. package/dist/services/declaration-error.d.ts +1 -1
  88. package/dist/services/declaration-error.js.map +1 -1
  89. package/dist/services/declarations.js +4 -1
  90. package/dist/services/declarations.js.map +1 -1
  91. package/dist/services/events.js +3 -1
  92. package/dist/services/events.js.map +1 -1
  93. package/dist/services/metrics.js +2 -5
  94. package/dist/services/metrics.js.map +1 -1
  95. package/dist/services/send.js +3 -4
  96. package/dist/services/send.js.map +1 -1
  97. package/dist/services/settings.d.ts +4 -2
  98. package/dist/services/settings.js +5 -4
  99. package/dist/services/settings.js.map +1 -1
  100. package/dist/services/starters.d.ts +3 -2
  101. package/dist/services/starters.js +1 -1
  102. package/dist/services/starters.js.map +1 -1
  103. package/dist/services/templates.d.ts +2 -1
  104. package/dist/services/templates.js +5 -5
  105. package/dist/services/templates.js.map +1 -1
  106. package/dist/services/webhook-status.d.ts +2 -2
  107. package/dist/services/webhook-status.js +6 -4
  108. package/dist/services/webhook-status.js.map +1 -1
  109. package/dist/types/json.d.ts +1 -0
  110. package/dist/types/json.js +3 -0
  111. package/dist/types/json.js.map +1 -0
  112. package/dist/types/settings.d.ts +2 -2
  113. package/dist/validation/declarations.schema.d.ts +28 -28
  114. package/dist/validation/declarations.schema.js +4 -3
  115. package/dist/validation/declarations.schema.js.map +1 -1
  116. package/dist/validation/guards.d.ts +0 -1
  117. package/dist/validation/guards.js +0 -4
  118. package/dist/validation/guards.js.map +1 -1
  119. package/dist/validation/json.d.ts +5 -0
  120. package/dist/validation/json.js +54 -0
  121. package/dist/validation/json.js.map +1 -0
  122. package/dist/validation/json.schema.d.ts +2 -0
  123. package/dist/validation/json.schema.js +9 -0
  124. package/dist/validation/json.schema.js.map +1 -0
  125. package/dist/validation/templates.schema.d.ts +25 -24
  126. package/dist/validation/templates.schema.js +4 -3
  127. package/dist/validation/templates.schema.js.map +1 -1
  128. package/frontend-vue/package.json +1 -1
  129. package/frontend-vue/pnpm-lock.yaml +44 -4
  130. package/package.json +16 -15
  131. package/skills/dms-mailing/SKILL.md +3 -3
  132. package/src/automation/email-event-trigger.ts +2 -2
  133. package/src/automation/send-template-action.ts +33 -14
  134. package/src/data/body.ts +5 -2
  135. package/src/data/templates-table.ts +46 -52
  136. package/src/data-types/categories-type.ts +2 -2
  137. package/src/data-types/category-type.ts +2 -2
  138. package/src/db/models/sends.model.ts +2 -2
  139. package/src/db/models/template-versions.model.ts +5 -3
  140. package/src/db/tables/templates.table.ts +1 -1
  141. package/src/engine/changes.ts +11 -29
  142. package/src/engine/conditions.ts +12 -4
  143. package/src/engine/coverage.ts +2 -1
  144. package/src/engine/formats.ts +31 -17
  145. package/src/engine/paths.ts +39 -6
  146. package/src/engine/plain-text.ts +2 -4
  147. package/src/engine/resolve.ts +12 -4
  148. package/src/engine/system-variables.ts +2 -1
  149. package/src/engine/tokens.ts +2 -1
  150. package/src/hooks/tenant-export.ts +11 -13
  151. package/src/pages/templates.ts +3 -1
  152. package/src/routes/events.ts +7 -4
  153. package/src/routes/index.ts +1 -0
  154. package/src/routes/metrics.ts +30 -30
  155. package/src/routes/period.ts +5 -5
  156. package/src/routes/sends.ts +13 -8
  157. package/src/routes/settings.ts +15 -10
  158. package/src/routes/suppressions.ts +12 -8
  159. package/src/routes/template-content.ts +45 -0
  160. package/src/routes/templates.ts +11 -44
  161. package/src/routes/zod-json.ts +28 -0
  162. package/src/services/attention.ts +16 -15
  163. package/src/services/catalog.ts +4 -8
  164. package/src/services/declaration-error.ts +1 -1
  165. package/src/services/declarations.ts +4 -1
  166. package/src/services/events.ts +4 -2
  167. package/src/services/metrics.ts +3 -5
  168. package/src/services/send.ts +3 -6
  169. package/src/services/settings.ts +17 -16
  170. package/src/services/starters.ts +8 -4
  171. package/src/services/templates.ts +16 -9
  172. package/src/services/webhook-status.ts +13 -8
  173. package/src/types/json.ts +1 -0
  174. package/src/types/settings.ts +2 -2
  175. package/src/validation/declarations.schema.ts +4 -3
  176. package/src/validation/guards.ts +0 -8
  177. package/src/validation/json.schema.ts +7 -0
  178. package/src/validation/json.ts +66 -0
  179. package/src/validation/templates.schema.ts +7 -3
@@ -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
 
@@ -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: {
@@ -18,13 +18,16 @@ import {
18
18
  WEBHOOK_SECRET_HEADER,
19
19
  } from "../services/webhook-status";
20
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";
21
24
  import { sendEventTypeSchema } from "../validation/send-event-type.schema";
22
25
 
23
26
  const eventSchema = z.object({
24
27
  messageId: z.string().min(1),
25
28
  type: sendEventTypeSchema,
26
29
  at: z.coerce.date().optional(),
27
- details: z.record(z.unknown()).optional(),
30
+ details: jsonObjectSchema.optional(),
28
31
  });
29
32
 
30
33
  type ParsedEvent = z.infer<typeof eventSchema>;
@@ -38,7 +41,7 @@ const STATUS_OF_REFUSAL: Record<WebhookRefusal, number> = {
38
41
  interface ReceivedEvent {
39
42
  provider: string;
40
43
  secret: string | undefined;
41
- body: unknown;
44
+ body: JsonValue | undefined;
42
45
  /** The tenant the URL names. */
43
46
  tenantId: string;
44
47
  }
@@ -54,9 +57,9 @@ export interface WebhookResponse {
54
57
  * The body as JSON, `undefined` when it is not: a malformed body is refused
55
58
  * as an invalid event, after the secret, never answered with a parser error.
56
59
  */
57
- function parseBody(raw: Buffer): unknown {
60
+ function parseBody(raw: Buffer): JsonValue | undefined {
58
61
  try {
59
- return JSON.parse(raw.toString("utf8"));
62
+ return parseJson(raw.toString("utf8"));
60
63
  } catch {
61
64
  return undefined;
62
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";
@@ -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"]);
@@ -88,14 +88,17 @@ const OPEN_RATE_METRIC: TopMetric = {
88
88
  minDeliveries: MIN_RANKED_DELIVERIES,
89
89
  };
90
90
 
91
- const TOP_METRICS: Record<string, TopMetric> = {
92
- "open-rate": OPEN_RATE_METRIC,
93
- "click-rate": {
94
- extract: (totals) => totals.clickRate,
95
- minDeliveries: MIN_RANKED_DELIVERIES,
96
- },
97
- sends: { extract: (totals) => totals.sends, minDeliveries: 0 },
98
- };
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
+ ]);
99
102
  const DEFAULT_TOP_METRIC = OPEN_RATE_METRIC;
100
103
 
101
104
  interface FunnelStepDefinition {
@@ -225,7 +228,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
225
228
  async kpi(
226
229
  @Parameter("metric", "param") metric: string,
227
230
  ): Promise<KpiCardData> {
228
- const extract = KPI_EXTRACTORS[metric];
231
+ const extract = KPI_EXTRACTORS.get(metric);
229
232
  assert(extract, HTTP_NOT_FOUND, UNKNOWN_METRIC);
230
233
  const range = currentRange(this.ctx);
231
234
  const [current, previous, tracking] = await Promise.all([
@@ -254,7 +257,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
254
257
  @Get("/top-templates")
255
258
  async topTemplates() {
256
259
  const metric =
257
- TOP_METRICS[this.ctx.url.searchParams.get("metric") ?? ""] ??
260
+ TOP_METRICS.get(this.ctx.url.searchParams.get("metric") ?? "") ??
258
261
  DEFAULT_TOP_METRIC;
259
262
  const range = currentRange(this.ctx);
260
263
  const [sends, previous, templates] = await Promise.all([
@@ -469,22 +472,19 @@ export function comparedSteps(
469
472
  /** Which sends the funnel counts: all, or one side of the marketing split. */
470
473
  export type FunnelKind = "all" | "marketing" | "transactional";
471
474
 
472
- const FUNNEL_KINDS: Record<string, FunnelKind> = {
473
- all: "all",
474
- marketing: "marketing",
475
- transactional: "transactional",
476
- };
477
-
478
- export function readFunnelKind(raw: string | null): FunnelKind {
479
- return FUNNEL_KINDS[raw ?? ""] ?? "all";
480
- }
481
-
482
475
  const KIND_KEEPS: Record<FunnelKind, (category: string) => boolean> = {
483
476
  all: () => true,
484
477
  marketing: (category) => isMarketingCategory(category),
485
478
  transactional: (category) => !isMarketingCategory(category),
486
479
  };
487
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
+
488
488
  /**
489
489
  * Sends of the templates on one side of the split. A template's category
490
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 {
@@ -69,6 +69,7 @@ import {
69
69
  snapshotOfVersion,
70
70
  } from "../services/templates";
71
71
  import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
72
+ import type { JsonObject } from "../types/json";
72
73
  import {
73
74
  type previewVersionSchema,
74
75
  realSendSchema,
@@ -184,10 +185,10 @@ const sameAddress = (left: string, right: string): boolean =>
184
185
  normalizeAddress(left) === normalizeAddress(right);
185
186
 
186
187
  /** A bad address gets its own message; any other invalid field is the content's. */
187
- function sendInputError(error: unknown): string {
188
- const isRecipient =
189
- error instanceof ZodError &&
190
- 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
+ );
191
192
  return isRecipient ? INVALID_RECIPIENT : INVALID_CONTENT;
192
193
  }
193
194
 
@@ -255,7 +256,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
255
256
  const input = assertValidation(
256
257
  body,
257
258
  (value) => testSendSchema.parse(value),
258
- sendInputError,
259
+ (error) =>
260
+ error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
259
261
  );
260
262
  const template = await requireTemplate(this.templates, id);
261
263
  const ownAddressSuppressed = await this.ownSuppression(input.to);
@@ -301,7 +303,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
301
303
  const input = assertValidation(
302
304
  body,
303
305
  (value) => realSendSchema.parse(value),
304
- sendInputError,
306
+ (error) =>
307
+ error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
305
308
  );
306
309
  const template = await requireTemplate(this.templates, id);
307
310
  const result = await this.send(
@@ -574,8 +577,10 @@ function requestedVersionOf(
574
577
  return version === "draft" || version === "published" ? undefined : version;
575
578
  }
576
579
 
577
- function variablesOf(send: MailingSend): Record<string, unknown> {
578
- 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;
579
584
  }
580
585
 
581
586
  function summarize(template: MailingTemplate): SendTemplateSummary {
@@ -48,7 +48,8 @@ import {
48
48
  settingsPatchSchema,
49
49
  } from "../validation/settings.schema";
50
50
  import { retentionLimit } from "../crons/retention";
51
- import { isJsonObject } from "../validation/guards";
51
+ import { isJsonObject } from "../validation/json";
52
+ import { zodIssuesJson } from "./zod-json";
52
53
 
53
54
  const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
54
55
  const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
@@ -90,7 +91,7 @@ export interface MailingSettingsResponse extends MailingSettingsValues {
90
91
  export interface SettingsNotice {
91
92
  title: string;
92
93
  color?: string;
93
- params?: Record<string, unknown>;
94
+ params?: Record<string, number | string>;
94
95
  }
95
96
 
96
97
  export interface SettingsWriteResponse extends MailingSettingsValues {
@@ -147,9 +148,11 @@ function formIssue(issue: z.ZodIssue): z.ZodIssue {
147
148
  }
148
149
 
149
150
  /** A refused write: the generic message, and each issue for its field. */
150
- function invalidSettings(error: unknown) {
151
- if (!(error instanceof z.ZodError)) return INVALID_SETTINGS;
152
- 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
+ };
153
156
  }
154
157
 
155
158
  function senderNotice(values: MailingSettingsValues): SettingsNotice[] {
@@ -241,7 +244,8 @@ export class MailingSettingsController extends Controller(
241
244
  return assertValidation(
242
245
  clearedToEmpty(body),
243
246
  (value) => settingsPatchSchema.parse(value),
244
- invalidSettings,
247
+ (error) =>
248
+ error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
245
249
  );
246
250
  }
247
251
 
@@ -295,7 +299,8 @@ export class MailingSettingsController extends Controller(
295
299
  return assertValidation(
296
300
  raw,
297
301
  (value) => retentionDaysSchema.parse(value),
298
- invalidSettings,
302
+ (error) =>
303
+ error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
299
304
  );
300
305
  }
301
306
 
@@ -306,12 +311,12 @@ export class MailingSettingsController extends Controller(
306
311
  @Get("/category-usage")
307
312
  async categoryUsage(): Promise<{ counts: Record<string, number> }> {
308
313
  const templates = await this.templates.getAll();
309
- const counts: Record<string, number> = {};
314
+ const counts = new Map<string, number>();
310
315
  for (const template of templates) {
311
316
  if (!template.category || template.status === ARCHIVED_STATUS) continue;
312
- counts[template.category] = (counts[template.category] ?? 0) + 1;
317
+ counts.set(template.category, (counts.get(template.category) ?? 0) + 1);
313
318
  }
314
- return { counts };
319
+ return { counts: Object.fromEntries(counts) };
315
320
  }
316
321
 
317
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) {
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,
@@ -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
+ }
@@ -375,20 +375,21 @@ type TemplateAttentionRule = (
375
375
  context: TemplateAttentionContext,
376
376
  ) => boolean;
377
377
 
378
- const TEMPLATE_ATTENTION_RULES: Record<
378
+ const TEMPLATE_ATTENTION_RULES = new Map<
379
379
  TemplateAttentionReason,
380
380
  TemplateAttentionRule
381
- > = {
382
- stale_draft: (template, context) => isStaleDraft(template, context.now),
383
- never_sent: (template, context) =>
384
- template.status === "live" && !context.sentSlugs.has(template.slug),
385
- missing_locale: (template, context) =>
386
- isMissingLocale(template, context.knownLocales),
387
- };
388
-
389
- const TEMPLATE_ATTENTION_REASONS = Object.keys(
390
- TEMPLATE_ATTENTION_RULES,
391
- ) 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
+ ]);
392
393
 
393
394
  /**
394
395
  * Why a template needs a look, in a stable order: a draft never published and
@@ -399,7 +400,7 @@ export function templateAttentionReasons(
399
400
  template: MailingTemplate,
400
401
  context: TemplateAttentionContext,
401
402
  ): TemplateAttentionReason[] {
402
- return TEMPLATE_ATTENTION_REASONS.filter((reason) =>
403
- TEMPLATE_ATTENTION_RULES[reason](template, context),
404
- );
403
+ return [...TEMPLATE_ATTENTION_RULES]
404
+ .filter(([, applies]) => applies(template, context))
405
+ .map(([reason]) => reason);
405
406
  }
@@ -1,21 +1,17 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ModuleStatus } from "@antelopejs/interface-dms/page";
4
+ import { isText } from "../validation/guards";
5
+ import { parseJsonObject } from "../validation/json";
4
6
  import { readCapabilities } from "./provider";
5
7
 
6
8
  const PACKAGE_JSON_PATH = path.join(__dirname, "../../package.json");
7
9
 
8
- interface PackageManifest {
9
- version?: string;
10
- }
11
-
12
10
  /** The module's published version, as the catalog tile and the sidebar show it. */
13
11
  export function moduleVersion(): string | undefined {
14
12
  try {
15
- const manifest = JSON.parse(
16
- readFileSync(PACKAGE_JSON_PATH, "utf8"),
17
- ) as PackageManifest;
18
- return manifest.version;
13
+ const manifest = parseJsonObject(readFileSync(PACKAGE_JSON_PATH, "utf8"));
14
+ return isText(manifest.version) ? manifest.version : undefined;
19
15
  } catch {
20
16
  return undefined;
21
17
  }
@@ -26,7 +26,7 @@ function describeIssues(error: ZodError): string {
26
26
  /** `input` parsed by `schema`, or a `DeclarationError` under `code`. */
27
27
  export function parseDeclared<Output, Input>(
28
28
  schema: ZodType<Output, ZodTypeDef, Input>,
29
- input: unknown,
29
+ input: Input,
30
30
  code: string,
31
31
  subject: string,
32
32
  ): Output {