@antelopejs/dms-mailing 0.7.0 → 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 (219) hide show
  1. package/README.md +28 -10
  2. package/dist/constants.d.ts +0 -2
  3. package/dist/constants.js +1 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/crons/jobs.d.ts +1 -1
  6. package/dist/data/body.js +2 -1
  7. package/dist/data/body.js.map +1 -1
  8. package/dist/data/templates-table.js +5 -11
  9. package/dist/data/templates-table.js.map +1 -1
  10. package/dist/db/models/index.d.ts +0 -1
  11. package/dist/db/models/index.js +0 -1
  12. package/dist/db/models/index.js.map +1 -1
  13. package/dist/db/models/send-events.model.js +2 -3
  14. package/dist/db/models/send-events.model.js.map +1 -1
  15. package/dist/db/models/sends.model.js +2 -3
  16. package/dist/db/models/sends.model.js.map +1 -1
  17. package/dist/db/models/template-versions.model.js.map +1 -1
  18. package/dist/db/models/templates.model.js +4 -10
  19. package/dist/db/models/templates.model.js.map +1 -1
  20. package/dist/db/tables/index.d.ts +0 -1
  21. package/dist/db/tables/index.js +0 -1
  22. package/dist/db/tables/index.js.map +1 -1
  23. package/dist/db/tables/settings.table.d.ts +0 -1
  24. package/dist/db/tables/settings.table.js +0 -4
  25. package/dist/db/tables/settings.table.js.map +1 -1
  26. package/dist/db/tables/templates.table.d.ts +0 -3
  27. package/dist/db/tables/templates.table.js +1 -13
  28. package/dist/db/tables/templates.table.js.map +1 -1
  29. package/dist/engine/changes.js +2 -1
  30. package/dist/engine/changes.js.map +1 -1
  31. package/dist/engine/conditions.js +2 -3
  32. package/dist/engine/conditions.js.map +1 -1
  33. package/dist/engine/fallback-html.js +8 -8
  34. package/dist/engine/fallback-html.js.map +1 -1
  35. package/dist/engine/formats.js +6 -6
  36. package/dist/engine/formats.js.map +1 -1
  37. package/dist/engine/paths.js +2 -3
  38. package/dist/engine/paths.js.map +1 -1
  39. package/dist/engine/system-variables.js +1 -1
  40. package/dist/engine/system-variables.js.map +1 -1
  41. package/dist/engine/tokens.js.map +1 -1
  42. package/dist/hooks/index.d.ts +0 -1
  43. package/dist/hooks/index.js +0 -1
  44. package/dist/hooks/index.js.map +1 -1
  45. package/dist/implementations/dms-mailing/index.d.ts +3 -4
  46. package/dist/implementations/dms-mailing/index.js +8 -9
  47. package/dist/implementations/dms-mailing/index.js.map +1 -1
  48. package/dist/index.js +0 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/pages/sends.js.map +1 -1
  51. package/dist/pages/settings/form.js +8 -0
  52. package/dist/pages/settings/form.js.map +1 -1
  53. package/dist/pages/toolbar.js.map +1 -1
  54. package/dist/routes/events.js +2 -2
  55. package/dist/routes/events.js.map +1 -1
  56. package/dist/routes/metrics.js +6 -5
  57. package/dist/routes/metrics.js.map +1 -1
  58. package/dist/routes/sends.js +1 -1
  59. package/dist/routes/sends.js.map +1 -1
  60. package/dist/routes/settings.js +6 -6
  61. package/dist/routes/settings.js.map +1 -1
  62. package/dist/routes/templates.js +6 -10
  63. package/dist/routes/templates.js.map +1 -1
  64. package/dist/services/attention.js +2 -1
  65. package/dist/services/attention.js.map +1 -1
  66. package/dist/services/categories.js +6 -6
  67. package/dist/services/categories.js.map +1 -1
  68. package/dist/services/checked-send.js +16 -9
  69. package/dist/services/checked-send.js.map +1 -1
  70. package/dist/services/declarations.js +42 -184
  71. package/dist/services/declarations.js.map +1 -1
  72. package/dist/services/events.js +11 -7
  73. package/dist/services/events.js.map +1 -1
  74. package/dist/services/localized-text.d.ts +2 -1
  75. package/dist/services/localized-text.js +2 -1
  76. package/dist/services/localized-text.js.map +1 -1
  77. package/dist/services/metrics.js.map +1 -1
  78. package/dist/services/render.js.map +1 -1
  79. package/dist/services/send-import.js +7 -5
  80. package/dist/services/send-import.js.map +1 -1
  81. package/dist/services/send.js +4 -3
  82. package/dist/services/send.js.map +1 -1
  83. package/dist/services/settings.d.ts +1 -1
  84. package/dist/services/settings.js +1 -5
  85. package/dist/services/settings.js.map +1 -1
  86. package/dist/services/starters.js +5 -5
  87. package/dist/services/starters.js.map +1 -1
  88. package/dist/services/template-stats.js +5 -2
  89. package/dist/services/template-stats.js.map +1 -1
  90. package/dist/services/templates.d.ts +2 -2
  91. package/dist/services/templates.js +54 -29
  92. package/dist/services/templates.js.map +1 -1
  93. package/dist/types/settings.d.ts +2 -0
  94. package/dist/types/settings.js +8 -1
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +16 -16
  97. package/dist/validation/declarations.schema.js +2 -2
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/guards.d.ts +4 -0
  100. package/dist/validation/guards.js +19 -0
  101. package/dist/validation/guards.js.map +1 -0
  102. package/dist/validation/send-event-type.schema.d.ts +2 -0
  103. package/dist/validation/send-event-type.schema.js +11 -0
  104. package/dist/validation/send-event-type.schema.js.map +1 -0
  105. package/dist/validation/settings.schema.d.ts +16 -16
  106. package/dist/validation/settings.schema.js +1 -1
  107. package/dist/validation/settings.schema.js.map +1 -1
  108. package/dist/validation/templates.schema.d.ts +19 -18
  109. package/dist/validation/templates.schema.js.map +1 -1
  110. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  111. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  112. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  113. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  114. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  115. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  116. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  117. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  118. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  119. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  120. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  121. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  122. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  123. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  124. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  125. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  126. package/frontend-vue/build/backend-boundary.ts +122 -0
  127. package/frontend-vue/dms.frontend.build.ts +7 -0
  128. package/frontend-vue/eslint.config.mjs +13 -0
  129. package/frontend-vue/package.json +4 -2
  130. package/frontend-vue/pnpm-lock.yaml +210 -61
  131. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  132. package/frontend-vue/tests/dnd.test.ts +1 -2
  133. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  134. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  135. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  136. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  137. package/frontend-vue/tests/page-reads.test.ts +26 -6
  138. package/frontend-vue/tests/send-actions.test.ts +0 -1
  139. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  140. package/frontend-vue/tests/send-modals.test.ts +8 -5
  141. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  142. package/frontend-vue/tests/suppression.test.ts +1 -2
  143. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  144. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  145. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  146. package/package.json +8 -13
  147. package/skills/dms-mailing/SKILL.md +11 -17
  148. package/skills/dms-mailing/cases.json +49 -0
  149. package/src/constants.ts +0 -2
  150. package/src/crons/jobs.ts +1 -1
  151. package/src/data/body.ts +2 -1
  152. package/src/data/templates-table.ts +15 -22
  153. package/src/db/models/index.ts +0 -1
  154. package/src/db/models/send-events.model.ts +2 -2
  155. package/src/db/models/sends.model.ts +7 -8
  156. package/src/db/models/template-versions.model.ts +5 -1
  157. package/src/db/models/templates.model.ts +9 -16
  158. package/src/db/tables/index.ts +0 -1
  159. package/src/db/tables/settings.table.ts +0 -8
  160. package/src/db/tables/templates.table.ts +2 -16
  161. package/src/engine/changes.ts +3 -2
  162. package/src/engine/conditions.ts +2 -4
  163. package/src/engine/fallback-html.ts +13 -13
  164. package/src/engine/formats.ts +6 -6
  165. package/src/engine/paths.ts +4 -3
  166. package/src/engine/system-variables.ts +4 -3
  167. package/src/engine/tokens.ts +1 -2
  168. package/src/hooks/index.ts +0 -1
  169. package/src/implementations/dms-mailing/index.ts +5 -18
  170. package/src/index.ts +1 -6
  171. package/src/pages/sends.ts +5 -3
  172. package/src/pages/settings/form.ts +16 -0
  173. package/src/pages/templates.ts +1 -1
  174. package/src/pages/toolbar.ts +4 -2
  175. package/src/routes/events.ts +3 -6
  176. package/src/routes/metrics.ts +7 -5
  177. package/src/routes/sends.ts +10 -18
  178. package/src/routes/settings.ts +8 -9
  179. package/src/routes/suppressions.ts +1 -1
  180. package/src/routes/templates.ts +16 -18
  181. package/src/services/attention.ts +4 -3
  182. package/src/services/categories.ts +6 -6
  183. package/src/services/checked-send.ts +17 -19
  184. package/src/services/declarations.ts +62 -318
  185. package/src/services/events.ts +16 -13
  186. package/src/services/localized-text.ts +4 -2
  187. package/src/services/metrics.ts +2 -2
  188. package/src/services/notify.ts +1 -1
  189. package/src/services/render.ts +5 -1
  190. package/src/services/send-import.ts +18 -13
  191. package/src/services/send.ts +7 -7
  192. package/src/services/settings.ts +3 -10
  193. package/src/services/starters.ts +6 -6
  194. package/src/services/template-stats.ts +11 -4
  195. package/src/services/templates.ts +69 -52
  196. package/src/types/settings.ts +20 -0
  197. package/src/validation/declarations.schema.ts +2 -2
  198. package/src/validation/guards.ts +25 -0
  199. package/src/validation/send-event-type.schema.ts +10 -0
  200. package/src/validation/settings.schema.ts +7 -2
  201. package/src/validation/templates.schema.ts +2 -1
  202. package/dist/db/models/template-slugs.model.d.ts +0 -27
  203. package/dist/db/models/template-slugs.model.js +0 -43
  204. package/dist/db/models/template-slugs.model.js.map +0 -1
  205. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  206. package/dist/db/tables/template-slugs.table.js +0 -35
  207. package/dist/db/tables/template-slugs.table.js.map +0 -1
  208. package/dist/hooks/database-ready.d.ts +0 -7
  209. package/dist/hooks/database-ready.js +0 -26
  210. package/dist/hooks/database-ready.js.map +0 -1
  211. package/dist/services/fingerprint.d.ts +0 -1
  212. package/dist/services/fingerprint.js +0 -20
  213. package/dist/services/fingerprint.js.map +0 -1
  214. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  215. package/frontend-vue/tests/i18n.test.ts +0 -138
  216. package/src/db/models/template-slugs.model.ts +0 -61
  217. package/src/db/tables/template-slugs.table.ts +0 -29
  218. package/src/hooks/database-ready.ts +0 -47
  219. package/src/services/fingerprint.ts +0 -22
