@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
@@ -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
  }
@@ -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");
@@ -16,6 +16,7 @@ import { publishSendUpdated } from "../realtime";
16
16
  import type { SendEventType, SendStatus } from "../types";
17
17
  import { notifyProblems } from "./notify";
18
18
  import { suppressAddress, suppressionReasonOf } from "./suppressions";
19
+ import { isObject, isText } from "../validation/guards";
19
20
 
20
21
  export interface SendCounters {
21
22
  status: SendStatus;
@@ -59,8 +60,11 @@ const HARD_BOUNCE_TYPE = /hard|permanent/i;
59
60
  /** A temporary SMTP answer: a `4xx` reply code or a `4.x.x` enhanced status. */
60
61
  const SOFT_SMTP_ANSWER = /^\s*4\d\d\b|\b4\.\d{1,3}\.\d{1,3}\b/;
61
62
 
62
- const textOf = (value: unknown): string =>
63
- typeof value === "string" ? value : "";
63
+ /** The text an event's details hold under `key`, else an empty string. */
64
+ function textOf(details: EmailEvent["details"], key: string): string {
65
+ const value = details?.[key];
66
+ return isText(value) ? value : "";
67
+ }
64
68
 
65
69
  /**
66
70
  * The provider's own words for a problem event (`550 5.1.1 Mailbox does not
@@ -70,7 +74,7 @@ const textOf = (value: unknown): string =>
70
74
  export function problemReason(event: EmailEvent): string | undefined {
71
75
  if (!PROBLEM_EVENTS.has(event.type)) return undefined;
72
76
  const reason = event.details?.reason;
73
- return typeof reason === "string" && reason ? reason : undefined;
77
+ return isText(reason) && reason ? reason : undefined;
74
78
  }
75
79
 
76
80
  function overridesStatus(current: SendStatus, next: SendStatus): boolean {
@@ -95,10 +99,10 @@ export function applyEvent(
95
99
  * provider's `bounceType` when it gives one, else the SMTP answer's class.
96
100
  */
97
101
  export function isSoftBounce(event: EmailEvent): boolean {
98
- const bounceType = textOf(event.details?.bounceType);
102
+ const bounceType = textOf(event.details, "bounceType");
99
103
  if (SOFT_BOUNCE_TYPE.test(bounceType)) return true;
100
104
  if (HARD_BOUNCE_TYPE.test(bounceType)) return false;
101
- return SOFT_SMTP_ANSWER.test(textOf(event.details?.reason));
105
+ return SOFT_SMTP_ANSWER.test(textOf(event.details, "reason"));
102
106
  }
103
107
 
104
108
  /**
@@ -144,8 +148,8 @@ async function publishEmailEvent(
144
148
  });
145
149
  }
146
150
 
147
- const eventIdOf = (details: Record<string, unknown> | undefined): string =>
148
- textOf(details?.eventId);
151
+ const eventIdOf = (details: EmailEvent["details"]): string =>
152
+ textOf(details, "eventId");
149
153
 
150
154
  type StoredEventIdentity = Pick<
151
155
  MailingSendEvent,
@@ -163,11 +167,10 @@ export function isSameEvent(
163
167
  ): boolean {
164
168
  const eventId = eventIdOf(event.details);
165
169
  if (eventId) {
166
- const storedDetails = JSON.parse(stored.json_details || "{}") as Record<
167
- string,
168
- unknown
169
- >;
170
- return eventIdOf(storedDetails) === eventId;
170
+ const storedDetails: unknown = JSON.parse(stored.json_details || "{}");
171
+ return (
172
+ eventIdOf(isObject(storedDetails) ? storedDetails : undefined) === eventId
173
+ );
171
174
  }
172
175
  if (!event.at || stored.type !== event.type) return false;
173
176
  return (
@@ -223,7 +226,7 @@ async function suppressAfterEvent(
223
226
  email: send.recipientEmail,
224
227
  reason,
225
228
  sendId: send._id,
226
- detail: textOf(event.details?.reason) || undefined,
229
+ detail: textOf(event.details, "reason") || undefined,
227
230
  });
228
231
  } catch (error) {
229
232
  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
  }
@@ -122,7 +122,7 @@ const wasOpened = (send: SendSummary): boolean =>
122
122
  function median(values: number[]): number {
123
123
  if (!values.length) return 0;
124
124
  const sorted = [...values].sort((left, right) => left - right);
125
- return sorted[Math.floor(sorted.length / 2)] as number;
125
+ return sorted[Math.floor(sorted.length / 2)];
126
126
  }
127
127
 
128
128
  /** A send to a suppressed address never left: no figure counts it. */
@@ -262,7 +262,7 @@ export function alignSeries(
262
262
  axis: SeriesPoint[],
263
263
  ): SeriesPoint[] {
264
264
  return points.slice(0, axis.length).map((point, index) => ({
265
- x: (axis[index] as SeriesPoint).x,
265
+ x: axis[index].x,
266
266
  y: point.y,
267
267
  }));
268
268
  }
@@ -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(
@@ -161,11 +162,10 @@ const OUTCOME_SEVERITY: Record<SendStatus, number> = {
161
162
  const describeError = (error: unknown): string =>
162
163
  error instanceof Error ? error.message : String(error);
163
164
 
164
- const addressOf = (to: EmailAddress): string =>
165
- typeof to === "string" ? to : to.email;
165
+ const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
166
166
 
167
167
  const nameOf = (to: EmailAddress): string | undefined =>
168
- typeof to === "string" ? undefined : to.name;
168
+ isText(to) ? undefined : to.name;
169
169
 
170
170
  const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
171
171
  Array.isArray(to) ? to : [to];
@@ -468,15 +468,15 @@ async function record(
468
468
  isTest: Boolean(params.isTest),
469
469
  });
470
470
  await GetModel(SendEventModel, tenantId).insertForActiveSend({
471
- sendId: sendId as string,
471
+ sendId,
472
472
  type: status,
473
473
  at: new Date(),
474
474
  json_details: JSON.stringify(
475
475
  outcome.error ? { reason: outcome.error } : {},
476
476
  ),
477
477
  });
478
- publishSendCreated(tenantId, sendId as string);
479
- return sendId as string;
478
+ publishSendCreated(tenantId, sendId);
479
+ return sendId;
480
480
  }
481
481
 
482
482
  async function recordOne(
@@ -525,7 +525,7 @@ export async function recordAll(
525
525
  export function aggregateResults(
526
526
  results: SendTemplateResult[],
527
527
  ): SendTemplateResult {
528
- const first = results[0] as SendTemplateResult;
528
+ const first = results[0];
529
529
  const worst = results.reduce((left, right) =>
530
530
  OUTCOME_SEVERITY[right.status] > OUTCOME_SEVERITY[left.status]
531
531
  ? right
@@ -32,16 +32,16 @@ const DEFAULT_TEST_DATA_LOCALE = "en";
32
32
  const STARTER_CURRENCY = "EUR";
33
33
 
34
34
  /** 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
- };
35
+ const ORDER_LINE_NAMES = new Map([
36
+ [DEFAULT_TEST_DATA_LOCALE, "Ergo chair · Graphite × 4"],
37
+ ["fr", "Fauteuil ergonomique · Graphite × 4"],
38
+ ]);
39
39
  const LOCALE_PARTS = /[-_]/;
40
40
 
41
41
  /** A test text in the language of `locale`, English when it has none. */
42
- function inLanguageOf(texts: Record<string, string>, locale: string): string {
42
+ function inLanguageOf(texts: ReadonlyMap<string, string>, locale: string) {
43
43
  const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
44
- return texts[language] ?? (texts[DEFAULT_TEST_DATA_LOCALE] as string);
44
+ return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE);
45
45
  }
46
46
 
47
47
  /** 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
  };
@@ -180,13 +180,18 @@ interface FooterLabels {
180
180
  preferences: string;
181
181
  }
182
182
 
183
+ const ENGLISH_FOOTER_LABELS: FooterLabels = {
184
+ unsubscribe: "Unsubscribe",
185
+ preferences: "Preferences",
186
+ };
187
+
183
188
  /**
184
189
  * The one piece of content the module still writes for a new template. An
185
190
  * e-mail without an unsubscribe link is a legal problem, so a blank template
186
191
  * ships a footer — and nothing else: no invented subject, prose or address.
187
192
  */
188
193
  const FOOTER_LABELS: Record<string, FooterLabels> = {
189
- en: { unsubscribe: "Unsubscribe", preferences: "Preferences" },
194
+ en: ENGLISH_FOOTER_LABELS,
190
195
  fr: { unsubscribe: "Se désinscrire", preferences: "Préférences" },
191
196
  };
192
197
 
@@ -194,7 +199,7 @@ const FOOTER_BLOCK_ID = "footer";
194
199
 
195
200
  /** A footer in `locale`: its legal line and the labels of its two links. */
196
201
  export function footerBlock(locale: string, text = ""): Block {
197
- const labels = FOOTER_LABELS[locale] ?? (FOOTER_LABELS.en as FooterLabels);
202
+ const labels = FOOTER_LABELS[locale] ?? ENGLISH_FOOTER_LABELS;
198
203
  return {
199
204
  id: FOOTER_BLOCK_ID,
200
205
  type: "footer",
@@ -226,16 +231,13 @@ export function isStaleRevision(
226
231
  return revision !== undefined && revision !== template.draftRevision;
227
232
  }
228
233
 
229
- function draftWriteFields(
230
- template: MailingTemplate,
231
- author: string,
232
- ): TemplateFields {
234
+ function draftWriteFields(template: MailingTemplate, author: string) {
233
235
  return {
234
236
  draftRevision: template.draftRevision + 1,
235
237
  draftUpdatedAt: new Date(),
236
238
  draftUpdatedBy: author,
237
239
  updatedBy: author,
238
- };
240
+ } satisfies TemplateFields;
239
241
  }
240
242
 
241
243
  /** Whether `draft` is exactly what customers and callers already have. */
@@ -271,7 +273,7 @@ export async function saveDraft(
271
273
  ...locales,
272
274
  });
273
275
  await refreshAttentionIfFlagged(tenantId, template, STALE_DRAFT);
274
- return fields.draftRevision as number;
276
+ return fields.draftRevision;
275
277
  }
276
278
 
277
279
  export async function discardDraft(
@@ -1,7 +1,7 @@
1
1
  import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
2
2
  import { z } from "zod";
3
3
  import { MAX_NAME_LENGTH } from "../constants";
4
- import { SEND_EVENT_TYPES } from "../types";
4
+ import { sendEventTypeSchema } from "./send-event-type.schema";
5
5
  import { categorySchema, settingsSchema } from "./settings.schema";
6
6
  import {
7
7
  slugSchema,
@@ -76,7 +76,7 @@ const recipientSchema = z.union([
76
76
  ]);
77
77
 
78
78
  const importedEventSchema = z.object({
79
- type: z.enum(SEND_EVENT_TYPES as [string, ...string[]]),
79
+ type: sendEventTypeSchema,
80
80
  at: z.coerce.date(),
81
81
  details: z.record(z.unknown()).optional(),
82
82
  });
@@ -0,0 +1,25 @@
1
+ /** Whether `value` is a string. */
2
+ export function isText(value: unknown): value is string {
3
+ return typeof value === "string";
4
+ }
5
+
6
+ /**
7
+ * Whether `value` is a JSON object as a parsed body holds one: an object,
8
+ * neither `null` nor an array. Its values are not checked.
9
+ */
10
+ export function isJsonObject(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+
14
+ /**
15
+ * Whether `value` is an object, arrays included, and not `null`: reading any
16
+ * key of it yields a value of unknown type.
17
+ */
18
+ export function isObject(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null;
20
+ }
21
+
22
+ /** Whether `value` is a number. */
23
+ export function isNumber(value: unknown): value is number {
24
+ return typeof value === "number";
25
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ import { SEND_EVENT_TYPES } from "../types";
3
+
4
+ const [FIRST_EVENT_TYPE, ...OTHER_EVENT_TYPES] = SEND_EVENT_TYPES;
5
+
6
+ /** A send event's type: one of {@link SEND_EVENT_TYPES}. */
7
+ export const sendEventTypeSchema = z.enum([
8
+ FIRST_EVENT_TYPE,
9
+ ...OTHER_EVENT_TYPES,
10
+ ]);
@@ -123,12 +123,18 @@ function moveIssues(
123
123
  kept: Set<string>,
124
124
  ): CategoryIssue[] {
125
125
  return entries.flatMap((entry, index) =>
126
- isKept(entry) || kept.has(entry.moveTo as string)
126
+ entry.moveTo === undefined || kept.has(entry.moveTo)
127
127
  ? []
128
128
  : [{ message: UNKNOWN_MOVE_TARGET, path: [index, "moveTo"] }],
129
129
  );
130
130
  }
131
131
 
132
+ /**
133
+ * Adds the issues of the category rows: blank labels and duplicates in each
134
+ * language, a removed marketing category, a move to a category that is not
135
+ * kept. Each issue is reported once per row and part, whichever language
136
+ * shows it first.
137
+ */
132
138
  function refineCategoryEntries(
133
139
  entries: CategoryEntry[],
134
140
  context: z.RefinementCtx,
@@ -147,7 +153,6 @@ function refineCategoryEntries(
147
153
  ...moveIssues(entriesIn(entries, LOCALIZED_TEXT_FALLBACK_LOCALE), kept),
148
154
  ]
149
155
  .filter((issue) => {
150
- // One issue per row and part, whichever language shows it first.
151
156
  const id = JSON.stringify(issue);
152
157
  if (seen.has(id)) return false;
153
158
  seen.add(id);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_NAME_LENGTH, MAX_SLUG_LENGTH, SLUG_PATTERN } from "../constants";
3
+ import type { Block } from "../types";
3
4
 
4
5
  const MIN_HEADING_SIZE = 12;
5
6
  const MAX_HEADING_SIZE = 48;
@@ -31,7 +32,7 @@ export const conditionSchema = z.object({
31
32
  const alignSchema = z.enum(["left", "center", "right"]);
32
33
  const base = { id: z.string().min(1), visibleIf: conditionSchema.nullable() };
33
34
 
34
- export const blockSchema: z.ZodType<unknown> = z.lazy(() =>
35
+ export const blockSchema: z.ZodType<Block, z.ZodTypeDef, unknown> = z.lazy(() =>
35
36
  z.discriminatedUnion("type", [
36
37
  z.object({
37
38
  ...base,
@@ -1,50 +0,0 @@
1
- import { readFileSync, readdirSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { describe, expect, it } from 'vitest'
4
- import en from '../i18n/locales/mailing-en-GB.json'
5
-
6
- const APP = join(__dirname, '..', 'app')
7
- const EDITOR_SOURCES = [
8
- join(APP, 'components', 'Editor.vue'),
9
- join(APP, 'composables', 'useEditorActions.ts'),
10
- join(APP, 'composables', 'useEditorShortcuts.ts'),
11
- ...readdirSync(join(APP, 'components', 'editor')).map((file) =>
12
- join(APP, 'components', 'editor', file),
13
- ),
14
- ]
15
- const LITERAL_KEY =
16
- /'(dms_mailing\.(?:editor|blocks|templates\.status)\.[a-z_.]+)'/g
17
- const PREFIX_CONSTANT = /const (\w*KEY) = '(dms_mailing[a-z_.]*)'/g
18
- const PREFIXED_KEY = /\$\{(\w*KEY)\}\.([a-z_.]+)`/g
19
-
20
- function keysUsedIn(source: string): string[] {
21
- const prefixes = new Map(
22
- [...source.matchAll(PREFIX_CONSTANT)].map((match) => [match[1], match[2]]),
23
- )
24
- const prefixValues = new Set(prefixes.values())
25
- const literal = [...source.matchAll(LITERAL_KEY)]
26
- .map((match) => match[1] as string)
27
- .filter((key) => !prefixValues.has(key))
28
- const prefixed = [...source.matchAll(PREFIXED_KEY)]
29
- .filter((match) => prefixes.has(match[1]))
30
- .map((match) => `${prefixes.get(match[1])}.${match[2]}`)
31
- return [...literal, ...prefixed]
32
- }
33
-
34
- function keysOf(value: unknown, prefix = ''): string[] {
35
- if (typeof value !== 'object' || value === null) return [prefix]
36
- return Object.entries(value).flatMap(([key, child]) =>
37
- keysOf(child, prefix ? `${prefix}.${key}` : key),
38
- )
39
- }
40
-
41
- describe('editor locales', () => {
42
- it('define every literal key the editor components use', () => {
43
- const known = new Set(keysOf(en))
44
- const used = EDITOR_SOURCES.flatMap((file) =>
45
- keysUsedIn(readFileSync(file, 'utf8')),
46
- )
47
- expect(used.length).toBeGreaterThan(0)
48
- expect(used.filter((key) => !known.has(key))).toEqual([])
49
- })
50
- })