@antelopejs/dms-mailing 0.8.0 → 0.9.1

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 (205) 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/settings.table.d.ts +1 -0
  32. package/dist/db/tables/settings.table.js +4 -0
  33. package/dist/db/tables/settings.table.js.map +1 -1
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -19
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +3 -2
  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/formats.d.ts +4 -6
  43. package/dist/engine/formats.js +17 -3
  44. package/dist/engine/formats.js.map +1 -1
  45. package/dist/engine/paths.d.ts +4 -2
  46. package/dist/engine/paths.js +15 -3
  47. package/dist/engine/paths.js.map +1 -1
  48. package/dist/engine/plain-text.js +2 -4
  49. package/dist/engine/plain-text.js.map +1 -1
  50. package/dist/engine/resolve.d.ts +2 -1
  51. package/dist/engine/resolve.js +1 -1
  52. package/dist/engine/resolve.js.map +1 -1
  53. package/dist/engine/system-variables.d.ts +2 -1
  54. package/dist/engine/system-variables.js.map +1 -1
  55. package/dist/engine/tokens.d.ts +2 -1
  56. package/dist/engine/tokens.js.map +1 -1
  57. package/dist/hooks/tenant-export.d.ts +1 -1
  58. package/dist/hooks/tenant-export.js +10 -9
  59. package/dist/hooks/tenant-export.js.map +1 -1
  60. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  61. package/dist/implementations/dms-mailing/index.js +3 -1
  62. package/dist/implementations/dms-mailing/index.js.map +1 -1
  63. package/dist/pages/sends.js.map +1 -1
  64. package/dist/pages/templates.js +3 -1
  65. package/dist/pages/templates.js.map +1 -1
  66. package/dist/routes/events.js +4 -2
  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 +27 -28
  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 +3 -4
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +4 -3
  78. package/dist/routes/settings.js +20 -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 +3 -2
  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 +14 -7
  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/declaration-error.d.ts +1 -1
  96. package/dist/services/declaration-error.js.map +1 -1
  97. package/dist/services/declarations.js +4 -1
  98. package/dist/services/declarations.js.map +1 -1
  99. package/dist/services/events.js +3 -1
  100. package/dist/services/events.js.map +1 -1
  101. package/dist/services/metrics.js +2 -5
  102. package/dist/services/metrics.js.map +1 -1
  103. package/dist/services/send-import.js +4 -4
  104. package/dist/services/send-import.js.map +1 -1
  105. package/dist/services/send.js +3 -4
  106. package/dist/services/send.js.map +1 -1
  107. package/dist/services/settings.d.ts +6 -3
  108. package/dist/services/settings.js +24 -5
  109. package/dist/services/settings.js.map +1 -1
  110. package/dist/services/starters.d.ts +3 -2
  111. package/dist/services/starters.js +1 -1
  112. package/dist/services/starters.js.map +1 -1
  113. package/dist/services/templates.d.ts +2 -1
  114. package/dist/services/templates.js +5 -5
  115. package/dist/services/templates.js.map +1 -1
  116. package/dist/services/webhook-secret-declaration.d.ts +4 -0
  117. package/dist/services/webhook-secret-declaration.js +25 -0
  118. package/dist/services/webhook-secret-declaration.js.map +1 -0
  119. package/dist/services/webhook-status.d.ts +2 -2
  120. package/dist/services/webhook-status.js +13 -6
  121. package/dist/services/webhook-status.js.map +1 -1
  122. package/dist/types/json.d.ts +1 -0
  123. package/dist/types/json.js +3 -0
  124. package/dist/types/json.js.map +1 -0
  125. package/dist/types/settings.d.ts +5 -2
  126. package/dist/types/settings.js +6 -1
  127. package/dist/types/settings.js.map +1 -1
  128. package/dist/validation/declarations.schema.d.ts +41 -28
  129. package/dist/validation/declarations.schema.js +10 -4
  130. package/dist/validation/declarations.schema.js.map +1 -1
  131. package/dist/validation/guards.d.ts +0 -1
  132. package/dist/validation/guards.js +0 -4
  133. package/dist/validation/guards.js.map +1 -1
  134. package/dist/validation/json.d.ts +5 -0
  135. package/dist/validation/json.js +54 -0
  136. package/dist/validation/json.js.map +1 -0
  137. package/dist/validation/json.schema.d.ts +2 -0
  138. package/dist/validation/json.schema.js +9 -0
  139. package/dist/validation/json.schema.js.map +1 -0
  140. package/dist/validation/templates.schema.d.ts +25 -24
  141. package/dist/validation/templates.schema.js +4 -3
  142. package/dist/validation/templates.schema.js.map +1 -1
  143. package/frontend-vue/app/components/editor/EditorCanvas.vue +8 -4
  144. package/frontend-vue/app/composables/useSortableList.ts +2 -2
  145. package/frontend-vue/package.json +37 -36
  146. package/frontend-vue/pnpm-lock.yaml +65 -5
  147. package/frontend-vue/tests/editor-canvas.test.ts +142 -0
  148. package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
  149. package/frontend-vue/tsconfig.typecheck.json +12 -0
  150. package/frontend-vue/vitest.config.ts +16 -0
  151. package/package.json +16 -15
  152. package/skills/dms-mailing/SKILL.md +5 -3
  153. package/src/automation/email-event-trigger.ts +2 -2
  154. package/src/automation/send-template-action.ts +33 -14
  155. package/src/data/body.ts +5 -2
  156. package/src/data/templates-table.ts +46 -52
  157. package/src/data-types/categories-type.ts +2 -2
  158. package/src/data-types/category-type.ts +2 -2
  159. package/src/db/models/sends.model.ts +2 -2
  160. package/src/db/models/template-versions.model.ts +5 -3
  161. package/src/db/tables/settings.table.ts +4 -0
  162. package/src/db/tables/templates.table.ts +1 -1
  163. package/src/engine/changes.ts +11 -29
  164. package/src/engine/conditions.ts +12 -4
  165. package/src/engine/coverage.ts +2 -1
  166. package/src/engine/formats.ts +31 -17
  167. package/src/engine/paths.ts +39 -6
  168. package/src/engine/plain-text.ts +2 -4
  169. package/src/engine/resolve.ts +12 -4
  170. package/src/engine/system-variables.ts +2 -1
  171. package/src/engine/tokens.ts +2 -1
  172. package/src/hooks/tenant-export.ts +15 -14
  173. package/src/implementations/dms-mailing/index.ts +1 -0
  174. package/src/pages/sends.ts +3 -2
  175. package/src/pages/templates.ts +3 -1
  176. package/src/routes/events.ts +7 -4
  177. package/src/routes/index.ts +1 -0
  178. package/src/routes/metrics.ts +30 -30
  179. package/src/routes/period.ts +5 -5
  180. package/src/routes/sends.ts +13 -8
  181. package/src/routes/settings.ts +34 -17
  182. package/src/routes/suppressions.ts +12 -8
  183. package/src/routes/template-content.ts +45 -0
  184. package/src/routes/templates.ts +11 -44
  185. package/src/routes/zod-json.ts +28 -0
  186. package/src/services/attention.ts +16 -15
  187. package/src/services/catalog.ts +4 -8
  188. package/src/services/declaration-error.ts +1 -1
  189. package/src/services/declarations.ts +4 -1
  190. package/src/services/events.ts +4 -2
  191. package/src/services/metrics.ts +3 -5
  192. package/src/services/send-import.ts +10 -8
  193. package/src/services/send.ts +3 -6
  194. package/src/services/settings.ts +55 -16
  195. package/src/services/starters.ts +8 -4
  196. package/src/services/templates.ts +16 -9
  197. package/src/services/webhook-secret-declaration.ts +47 -0
  198. package/src/services/webhook-status.ts +18 -9
  199. package/src/types/json.ts +1 -0
  200. package/src/types/settings.ts +16 -2
  201. package/src/validation/declarations.schema.ts +11 -3
  202. package/src/validation/guards.ts +0 -8
  203. package/src/validation/json.schema.ts +7 -0
  204. package/src/validation/json.ts +66 -0
  205. package/src/validation/templates.schema.ts +7 -3