@@ -1,21 +1,8 @@
1
- import { afterDatabaseReady } from "../../hooks/database-ready";
2
- import {
3
- declareTemplate,
4
- declareTemplateCategory,
1
+ export {
2
+ declareTemplate as DeclareTemplate,
3
+ declareTemplateCategory as DeclareTemplateCategory,
5
4
  } from "../../services/declarations";
6
- import { importSends } from "../../services/send-import";
7
- import { declareSender } from "../../services/sender-declaration";
8
-
5
+ export { importSends as ImportSends } from "../../services/send-import";
6
+ export { declareSender as DeclareSender } from "../../services/sender-declaration";
9
7
  export { recordEmailEvent as RecordEmailEvent } from "../../services/events";
10
8
  export { sendUnlessSuppressed as SendTemplate } from "../../services/checked-send";
11
-
12
- /*
13
- * Projects call these from their `start()`, which runs in parallel with the
14
- * DMS's: they wait for the database the DMS opens there rather than fail.
15
- */
16
- export const DeclareTemplate = afterDatabaseReady(declareTemplate);
17
- export const DeclareTemplateCategory = afterDatabaseReady(
18
- declareTemplateCategory,
19
- );
20
- export const DeclareSender = afterDatabaseReady(declareSender);
21
- export const ImportSends = afterDatabaseReady(importSends);
package/src/index.ts CHANGED
@@ -14,11 +14,7 @@ import {
14
14
  scheduleJob,
15
15
  stopJob,
16
16
  } from "./crons";
17
- import {
18
- registerDatabaseReady,
19
- registerTenantExport,
20
- unregisterTenantExport,
21
- } from "./hooks";
17
+ import { registerTenantExport, unregisterTenantExport } from "./hooks";
22
18
  import { registerRealtimeTopics } from "./realtime";
23
19
  import { frontendModule } from "./frontend-module";
24
20
 
@@ -34,7 +30,6 @@ const CRON_JOBS = [RETENTION_JOB, ATTENTION_JOB];
34
30
  let scheduledJobs: ScheduledJob[] = [];
35
31
 
36
32
  export async function construct(config: DmsMailingConfig = {}): Promise<void> {
37
- registerDatabaseReady();
38
33
  setConfig(config);
39
34
  ImplementInterface(
40
35
  await import("@antelopejs/interface-dms-mailing"),
@@ -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
  },
@@ -7,6 +7,8 @@ import { ComponentBuilder } from "@antelopejs/interface-dms/component";
7
7
  import { z } from "zod";
8
8
  import { API_BASE_PATH } from "../../constants";
9
9
  import { TemplateCategoriesType } from "../../data-types";
10
+ import { missingVariablesPolicy } from "../../services/settings";
11
+ import { MODULE_DEFAULT_SETTINGS } from "../../types";
10
12
  import {
11
13
  MAX_RETENTION_DAYS,
12
14
  MIN_RETENTION_DAYS,
@@ -37,6 +39,18 @@ const field = (id: string) => ({
37
39
  description: key(`fields.${id}.description`),
38
40
  });
39
41
 
42
+ /**
43
+ * A field holding a module default offers it: "Module default · Use default"
44
+ * while it holds something else. The retention does not: one click there
45
+ * could shorten it, and lowering it deletes sends, which its input asks to
46
+ * confirm instead. Nor do the categories: putting the default list back
47
+ * would move every template of a custom category.
48
+ */
49
+ const moduleDefault = (defaultValue: string) => ({
50
+ defaultValue,
51
+ offersDefault: true,
52
+ });
53
+
40
54
  const section = (
41
55
  id: string,
42
56
  icon: string,
@@ -84,12 +98,14 @@ const guards = section("guards", "i-ph-shield-check", [
84
98
  ],
85
99
  }),
86
100
  required: true,
101
+ ...moduleDefault(missingVariablesPolicy(MODULE_DEFAULT_SETTINGS)),
87
102
  },
88
103
  {
89
104
  ...field("fallbackLocale"),
90
105
  type: new DefaultDataTypes.StringType({}),
91
106
  inputComponent: CustomComponent("MailingLocaleSelect").serializeSync(),
92
107
  required: true,
108
+ ...moduleDefault(MODULE_DEFAULT_SETTINGS.fallbackLocale),
93
109
  },
94
110
  ]);
