@antelopejs/dms-mailing 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +46 -31
  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/crons/jobs.d.ts +1 -1
  7. package/dist/data/body.d.ts +2 -1
  8. package/dist/data/body.js +4 -2
  9. package/dist/data/body.js.map +1 -1
  10. package/dist/data/read-only-routes.d.ts +4 -4
  11. package/dist/data/sends-table.d.ts +4 -4
  12. package/dist/data/suppressions-table.d.ts +4 -4
  13. package/dist/data/templates-table.d.ts +35 -12
  14. package/dist/data/templates-table.js +30 -24
  15. package/dist/data/templates-table.js.map +1 -1
  16. package/dist/data-types/categories-type.d.ts +1 -2
  17. package/dist/data-types/categories-type.js +3 -5
  18. package/dist/data-types/categories-type.js.map +1 -1
  19. package/dist/data-types/category-type.d.ts +1 -2
  20. package/dist/data-types/category-type.js +3 -5
  21. package/dist/data-types/category-type.js.map +1 -1
  22. package/dist/db/models/send-events.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.d.ts +7 -5
  24. package/dist/db/models/sends.model.js.map +1 -1
  25. package/dist/db/models/settings.model.d.ts +7 -5
  26. package/dist/db/models/suppressions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.d.ts +7 -5
  28. package/dist/db/models/template-versions.model.js +4 -3
  29. package/dist/db/models/template-versions.model.js.map +1 -1
  30. package/dist/db/models/templates.model.d.ts +7 -5
  31. package/dist/db/models/templates.model.js +2 -1
  32. package/dist/db/models/templates.model.js.map +1 -1
  33. package/dist/db/models/webhook-status.model.d.ts +7 -5
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -18
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +5 -5
  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/fallback-html.js +8 -8
  43. package/dist/engine/fallback-html.js.map +1 -1
  44. package/dist/engine/formats.d.ts +4 -6
  45. package/dist/engine/formats.js +22 -8
  46. package/dist/engine/formats.js.map +1 -1
  47. package/dist/engine/paths.d.ts +4 -2
  48. package/dist/engine/paths.js +16 -5
  49. package/dist/engine/paths.js.map +1 -1
  50. package/dist/engine/plain-text.js +2 -4
  51. package/dist/engine/plain-text.js.map +1 -1
  52. package/dist/engine/resolve.d.ts +2 -1
  53. package/dist/engine/resolve.js +1 -1
  54. package/dist/engine/resolve.js.map +1 -1
  55. package/dist/engine/system-variables.d.ts +2 -1
  56. package/dist/engine/system-variables.js +1 -1
  57. package/dist/engine/system-variables.js.map +1 -1
  58. package/dist/engine/tokens.d.ts +2 -1
  59. package/dist/engine/tokens.js.map +1 -1
  60. package/dist/hooks/tenant-export.js +10 -9
  61. package/dist/hooks/tenant-export.js.map +1 -1
  62. package/dist/pages/sends.js.map +1 -1
  63. package/dist/pages/templates.js +3 -1
  64. package/dist/pages/templates.js.map +1 -1
  65. package/dist/pages/toolbar.js.map +1 -1
  66. package/dist/routes/events.js +6 -4
  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 +31 -31
  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 +4 -5
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +1 -1
  78. package/dist/routes/settings.js +16 -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 +7 -6
  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 +16 -8
  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/categories.js +6 -6
  96. package/dist/services/categories.js.map +1 -1
  97. package/dist/services/checked-send.js +16 -9
  98. package/dist/services/checked-send.js.map +1 -1
  99. package/dist/services/declaration-error.d.ts +1 -1
  100. package/dist/services/declaration-error.js.map +1 -1
  101. package/dist/services/declarations.js +4 -1
  102. package/dist/services/declarations.js.map +1 -1
  103. package/dist/services/events.js +13 -7
  104. package/dist/services/events.js.map +1 -1
  105. package/dist/services/localized-text.d.ts +2 -1
  106. package/dist/services/localized-text.js +2 -1
  107. package/dist/services/localized-text.js.map +1 -1
  108. package/dist/services/metrics.js +2 -5
  109. package/dist/services/metrics.js.map +1 -1
  110. package/dist/services/render.js.map +1 -1
  111. package/dist/services/send-import.js +7 -3
  112. package/dist/services/send-import.js.map +1 -1
  113. package/dist/services/send.js +7 -7
  114. package/dist/services/send.js.map +1 -1
  115. package/dist/services/settings.d.ts +4 -2
  116. package/dist/services/settings.js +5 -4
  117. package/dist/services/settings.js.map +1 -1
  118. package/dist/services/starters.d.ts +3 -2
  119. package/dist/services/starters.js +5 -5
  120. package/dist/services/starters.js.map +1 -1
  121. package/dist/services/template-stats.js +5 -2
  122. package/dist/services/template-stats.js.map +1 -1
  123. package/dist/services/templates.d.ts +2 -1
  124. package/dist/services/templates.js +8 -4
  125. package/dist/services/templates.js.map +1 -1
  126. package/dist/services/webhook-status.d.ts +2 -2
  127. package/dist/services/webhook-status.js +6 -4
  128. package/dist/services/webhook-status.js.map +1 -1
  129. package/dist/types/json.d.ts +1 -0
  130. package/dist/types/json.js +3 -0
  131. package/dist/types/json.js.map +1 -0
  132. package/dist/types/settings.d.ts +2 -2
  133. package/dist/validation/declarations.schema.d.ts +44 -44
  134. package/dist/validation/declarations.schema.js +6 -5
  135. package/dist/validation/declarations.schema.js.map +1 -1
  136. package/dist/validation/guards.d.ts +3 -0
  137. package/dist/validation/guards.js +15 -0
  138. package/dist/validation/guards.js.map +1 -0
  139. package/dist/validation/json.d.ts +5 -0
  140. package/dist/validation/json.js +54 -0
  141. package/dist/validation/json.js.map +1 -0
  142. package/dist/validation/json.schema.d.ts +2 -0
  143. package/dist/validation/json.schema.js +9 -0
  144. package/dist/validation/json.schema.js.map +1 -0
  145. package/dist/validation/send-event-type.schema.d.ts +2 -0
  146. package/dist/validation/send-event-type.schema.js +11 -0
  147. package/dist/validation/send-event-type.schema.js.map +1 -0
  148. package/dist/validation/settings.schema.js +1 -1
  149. package/dist/validation/settings.schema.js.map +1 -1
  150. package/dist/validation/templates.schema.d.ts +44 -42
  151. package/dist/validation/templates.schema.js +4 -3
  152. package/dist/validation/templates.schema.js.map +1 -1
  153. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  154. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  155. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  156. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  157. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  158. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  159. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  160. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  161. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  162. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  163. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  164. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  165. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  166. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  167. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  168. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  169. package/frontend-vue/build/backend-boundary.ts +122 -0
  170. package/frontend-vue/dms.frontend.build.ts +7 -0
  171. package/frontend-vue/eslint.config.mjs +13 -0
  172. package/frontend-vue/package.json +4 -2
  173. package/frontend-vue/pnpm-lock.yaml +250 -61
  174. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  175. package/frontend-vue/tests/dnd.test.ts +1 -2
  176. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  177. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  178. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  179. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  180. package/frontend-vue/tests/page-reads.test.ts +26 -6
  181. package/frontend-vue/tests/send-actions.test.ts +0 -1
  182. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  183. package/frontend-vue/tests/send-modals.test.ts +8 -5
  184. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  185. package/frontend-vue/tests/suppression.test.ts +1 -2
  186. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  187. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  188. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  189. package/package.json +16 -20
  190. package/skills/dms-mailing/SKILL.md +1 -1
  191. package/skills/dms-mailing/cases.json +49 -0
  192. package/src/automation/email-event-trigger.ts +2 -2
  193. package/src/automation/send-template-action.ts +33 -14
  194. package/src/crons/jobs.ts +1 -1
  195. package/src/data/body.ts +7 -3
  196. package/src/data/templates-table.ts +55 -56
  197. package/src/data-types/categories-type.ts +2 -2
  198. package/src/data-types/category-type.ts +2 -2
  199. package/src/db/models/sends.model.ts +2 -2
  200. package/src/db/models/template-versions.model.ts +10 -4
  201. package/src/db/models/templates.model.ts +6 -3
  202. package/src/db/tables/templates.table.ts +1 -1
  203. package/src/engine/changes.ts +11 -28
  204. package/src/engine/conditions.ts +14 -8
  205. package/src/engine/coverage.ts +2 -1
  206. package/src/engine/fallback-html.ts +13 -13
  207. package/src/engine/formats.ts +35 -21
  208. package/src/engine/paths.ts +41 -7
  209. package/src/engine/plain-text.ts +2 -4
  210. package/src/engine/resolve.ts +12 -4
  211. package/src/engine/system-variables.ts +6 -4
  212. package/src/engine/tokens.ts +3 -3
  213. package/src/hooks/tenant-export.ts +11 -13
  214. package/src/pages/sends.ts +5 -3
  215. package/src/pages/templates.ts +4 -2
  216. package/src/pages/toolbar.ts +4 -2
  217. package/src/routes/events.ts +10 -10
  218. package/src/routes/index.ts +1 -0
  219. package/src/routes/metrics.ts +35 -33
  220. package/src/routes/period.ts +5 -5
  221. package/src/routes/sends.ts +23 -26
  222. package/src/routes/settings.ts +21 -17
  223. package/src/routes/suppressions.ts +12 -8
  224. package/src/routes/template-content.ts +45 -0
  225. package/src/routes/templates.ts +16 -49
  226. package/src/routes/zod-json.ts +28 -0
  227. package/src/services/attention.ts +20 -18
  228. package/src/services/catalog.ts +4 -8
  229. package/src/services/categories.ts +6 -6
  230. package/src/services/checked-send.ts +17 -19
  231. package/src/services/declaration-error.ts +1 -1
  232. package/src/services/declarations.ts +7 -6
  233. package/src/services/events.ts +18 -13
  234. package/src/services/localized-text.ts +4 -2
  235. package/src/services/metrics.ts +5 -7
  236. package/src/services/notify.ts +1 -1
  237. package/src/services/render.ts +5 -1
  238. package/src/services/send-import.ts +16 -11
  239. package/src/services/send.ts +10 -13
  240. package/src/services/settings.ts +17 -16
  241. package/src/services/starters.ts +12 -8
  242. package/src/services/template-stats.ts +11 -4
  243. package/src/services/templates.ts +24 -15
  244. package/src/services/webhook-status.ts +13 -8
  245. package/src/types/json.ts +1 -0
  246. package/src/types/settings.ts +2 -2
  247. package/src/validation/declarations.schema.ts +6 -5
  248. package/src/validation/guards.ts +17 -0
  249. package/src/validation/json.schema.ts +7 -0
  250. package/src/validation/json.ts +66 -0
  251. package/src/validation/send-event-type.schema.ts +10 -0
  252. package/src/validation/settings.schema.ts +7 -2
  253. package/src/validation/templates.schema.ts +9 -4
  254. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  255. package/frontend-vue/tests/i18n.test.ts +0 -138
