@antelopejs/dms-mailing 0.7.1 → 0.8.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 (152) hide show
  1. package/README.md +21 -4
  2. package/dist/crons/jobs.d.ts +1 -1
  3. package/dist/data/body.js +2 -1
  4. package/dist/data/body.js.map +1 -1
  5. package/dist/data/templates-table.js +4 -3
  6. package/dist/data/templates-table.js.map +1 -1
  7. package/dist/db/models/template-versions.model.js.map +1 -1
  8. package/dist/db/models/templates.model.js +2 -1
  9. package/dist/db/models/templates.model.js.map +1 -1
  10. package/dist/engine/changes.js +2 -1
  11. package/dist/engine/changes.js.map +1 -1
  12. package/dist/engine/conditions.js +2 -3
  13. package/dist/engine/conditions.js.map +1 -1
  14. package/dist/engine/fallback-html.js +8 -8
  15. package/dist/engine/fallback-html.js.map +1 -1
  16. package/dist/engine/formats.js +6 -6
  17. package/dist/engine/formats.js.map +1 -1
  18. package/dist/engine/paths.js +2 -3
  19. package/dist/engine/paths.js.map +1 -1
  20. package/dist/engine/system-variables.js +1 -1
  21. package/dist/engine/system-variables.js.map +1 -1
  22. package/dist/engine/tokens.js.map +1 -1
  23. package/dist/pages/sends.js.map +1 -1
  24. package/dist/pages/toolbar.js.map +1 -1
  25. package/dist/routes/events.js +2 -2
  26. package/dist/routes/events.js.map +1 -1
  27. package/dist/routes/metrics.js +6 -5
  28. package/dist/routes/metrics.js.map +1 -1
  29. package/dist/routes/sends.js +1 -1
  30. package/dist/routes/sends.js.map +1 -1
  31. package/dist/routes/settings.js +6 -6
  32. package/dist/routes/settings.js.map +1 -1
  33. package/dist/routes/templates.js +4 -4
  34. package/dist/routes/templates.js.map +1 -1
  35. package/dist/services/attention.js +2 -1
  36. package/dist/services/attention.js.map +1 -1
  37. package/dist/services/categories.js +6 -6
  38. package/dist/services/categories.js.map +1 -1
  39. package/dist/services/checked-send.js +16 -9
  40. package/dist/services/checked-send.js.map +1 -1
  41. package/dist/services/declarations.js.map +1 -1
  42. package/dist/services/events.js +11 -7
  43. package/dist/services/events.js.map +1 -1
  44. package/dist/services/localized-text.d.ts +2 -1
  45. package/dist/services/localized-text.js +2 -1
  46. package/dist/services/localized-text.js.map +1 -1
  47. package/dist/services/metrics.js.map +1 -1
  48. package/dist/services/render.js.map +1 -1
  49. package/dist/services/send-import.js +7 -3
  50. package/dist/services/send-import.js.map +1 -1
  51. package/dist/services/send.js +4 -3
  52. package/dist/services/send.js.map +1 -1
  53. package/dist/services/starters.js +5 -5
  54. package/dist/services/starters.js.map +1 -1
  55. package/dist/services/template-stats.js +5 -2
  56. package/dist/services/template-stats.js.map +1 -1
  57. package/dist/services/templates.js +6 -2
  58. package/dist/services/templates.js.map +1 -1
  59. package/dist/validation/declarations.schema.d.ts +16 -16
  60. package/dist/validation/declarations.schema.js +2 -2
  61. package/dist/validation/declarations.schema.js.map +1 -1
  62. package/dist/validation/guards.d.ts +4 -0
  63. package/dist/validation/guards.js +19 -0
  64. package/dist/validation/guards.js.map +1 -0
  65. package/dist/validation/send-event-type.schema.d.ts +2 -0
  66. package/dist/validation/send-event-type.schema.js +11 -0
  67. package/dist/validation/send-event-type.schema.js.map +1 -0
  68. package/dist/validation/settings.schema.js +1 -1
  69. package/dist/validation/settings.schema.js.map +1 -1
  70. package/dist/validation/templates.schema.d.ts +19 -18
  71. package/dist/validation/templates.schema.js.map +1 -1
  72. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  73. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  74. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  75. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  76. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  77. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  78. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  79. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  80. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  81. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  82. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  83. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  84. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  85. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  86. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  87. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  88. package/frontend-vue/build/backend-boundary.ts +122 -0
  89. package/frontend-vue/dms.frontend.build.ts +7 -0
  90. package/frontend-vue/eslint.config.mjs +13 -0
  91. package/frontend-vue/package.json +4 -2
  92. package/frontend-vue/pnpm-lock.yaml +210 -61
  93. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  94. package/frontend-vue/tests/dnd.test.ts +1 -2
  95. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  96. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  97. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  98. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  99. package/frontend-vue/tests/page-reads.test.ts +26 -6
  100. package/frontend-vue/tests/send-actions.test.ts +0 -1
  101. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  102. package/frontend-vue/tests/send-modals.test.ts +8 -5
  103. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  104. package/frontend-vue/tests/suppression.test.ts +1 -2
  105. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  106. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  107. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  108. package/package.json +3 -8
  109. package/skills/dms-mailing/SKILL.md +3 -3
  110. package/skills/dms-mailing/cases.json +49 -0
  111. package/src/crons/jobs.ts +1 -1
  112. package/src/data/body.ts +2 -1
  113. package/src/data/templates-table.ts +12 -7
  114. package/src/db/models/template-versions.model.ts +5 -1
  115. package/src/db/models/templates.model.ts +6 -3
  116. package/src/engine/changes.ts +3 -2
  117. package/src/engine/conditions.ts +2 -4
  118. package/src/engine/fallback-html.ts +13 -13
  119. package/src/engine/formats.ts +6 -6
  120. package/src/engine/paths.ts +4 -3
  121. package/src/engine/system-variables.ts +4 -3
  122. package/src/engine/tokens.ts +1 -2
  123. package/src/pages/sends.ts +5 -3
  124. package/src/pages/templates.ts +1 -1
  125. package/src/pages/toolbar.ts +4 -2
  126. package/src/routes/events.ts +3 -6
  127. package/src/routes/metrics.ts +7 -5
  128. package/src/routes/sends.ts +10 -18
  129. package/src/routes/settings.ts +8 -9
  130. package/src/routes/suppressions.ts +1 -1
  131. package/src/routes/templates.ts +5 -5
  132. package/src/services/attention.ts +4 -3
  133. package/src/services/categories.ts +6 -6
  134. package/src/services/checked-send.ts +17 -19
  135. package/src/services/declarations.ts +3 -5
  136. package/src/services/events.ts +16 -13
  137. package/src/services/localized-text.ts +4 -2
  138. package/src/services/metrics.ts +2 -2
  139. package/src/services/notify.ts +1 -1
  140. package/src/services/render.ts +5 -1
  141. package/src/services/send-import.ts +16 -11
  142. package/src/services/send.ts +7 -7
  143. package/src/services/starters.ts +6 -6
  144. package/src/services/template-stats.ts +11 -4
  145. package/src/services/templates.ts +10 -8
  146. package/src/validation/declarations.schema.ts +2 -2
  147. package/src/validation/guards.ts +25 -0
  148. package/src/validation/send-event-type.schema.ts +10 -0
  149. package/src/validation/settings.schema.ts +7 -2
  150. package/src/validation/templates.schema.ts +2 -1
  151. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  152. package/frontend-vue/tests/i18n.test.ts +0 -138