95
111
 
@@ -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
  }
@@ -47,7 +47,6 @@ import {
47
47
  type TemplateStats,
48
48
  } from "../services/template-stats";
49
49
  import {
50
- assertSlugClaimed,
51
50
  contentFieldsOf,
52
51
  discardDraft,
53
52
  displayName,
@@ -70,6 +69,7 @@ import {
70
69
  workingSnapshot,
71
70
  workingContent,
72
71
  workingVariables,
72
+ refuseTakenSlug,
73
73
  requireTemplate,
74
74
  } from "../services/templates";
75
75
  import {
@@ -89,7 +89,6 @@ import {
89
89
  } from "../validation/templates.schema";
90
90
 
91
91
  const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
92
- const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
93
92
  const INVALID_CONTENT = "$dms_mailing.errors.invalid_content";
94
93
  const INVALID_TRANSITION = "$dms_mailing.errors.invalid_transition";
95
94
  const NOTHING_TO_PUBLISH = "$dms_mailing.errors.nothing_to_publish";
@@ -353,7 +352,7 @@ export class TemplatesController extends Controller(
353
352
  (value) => saveContentSchema.parse(value),
354
353
  () => INVALID_CONTENT,
355
354
  );
356
- const content = input.content as TemplateContent;
355
+ const { content } = input;
357
356
  const template = await requireTemplate(this.templates, id);
358
357
  assertCurrentDraft(template, input.revision);
359
358
  const variables = input.variables ?? workingVariables(template);
@@ -515,7 +514,7 @@ export class TemplatesController extends Controller(
515
514
  const { content, variables } = input.content
516
515
  ? {
517
516
  ...workingSnapshot(template),
518
- content: input.content as TemplateContent,
517
+ content: input.content,
519
518
  }
520
519
  : await this.previewSnapshot(template, input.version);
521
520
  const settings = await getSettings(this.tenantId);
@@ -543,9 +542,9 @@ export class TemplatesController extends Controller(
543
542
  version: "draft" | "published" | number | undefined,
544
543
  ): Promise<TemplateDraft> | TemplateDraft {
545
544
  if (version === "published") return publishedSnapshot(template);
546
- if (typeof version === "number")
547
- return snapshotOfVersion(this.tenantId, template, version);
548
- return workingSnapshot(template);
545
+ if (version === undefined || version === "draft")
546
+ return workingSnapshot(template);
547
+ return snapshotOfVersion(this.tenantId, template, version);
549
548
  }
550
549
 
551
550
  /**
@@ -573,17 +572,16 @@ export class TemplatesController extends Controller(
573
572
  duplicateSchema.parse(value),
574
573
  );
575
574
  const source = await requireTemplate(this.templates, id);
576
- const clash = await this.templates.getBySlug(slug);
577
- assert(!clash, HTTP_CONFLICT, DUPLICATE_SLUG);
578
- const [copyId] = await this.templates.insertFields({
579
- slug,
580
- name: withTextIn(undefined, this.locale, name),
581
- category: source.category,
582
- status: "draft",
583
- ...contentFieldsOf(source),
584
- updatedBy: this.author,
585
- });
586
- await assertSlugClaimed(this.tenantId, slug, copyId as string);
575
+ const [copyId] = await refuseTakenSlug(
576
+ this.templates.insertFields({
577
+ slug,
578
+ name: withTextIn(undefined, this.locale, name),
579
+ category: source.category,
580
+ status: "draft",
581
+ ...contentFieldsOf(source),
582
+ updatedBy: this.author,
583
+ }),
584
+ );
587
585
  await refreshTemplateAttention(this.tenantId);
588
586
  return { id: copyId };
589
587
  }
@@ -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;
@@ -48,10 +48,10 @@ export function reassignRemovedCategories(
48
48
  moves: ReadonlyMap<string, string> = new Map(),
49
49
  ): CategoryReassignment[] {
50
50
  const kept = new Set(categories.map((category) => category.id));
51
- return templates
52
- .filter((template) => template.category && !kept.has(template.category))
53
- .map((template) => ({
54
- templateId: template._id,
55
- category: moves.get(template.category as string) ?? "",
56
- }));
51
+ return templates.flatMap((template) => {
52
+ const { category } = template;
53
+ return category && !kept.has(category)
54
+ ? [{ templateId: template._id, category: moves.get(category) ?? "" }]
55
+ : [];
56
+ });
57
57
  }
@@ -10,6 +10,7 @@ import {
10
10
  sendTemplate,
11
11
  } from "./send";
12
12
  import { suppressionsOf } from "./suppressions";
13
+ import { isText } from "../validation/guards";
13
14
 
14
15
  /**
15
16
  * A send the module checks against the suppression list. `allowSuppressed`
@@ -24,8 +25,7 @@ export const SUPPRESSED_ERROR_PREFIX = "$dms_mailing.errors.suppressed.";
24
25
 
25
26
  const NO_LATENCY = null;
26
27
 
27
- const addressOf = (to: EmailAddress): string =>
28
- typeof to === "string" ? to : to.email;
28
+ const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
29
29
 
30
30
  const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
31
31
  Array.isArray(to) ? to : [to];
@@ -68,16 +68,10 @@ function suppressedMessage(
68
68
  async function recordSuppressed(
69
69
  slug: string,
70
70
  params: CheckedSendParams,
71
- blocked: EmailAddress[],
72
- rows: Map<string, MailingSuppression>,
71
+ blocked: { to: EmailAddress; row: MailingSuppression }[],
73
72
  ): Promise<SendTemplateResult[]> {
74
73
  const prepared = await prepare(params.tenantId, slug, params);
75
- const messages = blocked.map((to) =>
76
- suppressedMessage(
77
- to,
78
- rows.get(normalizeAddress(addressOf(to))) as MailingSuppression,
79
- ),
80
- );
74
+ const messages = blocked.map(({ to, row }) => suppressedMessage(to, row));
81
75
  return recordAll(prepared, params, messages);
82
76
  }
83
77
 
@@ -103,21 +97,25 @@ export async function sendUnlessSuppressed(
103
97
  const recipients = toRecipients(params.to);
104
98
  const rows = await blockedRecipients(params, recipients);
105
99
  if (!rows.size) return sendTemplate(slug, params);
106
- const isBlocked = (to: EmailAddress): boolean =>
107
- rows.has(normalizeAddress(addressOf(to)));
108
- const blocked = recipients.filter(isBlocked);
109
- const suppressedResults = await recordSuppressed(slug, params, blocked, rows);
100
+ const rowOf = (to: EmailAddress) => rows.get(normalizeAddress(addressOf(to)));
101
+ const isBlocked = (to: EmailAddress): boolean => rowOf(to) !== undefined;
102
+ const blocked = recipients.flatMap((to) => {
103
+ const row = rowOf(to);
104
+ return row ? [{ to, row }] : [];
105
+ });
106
+ const suppressedResults = await recordSuppressed(slug, params, blocked);
110
107
  const sentResults = await sendAllowed(
111
108
  slug,
112
109
  params,
113
110
  recipients.filter((to) => !isBlocked(to)),
114
111
  );
112
+ let nextSuppressed = 0;
113
+ let nextSent = 0;
115
114
  return aggregateResults(
116
- recipients.map(
117
- (to) =>
118
- (isBlocked(to)
119
- ? suppressedResults.shift()
120
- : sentResults.shift()) as SendTemplateResult,
115
+ recipients.map((to) =>
116
+ isBlocked(to)
117
+ ? suppressedResults[nextSuppressed++]
118
+ : sentResults[nextSent++],
121
119
  ),
122
120
  );
123
121
  }