@@ -1,21 +1,17 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ModuleStatus } from "@antelopejs/interface-dms/page";
4
+ import { isText } from "../validation/guards";
5
+ import { parseJsonObject } from "../validation/json";
4
6
  import { readCapabilities } from "./provider";
5
7
 
6
8
  const PACKAGE_JSON_PATH = path.join(__dirname, "../../package.json");
7
9
 
8
- interface PackageManifest {
9
- version?: string;
10
- }
11
-
12
10
  /** The module's published version, as the catalog tile and the sidebar show it. */
13
11
  export function moduleVersion(): string | undefined {
14
12
  try {
15
- const manifest = JSON.parse(
16
- readFileSync(PACKAGE_JSON_PATH, "utf8"),
17
- ) as PackageManifest;
18
- return manifest.version;
13
+ const manifest = parseJsonObject(readFileSync(PACKAGE_JSON_PATH, "utf8"));
14
+ return isText(manifest.version) ? manifest.version : undefined;
19
15
  } catch {
20
16
  return undefined;
21
17
  }
@@ -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
  }
@@ -26,7 +26,7 @@ function describeIssues(error: ZodError): string {
26
26
  /** `input` parsed by `schema`, or a `DeclarationError` under `code`. */
27
27
  export function parseDeclared<Output, Input>(
28
28
  schema: ZodType<Output, ZodTypeDef, Input>,
29
- input: unknown,
29
+ input: Input,
30
30
  code: string,
31
31
  subject: string,
32
32
  ): Output {
@@ -93,16 +93,14 @@ async function createDeclared(
93
93
  ...publishedAtCreation(
94
94
  randomUUID(),
95
95
  {
96
- content: declaration.content as TemplateContent,
96
+ content: declaration.content,
97
97
  variables: declaration.variables,
98
98
  },
99
99
  author,
100
100
  ),
101
101
  });
102
- template = await requireTemplate(model, id as string);
102
+ template = await requireTemplate(model, id);
103
103
  } catch (error) {
104
- // A concurrent declaration of the slug created it first: expected, the
105
- // caller goes on with that template.
106
104
  if (IsDuplicateKeyError(error)) return undefined;
107
105
  throw error;
108
106
  }
