@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
@@ -133,7 +133,10 @@ export async function declareTemplate(
133
133
  assertKnownCategory(settings.categories, declaration.category, slug);
134
134
  const created = await createDeclared(declaration);
135
135
  if (created) return resultOf(created, "created");
136
- return resultOf((await model.getBySlug(slug)) as MailingTemplate, "kept");
136
+ const winner = await model.getBySlug(slug);
137
+ if (!winner)
138
+ throw new Error(`Template "${slug}" disappeared before it was read`);
139
+ return resultOf(winner, "kept");
137
140
  }
138
141
 
139
142
  /** Category declarations of one tenant run one after the other. */
@@ -16,7 +16,8 @@ import { publishSendUpdated } from "../realtime";
16
16
  import type { SendEventType, SendStatus } from "../types";
17
17
  import { notifyProblems } from "./notify";
18
18
  import { suppressAddress, suppressionReasonOf } from "./suppressions";
19
- import { isObject, isText } from "../validation/guards";
19
+ import { isText } from "../validation/guards";
20
+ import { isJsonObject } from "../validation/json";
20
21
 
21
22
  export interface SendCounters {
22
23
  status: SendStatus;
@@ -169,7 +170,8 @@ export function isSameEvent(
169
170
  if (eventId) {
170
171
  const storedDetails: unknown = JSON.parse(stored.json_details || "{}");
171
172
  return (
172
- eventIdOf(isObject(storedDetails) ? storedDetails : undefined) === eventId
173
+ eventIdOf(isJsonObject(storedDetails) ? storedDetails : undefined) ===
174
+ eventId
173
175
  );
174
176
  }
175
177
  if (!event.at || stored.type !== event.type) return false;
@@ -11,14 +11,12 @@ const AUDIENCE_KEEPS: Record<SendAudience, (send: SendSummary) => boolean> = {
11
11
  [OPERATIONAL_AUDIENCE]: () => true,
12
12
  };
13
13
 
14
- const AUDIENCE_BY_NAME: Record<string, SendAudience> = {
15
- [BUSINESS_AUDIENCE]: BUSINESS_AUDIENCE,
16
- [OPERATIONAL_AUDIENCE]: OPERATIONAL_AUDIENCE,
17
- };
14
+ const isAudience = (raw: string): raw is SendAudience =>
15
+ Object.hasOwn(AUDIENCE_KEEPS, raw);
18
16
 
19
17
  /** Named audience behind a query value; anything unknown is the business view. */
20
18
  export function readAudience(raw: string | null | undefined): SendAudience {
21
- return AUDIENCE_BY_NAME[raw ?? ""] ?? BUSINESS_AUDIENCE;
19
+ return raw != null && isAudience(raw) ? raw : BUSINESS_AUDIENCE;
22
20
  }
23
21
 
24
22
  const STATS_WINDOW_DAYS = 30;
@@ -159,9 +159,6 @@ const OUTCOME_SEVERITY: Record<SendStatus, number> = {
159
159
  failed: 9,
160
160
  };
161
161
 
162
- const describeError = (error: unknown): string =>
163
- error instanceof Error ? error.message : String(error);
164
-
165
162
  const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
166
163
 
167
164
  const nameOf = (to: EmailAddress): string | undefined =>
@@ -376,7 +373,7 @@ async function deliverOne(
376
373
  status: "failed",
377
374
  outcome: {
378
375
  latencyMs: Date.now() - startedAt,
379
- error: describeError(error),
376
+ error: error instanceof Error ? error.message : String(error),
380
377
  },
381
378
  };
382
379
  }
@@ -423,7 +420,7 @@ export async function deliverBatch(
423
420
  return failAll(
424
421
  input.recipients,
425
422
  Date.now() - startedAt,
426
- describeError(error),
423
+ error instanceof Error ? error.message : String(error),
427
424
  );
428
425
  }
429
426
  }
@@ -504,7 +501,7 @@ async function recordOne(
504
501
  sendId: UNRECORDED_SEND_ID,
505
502
  status: "failed",
506
503
  messageId: message.outcome.providerMessageId,
507
- error: describeError(error),
504
+ error: error instanceof Error ? error.message : String(error),
508
505
  };
509
506
  }