@@ -0,0 +1,49 @@
1
+ {
2
+ "version": 1,
3
+ "cases": [
4
+ {
5
+ "id": "mailing-send-template",
6
+ "area": "modules",
7
+ "style": "clean",
8
+ "prompt": "Send the welcome e-mail template when a user signs up.",
9
+ "expect": {
10
+ "skills": ["dms-mailing"],
11
+ "acceptable": ["dms", "dms-auth"],
12
+ "mustMention": ["SendTemplate"]
13
+ }
14
+ },
15
+ {
16
+ "id": "mailing-bounces",
17
+ "area": "modules",
18
+ "style": "clean",
19
+ "prompt": "Our e-mail provider reports bounces through a webhook. How do we record them?",
20
+ "expect": {
21
+ "skills": ["dms-mailing"],
22
+ "acceptable": ["dms"],
23
+ "mustMention": ["RecordEmailEvent"]
24
+ }
25
+ },
26
+ {
27
+ "id": "mailing-missing-variable-casual",
28
+ "area": "modules",
29
+ "style": "casual",
30
+ "prompt": "mail send says missing variable firstName??",
31
+ "expect": {
32
+ "skills": ["dms-mailing"],
33
+ "acceptable": ["dms"]
34
+ }
35
+ },
36
+ {
37
+ "id": "dms-mailing-avoid-tracker",
38
+ "area": "modules",
39
+ "style": "clean",
40
+ "prompt": "Put the tracking script on our public website and count how many visitors click the signup button.",
41
+ "expect": {
42
+ "skills": ["dms-marketing"],
43
+ "acceptable": ["dms"],
44
+ "mustMention": ["data-website-id"],
45
+ "avoid": ["dms-mailing"]
46
+ }
47
+ }
48
+ ]
49
+ }
package/src/crons/jobs.ts CHANGED
@@ -5,7 +5,7 @@ import cron, { type ScheduledTask } from "node-cron";
5
5
  export interface CronJob {
6
6
  name: string;
7
7
  schedule: string;
8
- run: () => Promise<unknown>;
8
+ run: () => Promise<number | void>;
9
9
  }