@@ -127,7 +125,7 @@ export async function declareTemplate(
127
125
  `template declaration "${String(input?.slug)}"`,
128
126
  );
129
127
  const { tenantId, slug } = declaration;
130
- assertPublishable(declaration.content as TemplateContent, slug);
128
+ assertPublishable(declaration.content, slug);
131
129
  const model = GetModel(TemplateModel, tenantId);
132
130
  const existing = await model.getBySlug(slug);
133
131
  if (existing) return resultOf(existing, "kept");
@@ -135,7 +133,10 @@ export async function declareTemplate(
135
133
  assertKnownCategory(settings.categories, declaration.category, slug);
136
134
  const created = await createDeclared(declaration);
137
135
  if (created) return resultOf(created, "created");
138
- return resultOf((await model.getBySlug(slug)) as MailingTemplate, "kept");
136
+ const winner = await model.getBySlug(slug);
137
+ if (!winner)
138
+ throw new Error(`Template "${slug}" disappeared before it was read`);
139
+ return resultOf(winner, "kept");
139
140
  }
140
141
 
141
142
  /** Category declarations of one tenant run one after the other. */
@@ -16,6 +16,8 @@ import { publishSendUpdated } from "../realtime";
16
16
  import type { SendEventType, SendStatus } from "../types";