510
507
  }
@@ -6,6 +6,8 @@ import {
6
6
  type MailingSettingsValues,
7
7
  type TemplateCategory,
8
8
  } from "../types";
9
+ import type { JsonObject } from "../types/json";
10
+ import type { SettingsPatch } from "../validation/settings.schema";
9
11
 
10
12
  export const WEBHOOK_SECRET_BYTES = 24;
11
13
 
@@ -28,6 +30,8 @@ function toValues(row: MailingSettings): MailingSettingsValues {
28
30
  senderEmail: row.senderEmail,
29
31
  replyTo: row.replyTo,
30
32
  webhookSecret: row.webhookSecret,
33
+ // SAFETY: json_categories is written only by toRow as the JSON.stringify of categories that categoriesSchema
34
+ // validated (settingsPatchSchema, applyCategory) or that the module defaults supplied.
31
35
  categories: JSON.parse(row.json_categories) as TemplateCategory[],
32
36
  };
33
37
  }
@@ -71,7 +75,12 @@ export async function getSettings(
71
75
  if (existing) return existing;
72
76
  const created = defaults();
73
77
  if (await insertKeyedSettings(tenantId, created)) return created;
74
- return (await findSettings(tenantId)) as MailingSettingsValues;
78
+ const winner = await findSettings(tenantId);
79
+ if (!winner)
80
+ throw new Error(
81
+ `The settings of tenant "${tenantId}" disappeared before they were read`,
82
+ );
83
+ return winner;
75
84
  }
76
85
 
