@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
@@ -1,7 +1,13 @@
1
+ import type {
2
+ TemplateVariables,
3
+ TemplateVariableValue,
4
+ } from "@antelopejs/interface-dms-mailing";
1
5
  import { isObject } from "../validation/guards";
2
6
 
3
7
  const PATH_SEPARATOR = ".";
4
8
  const ARRAY_SUFFIX = "[]";
9
+ const ARRAY_LENGTH = "length";
10
+ const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
5
11
 
6
12
  export function normalizePath(path: string): string {
7
13
  const trimmed = path.trim();
@@ -17,18 +23,45 @@ export function normalizePath(path: string): string {
17
23
  */
18
24
  const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
19
25
 
20
- function readSegment(current: unknown, segment: string): unknown {
21
- if (!isObject(current)) return undefined;
26
+ /** Whether a path can step into `value`: a record or a money value, not a list or a `Date`. */
27
+ export function isRecord(
28
+ value: TemplateVariableValue | undefined,
29
+ ): value is TemplateVariables {
30
+ return isObject(value) && !Array.isArray(value) && !(value instanceof Date);
31
+ }
32
+
33
+ function readKey(record: TemplateVariables, key: string) {
34
+ return Object.hasOwn(record, key) ? record[key] : undefined;
35
+ }
36
+
37
+ function readSegment(
38
+ current: TemplateVariableValue | undefined,
39
+ segment: string,
40
+ ): TemplateVariableValue | undefined {
22
41
  if (RESERVED_SEGMENTS.includes(segment)) return undefined;
23
- return current[segment];
42
+ if (Array.isArray(current)) {
43
+ if (segment === ARRAY_LENGTH) return current.length;
44
+ return ARRAY_INDEX.test(segment) ? current[Number(segment)] : undefined;
45
+ }
46
+ return isRecord(current) ? readKey(current, segment) : undefined;
24
47
  }
25
48
 
26
- export function getPath(data: unknown, path: string): unknown {
49
+ /**
50
+ * The value `path` names in `data`: a key a record owns, a list's element by
51
+ * index or its length. `undefined` when the path leaves the data.
52
+ */
53
+ export function getPath(
54
+ data: TemplateVariableValue | undefined,
55
+ path: string,
56
+ ): TemplateVariableValue | undefined {
27
57
  return normalizePath(path)
28
58
  .split(PATH_SEPARATOR)
29
- .reduce<unknown>(readSegment, data);
59
+ .reduce<TemplateVariableValue | undefined>(readSegment, data);
30
60
  }
31
61
 
32
- export function isPresent(value: unknown): boolean {
62
+ /** Whether `value` is neither missing nor `null`. */
63
+ export function isPresent(
64
+ value: TemplateVariableValue | undefined,
65
+ ): value is Exclude<TemplateVariableValue, null> {
33
66
  return value !== undefined && value !== null;
34
67
  }
@@ -13,14 +13,12 @@ const FOOTER_TYPE = "footer";
13
13
  * Languages whose typography spaces a colon off its label, with a no-break
14
14
  * space so the colon never starts a line ("Total : 64,00 €").
15
15
  */
16
- const LABEL_SEPARATORS: Record<string, string> = {
17
- fr: " : ",
18
- };
16
+ const LABEL_SEPARATORS = new Map([["fr", " : "]]);
19
17
 
20
18
  /** What goes between a label and its value in the e-mail's language. */
21
19
  export function labelSeparatorOf(locale = ""): string {
22
20
  const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
23
- return LABEL_SEPARATORS[language] ?? DEFAULT_LABEL_SEPARATOR;
21
+ return LABEL_SEPARATORS.get(language) ?? DEFAULT_LABEL_SEPARATOR;
24
22
  }
25
23
 
26
24
  const linked = (label: string, href: string, separator: string): string =>
@@ -1,3 +1,7 @@
1
+ import type {
2
+ TemplateVariables,
3
+ TemplateVariableValue,
4
+ } from "@antelopejs/interface-dms-mailing";
1
5
  import {
2
6
  PREFERENCES_URL_PATH,
3
7
  SYSTEM_VARIABLE_PATHS,
@@ -15,12 +19,12 @@ import type {
15
19
  } from "../types";
16
20
  import { evaluateCondition } from "./conditions";
17
21
  import { safeUrl } from "./fallback-html";
18
- import { formatValue, type ValueFormat } from "./formats";
22
+ import { formatValue, plainText, type ValueFormat } from "./formats";
19
23
  import { getPath, isPresent } from "./paths";
20
24
  import { footerLinkPaths } from "./system-variables";
21
25
  import { type InterpolationMode, interpolate } from "./tokens";
22
26
 
23
- type Data = Record<string, unknown>;
27
+ type Data = TemplateVariables;
24
28
 
25
29
  interface ResolveState {
26
30
  data: Data;
@@ -60,11 +64,15 @@ function systemUrl(
60
64
  ): string {
61
65
  if (!footerLinkPaths(block).includes(path)) return "";
62
66
  const value = getPath(state.data, path);
63
- return isPresent(value) ? safeUrl(String(value)) : "";
67
+ return isPresent(value) ? safeUrl(plainText(value)) : "";
64
68
  }
65
69
 
66
70
  /** A list cell: a money value or a date in it is written for the locale. */
67
- function cell(item: unknown, path: string, state: ResolveState): string {
71
+ function cell(
72
+ item: TemplateVariableValue,
73
+ path: string,
74
+ state: ResolveState,
75
+ ): string {
68
76
  const value = getPath(item, path);
69
77
  return isPresent(value) ? formatValue(value, "", state.format) : "";
70
78
  }
@@ -1,3 +1,4 @@
1
+ import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
1
2
  import {
2
3
  PREFERENCES_URL_PATH,
3
4
  SYSTEM_VARIABLE_PATHS,
@@ -93,7 +94,7 @@ export function systemVariables(
93
94
  export function missingSystemVariables(
94
95
  content: LocaleContent,
95
96
  category: string | null | undefined,
96
- data: Record<string, unknown>,
97
+ data: TemplateVariables,
97
98
  ): string[] {
98
99
  return systemVariables(localeSystemPaths(content), category)
99
100
  .filter((variable) => variable.required)
@@ -1,3 +1,4 @@
1
+ import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
1
2
  import { formatValue, type ValueFormat } from "./formats";
2
3
  import { getPath, isPresent } from "./paths";
3
4
 
@@ -31,7 +32,7 @@ export function extractTokens(text: string): string[] {
31
32
  */
32
33
  export function interpolate(
33
34
  text: string,
34
- data: Record<string, unknown>,
35
+ data: TemplateVariables,
35
36
  mode: InterpolationMode = "preview",
36
37
  format?: ValueFormat,
37
38
  ): InterpolationResult {
@@ -11,14 +11,17 @@ 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
- export type ExportedSettings = Omit<MailingSettingsValues, "webhookSecret">;
21
+ export type ExportedSettings = Omit<
22
+ MailingSettingsValues,
23
+ "webhookSecret" | "webhookSecretSource"
24
+ >;
22
25
 
23
26
  export type ExportPageLoader<T> = (
24
27
  offset: number,
@@ -26,7 +29,7 @@ export type ExportPageLoader<T> = (
26
29
  ) => Promise<T[]>;
27
30
 
28
31
  interface RowCodec<T> {
29
- fromDatabase(row: unknown): T | undefined;
32
+ fromDatabase(row: T): T | undefined;
30
33
  }
31
34
 
32
35
  export interface ExportedCollection {
@@ -72,18 +75,16 @@ export async function* jsonArrayChunks<T>(
72
75
  async function exportedSettings(
73
76
  tenantId: string,
74
77
  ): Promise<ExportedSettings | null> {
75
- const row = await GetModel(SettingsModel, tenantId).get(tenantId);
76
- if (!row) return null;
78
+ const settings = await findSettings(tenantId);
79
+ if (!settings) return null;
77
80
  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"],
81
+ fallbackLocale: settings.fallbackLocale,
82
+ logRetentionDays: settings.logRetentionDays,
83
+ blockOnMissingVariables: settings.blockOnMissingVariables,
84
+ senderName: settings.senderName,
85
+ senderEmail: settings.senderEmail,
86
+ replyTo: settings.replyTo,
87
+ categories: settings.categories,
87
88
  };
88
89
  }
89
90
 
@@ -4,5 +4,6 @@ export {
4
4
  } from "../../services/declarations";
5
5
  export { importSends as ImportSends } from "../../services/send-import";
6
6
  export { declareSender as DeclareSender } from "../../services/sender-declaration";
7
+ export { declareWebhookSecret as DeclareWebhookSecret } from "../../services/webhook-secret-declaration";
7
8
  export { recordEmailEvent as RecordEmailEvent } from "../../services/events";
8
9
  export { sendUnlessSuppressed as SendTemplate } from "../../services/checked-send";
@@ -225,8 +225,9 @@ export class SendsPageController extends PageController(
225
225
  pageSize: 50,
226
226
  rowClick: "single",
227
227
  /**
228
- * New sends wait in a "N new sends" strip while the user reads a send,
229
- * scrolls down the log or searches it, so the rows do not move under them.
228
+ * New sends wait behind a "N new sends" pill while the log is on screen,
229
+ * so the rows do not move under the user. A send open in the drawer, a
230
+ * scroll down the log or a search also names the hold above the rows.
230
231
  */
231
232
  liveHold: {
232
233
  whileRowOpen: true,
@@ -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 {
@@ -33,6 +33,7 @@ import {
33
33
  mergeSettingsPatch,
34
34
  missingVariablesPolicy,
35
35
  saveSettings,
36
+ saveWebhookSecret,
36
37
  WEBHOOK_SECRET_BYTES,
37
38
  } from "../services/settings";
38
39
  import {
@@ -48,7 +49,8 @@ import {
48
49
  settingsPatchSchema,
49
50
  } from "../validation/settings.schema";
50
51
  import { retentionLimit } from "../crons/retention";
51
- import { isJsonObject } from "../validation/guards";
52
+ import { isJsonObject } from "../validation/json";
53
+ import { zodIssuesJson } from "./zod-json";
52
54
 
53
55
  const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
54
56
  const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
@@ -77,7 +79,15 @@ const retentionDaysSchema = z.coerce
77
79
  * The DMS form reads and writes localized values whole
78
80
  * (`x-content-language: *`): category labels travel with every language.
79
81
  */
80
- export interface MailingSettingsResponse extends MailingSettingsValues {
82
+ /** The settings as the form reads and writes them: who set the secret stays inside the module. */
83
+ type FormSettings = Omit<MailingSettingsValues, "webhookSecretSource">;
84
+
85
+ function formSettings(values: MailingSettingsValues): FormSettings {
86
+ const { webhookSecretSource: _source, ...form } = values;
87
+ return form;
88
+ }
89
+
90
+ export interface MailingSettingsResponse extends FormSettings {
81
91
  /** Where the provider posts its events; read-only. */
82
92
  webhookUrl: string;
83
93
  /** The configured provider, as the read-only provider row shows it. */
@@ -90,10 +100,10 @@ export interface MailingSettingsResponse extends MailingSettingsValues {
90
100
  export interface SettingsNotice {
91
101
  title: string;
92
102
  color?: string;
93
- params?: Record<string, unknown>;
103
+ params?: Record<string, number | string>;
94
104
  }
95
105
 
96
- export interface SettingsWriteResponse extends MailingSettingsValues {
106
+ export interface SettingsWriteResponse extends FormSettings {
97
107
  notice?: SettingsNotice;
98
108
  }
99
109
 
@@ -147,9 +157,11 @@ function formIssue(issue: z.ZodIssue): z.ZodIssue {
147
157
  }
148
158
 
149
159
  /** 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) };
160
+ function invalidSettings(error: z.ZodError) {
161
+ return {
162
+ message: INVALID_SETTINGS,
163
+ issues: zodIssuesJson(error.issues.map(formIssue)),
164
+ };
153
165
  }
154
166
 
155
167
  function senderNotice(values: MailingSettingsValues): SettingsNotice[] {
@@ -208,7 +220,7 @@ export class MailingSettingsController extends Controller(
208
220
  readCapabilities(),
209
221
  ]);
210
222
  return {
211
- ...values,
223
+ ...formSettings(values),
212
224
  webhookUrl: await this.providerWebhookUrl(capabilities?.name),
213
225
  provider: capabilities?.name ?? "",
214
226
  missingVariables: missingVariablesPolicy(values),
@@ -231,7 +243,7 @@ export class MailingSettingsController extends Controller(
231
243
  ? await this.reassignCategories(categories, categoryMoves(entries))
232
244
  : 0;
233
245
  const [notice] = [...reassignedNotice(reassigned), ...senderNotice(saved)];
234
- return { ...saved, ...(notice && { notice }) };
246
+ return { ...formSettings(saved), ...(notice && { notice }) };
235
247
  }
236
248
 
237
249
  /** The body as a checked patch; 400 for anything else. */
@@ -241,16 +253,20 @@ export class MailingSettingsController extends Controller(
241
253
  return assertValidation(
242
254
  clearedToEmpty(body),
243
255
  (value) => settingsPatchSchema.parse(value),
244
- invalidSettings,
256
+ (error) =>
257
+ error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
245
258
  );
246
259
  }
247
260
 
248
- /** Issues a new webhook secret; the old one stops working at once. */
261
+ /**
262
+ * Issues a new webhook secret, the workspace owner's from now on; the old
263
+ * one stops working at once.
264
+ */
249
265
  @Post("/webhook-secret/rotate")
250
266
  async rotateSecret(): Promise<{ value: string }> {
251
- const current = await getSettings(this.tenantId);
267
+ await getSettings(this.tenantId);
252
268
  const value = randomBytes(WEBHOOK_SECRET_BYTES).toString("hex");
253
- await saveSettings(this.tenantId, { ...current, webhookSecret: value });
269
+ await saveWebhookSecret(this.tenantId, value, "operator");
254
270
  return { value };
255
271
  }
256
272
 
@@ -295,7 +311,8 @@ export class MailingSettingsController extends Controller(
295
311
  return assertValidation(
296
312
  raw,
297
313
  (value) => retentionDaysSchema.parse(value),
298
- invalidSettings,
314
+ (error) =>
315
+ error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
299
316
  );
300
317
  }
301
318
 
@@ -306,12 +323,12 @@ export class MailingSettingsController extends Controller(
306
323
  @Get("/category-usage")
307
324
  async categoryUsage(): Promise<{ counts: Record<string, number> }> {
308
325
  const templates = await this.templates.getAll();
309
- const counts: Record<string, number> = {};
326
+ const counts = new Map<string, number>();
310
327
  for (const template of templates) {
311
328
  if (!template.category || template.status === ARCHIVED_STATUS) continue;
312
- counts[template.category] = (counts[template.category] ?? 0) + 1;
329
+ counts.set(template.category, (counts.get(template.category) ?? 0) + 1);
313
330
  }
314
- return { counts };
331
+ return { counts: Object.fromEntries(counts) };
315
332
  }
316
333
 
317
334
  /**
@@ -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,