10
10
 
11
11
  /** A job as it runs once scheduled. */
package/src/data/body.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { HTTPResult } from "@antelopejs/interface-api";
2
2
  import { HTTP_BAD_REQUEST } from "../constants";
3
+ import { isText } from "../validation/guards";
3
4
 
4
5
  const INVALID_BODY = "$dms_mailing.errors.invalid_body";
5
6
 
6
7
  export function parseBody(body: Buffer | string): Record<string, unknown> {
7
- const raw = typeof body === "string" ? body : body.toString("utf8");
8
+ const raw = isText(body) ? body : body.toString("utf8");
8
9
  if (!raw) return {};
9
10
  try {
10
11
  return JSON.parse(raw) as Record<string, unknown>;
@@ -84,6 +84,7 @@ import {
84
84
  createTemplateSchema,
85
85
  templateDetailsSchema,
86
86
  } from "../validation/templates.schema";
87
+ import { isObject, isText } from "../validation/guards";
87
88
  import { parseBody } from "./body";
88
89
  import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
89
90
 
@@ -155,7 +156,7 @@ function assertKnownCategory(
155
156
  category: unknown,
156
157
  ): void {
157
158
  if (isBlank(category)) return;
158
- if (typeof category === "string" && findCategory(categories, category)) {
159
+ if (isText(category) && findCategory(categories, category)) {
159
160
  return;
160
161
  }
161
162
  throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
@@ -171,7 +172,8 @@ interface CreatedRow {
171
172
  * The creation's seed, and the body to store: slug trimmed, and the name
172
173
  * typed in the reader's language as every language of the localized field,
173
174
  * the fallback one included until someone translates it. A body a guard
174
- * already parsed arrives as an object.
175
+ * already parsed arrives as an object. The body to store is handed on as an
176
+ * object: interface-data-api 0.3 refuses a JSON string.
175
177
  */
176
178
  function parseCreation(body: Validation.WrittenBody, locale: string) {
177
179
  const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
@@ -181,7 +183,6 @@ function parseCreation(body: Validation.WrittenBody, locale: string) {
181
183
  () => INVALID_TEMPLATE,
182
184
  HTTP_BAD_REQUEST,
183
185
  );
184
- // Handed as an object: interface-data-api 0.3 refuses a JSON string.
185
186
  const normalized = {
186
187
  ...raw,
187
188
  slug: seed.slug,
@@ -198,6 +199,12 @@ interface LocalizedController {
198
199
  /** The key that writes every language of a localized field at once. */
199
200
  const ALL_LANGUAGES = "*";
200
201
 
202
+ /**
203
+ * The data API's `new` callback, seeding the template it creates. The
204
+ * module's creation dialogs send one typed name, and the stored name holds
205
+ * every language, written at once: the controller's language key is set to
206
+ * all languages before the creation.
207
+ */
201
208
  function withSeeding(base: DataControllerCallback): DataControllerCallback {
202
209
  return {
203
210
  ...base,
@@ -213,8 +220,6 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
213
220
  const settings = await getSettings(tenantId);
214
221
  assertKnownCategory(settings.categories, seed.category);
215
222
  const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
216
- // The module's creation dialogs send one typed name; the stored name
217
- // holds every language, written at once.
218
223
  (this as LocalizedController).language = ALL_LANGUAGES;
219
224
  const created = (await refuseTakenSlug(
220
225
  base.func.call(this, ctx, params, normalized, ...rest),
@@ -334,8 +339,8 @@ export async function saveDetails(
334
339
  * or, from a form editing every language, all of them.
335
340
  */
336
341
  function isBlankName(name: unknown): boolean {
337
- const texts = typeof name === "object" && name ? Object.values(name) : [name];
338
- return !texts.some((text) => typeof text === "string" && text.trim());
342
+ const texts = isObject(name) ? Object.values(name) : [name];
343
+ return !texts.some((text) => isText(text) && text.trim());
339
344
  }
340
345
 
341
346
  /** The names trimmed, blank ones left out, the fallback language filled. */
@@ -45,8 +45,12 @@ export class TemplateVersionModel extends BasicDataModel(
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * The published versions of a template, newest first. It reads the
50
+ * template's publication before the version rows: any later publication
51
+ * must have materialized the version that read names.
52
+ */
48
53
  async listForTemplate(templateId: string): Promise<MailingTemplateVersion[]> {
49
- // Read the publication first: any later publication must have materialized it.
50
54
  const template = await new TemplateModel(this.database).get(templateId);
51
55
  if (!template) return [];
52
56
  const rows = await this.getBy("templateId", templateId);
@@ -5,6 +5,7 @@ import {
5
5
  } from "@antelopejs/interface-database-decorators";
6
6
  import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
7
7
  import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
8
+ import { isText } from "../../validation/guards";
8
9
 
9
10
  /** The attention fields of a template, as the refresh writes them. */
10
11
  export type TemplateAttentionFields = Pick<
@@ -15,7 +16,7 @@ export type TemplateAttentionFields = Pick<
15
16
  const isLocalizedText = (value: unknown): value is LocalizedText =>
16
17
  typeof value === "object" &&
17
18
  value !== null &&
18
- Object.values(value).every((text) => typeof text === "string");
19
+ Object.values(value).every(isText);
19
20
 
20
21
  /** Where a row keeps its modifier-held fields, the localized name among them. */
21
22
  const MODIFIER_CONTAINER = "_internal";
@@ -37,9 +38,11 @@ export class TemplateModel extends BasicDataModel(
37
38
  return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
38
39
  }
39
40
 
40
- /** The names of `template`, one per language. */
41
+ /**
42
+ * The names of `template`, one per language: unlocked with `"*"`, the
43
+ * localized field holds the whole per-language record.
44
+ */
41
45
  static namesOf(template: MailingTemplate): LocalizedText {
42
- // Unlocked with "*", the localized field holds the whole per-language record.
43
46
  const names: unknown = template.localize("*", ["name"]).name;
44
47
  return isLocalizedText(names) ? names : {};
45
48
  }
@@ -6,6 +6,7 @@ import type {
6
6
  VariableDefinition,
7
7
  } from "../types";
8
8
  import { walkBlocks } from "./walk";
9
+ import { isObject } from "../validation/guards";
9
10
 
10
11
  export type TemplateChangeKind =
11
12
  | "locale_added"
@@ -51,11 +52,11 @@ const NESTED_KEYS = new Set(["children", "elseChildren"]);
51
52
  */
52
53
  function canonical(value: unknown): unknown {
53
54
  if (Array.isArray(value)) return value.map(canonical);
54
- if (!value || typeof value !== "object") return value;
55
+ if (!isObject(value)) return value;
55
56
  return Object.fromEntries(
56
57
  Object.keys(value)
57
58
  .sort()
58
- .map((key) => [key, canonical((value as Record<string, unknown>)[key])]),
59
+ .map((key) => [key, canonical(value[key])]),
59
60
  );
60
61
  }
61
62
 
@@ -3,15 +3,13 @@ import { getPath } from "./paths";
3
3
 
4
4
  type Evaluator = (actual: unknown, expected: string) => boolean;
5
5
 
6
- const asNumber = (value: unknown): number => Number(value);
7
-
8
6
  const EVALUATORS: Record<ConditionOperator, Evaluator> = {
9
7
  truthy: (actual) => Boolean(actual),
10
8
  falsy: (actual) => !actual,
11
9
  eq: (actual, expected) => String(actual) === expected,
12
10
  ne: (actual, expected) => String(actual) !== expected,
13
- gt: (actual, expected) => asNumber(actual) > asNumber(expected),
14
- lt: (actual, expected) => asNumber(actual) < asNumber(expected),
11
+ gt: (actual, expected) => Number(actual) > Number(expected),
12
+ lt: (actual, expected) => Number(actual) < Number(expected),
15
13
  };
16
14
 
17
15
  /** Whether `data` passes the condition a block is shown under. */
@@ -1,15 +1,15 @@
1
1
  import type { ResolvedBlock, ResolvedEmail } from "../types";
2
2
 
3
- const ESCAPES: Record<string, string> = {
4
- "&": "&amp;",
5
- "<": "&lt;",
6
- ">": "&gt;",
7
- '"': "&quot;",
8
- "'": "&#39;",
9
- };
3
+ const ESCAPES = new Map([
4
+ ["&", "&amp;"],
5
+ ["<", "&lt;"],
6
+ [">", "&gt;"],
7
+ ['"', "&quot;"],
8
+ ["'", "&#39;"],
9
+ ]);
10
10
 
11
11
  export const escapeHtml = (value: string): string =>
12
- value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
12
+ value.replace(/[&<>"']/g, (char) => ESCAPES.get(char) ?? char);
13
13
 
14
14
  const LINE_BREAK = /\r?\n/g;
15
15
 
@@ -77,7 +77,7 @@ const row = (label: string, value: string, weight: string): string =>
77
77
  const table = (rows: string): string =>
78
78
  `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BLOCK_SPACING}">${rows}</table>`;
79
79
 
80
- const TEXT_RENDERERS: Record<string, BlockHtml> = {
80
+ const TEXT_RENDERERS = {
81
81
  heading: (block) =>
82
82
  block.type === "heading"
83
83
  ? `<h1 style="${FONT};font-size:${block.size}px;font-weight:600;color:${HEADING_COLOR};text-align:${block.align};margin:0 0 24px;${WRAP}">${escapeLines(block.text)}</h1>`
@@ -94,7 +94,7 @@ const TEXT_RENDERERS: Record<string, BlockHtml> = {
94
94
  block.type === "button"
95
95
  ? `<div style="text-align:${block.align};margin:24px 0"><a href="${escapeHtml(safeUrl(block.href))}" style="${FONT};${BUTTON_STYLE}">${escapeHtml(block.text)}</a></div>`
96
96
  : "",
97
- };
97
+ } satisfies Record<string, BlockHtml>;
98
98
 
99
99
  const footerHtml = (block: ResolvedBlock): string => {
100
100
  if (block.type !== "footer") return "";
@@ -109,7 +109,7 @@ const footerHtml = (block: ResolvedBlock): string => {
109
109
  return `${DIVIDER}<p style="${FOOTER_STYLE}">${escapeLines(block.text)}${linkLine}</p>`;
110
110
  };
111
111
 
112
- const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
112
+ const LAYOUT_RENDERERS = {
113
113
  hero: (block) =>
114
114
  block.type === "hero" && block.imageUrl
115
115
  ? `<img src="${escapeHtml(safeUrl(block.imageUrl))}" alt="${escapeHtml(block.alt)}" style="display:block;width:100%;border-radius:8px;margin:0 0 20px">`
@@ -124,12 +124,12 @@ const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
124
124
  block.type === "total" ? table(row(block.label, block.value, "600")) : "",
125
125
  divider: () => DIVIDER,
126
126
  footer: footerHtml,
127
- };
127
+ } satisfies Record<string, BlockHtml>;
128
128
 
129
129
  const RENDERERS: Record<ResolvedBlock["type"], BlockHtml> = {
130
130
  ...TEXT_RENDERERS,
131
131
  ...LAYOUT_RENDERERS,
132
- } as Record<ResolvedBlock["type"], BlockHtml>;
132
+ };
133
133
 
134
134
  const FOOTER_TYPE = "footer";
135
135
 
@@ -1,4 +1,5 @@
1
1
  import type { VariableDefinition } from "../types";
2
+ import { isObject, isText } from "../validation/guards";
2
3
 
3
4
  /**
4
5
  * An amount in the minor unit of its currency (cents for `EUR`), like the
@@ -55,12 +56,11 @@ export function valueFormatOf(
55
56
  }
56
57
 
57
58
  export function isMoneyValue(value: unknown): value is MoneyValue {
58
- if (!value || typeof value !== "object") return false;
59
- const candidate = value as Partial<MoneyValue>;
59
+ if (!isObject(value)) return false;
60
60
  return (
61
- typeof candidate.value === "number" &&
62
- Number.isFinite(candidate.value) &&
63
- typeof candidate.currency === "string"
61
+ typeof value.value === "number" &&
62
+ Number.isFinite(value.value) &&
63
+ typeof value.currency === "string"
64
64
  );
65
65
  }
66
66
 
@@ -118,7 +118,7 @@ export function formatValue(
118
118
  return Number.isNaN(value.getTime())
119
119
  ? String(value)
120
120
  : formatDate(value, DATE_STYLE, locale);
121
- if (typeof value === "string" && format?.datePaths.has(path))
121
+ if (isText(value) && format?.datePaths.has(path))
122
122
  return formatIsoDate(value, locale) ?? value;
123
123
  return String(value);
124
124
  }
@@ -1,3 +1,5 @@
1
+ import { isObject } from "../validation/guards";
2
+
1
3
  const PATH_SEPARATOR = ".";
2
4
  const ARRAY_SUFFIX = "[]";
3
5
 
@@ -16,10 +18,9 @@ export function normalizePath(path: string): string {
16
18
  const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
17
19
 
18
20
  function readSegment(current: unknown, segment: string): unknown {
19
- if (current === null || current === undefined) return undefined;
20
- if (typeof current !== "object") return undefined;
21
+ if (!isObject(current)) return undefined;
21
22
  if (RESERVED_SEGMENTS.includes(segment)) return undefined;
22
- return (current as Record<string, unknown>)[segment];
23
+ return current[segment];
23
24
  }
24
25
 
25
26
  export function getPath(data: unknown, path: string): unknown {
@@ -22,7 +22,10 @@ export const MARKETING_CATEGORY = "marketing";
22
22
 
23
23
  const SYSTEM_VARIABLE_TYPE = "url";
24
24
 
25
- type FooterLabelKey = "unsubscribeLabel" | "preferencesLabel";
25
+ /** The footer fields that label a link, in the order the links show. */
26
+ const LABEL_KEYS = ["unsubscribeLabel", "preferencesLabel"] as const;
27
+
28
+ type FooterLabelKey = (typeof LABEL_KEYS)[number];
26
29
 
27
30
  /** Each footer link: the label that shows it and the URL the caller passes. */
28
31
  const FOOTER_LINKS: Record<FooterLabelKey, string> = {
@@ -30,8 +33,6 @@ const FOOTER_LINKS: Record<FooterLabelKey, string> = {
30
33
  preferencesLabel: PREFERENCES_URL_PATH,
31
34
  };
32
35
 
33
- const LABEL_KEYS = Object.keys(FOOTER_LINKS) as FooterLabelKey[];
34
-
35
36
  export function isMarketingCategory(category?: string | null): boolean {
36
37
  return category === MARKETING_CATEGORY;
37
38
  }
@@ -21,8 +21,7 @@ const MISSING_TOKEN: Record<InterpolationMode, (token: string) => string> = {
21
21
 
22
22
  export function extractTokens(text: string): string[] {
23
23
  const found = new Set<string>();
24
- for (const match of text.matchAll(TOKEN_PATTERN))
25
- found.add(match[1] as string);
24
+ for (const match of text.matchAll(TOKEN_PATTERN)) found.add(match[1]);
26
25
  return [...found];
27
26
  }
28
27
 
@@ -313,11 +313,13 @@ export class SendsPageController extends PageController(
313
313
  permissionMeta("send_drawer", "i-ph-info"),
314
314
  ),
315
315
  title: "$dms_mailing.sends.actions.details",
316
- // Names the open send: its recipient and the template it used.
316
+ /** Names the open send: its recipient and the template it used. */
317
317
  rowTitle: "$dms_mailing.sends.drawer.heading",
318
318
  positionLabel: "$dms_mailing.sends.drawer.position",
319
- // A side panel keeps the open send in the URL and words a missing
320
- // one by default; a drawer does both only when asked.
319
+ /**
320
+ * A side panel keeps the open send in the URL and words a missing
321
+ * one by default; a drawer does both only when asked.
322
+ */
321
323
  deepLink: true,
322
324
  notFound: {},
323
325
  },
@@ -146,7 +146,7 @@ export class TemplatesPageController extends PageController(
146
146
  deepLink: true,
147
147
  target: {
148
148
  type: "drawer",
149
- // On the right, as the gallery opens it (useTemplateDrawer).
149
+ /** On the right, as the gallery opens it (`useTemplateDrawer`). */
150
150
  direction: "right",
151
151
  component: templateDrawer,
152
152
  title: "$dms_mailing.templates.actions.details",
@@ -65,8 +65,10 @@ export function mailingToolbar(
65
65
  id: period.scope,
66
66
  presets: period.presets,
67
67
  defaultPreset: period.defaultPreset,
68
- // More than one comparison: pageToolbar() draws them as a "vs …" menu
69
- // on the first one, the previous period.
68
+ /**
69
+ * More than one comparison: `pageToolbar()` draws them as a "vs …" menu
70
+ * on the first one, the previous period.
71
+ */
70
72
  comparisons: MAILING_COMPARISONS,
71
73
  },
72
74
  }).meta({
@@ -17,15 +17,12 @@ import {
17
17
  secretRefusal,
18
18
  WEBHOOK_SECRET_HEADER,
19
19
  } from "../services/webhook-status";
20
- import {
21
- SEND_EVENT_TYPES,
22
- type SendEventType,
23
- type WebhookRefusal,
24
- } from "../types";
20
+ import type { WebhookRefusal } from "../types";
21
+ import { sendEventTypeSchema } from "../validation/send-event-type.schema";
25
22
 
26
23
  const eventSchema = z.object({
27
24
  messageId: z.string().min(1),
28
- type: z.enum(SEND_EVENT_TYPES as [SendEventType, ...SendEventType[]]),
25
+ type: sendEventTypeSchema,
29
26
  at: z.coerce.date().optional(),
30
27
  details: z.record(z.unknown()).optional(),
31
28
  });
@@ -83,18 +83,20 @@ export interface TopMetric {
83
83
  minDeliveries: number;
84
84
  }
85
85
 
86
+ const OPEN_RATE_METRIC: TopMetric = {
87
+ extract: (totals) => totals.openRate,
88
+ minDeliveries: MIN_RANKED_DELIVERIES,
89
+ };
90
+
86
91
  const TOP_METRICS: Record<string, TopMetric> = {
87
- "open-rate": {
88
- extract: (totals) => totals.openRate,
89
- minDeliveries: MIN_RANKED_DELIVERIES,
90
- },
92
+ "open-rate": OPEN_RATE_METRIC,
91
93
  "click-rate": {
92
94
  extract: (totals) => totals.clickRate,
93
95
  minDeliveries: MIN_RANKED_DELIVERIES,
94
96
  },
95
97
  sends: { extract: (totals) => totals.sends, minDeliveries: 0 },
96
98
  };
97
- const DEFAULT_TOP_METRIC = TOP_METRICS["open-rate"] as TopMetric;
99
+ const DEFAULT_TOP_METRIC = OPEN_RATE_METRIC;
98
100
 
99
101
  interface FunnelStepDefinition {
100
102
  id: string;
@@ -10,7 +10,7 @@ import {
10
10
  } from "@antelopejs/interface-api";
11
11
  import { assert, assertValidation } from "@antelopejs/interface-api-util";
12
12
  import { AuthUserWithPermission } from "@antelopejs/interface-dms/guards";
13
- import { ZodError } from "zod";
13
+ import { ZodError, type z } from "zod";
14
14
  import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
15
15
  import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
16
16
  import type { User } from "@antelopejs/interface-dms/auth/db";
@@ -68,13 +68,9 @@ import {
68
68
  requireTemplate,
69
69
  snapshotOfVersion,
70
70
  } from "../services/templates";
71
- import type {
72
- SendStatus,
73
- SuppressionSummary,
74
- TemplateContent,
75
- TemplateStatus,
76
- } from "../types";
71
+ import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
77
72
  import {
73
+ type previewVersionSchema,
78
74
  realSendSchema,
79
75
  replayFailedSchema,
80
76
  replaySchema,
@@ -98,12 +94,6 @@ const DRAFT_VERSION = 0;
98
94
  /** Renders as the recipient got it, not as the editor previews it. */
99
95
  const SEND_MODE: InterpolationMode = "send";
100
96
 
101
- interface TestSendInput {
102
- locale: string;
103
- content?: TemplateContent;
104
- data?: Record<string, unknown>;
105
- }
106
-
107
97
  export interface SendTemplateSummary {
108
98
  id: string;
109
99
  name: string;
@@ -274,9 +264,9 @@ export class SendsController extends Controller(API_BASE_PATH) {
274
264
  tenantId: this.tenantId,
275
265
  to: input.to,
276
266
  allowSuppressed: [this.user.email],
277
- locale: (input as TestSendInput).locale,
278
- variables: (input as TestSendInput).data,
279
- content: (input as TestSendInput).content,
267
+ locale: input.locale,
268
+ variables: input.data,
269
+ content: input.content,
280
270
  version: requestedVersionOf(input.version),
281
271
  isTest: true,
282
272
  source: TEST_SEND_SOURCE,
@@ -578,8 +568,10 @@ export class SendsController extends Controller(API_BASE_PATH) {
578
568
  * The version a test of a published version records: a number names one,
579
569
  * "draft" or "published" let the send decide.
580
570
  */
581
- function requestedVersionOf(version: unknown): number | undefined {
582
- return typeof version === "number" ? version : undefined;
571
+ function requestedVersionOf(
572
+ version: z.infer<typeof previewVersionSchema> | undefined,
573
+ ): number | undefined {
574
+ return version === "draft" || version === "published" ? undefined : version;
583
575
  }
584
576
 
585
577
  function variablesOf(send: MailingSend): Record<string, unknown> {
@@ -48,6 +48,7 @@ import {
48
48
  settingsPatchSchema,
49
49
  } from "../validation/settings.schema";
50
50
  import { retentionLimit } from "../crons/retention";
51
+ import { isJsonObject } from "../validation/guards";
51
52
 
52
53
  const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
53
54
  const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
@@ -61,9 +62,9 @@ const NEXT_RUN_MINUTE = 15;
61
62
  const RETENTION_DAYS_PARAM = "days";
62
63
 
63
64
  /** A settings field the form shows under another name. */
64
- const FORM_FIELD_OF: Record<string, string> = {
65
- blockOnMissingVariables: "missingVariables",
66
- };
65
+ const FORM_FIELD_OF = new Map([
66
+ ["blockOnMissingVariables", "missingVariables"],
67
+ ]);
67
68
 
68
69
  const retentionDaysSchema = z.coerce
69
70
  .number()
@@ -141,12 +142,12 @@ function webhookUrl(
141
142
  /** The issue of a refused write, under the form field it belongs to. */
142
143
  function formIssue(issue: z.ZodIssue): z.ZodIssue {
143
144
  const [field, ...rest] = issue.path;
144
- const formField = FORM_FIELD_OF[String(field)];
145
+ const formField = FORM_FIELD_OF.get(String(field));
145
146
  return formField ? { ...issue, path: [formField, ...rest] } : issue;
146
147
  }
147
148
 
148
149
  /** A refused write: the generic message, and each issue for its field. */
149
- function invalidSettings(error: unknown): unknown {
150
+ function invalidSettings(error: unknown) {
150
151
  if (!(error instanceof z.ZodError)) return INVALID_SETTINGS;
151
152
  return { message: INVALID_SETTINGS, issues: error.issues.map(formIssue) };
152
153
  }
@@ -236,11 +237,9 @@ export class MailingSettingsController extends Controller(
236
237
  /** The body as a checked patch; 400 for anything else. */
237
238
  private readPatch(raw: Buffer): SettingsPatch {
238
239
  const body: unknown = parseBody(raw);
239
- const isPatch =
240
- typeof body === "object" && body !== null && !Array.isArray(body);
241
- assert(isPatch, HTTP_BAD_REQUEST, INVALID_SETTINGS);
240
+ assert(isJsonObject(body), HTTP_BAD_REQUEST, INVALID_SETTINGS);
242
241
  return assertValidation(
243
- clearedToEmpty(body as Record<string, unknown>),
242
+ clearedToEmpty(body),
244
243
  (value) => settingsPatchSchema.parse(value),
245
244
  invalidSettings,
246
245
  );
@@ -33,7 +33,7 @@ const manualSuppressionSchema = z.object({
33
33
  });
34
34
 
35
35
  /** A refused entry: the generic message, and each issue under its field. */
36
- function invalidSuppression(error: unknown): unknown {
36
+ function invalidSuppression(error: unknown) {
37
37
  if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
38
38
  return { message: INVALID_SUPPRESSION, issues: error.issues };
39
39
  }
@@ -352,7 +352,7 @@ export class TemplatesController extends Controller(
352
352
  (value) => saveContentSchema.parse(value),
353
353
  () => INVALID_CONTENT,
354
354
  );
355
- const content = input.content as TemplateContent;
355
+ const { content } = input;
356
356
  const template = await requireTemplate(this.templates, id);
357
357
  assertCurrentDraft(template, input.revision);
358
358
  const variables = input.variables ?? workingVariables(template);
@@ -514,7 +514,7 @@ export class TemplatesController extends Controller(
514
514
  const { content, variables } = input.content
515
515
  ? {
516
516
  ...workingSnapshot(template),
517
- content: input.content as TemplateContent,
517
+ content: input.content,
518
518
  }
519
519
  : await this.previewSnapshot(template, input.version);
520
520
  const settings = await getSettings(this.tenantId);
@@ -542,9 +542,9 @@ export class TemplatesController extends Controller(
542
542
  version: "draft" | "published" | number | undefined,
543
543
  ): Promise<TemplateDraft> | TemplateDraft {
544
544
  if (version === "published") return publishedSnapshot(template);
545
- if (typeof version === "number")
546
- return snapshotOfVersion(this.tenantId, template, version);
547
- return workingSnapshot(template);
545
+ if (version === undefined || version === "draft")
546
+ return workingSnapshot(template);
547
+ return snapshotOfVersion(this.tenantId, template, version);
548
548
  }
549
549
 
550
550
  /**
@@ -11,6 +11,7 @@ import {
11
11
  type TemplateAttentionReason,
12
12
  } from "../types";
13
13
  import { isDeliveryUntracked, type ProviderTracking } from "./metrics";
14
+ import { isNumber } from "../validation/guards";
14
15
 
15
16
  export type AttentionTone = "error" | "warning" | "neutral";
16
17
 
@@ -238,7 +239,7 @@ function namesOf(templates: MailingTemplate[]): ComposedTextParam {
238
239
  };
239
240
  }
240
241
 
241
- function problemBreakdown(problems: SendSummary[]): Record<string, number> {
242
+ function problemBreakdown(problems: SendSummary[]) {
242
243
  const countOf = (status: string) =>
243
244
  problems.filter((send) => send.status === status).length;
244
245
  return {
@@ -334,9 +335,9 @@ const COUNT_PARAM = "count";
334
335
  function feedParams(
335
336
  item: AttentionItem,
336
337
  ): Record<string, ComposedTextParam> | undefined {
337
- const params: Record<string, ComposedTextParam> = { ...item.params };
338
+ const params = { ...item.params };
338
339
  const count = item.count ?? item.params?.[COUNT_PARAM];
339
- if (typeof count === "number") {
340
+ if (isNumber(count)) {
340
341
  params[COUNT_PARAM] = { type: "count", value: count };
341
342
  }
342
343
  return Object.keys(params).length ? params : undefined;