77
86
  export async function saveSettings(
@@ -105,36 +114,28 @@ export function missingVariablesPolicy(
105
114
  }
106
115
 
107
116
  /** The form-only field folded back into `blockOnMissingVariables`. */
108
- const MISSING_VARIABLES_KEY = "missingVariables";
109
117
  const CLEARED_VALUE = "";
110
118
  /** The patch with each cleared field (`null`) as the empty value it stores. */
111
- export function clearedToEmpty(
112
- patch: Record<string, unknown>,
113
- ): Record<string, unknown> {
119
+ export function clearedToEmpty(patch: JsonObject): JsonObject {
114
120
  return Object.fromEntries(
115
121
  Object.entries(patch).map(([key, value]) => [key, value ?? CLEARED_VALUE]),
116
122
  );
117
123
  }
118
124
 
119
125
  /**
120
- * The settings once `patch` (the fields a form changed, `null` for a cleared
121
- * one) is laid over the stored ones, the missing-variables choice folded back
122
- * into its boolean.
126
+ * The settings once `patch` (the fields a form changed, cleared ones already
127
+ * stored as empty) is laid over the stored ones, the missing-variables choice
128
+ * folded back into its boolean.
123
129
  */
124
130
  export function mergeSettingsPatch(
125
131
  current: MailingSettingsValues,
126
- patch: Record<string, unknown>,
132
+ patch: SettingsPatch,
127
133
  ): MailingSettingsValues {
128
- const written = Object.fromEntries(
129
- Object.entries(clearedToEmpty(patch)).filter(
130
- ([key]) => key !== MISSING_VARIABLES_KEY,
131
- ),
132
- );
133
- const policy = patch.missingVariables;
134
+ const { missingVariables: policy, ...written } = patch;
134
135
  const isChoice = policy === REFUSE_POLICY || policy === SEND_POLICY;
135
136
  const guard =
136
137
  isChoice && !("blockOnMissingVariables" in patch)
137
138
  ? { blockOnMissingVariables: policy === REFUSE_POLICY }
138
139
  : {};
139
- return { ...current, ...written, ...guard } as MailingSettingsValues;
140
+ return { ...current, ...written, ...guard };
140
141
  }
@@ -5,6 +5,7 @@ import type {
5
5
  TemplateContent,
6
6
  VariableDefinition,
7
7
  } from "../types";
8
+ import type { JsonObject } from "../types/json";
8
9
  import { footerForCategory, formattingLocale } from "../engine";
9
10
  import { blankFields, footerBlock } from "./templates";
10
11
 
@@ -21,9 +22,9 @@ export interface StarterTemplate {
21
22
  content: TemplateContent;
22
23
  variables: VariableDefinition[];
23
24
  /** The test data as an English-speaking workspace gets it. */
24
- testData: Record<string, unknown>;
25
+ testData: JsonObject;
25
26
  /** The test data with its amounts written the way `locale` writes them. */
26
- testDataFor: (locale: string) => Record<string, unknown>;
27
+ testDataFor: (locale: string) => JsonObject;
27
28
  }
28
29
 
29
30
  type StarterSource = Omit<StarterTemplate, "testData">;
@@ -39,9 +40,12 @@ const ORDER_LINE_NAMES = new Map([
39
40
  const LOCALE_PARTS = /[-_]/;
40
41
 
41
42
  /** A test text in the language of `locale`, English when it has none. */
42
- function inLanguageOf(texts: ReadonlyMap<string, string>, locale: string) {
43
+ function inLanguageOf(
44
+ texts: ReadonlyMap<string, string>,
45
+ locale: string,
46
+ ): string {
43
47
  const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
44
- return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE);
48
+ return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE) ?? "";
45
49
  }
46
50
 
47
51
  /** An amount the way `locale` writes it: "€1,556.00", "1 556,00 €". */
@@ -27,6 +27,7 @@ import type {
27
27
  VariableDefinition,
28
28
  } from "../types";
29
29
  import { DEFAULT_FALLBACK_LOCALE } from "../types";
30
+ import type { JsonObject } from "../types/json";
30
31
 
31
32
  export interface TemplateAuthor {
32
33
  name?: string;
@@ -73,6 +74,7 @@ export async function refuseTakenSlug<T>(write: Promise<T>): Promise<T> {
73
74
 
74
75
  /** The content customers receive: the last published version. */
75
76
  export function parseContent(template: MailingTemplate): TemplateContent {
77
+ // SAFETY: json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
76
78
  return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;
77
79
  }
78
80
 
@@ -83,6 +85,7 @@ export function hasDraft(template: MailingTemplate): boolean {
83
85
  /** The content the editor works on: the draft, or the published content. */
84
86
  export function workingContent(template: MailingTemplate): TemplateContent {
85
87
  if (!template.json_draft) return parseContent(template);
88
+ // SAFETY: json_draft is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
86
89
  return JSON.parse(template.json_draft) as TemplateContent;
87
90
  }
88
91
 
@@ -102,6 +105,7 @@ export function isPublished(template: MailingTemplate): boolean {
102
105
  export function parseVariables(
103
106
  template: MailingTemplate,
104
107
  ): VariableDefinition[] {
108
+ // SAFETY: json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
105
109
  return JSON.parse(template.json_variables) as VariableDefinition[];
106
110
  }
107
111
 
@@ -114,6 +118,7 @@ export function workingVariables(
114
118
  ): VariableDefinition[] {
115
119
  if (!hasDraft(template) || !template.json_variables_draft)
116
120
  return parseVariables(template);
121
+ // SAFETY: json_variables_draft is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
117
122
  return JSON.parse(template.json_variables_draft) as VariableDefinition[];
118
123
  }
119
124
 
@@ -169,10 +174,9 @@ export function pendingChanges(template: MailingTemplate): TemplateChange[] {
169
174
  });
170
175
  }
171
176
 
172
- export function parseTestData(
173
- template: MailingTemplate,
174
- ): Record<string, unknown> {
175
- return JSON.parse(template.json_test_data) as Record<string, unknown>;
177
+ export function parseTestData(template: MailingTemplate): JsonObject {
178
+ // SAFETY: json_test_data is written only as JSON.stringify of an object that its route or declaration schema validated, or the module built.
179
+ return JSON.parse(template.json_test_data) as JsonObject;
176
180
  }
177
181
 
178
182
  interface FooterLabels {
@@ -190,16 +194,16 @@ const ENGLISH_FOOTER_LABELS: FooterLabels = {
190
194
  * e-mail without an unsubscribe link is a legal problem, so a blank template
191
195
  * ships a footer — and nothing else: no invented subject, prose or address.
192
196
  */
193
- const FOOTER_LABELS: Record<string, FooterLabels> = {
194
- en: ENGLISH_FOOTER_LABELS,
195
- fr: { unsubscribe: "Se désinscrire", preferences: "Préférences" },
196
- };
197
+ const FOOTER_LABELS = new Map<string, FooterLabels>([
198
+ ["en", ENGLISH_FOOTER_LABELS],
199
+ ["fr", { unsubscribe: "Se désinscrire", preferences: "Préférences" }],
200
+ ]);
197
201
 
198
202
  const FOOTER_BLOCK_ID = "footer";
199
203
 
200
204
  /** A footer in `locale`: its legal line and the labels of its two links. */
201
205
  export function footerBlock(locale: string, text = ""): Block {
202
- const labels = FOOTER_LABELS[locale] ?? ENGLISH_FOOTER_LABELS;
206
+ const labels = FOOTER_LABELS.get(locale) ?? ENGLISH_FOOTER_LABELS;
203
207
  return {
204
208
  id: FOOTER_BLOCK_ID,
205
209
  type: "footer",
@@ -293,6 +297,7 @@ export async function discardDraft(
293
297
  export function parseVersionChanges(
294
298
  row: MailingTemplateVersion,
295
299
  ): TemplateChange[] {
300
+ // SAFETY: json_changes is written only as JSON.stringify of the TemplateChange[] from diffContents and diffVariables.
296
301
  return JSON.parse(row.json_changes) as TemplateChange[];
297
302
  }
298
303
 
@@ -300,6 +305,7 @@ export function parseVersionChanges(
300
305
  export function parseVersionVariables(
301
306
  row: MailingTemplateVersion,
302
307
  ): VariableDefinition[] {
308
+ // SAFETY: a version row's json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
303
309
  return JSON.parse(row.json_variables) as VariableDefinition[];
304
310
  }
305
311
 
@@ -455,6 +461,7 @@ export async function contentOfVersion(
455
461
  export function parseVersionContent(
456
462
  row: MailingTemplateVersion,
457
463
  ): TemplateContent {
464
+ // SAFETY: a version row's json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated.
458
465
  return JSON.parse(row.json_content) as TemplateContent;
459
466
  }
460
467
 
@@ -7,6 +7,7 @@ import type {
7
7
  WebhookRefusal,
8
8
  WebhookStatus,
9
9
  } from "../types";
10
+ import { isObject, isText } from "../validation/guards";
10
11
  import { webhookSecretMatches } from "./events";
11
12
  import { WEBHOOK_SECRET_HEADER } from "../constants";
12
13
 
@@ -28,11 +29,11 @@ export const WEBHOOK_REFUSAL_MESSAGES: Record<WebhookRefusal, string> = {
28
29
  };
29
30
 
30
31
  /** The body of a refused webhook call. */
31
- export interface WebhookRefusalBody {
32
+ export type WebhookRefusalBody = {
32
33
  error: "webhook_refused";
33
34
  reason: WebhookRefusal;
34
35
  message: string;
35
- }
36
+ };
36
37
 
37
38
  /** What "Send a test event" found when it called the webhook URL. */
38
39
  export interface WebhookTestOutcome {
@@ -143,15 +144,13 @@ export async function readWebhookStatus(
143
144
  );
144
145
  }
145
146
 
146
- const describeError = (error: unknown): string =>
147
- error instanceof Error
148
- ? String((error.cause as Error | undefined)?.message ?? error.message)
149
- : String(error);
150
-
151
147
  async function refusalOf(
152
148
  response: Response,
153
149
  ): Promise<WebhookRefusal | undefined> {
154
150
  try {
151
+ // SAFETY: Response.json() answers any, and the URL under test is this module's own webhook route, which refuses
152
+ // with refusalBody(reason); another endpoint can answer any JSON, which is read the same way, and a non-object
153
+ // reads as no refusal (only null throws, into the catch below).
155
154
  const body = (await response.json()) as Partial<WebhookRefusalBody>;
156
155
  return body.reason;
157
156
  } catch {
@@ -192,11 +191,17 @@ export async function sendWebhookTest(
192
191
  refusal: response.ok ? undefined : await refusalOf(response),
193
192
  };
194
193
  } catch (error) {
194
+ const cause = error instanceof Error ? error.cause : undefined;
195
+ const causeMessage =
196
+ isObject(cause) && isText(cause.message) ? cause.message : undefined;
195
197
  return {
196
198
  isReceived: false,
197
199
  url,
198
200
  durationMs: elapsed(),
199
- networkError: describeError(error),
201
+ networkError:
202
+ error instanceof Error
203
+ ? (causeMessage ?? error.message)
204
+ : String(error),
200
205
  };
201
206
  }
202
207
  }
@@ -0,0 +1 @@
1
+ export type { JsonObject, JsonValue } from "@antelopejs/interface-dms-mailing";
@@ -1,11 +1,11 @@
1
1
  import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
2
2
 
3
3
  /** A template category as stored: its label written once per language. */
4
- export interface TemplateCategory {
4
+ export type TemplateCategory = {
5
5
  id: string;
6
6
  label: LocalizedText;
7
7
  icon: string;
8
- }
8
+ };
9
9
 
10
10
  /** A template category as one reader sees it: the label in their language. */
11
11
  export interface CategoryInLocale {
@@ -8,6 +8,7 @@ import {
8
8
  templateContentSchema,
9
9
  variableListSchema,
10
10
  } from "./templates.schema";
11
+ import { jsonObjectSchema } from "./json.schema";
11
12
 
12
13
  const MAX_AUTHOR_LENGTH = MAX_NAME_LENGTH;
13
14
  const MAX_IMPORT_ID_LENGTH = 200;
@@ -36,7 +37,7 @@ export const templateDeclarationSchema = z.object({
36
37
  category: z.string().trim().default(""),
37
38
  content: templateContentSchema,
38
39
  variables: variableListSchema.default([]),
39
- testData: z.record(z.unknown()).default({}),
40
+ testData: jsonObjectSchema.default({}),
40
41
  author: z.string().trim().min(1).max(MAX_AUTHOR_LENGTH).optional(),
41
42
  });
42
43
 
@@ -78,7 +79,7 @@ const recipientSchema = z.union([
78
79
  const importedEventSchema = z.object({
79
80
  type: sendEventTypeSchema,
80
81
  at: z.coerce.date(),
81
- details: z.record(z.unknown()).optional(),
82
+ details: jsonObjectSchema.optional(),
82
83
  });
83
84
 
84
85
  const importedSendSchema = z.object({
@@ -87,7 +88,7 @@ const importedSendSchema = z.object({
87
88
  to: recipientSchema,
88
89
  sentAt: z.coerce.date(),
89
90
  locale: z.string().trim().min(1).optional(),
90
- variables: z.record(z.unknown()).default({}),
91
+ variables: jsonObjectSchema.default({}),
91
92
  provider: z.string().optional(),
92
93
  providerMessageId: z.string().optional(),
93
94
  source: z.string().optional(),
@@ -3,14 +3,6 @@ export function isText(value: unknown): value is string {
3
3
  return typeof value === "string";
4
4
  }
5
5
 
6
- /**
7
- * Whether `value` is a JSON object as a parsed body holds one: an object,
8
- * neither `null` nor an array. Its values are not checked.
9
- */
10
- export function isJsonObject(value: unknown): value is Record<string, unknown> {
11
- return typeof value === "object" && value !== null && !Array.isArray(value);
12
- }
13
-
14
6
  /**
15
7
  * Whether `value` is an object, arrays included, and not `null`: reading any
16
8
  * key of it yields a value of unknown type.
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ import { isJsonObject } from "./json";
3
+
4
+ /** A JSON object whose every value is JSON data, such as a request body carries. */
5
+ export const jsonObjectSchema = z
6
+ .unknown()
7
+ .refine(isJsonObject, "expected a JSON object");
@@ -0,0 +1,66 @@
1
+ import type { JsonObject, JsonValue } from "../types/json";
2
+ import { isNumber, isObject, isText } from "./guards";
3
+
4
+ /** A step of the walk in {@link isJsonValue}: visit a node, or leave one. */
5
+ type JsonWalkStep = { visit: unknown } | { leave: object };
6
+
7
+ /**
8
+ * Whether `value` is wholly JSON data: strings, numbers, booleans, null, and
9
+ * arrays and plain objects of the same, none of them reached through itself.
10
+ * Walked with a stack, so a deeply nested value cannot overflow it.
11
+ */
12
+ export function isJsonValue(value: unknown): value is JsonValue {
13
+ const stack: JsonWalkStep[] = [{ visit: value }];
14
+ const ancestors = new Set<object>();
15
+ for (let step = stack.pop(); step; step = stack.pop()) {
16
+ if ("leave" in step) {
17
+ ancestors.delete(step.leave);
18
+ continue;
19
+ }
20
+ const item = step.visit;
21
+ if (
22
+ item === null ||
23
+ isText(item) ||
24
+ isNumber(item) ||
25
+ typeof item === "boolean"
26
+ )
27
+ continue;
28
+ if (typeof item !== "object" || ancestors.has(item)) return false;
29
+ const prototype: unknown = Object.getPrototypeOf(item);
30
+ if (
31
+ !Array.isArray(item) &&
32
+ prototype !== Object.prototype &&
33
+ prototype !== null
34
+ )
35
+ return false;
36
+ ancestors.add(item);
37
+ stack.push({ leave: item });
38
+ for (const child of Object.values(item)) stack.push({ visit: child });
39
+ }
40
+ return true;
41
+ }
42
+
43
+ /** Whether `value` is wholly JSON data and an object: neither `null` nor an array. */
44
+ export function isJsonObject(value: unknown): value is JsonObject {
45
+ return (
46
+ typeof value === "object" &&
47
+ value !== null &&
48
+ !Array.isArray(value) &&
49
+ isJsonValue(value)
50
+ );
51
+ }
52
+
53
+ /** `text` parsed as JSON; a `SyntaxError` when it is not. */
54
+ export function parseJson(text: string): JsonValue {
55
+ const parsed: unknown = JSON.parse(text);
56
+ if (!isJsonValue(parsed)) throw new TypeError("expected JSON data");
57
+ return parsed;
58
+ }
59
+
60
+ /** `text` parsed as a JSON object. */
61
+ export function parseJsonObject(text: string): JsonObject {
62
+ const parsed = parseJson(text);
63
+ if (!isObject(parsed) || Array.isArray(parsed))
64
+ throw new TypeError("expected a JSON object");
65
+ return parsed;
66
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_NAME_LENGTH, MAX_SLUG_LENGTH, SLUG_PATTERN } from "../constants";
3
3
  import type { Block } from "../types";
4
+ import { jsonObjectSchema } from "./json.schema";
4
5
 
5
6
  const MIN_HEADING_SIZE = 12;
6
7
  const MAX_HEADING_SIZE = 48;
@@ -149,7 +150,7 @@ export const saveContentSchema = z.object({
149
150
  revision: draftRevisionSchema,
150
151
  });
151
152
 
152
- export const testDataSchema = z.object({ data: z.record(z.unknown()) });
153
+ export const testDataSchema = z.object({ data: jsonObjectSchema });
153
154
 
154
155
  /**
155
156
  * Which content a preview renders when it carries none: the working draft
@@ -166,7 +167,7 @@ export const previewSchema = z.object({
166
167
  locale: z.string().min(1).optional(),
167
168
  version: previewVersionSchema.optional(),
168
169
  content: templateContentSchema.optional(),
169
- data: z.record(z.unknown()).optional(),
170
+ data: jsonObjectSchema.optional(),
170
171
  });
171
172
 
172
173
  /**
@@ -176,7 +177,7 @@ export const previewSchema = z.object({
176
177
  export const realSendSchema = z.object({
177
178
  to: z.array(z.string().email()).min(1).max(MAX_TEST_RECIPIENT_COUNT),
178
179
  locale: z.string().min(1).optional(),
179
- data: z.record(z.unknown()).optional(),
180
+ data: jsonObjectSchema.optional(),
180
181
  });
181
182
 
182
183
  export const testSendSchema = previewSchema.extend({
@@ -193,6 +194,9 @@ export const templateDetailsSchema = z.object({
193
194
  category: z.string().nullable().optional(),
194
195
  });
195
196
 
197
+ /** The details a template's drawer or the editor saves, validated. */
198
+ export type TemplateDetails = z.infer<typeof templateDetailsSchema>;
199
+
196
200
  export const duplicateSchema = z.object({
197
201
  slug: slugSchema,
198
202
  name: z.string().trim().min(1).max(MAX_NAME_LENGTH),