@@ -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 {
@@ -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;
@@ -172,10 +172,12 @@ interface EntryOutcome {
172
172
  }
173
173
 
174
174
  /**
175
- * Writes one send: its events first, under ids derived from the send's, then
176
- * the send itself. A run stopped half-way leaves events no send shows yet,
177
- * which the next run completes; each write skips the row an earlier run
178
- * wrote, and a send present means it was fully imported.
175
+ * Writes one send: the send itself first, then its events under ids derived
176
+ * from the send's. An event is written only after its send, so the
177
+ * retention's orphan-event pass never finds an import's events before their
178
+ * send. A run stopped half-way leaves a send with part of its history, which
179
+ * the next run completes: each write skips the row an earlier run wrote, and
180
+ * the events are written whether or not the send was already there.
179
181
  */
180
182
  async function importOne(
181
183
  tenantId: string,
@@ -184,8 +186,10 @@ async function importOne(
184
186
  fallbackLocale: string,
185
187
  ): Promise<EntryOutcome> {
186
188
  const id = importedSendIdOf(tenantId, send.importId);
187
- const sends = GetModel(SendModel, tenantId);
188
189
  const timeline = timelineOf(send);
190
+ const locale = send.locale ?? fallbackLocale;
191
+ const row = sendRow(id, send, template, locale, foldEvents(send, timeline));
192
+ const isImported = await GetModel(SendModel, tenantId).insertImported(row);
189
193
  const events = GetModel(SendEventModel, tenantId);
190
194
  for (const [index, entry] of timeline.entries()) {
191
195
  await events.insertImported({
@@ -196,9 +200,7 @@ async function importOne(
196
200
  json_details: JSON.stringify(entry.details ?? {}),
197
201
  });
198
202
  }
199
- const locale = send.locale ?? fallbackLocale;
200
- const row = sendRow(id, send, template, locale, foldEvents(send, timeline));
201
- return { id, isImported: await sends.insertImported(row) };
203
+ return { id, isImported };
202
204
  }
203
205
 
204
206
  /**
@@ -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
  }
@@ -5,10 +5,17 @@ import {
5
5
  MODULE_DEFAULT_SETTINGS,
6
6
  type MailingSettingsValues,
7
7
  type TemplateCategory,
8
+ WEBHOOK_SECRET_SOURCES,
9
+ type WebhookSecretSource,
8
10
  } from "../types";
11
+ import type { JsonObject } from "../types/json";
12
+ import type { SettingsPatch } from "../validation/settings.schema";
9
13
 
10
14
  export const WEBHOOK_SECRET_BYTES = 24;
11
15
 
16
+ const GENERATED_SECRET: WebhookSecretSource = "generated";
17
+ const OPERATOR_SECRET: WebhookSecretSource = "operator";
18
+
12
19
  function defaults(): MailingSettingsValues {
13
20
  return {
14
21
  ...MODULE_DEFAULT_SETTINGS,
@@ -16,9 +23,21 @@ function defaults(): MailingSettingsValues {
16
23
  senderEmail: "",
17
24
  replyTo: "",
18
25
  webhookSecret: randomBytes(WEBHOOK_SECRET_BYTES).toString("hex"),
26
+ webhookSecretSource: GENERATED_SECRET,
19
27
  };
20
28
  }
21
29
 
30
+ /**
31
+ * The stored source, or `operator` for a row written before the source was
32
+ * kept: a secret of unknown origin is the workspace's until proven otherwise.
33
+ */
34
+ function storedSecretSource(stored: string | undefined): WebhookSecretSource {
35
+ return (
36
+ WEBHOOK_SECRET_SOURCES.find((source) => source === stored) ??
37
+ OPERATOR_SECRET
38
+ );
39
+ }
40
+
22
41
  function toValues(row: MailingSettings): MailingSettingsValues {
23
42
  return {
24
43
  fallbackLocale: row.fallbackLocale,
@@ -28,6 +47,9 @@ function toValues(row: MailingSettings): MailingSettingsValues {
28
47
  senderEmail: row.senderEmail,
29
48
  replyTo: row.replyTo,
30
49
  webhookSecret: row.webhookSecret,
50
+ webhookSecretSource: storedSecretSource(row.webhookSecretSource),
51
+ // SAFETY: json_categories is written only by toRow as the JSON.stringify of categories that categoriesSchema
52
+ // validated (settingsPatchSchema, applyCategory) or that the module defaults supplied.
31
53
  categories: JSON.parse(row.json_categories) as TemplateCategory[],
32
54
  };
33
55
  }
@@ -71,7 +93,12 @@ export async function getSettings(
71
93
  if (existing) return existing;
72
94
  const created = defaults();
73
95
  if (await insertKeyedSettings(tenantId, created)) return created;
74
- return (await findSettings(tenantId)) as MailingSettingsValues;
96
+ const winner = await findSettings(tenantId);
97
+ if (!winner)
98
+ throw new Error(
99
+ `The settings of tenant "${tenantId}" disappeared before they were read`,
100
+ );
101
+ return winner;
75
102
  }
76
103
 
77
104
  export async function saveSettings(
@@ -85,6 +112,22 @@ export async function saveSettings(
85
112
  return values;
86
113
  }
87
114
 
115
+ /**
116
+ * Writes the webhook secret and its source alone, so a concurrent writer of
117
+ * the other settings keeps its fields. The tenant's row must exist: read it
118
+ * with `getSettings` first.
119
+ */
120
+ export async function saveWebhookSecret(
121
+ tenantId: string,
122
+ webhookSecret: string,
123
+ webhookSecretSource: WebhookSecretSource,
124
+ ): Promise<void> {
125
+ await GetModel(SettingsModel, tenantId).update(tenantId, {
126
+ webhookSecret,
127
+ webhookSecretSource,
128
+ });
129
+ }
130
+
88
131
  export function findCategory(
89
132
  categories: TemplateCategory[],
90
133
  id: string,
@@ -105,36 +148,32 @@ export function missingVariablesPolicy(
105
148
  }
106
149
 
107
150
  /** The form-only field folded back into `blockOnMissingVariables`. */
108
- const MISSING_VARIABLES_KEY = "missingVariables";
109
151
  const CLEARED_VALUE = "";
110
152
  /** 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> {
153
+ export function clearedToEmpty(patch: JsonObject): JsonObject {
114
154
  return Object.fromEntries(
115
155
  Object.entries(patch).map(([key, value]) => [key, value ?? CLEARED_VALUE]),
116
156
  );
117
157
  }
118
158
 
119
159
  /**
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.
160
+ * The settings once `patch` (the fields a form changed, cleared ones already
161
+ * stored as empty) is laid over the stored ones, the missing-variables choice
162
+ * folded back into its boolean. A secret the patch changes becomes the
163
+ * workspace owner's.
123
164
  */
124
165
  export function mergeSettingsPatch(
125
166
  current: MailingSettingsValues,
126
- patch: Record<string, unknown>,
167
+ patch: SettingsPatch,
127
168
  ): 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;
169
+ const { missingVariables: policy, ...written } = patch;
134
170
  const isChoice = policy === REFUSE_POLICY || policy === SEND_POLICY;
135
171
  const guard =
136
172
  isChoice && !("blockOnMissingVariables" in patch)
137
173
  ? { blockOnMissingVariables: policy === REFUSE_POLICY }
138
174
  : {};
139
- return { ...current, ...written, ...guard } as MailingSettingsValues;
175
+ const merged = { ...current, ...written, ...guard };
176
+ return merged.webhookSecret === current.webhookSecret
177
+ ? merged
178
+ : { ...merged, webhookSecretSource: OPERATOR_SECRET };
140
179
  }
@@ -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