17
17
  import { notifyProblems } from "./notify";
18
18
  import { suppressAddress, suppressionReasonOf } from "./suppressions";
19
+ import { isText } from "../validation/guards";
20
+ import { isJsonObject } from "../validation/json";
19
21
 
20
22
  export interface SendCounters {
21
23
  status: SendStatus;
@@ -59,8 +61,11 @@ const HARD_BOUNCE_TYPE = /hard|permanent/i;
59
61
  /** A temporary SMTP answer: a `4xx` reply code or a `4.x.x` enhanced status. */
60
62
  const SOFT_SMTP_ANSWER = /^\s*4\d\d\b|\b4\.\d{1,3}\.\d{1,3}\b/;
61
63
 
62
- const textOf = (value: unknown): string =>
63
- typeof value === "string" ? value : "";
64
+ /** The text an event's details hold under `key`, else an empty string. */
65
+ function textOf(details: EmailEvent["details"], key: string): string {
66
+ const value = details?.[key];
67
+ return isText(value) ? value : "";
68
+ }
64
69
 
65
70
  /**
66
71
  * The provider's own words for a problem event (`550 5.1.1 Mailbox does not
@@ -70,7 +75,7 @@ const textOf = (value: unknown): string =>
70
75
  export function problemReason(event: EmailEvent): string | undefined {
71
76
  if (!PROBLEM_EVENTS.has(event.type)) return undefined;
72
77
  const reason = event.details?.reason;
73
- return typeof reason === "string" && reason ? reason : undefined;
78
+ return isText(reason) && reason ? reason : undefined;
74
79
  }
75
80
 
76
81
  function overridesStatus(current: SendStatus, next: SendStatus): boolean {
@@ -95,10 +100,10 @@ export function applyEvent(
95
100
  * provider's `bounceType` when it gives one, else the SMTP answer's class.
96
101
  */
97
102
  export function isSoftBounce(event: EmailEvent): boolean {
98
- const bounceType = textOf(event.details?.bounceType);
103
+ const bounceType = textOf(event.details, "bounceType");
99
104
  if (SOFT_BOUNCE_TYPE.test(bounceType)) return true;
100
105
  if (HARD_BOUNCE_TYPE.test(bounceType)) return false;
101
- return SOFT_SMTP_ANSWER.test(textOf(event.details?.reason));
106
+ return SOFT_SMTP_ANSWER.test(textOf(event.details, "reason"));
102
107
  }
103
108
 
104
109
  /**
@@ -144,8 +149,8 @@ async function publishEmailEvent(
144
149
  });
145
150
  }
146
151
 
147
- const eventIdOf = (details: Record<string, unknown> | undefined): string =>
148
- textOf(details?.eventId);
152
+ const eventIdOf = (details: EmailEvent["details"]): string =>
153
+ textOf(details, "eventId");
149
154
 
150
155
  type StoredEventIdentity = Pick<
151
156
  MailingSendEvent,
@@ -163,11 +168,11 @@ export function isSameEvent(
163
168
  ): boolean {
164
169
  const eventId = eventIdOf(event.details);
165
170
  if (eventId) {
166
- const storedDetails = JSON.parse(stored.json_details || "{}") as Record<
167
- string,
168
- unknown
169
- >;
170
- return eventIdOf(storedDetails) === eventId;
171
+ const storedDetails: unknown = JSON.parse(stored.json_details || "{}");
172
+ return (
173
+ eventIdOf(isJsonObject(storedDetails) ? storedDetails : undefined) ===
174
+ eventId
175
+ );
171
176
  }
172
177
  if (!event.at || stored.type !== event.type) return false;
173
178
  return (
@@ -223,7 +228,7 @@ async function suppressAfterEvent(
223
228
  email: send.recipientEmail,
224
229
  reason,
225
230
  sendId: send._id,
226
- detail: textOf(event.details?.reason) || undefined,
231
+ detail: textOf(event.details, "reason") || undefined,
227
232
  });
228
233
  } catch (error) {
229
234
  Logging.Error("[dms-mailing] Could not suppress an address:", error);
@@ -1,8 +1,10 @@
1
+ import type { IncomingHttpHeaders } from "node:http";
1
2
  import type { RequestContext } from "@antelopejs/interface-api";
2
3
  import {
3
4
  LOCALIZED_TEXT_FALLBACK_LOCALE,
4
5
  type LocalizedText,
5
6
  } from "@antelopejs/interface-dms-mailing";
7
+ import { isText } from "../validation/guards";
6
8
 
7
9
  /** Header the DMS frontend states the reader's language in on every request. */
8
10
  export const CONTENT_LANGUAGE_HEADER = "x-content-language";
@@ -13,8 +15,8 @@ export const CONTENT_LANGUAGE_HEADER = "x-content-language";
13
15
  *
14
16
  * @param header Value of {@link CONTENT_LANGUAGE_HEADER}
15
17
  */
16
- export function requestLocale(header: unknown): string {
17
- return typeof header === "string" && header.length > 0
18
+ export function requestLocale(header: IncomingHttpHeaders[string]): string {
19
+ return isText(header) && header.length > 0
18
20
  ? header
19
21
  : LOCALIZED_TEXT_FALLBACK_LOCALE;
20
22
  }
@@ -11,14 +11,12 @@ const AUDIENCE_KEEPS: Record<SendAudience, (send: SendSummary) => boolean> = {
11
11
  [OPERATIONAL_AUDIENCE]: () => true,
12
12
  };
13
13
 
14
- const AUDIENCE_BY_NAME: Record<string, SendAudience> = {
15
- [BUSINESS_AUDIENCE]: BUSINESS_AUDIENCE,
16
- [OPERATIONAL_AUDIENCE]: OPERATIONAL_AUDIENCE,
17
- };
14
+ const isAudience = (raw: string): raw is SendAudience =>
15
+ Object.hasOwn(AUDIENCE_KEEPS, raw);
18
16
 
19
17
  /** Named audience behind a query value; anything unknown is the business view. */
20
18
  export function readAudience(raw: string | null | undefined): SendAudience {
21
- return AUDIENCE_BY_NAME[raw ?? ""] ?? BUSINESS_AUDIENCE;
19
+ return raw != null && isAudience(raw) ? raw : BUSINESS_AUDIENCE;
22
20
  }
23
21
 
24
22
  const STATS_WINDOW_DAYS = 30;
@@ -122,7 +120,7 @@ const wasOpened = (send: SendSummary): boolean =>
122
120
  function median(values: number[]): number {
123
121
  if (!values.length) return 0;
124
122
  const sorted = [...values].sort((left, right) => left - right);
125
- return sorted[Math.floor(sorted.length / 2)] as number;
123
+ return sorted[Math.floor(sorted.length / 2)];
126
124
  }
127
125
 
128
126
  /** A send to a suppressed address never left: no figure counts it. */
@@ -262,7 +260,7 @@ export function alignSeries(
262
260
  axis: SeriesPoint[],
263
261
  ): SeriesPoint[] {
264
262
  return points.slice(0, axis.length).map((point, index) => ({
265
- x: (axis[index] as SeriesPoint).x,
263
+ x: axis[index].x,
266
264
  y: point.y,
267
265
  }));
268
266
  }
@@ -38,7 +38,7 @@ const PERCENT = 100;
38
38
  const SENDS_PAGE = "/modules/mailing/sends";
39
39
  const PROBLEMS_VIEW = "problems";
40
40
  const IDEMPOTENCY_KEY_SEPARATOR = ":";
41
- // The DMS keys notifications across every module: ours carry the module's name.
41
+ /** The DMS keys notifications across every module: ours carry the module's name. */
42
42
  const IDEMPOTENCY_KEY_PREFIX = "mailing:problems";
43
43
  const PROBLEMS_ICON = "i-ph-warning";
44
44
  const PROBLEMS_TITLE = "$dms_mailing.notifications.problems_title";
@@ -17,13 +17,17 @@ export interface MailingEmailProps {
17
17
  const MailingEmailTemplate =
18
18
  RegisterHtmlTemplate<MailingEmailProps>(EMAIL_TEMPLATE_NAME);
19
19
 
20
+ /**
21
+ * The HTML of `email`: through the DMS renderer, which already drops Vue's
22
+ * SSR markers (dms-frontend 0.5.4), else the module's fallback HTML when the
23
+ * configuration turns HTML rendering off or the renderer fails.
24
+ */
20
25
  export async function renderEmailHtml(
21
26
  email: ResolvedEmail,
22
27
  locale: string,
23
28
  ): Promise<string> {
24
29
  if (!getConfig().useHtmlRender) return renderFallbackHtml(email, locale);
25
30
  try {
26
- // The DMS renderer already drops Vue's SSR markers (dms-frontend 0.5.4).
27
31
  return await GenerateHtml(MailingEmailTemplate, { locale, email });
28
32
  } catch (error) {
29
33
  Logging.Warn(
@@ -27,6 +27,7 @@ import {
27
27
  } from "./events";
28
28
  import { getSettings } from "./settings";
29
29
  import { refreshTemplateAttention } from "./template-attention";
30
+ import { isText } from "../validation/guards";
30
31
 
31
32
  /** Labels an imported send in the log when its caller gives no source. */
32
33
  const IMPORT_SOURCE = "import";
@@ -47,7 +48,7 @@ function importedSendIdOf(tenantId: string, importId: string): string {
47
48
  }
48
49
 
49
50
  const addressOf = (to: ParsedImportedSend["to"]) =>
50
- typeof to === "string" ? { email: to } : to;
51
+ isText(to) ? { email: to } : to;
51
52
 
52
53
  /** Chronological events, with the webhook's retry deduplication rules. */
53
54
  function timelineOf(send: ParsedImportedSend): ImportedEvent[] {
@@ -59,11 +60,11 @@ function timelineOf(send: ParsedImportedSend): ImportedEvent[] {
59
60
  const repeated = timeline.some((stored) =>
60
61
  isSameEvent(
61
62
  {
62
- type: stored.type as SendEventType,
63
+ type: stored.type,
63
64
  at: stored.at,
64
65
  json_details: JSON.stringify(stored.details ?? {}),
65
66
  },
66
- { ...entry, type: entry.type as SendEventType },
67
+ entry,
67
68
  ),
68
69
  );
69
70
  if (!repeated) timeline.push(entry);
@@ -87,7 +88,7 @@ function foldEvents(
87
88
  const event: EmailEvent = {
88
89
  provider: send.provider ?? IMPORT_SOURCE,
89
90
  messageId: send.providerMessageId ?? "",
90
- type: entry.type as SendEventType,
91
+ type: entry.type,
91
92
  at: entry.at,
92
93
  details: entry.details,
93
94
  };
@@ -141,6 +142,13 @@ function sendRow(
141
142
  };
142
143
  }
143
144
 
145
+ /** The refusal of an import that names a template the workspace lacks. */
146
+ const missingTemplate = (slug: string): DeclarationError =>
147
+ new DeclarationError(
148
+ "template_not_found",
149
+ `Imported sends name template "${slug}", which the workspace does not have`,
150
+ );
151
+
144
152
  /** The templates the sends name, by slug; refused before anything is written. */
145
153
  async function templatesOf(
146
154
  tenantId: string,
@@ -151,11 +159,7 @@ async function templatesOf(
151
159
  const templates = new Map<string, MailingTemplate>();
152
160
  for (const slug of slugs) {
153
161
  const template = await model.getBySlug(slug);
154
- if (!template)
155
- throw new DeclarationError(
156
- "template_not_found",
157
- `Imported sends name template "${slug}", which the workspace does not have`,
158
- );
162
+ if (!template) throw missingTemplate(slug);
159
163
  templates.set(slug, template);
160
164
  }
161
165
  return templates;
@@ -187,7 +191,7 @@ async function importOne(
187
191
  await events.insertImported({
188
192
  _id: `${id}-${index}`,
189
193
  sendId: id,
190
- type: entry.type as SendEventType,
194
+ type: entry.type,
191
195
  at: entry.at,
192
196
  json_details: JSON.stringify(entry.details ?? {}),
193
197
  });
@@ -219,7 +223,8 @@ export async function importSends(
219
223
  const { fallbackLocale } = await getSettings(tenantId);
220
224
  const result: ImportSendsResult = { imported: 0, skipped: 0, sendIds: [] };
221
225
  for (const send of sends) {
222
- const template = templates.get(send.templateSlug) as MailingTemplate;
226
+ const template = templates.get(send.templateSlug);
227
+ if (!template) throw missingTemplate(send.templateSlug);
223
228
  const { id, isImported } = await importOne(
224
229
  tenantId,
225
230
  send,
@@ -52,6 +52,7 @@ import {
52
52
  type VersionedSnapshot,
53
53
  workingSnapshot,
54
54
  } from "./templates";
55
+ import { isText } from "../validation/guards";
55
56
 
56
57
  export class SendError extends Error {
57
58
  constructor(
@@ -158,14 +159,10 @@ const OUTCOME_SEVERITY: Record<SendStatus, number> = {
158
159
  failed: 9,
159
160
  };
160
161
 
161
- const describeError = (error: unknown): string =>
162
- error instanceof Error ? error.message : String(error);
163
-
164
- const addressOf = (to: EmailAddress): string =>
165
- typeof to === "string" ? to : to.email;
162
+ const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
166
163
 
167
164
  const nameOf = (to: EmailAddress): string | undefined =>
168
- typeof to === "string" ? undefined : to.name;
165
+ isText(to) ? undefined : to.name;
169
166
 
170
167
  const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
171
168
  Array.isArray(to) ? to : [to];
@@ -376,7 +373,7 @@ async function deliverOne(
376
373
  status: "failed",
377
374
  outcome: {
378
375
  latencyMs: Date.now() - startedAt,
379
- error: describeError(error),
376
+ error: error instanceof Error ? error.message : String(error),
380
377
  },
381
378
  };
382
379
  }
@@ -423,7 +420,7 @@ export async function deliverBatch(
423
420
  return failAll(
424
421
  input.recipients,
425
422
  Date.now() - startedAt,
426
- describeError(error),
423
+ error instanceof Error ? error.message : String(error),
427
424
  );
428
425
  }
429
426
  }
@@ -468,15 +465,15 @@ async function record(
468
465
  isTest: Boolean(params.isTest),
469
466
  });
470
467
  await GetModel(SendEventModel, tenantId).insertForActiveSend({
471
- sendId: sendId as string,
468
+ sendId,
472
469
  type: status,
473
470
  at: new Date(),
474
471
  json_details: JSON.stringify(
475
472
  outcome.error ? { reason: outcome.error } : {},
476
473
  ),
477
474
  });
478
- publishSendCreated(tenantId, sendId as string);
479
- return sendId as string;
475
+ publishSendCreated(tenantId, sendId);
476
+ return sendId;
480
477
  }
481
478
 
482
479
  async function recordOne(
@@ -504,7 +501,7 @@ async function recordOne(
504
501
  sendId: UNRECORDED_SEND_ID,
505
502
  status: "failed",
506
503
  messageId: message.outcome.providerMessageId,
507
- error: describeError(error),
504
+ error: error instanceof Error ? error.message : String(error),
508
505
  };
509
506
  }
510
507
  }
@@ -525,7 +522,7 @@ export async function recordAll(
525
522
  export function aggregateResults(
526
523
  results: SendTemplateResult[],
527
524
  ): SendTemplateResult {
528
- const first = results[0] as SendTemplateResult;
525
+ const first = results[0];
529
526
  const worst = results.reduce((left, right) =>
530
527
  OUTCOME_SEVERITY[right.status] > OUTCOME_SEVERITY[left.status]
531
528
  ? right
@@ -6,6 +6,8 @@ import {
6
6
  type MailingSettingsValues,
7
7
  type TemplateCategory,
8
8
  } from "../types";
9
+ import type { JsonObject } from "../types/json";
10
+ import type { SettingsPatch } from "../validation/settings.schema";
9
11
 
10
12
  export const WEBHOOK_SECRET_BYTES = 24;
11
13
 
@@ -28,6 +30,8 @@ function toValues(row: MailingSettings): MailingSettingsValues {
28
30
  senderEmail: row.senderEmail,
29
31
  replyTo: row.replyTo,
30
32
  webhookSecret: row.webhookSecret,
33
+ // SAFETY: json_categories is written only by toRow as the JSON.stringify of categories that categoriesSchema
34
+ // validated (settingsPatchSchema, applyCategory) or that the module defaults supplied.
31
35
  categories: JSON.parse(row.json_categories) as TemplateCategory[],
32
36
  };
33
37
  }
@@ -71,7 +75,12 @@ export async function getSettings(
71
75
  if (existing) return existing;
72
76
  const created = defaults();
73
77
  if (await insertKeyedSettings(tenantId, created)) return created;
74
- return (await findSettings(tenantId)) as MailingSettingsValues;
78
+ const winner = await findSettings(tenantId);
79
+ if (!winner)
80
+ throw new Error(
81
+ `The settings of tenant "${tenantId}" disappeared before they were read`,
82
+ );
83
+ return winner;
75
84
  }
76
85
 
77
86
  export async function saveSettings(
@@ -105,36 +114,28 @@ export function missingVariablesPolicy(
105
114
  }
106
115
 
107
116
  /** The form-only field folded back into `blockOnMissingVariables`. */
108
- const MISSING_VARIABLES_KEY = "missingVariables";
109
117
  const CLEARED_VALUE = "";
110
118
  /** The patch with each cleared field (`null`) as the empty value it stores. */
111
- export function clearedToEmpty(
112
- patch: Record<string, unknown>,
113
- ): Record<string, unknown> {
119
+ export function clearedToEmpty(patch: JsonObject): JsonObject {
114
120
  return Object.fromEntries(
115
121
  Object.entries(patch).map(([key, value]) => [key, value ?? CLEARED_VALUE]),
116
122
  );
117
123
  }
118
124
 
119
125
  /**
120
- * The settings once `patch` (the fields a form changed, `null` for a cleared
121
- * one) is laid over the stored ones, the missing-variables choice folded back
122
- * into its boolean.
126
+ * The settings once `patch` (the fields a form changed, cleared ones already
127
+ * stored as empty) is laid over the stored ones, the missing-variables choice
128
+ * folded back into its boolean.
123
129
  */
124
130
  export function mergeSettingsPatch(
125
131
  current: MailingSettingsValues,
126
- patch: Record<string, unknown>,
132
+ patch: SettingsPatch,
127
133
  ): MailingSettingsValues {
128
- const written = Object.fromEntries(
129
- Object.entries(clearedToEmpty(patch)).filter(
130
- ([key]) => key !== MISSING_VARIABLES_KEY,
131
- ),
132
- );
133
- const policy = patch.missingVariables;
134
+ const { missingVariables: policy, ...written } = patch;
134
135
  const isChoice = policy === REFUSE_POLICY || policy === SEND_POLICY;
135
136
  const guard =
136
137
  isChoice && !("blockOnMissingVariables" in patch)
137
138
  ? { blockOnMissingVariables: policy === REFUSE_POLICY }
138
139
  : {};
139
- return { ...current, ...written, ...guard } as MailingSettingsValues;
140
+ return { ...current, ...written, ...guard };
140
141
  }
@@ -5,6 +5,7 @@ import type {
5
5
  TemplateContent,
6
6
  VariableDefinition,
7
7
  } from "../types";
8
+ import type { JsonObject } from "../types/json";
8
9
  import { footerForCategory, formattingLocale } from "../engine";
9
10
  import { blankFields, footerBlock } from "./templates";
10
11
 
@@ -21,9 +22,9 @@ export interface StarterTemplate {
21
22
  content: TemplateContent;
22
23
  variables: VariableDefinition[];
23
24
  /** The test data as an English-speaking workspace gets it. */
24
- testData: Record<string, unknown>;
25
+ testData: JsonObject;
25
26
  /** The test data with its amounts written the way `locale` writes them. */
26
- testDataFor: (locale: string) => Record<string, unknown>;
27
+ testDataFor: (locale: string) => JsonObject;
27
28
  }
28
29
 
29
30
  type StarterSource = Omit<StarterTemplate, "testData">;
@@ -32,16 +33,19 @@ const DEFAULT_TEST_DATA_LOCALE = "en";
32
33
  const STARTER_CURRENCY = "EUR";
33
34
 
34
35
  /** The ordered item of the order starter's test data, by language. */
35
- const ORDER_LINE_NAMES: Record<string, string> = {
36
- en: "Ergo chair · Graphite × 4",
37
- fr: "Fauteuil ergonomique · Graphite × 4",
38
- };
36
+ const ORDER_LINE_NAMES = new Map([
37
+ [DEFAULT_TEST_DATA_LOCALE, "Ergo chair · Graphite × 4"],
38
+ ["fr", "Fauteuil ergonomique · Graphite × 4"],
39
+ ]);
39
40
  const LOCALE_PARTS = /[-_]/;
40
41
 
41
42
  /** A test text in the language of `locale`, English when it has none. */
42
- function inLanguageOf(texts: Record<string, string>, locale: string): string {
43
+ function inLanguageOf(
44
+ texts: ReadonlyMap<string, string>,
45
+ locale: string,
46
+ ): string {
43
47
  const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
44
- return texts[language] ?? (texts[DEFAULT_TEST_DATA_LOCALE] as string);
48
+ return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE) ?? "";
45
49
  }
46
50
 
47
51
  /** An amount the way `locale` writes it: "€1,556.00", "1 556,00 €". */
@@ -66,18 +66,25 @@ export function sourcesOf(sends: SendSummary[]): SourceCount[] {
66
66
  .slice(0, MAX_SOURCES);
67
67
  }
68
68
 
69
- export function isFallback(send: SendSummary): boolean {
69
+ /** Whether `send` went out in another locale than the one it asked for. */
70
+ function isRequestedFallback(
71
+ send: SendSummary,
72
+ ): send is SendSummary & { requestedLocale: string } {
70
73
  return Boolean(send.requestedLocale && send.requestedLocale !== send.locale);
71
74
  }
72
75
 
76
+ export function isFallback(send: SendSummary): boolean {
77
+ return isRequestedFallback(send);
78
+ }
79
+
73
80
  export function fallbacksOf(sends: SendSummary[]): FallbackCount[] {
74
- const fallbacks = sends.filter(isFallback);
81
+ const fallbacks = sends.filter(isRequestedFallback);
75
82
  return [
76
83
  ...groupBy(fallbacks, (send) => `${send.requestedLocale}>${send.locale}`),
77
84
  ].map(([, rows]) => {
78
- const first = rows[0] as SendSummary;
85
+ const first = rows[0];
79
86
  return {
80
- requested: first.requestedLocale as string,
87
+ requested: first.requestedLocale,
81
88
  used: first.locale,
82
89
  count: rows.length,
83
90
  };