@antelopejs/dms-mailing 0.6.0 → 0.7.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 (187) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.js +2 -1
  3. package/dist/constants.js.map +1 -1
  4. package/dist/data/templates-table.d.ts +4 -1
  5. package/dist/data/templates-table.js +55 -13
  6. package/dist/data/templates-table.js.map +1 -1
  7. package/dist/data-types/categories-type.d.ts +5 -5
  8. package/dist/db/models/templates.model.d.ts +10 -1
  9. package/dist/db/models/templates.model.js +27 -1
  10. package/dist/db/models/templates.model.js.map +1 -1
  11. package/dist/db/tables/templates.table.d.ts +7 -1
  12. package/dist/db/tables/templates.table.js +3 -1
  13. package/dist/db/tables/templates.table.js.map +1 -1
  14. package/dist/engine/formats.d.ts +13 -0
  15. package/dist/engine/formats.js +85 -0
  16. package/dist/engine/formats.js.map +1 -0
  17. package/dist/engine/index.d.ts +1 -0
  18. package/dist/engine/index.js +1 -0
  19. package/dist/engine/index.js.map +1 -1
  20. package/dist/engine/resolve.d.ts +2 -1
  21. package/dist/engine/resolve.js +14 -7
  22. package/dist/engine/resolve.js.map +1 -1
  23. package/dist/engine/tokens.d.ts +2 -1
  24. package/dist/engine/tokens.js +3 -5
  25. package/dist/engine/tokens.js.map +1 -1
  26. package/dist/frontend-module.d.ts +2 -0
  27. package/dist/frontend-module.js +24 -0
  28. package/dist/frontend-module.js.map +1 -0
  29. package/dist/hooks/tenant-export.js +4 -1
  30. package/dist/hooks/tenant-export.js.map +1 -1
  31. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  32. package/dist/implementations/dms-mailing/index.js +3 -1
  33. package/dist/implementations/dms-mailing/index.js.map +1 -1
  34. package/dist/index.js +2 -19
  35. package/dist/index.js.map +1 -1
  36. package/dist/pages/overview.d.ts +1 -1
  37. package/dist/pages/overview.js +11 -13
  38. package/dist/pages/overview.js.map +1 -1
  39. package/dist/pages/sends.d.ts +1 -1
  40. package/dist/pages/sends.js +16 -8
  41. package/dist/pages/sends.js.map +1 -1
  42. package/dist/pages/templates.js +3 -8
  43. package/dist/pages/templates.js.map +1 -1
  44. package/dist/pages/toolbar.d.ts +12 -0
  45. package/dist/pages/toolbar.js +41 -0
  46. package/dist/pages/toolbar.js.map +1 -0
  47. package/dist/realtime.d.ts +2 -2
  48. package/dist/realtime.js +8 -8
  49. package/dist/realtime.js.map +1 -1
  50. package/dist/routes/metrics.d.ts +9 -2
  51. package/dist/routes/metrics.js +90 -35
  52. package/dist/routes/metrics.js.map +1 -1
  53. package/dist/routes/period.d.ts +1 -0
  54. package/dist/routes/period.js +19 -0
  55. package/dist/routes/period.js.map +1 -1
  56. package/dist/routes/sends.js +6 -3
  57. package/dist/routes/sends.js.map +1 -1
  58. package/dist/routes/settings.js +6 -7
  59. package/dist/routes/settings.js.map +1 -1
  60. package/dist/routes/templates.d.ts +8 -4
  61. package/dist/routes/templates.js +40 -15
  62. package/dist/routes/templates.js.map +1 -1
  63. package/dist/services/categories.d.ts +2 -1
  64. package/dist/services/categories.js +9 -0
  65. package/dist/services/categories.js.map +1 -1
  66. package/dist/services/declarations.js +2 -2
  67. package/dist/services/declarations.js.map +1 -1
  68. package/dist/services/events.js +1 -1
  69. package/dist/services/events.js.map +1 -1
  70. package/dist/services/localized-text.d.ts +7 -0
  71. package/dist/services/localized-text.js +28 -0
  72. package/dist/services/localized-text.js.map +1 -0
  73. package/dist/services/send-stat-items.js +34 -12
  74. package/dist/services/send-stat-items.js.map +1 -1
  75. package/dist/services/send-stats.d.ts +7 -0
  76. package/dist/services/send-stats.js +17 -5
  77. package/dist/services/send-stats.js.map +1 -1
  78. package/dist/services/send.d.ts +2 -0
  79. package/dist/services/send.js +11 -12
  80. package/dist/services/send.js.map +1 -1
  81. package/dist/services/sender-declaration.d.ts +2 -0
  82. package/dist/services/sender-declaration.js +18 -0
  83. package/dist/services/sender-declaration.js.map +1 -0
  84. package/dist/services/starters.d.ts +2 -2
  85. package/dist/services/starters.js +1 -10
  86. package/dist/services/starters.js.map +1 -1
  87. package/dist/services/templates.d.ts +11 -5
  88. package/dist/services/templates.js +24 -6
  89. package/dist/services/templates.js.map +1 -1
  90. package/dist/services/webhook-status.d.ts +2 -1
  91. package/dist/services/webhook-status.js +6 -5
  92. package/dist/services/webhook-status.js.map +1 -1
  93. package/dist/types/settings.d.ts +6 -0
  94. package/dist/types/settings.js +25 -5
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +25 -7
  97. package/dist/validation/declarations.schema.js +16 -3
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/settings.schema.d.ts +25 -25
  100. package/dist/validation/settings.schema.js +30 -6
  101. package/dist/validation/settings.schema.js.map +1 -1
  102. package/dist/validation/templates.schema.d.ts +13 -0
  103. package/dist/validation/templates.schema.js +6 -1
  104. package/dist/validation/templates.schema.js.map +1 -1
  105. package/frontend-vue/app/components/CategoriesInput.vue +15 -13
  106. package/frontend-vue/app/components/FunnelCard.vue +46 -3
  107. package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
  108. package/frontend-vue/app/components/OverviewGate.vue +0 -8
  109. package/frontend-vue/app/components/ProviderPill.vue +50 -0
  110. package/frontend-vue/app/components/SendDrawer.vue +206 -183
  111. package/frontend-vue/app/components/StarterGallery.vue +1 -1
  112. package/frontend-vue/app/components/TemplateCard.vue +4 -1
  113. package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
  114. package/frontend-vue/app/components/TemplateGallery.vue +57 -17
  115. package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
  116. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
  117. package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
  118. package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
  119. package/frontend-vue/app/composables/useMailingApi.ts +3 -3
  120. package/frontend-vue/app/composables/useMailingReads.ts +2 -2
  121. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
  122. package/frontend-vue/app/types/mailing.ts +18 -0
  123. package/frontend-vue/app/utils/categories.ts +58 -49
  124. package/frontend-vue/app/utils/funnel.ts +25 -0
  125. package/frontend-vue/app/utils/period.ts +0 -7
  126. package/frontend-vue/app/utils/starters.ts +3 -1
  127. package/frontend-vue/app/utils/tokens.ts +6 -1
  128. package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
  129. package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
  130. package/frontend-vue/package.json +1 -1
  131. package/frontend-vue/tests/categories-input.test.ts +4 -10
  132. package/frontend-vue/tests/categories.test.ts +55 -17
  133. package/frontend-vue/tests/conditions.test.ts +1 -4
  134. package/frontend-vue/tests/funnel-card.test.ts +27 -0
  135. package/frontend-vue/tests/funnel.test.ts +25 -0
  136. package/frontend-vue/tests/localized-defaults.test.ts +0 -29
  137. package/frontend-vue/tests/new-template-modal.test.ts +1 -1
  138. package/frontend-vue/tests/overview-gate.test.ts +1 -24
  139. package/frontend-vue/tests/page-reads.test.ts +9 -25
  140. package/frontend-vue/tests/period.test.ts +0 -12
  141. package/frontend-vue/tests/replay-toast.test.ts +2 -1
  142. package/frontend-vue/tests/send-drawer.test.ts +123 -0
  143. package/frontend-vue/tests/template-drawer.test.ts +1 -1
  144. package/frontend-vue/tests/template-gallery.test.ts +90 -3
  145. package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
  146. package/package.json +6 -5
  147. package/skills/dms-mailing/SKILL.md +17 -4
  148. package/src/constants.ts +2 -0
  149. package/src/data/sends-table.ts +1 -1
  150. package/src/data/templates-table.ts +123 -19
  151. package/src/db/models/templates.model.ts +61 -3
  152. package/src/db/tables/templates.table.ts +10 -1
  153. package/src/engine/formats.ts +124 -0
  154. package/src/engine/index.ts +1 -0
  155. package/src/engine/resolve.ts +18 -7
  156. package/src/engine/tokens.ts +7 -5
  157. package/src/frontend-module.ts +36 -0
  158. package/src/hooks/tenant-export.ts +8 -1
  159. package/src/implementations/dms-mailing/index.ts +2 -0
  160. package/src/index.ts +2 -23
  161. package/src/pages/overview.ts +20 -16
  162. package/src/pages/sends.ts +27 -11
  163. package/src/pages/templates.ts +4 -8
  164. package/src/pages/toolbar.ts +77 -0
  165. package/src/realtime.ts +17 -8
  166. package/src/routes/metrics.ts +158 -45
  167. package/src/routes/period.ts +32 -0
  168. package/src/routes/sends.ts +13 -4
  169. package/src/routes/settings.ts +11 -8
  170. package/src/routes/templates.ts +72 -26
  171. package/src/services/categories.ts +14 -1
  172. package/src/services/declarations.ts +4 -3
  173. package/src/services/events.ts +1 -1
  174. package/src/services/localized-text.ts +53 -0
  175. package/src/services/send-stat-items.ts +60 -13
  176. package/src/services/send-stats.ts +37 -5
  177. package/src/services/send.ts +19 -20
  178. package/src/services/sender-declaration.ts +27 -0
  179. package/src/services/starters.ts +3 -15
  180. package/src/services/templates.ts +50 -19
  181. package/src/services/webhook-status.ts +3 -3
  182. package/src/types/settings.ts +35 -5
  183. package/src/validation/declarations.schema.ts +29 -3
  184. package/src/validation/settings.schema.ts +56 -14
  185. package/src/validation/templates.schema.ts +10 -0
  186. package/frontend-vue/app/components/PageHeader.vue +0 -94
  187. package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
@@ -5,6 +5,7 @@ import {
5
5
  JSONBody,
6
6
  Parameter,
7
7
  Post,
8
+ Put,
8
9
  type RequestContext,
9
10
  } from "@antelopejs/interface-api";
10
11
  import { assert, assertValidation } from "@antelopejs/interface-api-util";
@@ -22,12 +23,21 @@ import {
22
23
  collectContentVariablePaths,
23
24
  resolveEmail,
24
25
  type TemplateChange,
26
+ valueFormatOf,
25
27
  } from "../engine";
26
- import { type MailingTemplate, SendModel, TemplateModel } from "../db";
28
+ import {
29
+ type MailingTemplate,
30
+ SendModel,
31
+ type TemplateFields,
32
+ TemplateModel,
33
+ } from "../db";
34
+ import { saveDetails } from "../data/templates-table";
27
35
  import { TemplatesPageController } from "../pages/templates";
28
36
  import { forAudience, statsWindowStart } from "../services/metrics";
29
37
  import { refreshTemplateAttention } from "../services/template-attention";
30
38
  import { renderEmailHtml } from "../services/render";
39
+ import { categoriesIn } from "../services/categories";
40
+ import { localeOfRequest, withTextIn } from "../services/localized-text";
31
41
  import { getSettings } from "../services/settings";
32
42
  import { findStarter, STARTER_TEMPLATES } from "../services/starters";
33
43
  import {
@@ -39,7 +49,6 @@ import {
39
49
  import {
40
50
  assertSlugClaimed,
41
51
  contentFieldsOf,
42
- contentOfVersion,
43
52
  discardDraft,
44
53
  displayName,
45
54
  hasDraft,
@@ -55,13 +64,17 @@ import {
55
64
  publishedVersionOf,
56
65
  publishTemplate,
57
66
  saveDraft,
67
+ publishedSnapshot,
68
+ snapshotOfVersion,
69
+ type TemplateDraft,
70
+ workingSnapshot,
58
71
  workingContent,
59
72
  workingVariables,
60
73
  requireTemplate,
61
74
  } from "../services/templates";
62
75
  import {
63
76
  BUSINESS_AUDIENCE,
64
- type MailingSettingsValues,
77
+ type CategoryInLocale,
65
78
  type TemplateContent,
66
79
  type TemplateStatus,
67
80
  type VariableDefinition,
@@ -104,14 +117,13 @@ const TARGET_STATUS: Record<
104
117
  };
105
118
 
106
119
  /** What a lifecycle action records besides the new status. */
107
- const TRANSITION_FIELDS: Partial<
108
- Record<TemplateAction, () => Partial<MailingTemplate>>
109
- > = {
110
- unpublish: () => ({ unpublishedAt: new Date() }),
111
- };
120
+ const TRANSITION_FIELDS: Partial<Record<TemplateAction, () => TemplateFields>> =
121
+ {
122
+ unpublish: () => ({ unpublishedAt: new Date() }),
123
+ };
112
124
 
113
125
  export interface CategoriesResponse {
114
- categories: MailingSettingsValues["categories"];
126
+ categories: CategoryInLocale[];
115
127
  }
116
128
 
117
129
  export interface TemplateSender {
@@ -121,6 +133,7 @@ export interface TemplateSender {
121
133
  }
122
134
 
123
135
  export interface TemplateContentResponse {
136
+ /** The template, its name in the reader's language. */
124
137
  template: MailingTemplate;
125
138
  /** The content the editor works on: the draft, else the published one. */
126
139
  content: TemplateContent;
@@ -146,7 +159,7 @@ export interface TemplateContentResponse {
146
159
  fallbackLocale: string;
147
160
  /** Whether a send missing a variable fails rather than goes out blank. */
148
161
  blockOnMissingVariables: boolean;
149
- categories: MailingSettingsValues["categories"];
162
+ categories: CategoryInLocale[];
150
163
  sender: TemplateSender;
151
164
  }
152
165
 
@@ -192,6 +205,11 @@ export class TemplatesController extends Controller(
192
205
  return displayName(this.user);
193
206
  }
194
207
 
208
+ /** The language the reader reads names and labels in. */
209
+ private get locale(): string {
210
+ return localeOfRequest(this.ctx);
211
+ }
212
+
195
213
  /**
196
214
  * The tenant's categories for a category picker. Separate from `/settings`,
197
215
  * which is guarded by the settings page permission a template editor need
@@ -200,7 +218,7 @@ export class TemplatesController extends Controller(
200
218
  @Get("/categories")
201
219
  async categories(): Promise<CategoriesResponse> {
202
220
  const settings = await getSettings(this.tenantId);
203
- return { categories: settings.categories };
221
+ return { categories: categoriesIn(settings.categories, this.locale) };
204
222
  }
205
223
 
206
224
  @Get("/starters")
@@ -236,7 +254,12 @@ export class TemplatesController extends Controller(
236
254
  );
237
255
  const localeContent = localeContentOf(starter.content, locale);
238
256
  assert(localeContent, HTTP_UNPROCESSABLE, INVALID_CONTENT);
239
- const email = resolveEmail(localeContent, starter.testDataFor(locale));
257
+ const email = resolveEmail(
258
+ localeContent,
259
+ starter.testDataFor(locale),
260
+ undefined,
261
+ valueFormatOf(locale, starter.variables),
262
+ );
240
263
  return { html: await renderEmailHtml(email, locale), locale };
241
264
  }
242
265
 
@@ -289,7 +312,7 @@ export class TemplatesController extends Controller(
289
312
  ? parseContent(template)
290
313
  : null;
291
314
  return {
292
- template,
315
+ template: template.localize(this.locale, ["name"]),
293
316
  content,
294
317
  publishedContent,
295
318
  hasDraft: hasDraft(template),
@@ -305,7 +328,7 @@ export class TemplatesController extends Controller(
305
328
  testData: parseTestData(template),
306
329
  fallbackLocale: settings.fallbackLocale,
307
330
  blockOnMissingVariables: settings.blockOnMissingVariables,
308
- categories: settings.categories,
331
+ categories: categoriesIn(settings.categories, this.locale),
309
332
  sender: {
310
333
  name: settings.senderName,
311
334
  email: settings.senderEmail,
@@ -431,7 +454,7 @@ export class TemplatesController extends Controller(
431
454
  ) {
432
455
  await this.requireTransition(id, action);
433
456
  const status = TARGET_STATUS[action];
434
- await this.templates.update(id, {
457
+ await this.templates.updateFields(id, {
435
458
  status,
436
459
  updatedBy: this.author,
437
460
  ...TRANSITION_FIELDS[action]?.(),
@@ -472,7 +495,9 @@ export class TemplatesController extends Controller(
472
495
  testDataSchema.parse(value),
473
496
  );
474
497
  await requireTemplate(this.templates, id);
475
- await this.templates.update(id, { json_test_data: JSON.stringify(data) });
498
+ await this.templates.updateFields(id, {
499
+ json_test_data: JSON.stringify(data),
500
+ });
476
501
  return { data };
477
502
  }
478
503
 
@@ -487,9 +512,12 @@ export class TemplatesController extends Controller(
487
512
  () => INVALID_CONTENT,
488
513
  );
489
514
  const template = await requireTemplate(this.templates, id);
490
- const content =
491
- (input.content as TemplateContent | undefined) ??
492
- (await this.previewContent(template, input.version));
515
+ const { content, variables } = input.content
516
+ ? {
517
+ ...workingSnapshot(template),
518
+ content: input.content as TemplateContent,
519
+ }
520
+ : await this.previewSnapshot(template, input.version);
493
521
  const settings = await getSettings(this.tenantId);
494
522
  const locale = pickLocale(content, input.locale, settings.fallbackLocale);
495
523
  const localeContent = localeContentOf(content, locale);
@@ -497,6 +525,8 @@ export class TemplatesController extends Controller(
497
525
  const email = resolveEmail(
498
526
  localeContent,
499
527
  input.data ?? parseTestData(template),
528
+ undefined,
529
+ valueFormatOf(locale, variables),
500
530
  );
501
531
  const html = await renderEmailHtml(email, locale);
502
532
  return {
@@ -508,14 +538,30 @@ export class TemplatesController extends Controller(
508
538
  };
509
539
  }
510
540
 
511
- private previewContent(
541
+ private previewSnapshot(
512
542
  template: MailingTemplate,
513
543
  version: "draft" | "published" | number | undefined,
514
- ): Promise<TemplateContent> | TemplateContent {
515
- if (version === "published") return parseContent(template);
544
+ ): Promise<TemplateDraft> | TemplateDraft {
545
+ if (version === "published") return publishedSnapshot(template);
516
546
  if (typeof version === "number")
517
- return contentOfVersion(this.tenantId, template, version);
518
- return workingContent(template);
547
+ return snapshotOfVersion(this.tenantId, template, version);
548
+ return workingSnapshot(template);
549
+ }
550
+
551
+ /**
552
+ * Renames a template in the reader's language, its other languages kept,
553
+ * and sets its category: what the module's drawer and editor save, each
554
+ * showing one language. The DMS edit form, which shows every language,
555
+ * writes through the table's edit route.
556
+ */
557
+ @Put("/:id/details")
558
+ async details(
559
+ @Parameter("id", "param") id: string,
560
+ @JSONBody() body: unknown,
561
+ ) {
562
+ const template = await requireTemplate(this.templates, id);
563
+ await saveDetails(this.ctx, template, body, this.author);
564
+ return { id };
519
565
  }
520
566
 
521
567
  @Post("/:id/duplicate")
@@ -529,9 +575,9 @@ export class TemplatesController extends Controller(
529
575
  const source = await requireTemplate(this.templates, id);
530
576
  const clash = await this.templates.getBySlug(slug);
531
577
  assert(!clash, HTTP_CONFLICT, DUPLICATE_SLUG);
532
- const [copyId] = await this.templates.insert({
578
+ const [copyId] = await this.templates.insertFields({
533
579
  slug,
534
- name,
580
+ name: withTextIn(undefined, this.locale, name),
535
581
  category: source.category,
536
582
  status: "draft",
537
583
  ...contentFieldsOf(source),
@@ -1,6 +1,19 @@
1
1
  import type { MailingTemplate } from "../db";
2
- import type { TemplateCategory } from "../types";
2
+ import type { CategoryInLocale, TemplateCategory } from "../types";
3
3
  import type { CategoryEntry } from "../validation/settings.schema";
4
+ import { textIn } from "./localized-text";
5
+
6
+ /** The categories as a reader of `locale` sees them. */
7
+ export function categoriesIn(
8
+ categories: TemplateCategory[],
9
+ locale: string,
10
+ ): CategoryInLocale[] {
11
+ return categories.map(({ id, label, icon }) => ({
12
+ id,
13
+ label: textIn(label, locale),
14
+ icon,
15
+ }));
16
+ }
4
17
 
5
18
  /** The categories a list keeps, without the removed ones it moves away. */
6
19
  export function keptCategories(entries: CategoryEntry[]): TemplateCategory[] {
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
3
  DeclarationOutcome,
4
+ LocalizedText,
4
5
  DeclareTemplateCategoryResult,
5
6
  DeclareTemplateResult,
6
7
  TemplateCategoryDeclaration,
@@ -55,7 +56,7 @@ const MAX_DECLARATION_ATTEMPTS = 5;
55
56
 
56
57
  /** What a declaration fingerprints: everything a user may change after it. */
57
58
  interface DeclaredTemplateState {
58
- name: string;
59
+ name: LocalizedText;
59
60
  category: string;
60
61
  content: TemplateContent;
61
62
  variables: VariableDefinition[];
@@ -131,7 +132,7 @@ function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
131
132
  function currentFingerprint(template: MailingTemplate): string | null {
132
133
  if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
133
134
  return fingerprintOf({
134
- name: template.name,
135
+ name: TemplateModel.namesOf(template),
135
136
  category: template.category ?? "",
136
137
  content: parseContent(template),
137
138
  variables: parseVariables(template),
@@ -165,7 +166,7 @@ async function createDeclared(
165
166
  const settings = await getSettings(tenantId);
166
167
  assertKnownCategory(settings.categories, state.category, slug);
167
168
  const revision = randomUUID();
168
- const [id] = await model.insert({
169
+ const [id] = await model.insertFields({
169
170
  slug,
170
171
  name: state.name,
171
172
  category: state.category,
@@ -135,7 +135,7 @@ async function publishEmailEvent(
135
135
  send: MailingSend,
136
136
  ): Promise<void> {
137
137
  emitEmailEvent(payload);
138
- publishSendUpdated(payload.sendId);
138
+ publishSendUpdated(payload.tenantId, payload.sendId);
139
139
  await notifyProblems(payload.tenantId, {
140
140
  sendId: send._id,
141
141
  templateSlug: send.templateSlug,
@@ -0,0 +1,53 @@
1
+ import type { RequestContext } from "@antelopejs/interface-api";
2
+ import {
3
+ LOCALIZED_TEXT_FALLBACK_LOCALE,
4
+ type LocalizedText,
5
+ } from "@antelopejs/interface-dms-mailing";
6
+
7
+ /** Header the DMS frontend states the reader's language in on every request. */
8
+ export const CONTENT_LANGUAGE_HEADER = "x-content-language";
9
+
10
+ /**
11
+ * The language to read localized texts in: the header's, else the one every
12
+ * localized text falls back to.
13
+ *
14
+ * @param header Value of {@link CONTENT_LANGUAGE_HEADER}
15
+ */
16
+ export function requestLocale(header: unknown): string {
17
+ return typeof header === "string" && header.length > 0
18
+ ? header
19
+ : LOCALIZED_TEXT_FALLBACK_LOCALE;
20
+ }
21
+
22
+ /** The language a request reads and writes localized texts in. */
23
+ export function localeOfRequest(ctx: RequestContext): string {
24
+ return requestLocale(ctx.rawRequest.headers[CONTENT_LANGUAGE_HEADER]);
25
+ }
26
+
27
+ /**
28
+ * A localized text in `locale`, else in the fallback language, as
29
+ * `@Localized({ fallbackLocale })` reads a table field.
30
+ */
31
+ export function textIn(
32
+ text: LocalizedText | undefined,
33
+ locale: string,
34
+ ): string {
35
+ return text?.[locale] ?? text?.[LOCALIZED_TEXT_FALLBACK_LOCALE] ?? "";
36
+ }
37
+
38
+ /**
39
+ * `text` with `value` written in `locale`. The fallback language gets the
40
+ * same value while it has none, so a text first written in another language
41
+ * still reads in every language.
42
+ */
43
+ export function withTextIn(
44
+ text: LocalizedText | undefined,
45
+ locale: string,
46
+ value: string,
47
+ ): LocalizedText {
48
+ return {
49
+ [LOCALIZED_TEXT_FALLBACK_LOCALE]: value,
50
+ ...text,
51
+ [locale]: value,
52
+ };
53
+ }
@@ -3,6 +3,7 @@ import type {
3
3
  BlockText,
4
4
  ComposedText,
5
5
  } from "@antelopejs/interface-dms/base/types";
6
+ import { deltaPercent } from "./metrics";
6
7
  import type { SendStats } from "./send-stats";
7
8
 
8
9
  /** Past this wait the oldest queued send is worth a warning. */
@@ -39,11 +40,24 @@ function joined(parts: ComposedText[], fallback: ComposedText): BlockText {
39
40
  return parts.reduce((left, right) => text("joined", { left, right }));
40
41
  }
41
42
 
42
- function deltaText(delta: number | null): ComposedText[] {
43
- if (!delta) return [];
43
+ /** A figure of the comparison window, as its cell writes it. */
44
+ type ComparedValue = NonNullable<ComposedText["params"]>[string];
45
+
46
+ /**
47
+ * The change and the figure it is measured against ("▲ 8 % vs 120"), or the
48
+ * figure alone when no change can be stated (an equal or a zero figure).
49
+ * Nothing when the comparison is off or the window has no such figure.
50
+ */
51
+ function comparedText(
52
+ delta: number | null,
53
+ previous: ComparedValue | null,
54
+ ): ComposedText[] {
55
+ if (previous === null) return [];
56
+ if (!delta) return [text("versus", { previous })];
44
57
  return [
45
- text(delta > 0 ? "delta_up" : "delta_down", {
58
+ text(delta > 0 ? "delta_up_versus" : "delta_down_versus", {
46
59
  delta: percent(Math.abs(delta)),
60
+ previous,
47
61
  }),
48
62
  ];
49
63
  }
@@ -52,18 +66,29 @@ function sendsItem(stats: SendStats): StatGroupItem {
52
66
  const tests = stats.tests
53
67
  ? [text("tests_excluded", { count: count(stats.tests) })]
54
68
  : [];
69
+ const previous = stats.compared?.sends ?? null;
55
70
  return {
56
71
  id: "sends",
57
72
  icon: "i-ph-paper-plane-tilt",
58
73
  eyebrow: key("sends"),
59
74
  value: stats.sends,
60
75
  detail: joined(
61
- [...deltaText(stats.sendsDelta), ...tests],
76
+ [...comparedText(stats.sendsDelta, previous), ...tests],
62
77
  text("no_tests"),
63
78
  ),
64
79
  };
65
80
  }
66
81
 
82
+ /** The deliverability's change; none without a figure on either side. */
83
+ function deliverabilityCompared(stats: SendStats): ComposedText[] {
84
+ const previous = stats.compared?.deliverability ?? null;
85
+ if (!stats.sends || previous === null) return [];
86
+ return comparedText(
87
+ deltaPercent(stats.deliverability, previous),
88
+ percent(previous),
89
+ );
90
+ }
91
+
67
92
  function deliveredItem(stats: SendStats, provider: string): StatGroupItem {
68
93
  if (stats.isDeliveryUntracked) {
69
94
  return {
@@ -74,6 +99,10 @@ function deliveredItem(stats: SendStats, provider: string): StatGroupItem {
74
99
  detail: text("not_tracked", { provider: provider || THE_PROVIDER }),
75
100
  };
76
101
  }
102
+ const deliveredOf = text("delivered_of", {
103
+ delivered: stats.delivered,
104
+ sends: stats.sends,
105
+ });
77
106
  return {
78
107
  id: "delivered",
79
108
  icon: "i-ph-check-circle",
@@ -82,10 +111,10 @@ function deliveredItem(stats: SendStats, provider: string): StatGroupItem {
82
111
  stats.sends && stats.deliverability !== null
83
112
  ? text("rate", { rate: percent(stats.deliverability) })
84
113
  : EMPTY_FIGURE,
85
- detail: text("delivered_of", {
86
- delivered: stats.delivered,
87
- sends: stats.sends,
88
- }),
114
+ detail: joined(
115
+ [...deliverabilityCompared(stats), deliveredOf],
116
+ deliveredOf,
117
+ ),
89
118
  };
90
119
  }
91
120
 
@@ -93,13 +122,19 @@ function problemsItem(stats: SendStats): StatGroupItem {
93
122
  const parts = PROBLEM_PARTS.filter((part) => stats[part]).map((part) =>
94
123
  text(`breakdown.${part}`, { count: count(stats[part]) }),
95
124
  );
125
+ const previous = stats.compared?.problems ?? null;
126
+ const compared = comparedText(
127
+ deltaPercent(stats.problems, previous),
128
+ previous,
129
+ );
130
+ const breakdown = parts.length ? parts : [text("no_problems")];
96
131
  return {
97
132
  id: "problems",
98
133
  icon: "i-ph-warning-octagon",
99
134
  tone: stats.problems ? "error" : undefined,
100
135
  eyebrow: key("problems"),
101
136
  value: stats.problems,
102
- detail: joined(parts, text("no_problems")),
137
+ detail: joined([...compared, ...breakdown], text("no_problems")),
103
138
  to: stats.problems ? PROBLEMS_PAGE : undefined,
104
139
  };
105
140
  }
@@ -127,15 +162,26 @@ export function latencyText(ms: number): ComposedText {
127
162
  });
128
163
  }
129
164
 
165
+ /** The latency's change; none without a timed send on either side. */
166
+ function latencyCompared(stats: SendStats): ComposedText[] {
167
+ const previous = stats.compared?.latencyMedian ?? null;
168
+ if (!stats.sends || previous === null) return [];
169
+ return comparedText(
170
+ deltaPercent(stats.latencyMedian, previous),
171
+ latencyText(previous),
172
+ );
173
+ }
174
+
130
175
  function latencyItem(stats: SendStats, provider: string): StatGroupItem {
176
+ const call = provider
177
+ ? text("latency_detail", { provider })
178
+ : text("latency_detail_generic");
131
179
  return {
132
180
  id: "latency",
133
181
  icon: "i-ph-timer",
134
182
  eyebrow: key("latency"),
135
183
  value: stats.sends ? latencyText(stats.latencyMedian) : EMPTY_FIGURE,
136
- detail: provider
137
- ? text("latency_detail", { provider })
138
- : text("latency_detail_generic"),
184
+ detail: joined([...latencyCompared(stats), call], call),
139
185
  };
140
186
  }
141
187
 
@@ -143,7 +189,8 @@ function latencyItem(stats: SendStats, provider: string): StatGroupItem {
143
189
  * The five cells of the send log's stat strip, as `StatGroup` items whose
144
190
  * texts the DMS composes in the reader's language: sends with the tests left
145
191
  * out, deliverability, problems with their breakdown, the queue with its
146
- * oldest wait, and the median API latency.
192
+ * oldest wait, and the median API latency. Every cell but the queue, which is
193
+ * what waits now, states its change against the comparison window.
147
194
  */
148
195
  export function buildSendStatItems(
149
196
  stats: SendStats,
@@ -35,6 +35,21 @@ export interface SendStats {
35
35
  /** When the oldest send still queued was made; `null` with none queued. */
36
36
  oldestQueuedAt: string | null;
37
37
  latencyMedian: number;
38
+ /** The same figures over the comparison window; `null` with it turned off. */
39
+ compared: ComparedSendStats | null;
40
+ }
41
+
42
+ /**
43
+ * The figures of the strip a comparison window states. The queue is left out:
44
+ * it is what waits now, and what waited back then has long left.
45
+ */
46
+ export interface ComparedSendStats {
47
+ sends: number;
48
+ /** `null` without a real send, or when nothing reported deliveries. */
49
+ deliverability: number | null;
50
+ problems: number;
51
+ /** `null` without a real send to time. */
52
+ latencyMedian: number | null;
38
53
  }
39
54
 
40
55
  const isQueued = (send: SendSummary): boolean => send.status === "queued";
@@ -74,20 +89,36 @@ function problemBreakdown(real: SendSummary[]): ProblemBreakdown {
74
89
  return { problems: bounced + failed + spam, bounced, failed, spam };
75
90
  }
76
91
 
92
+ const isRealSend = (send: SendSummary): boolean => !send.isTest;
93
+
94
+ function comparedStats(
95
+ previous: SendSummary[],
96
+ tracking: ProviderTracking | null,
97
+ ): ComparedSendStats {
98
+ const real = previous.filter(isRealSend);
99
+ const totals = aggregate(real);
100
+ return {
101
+ sends: totals.sends,
102
+ deliverability: isDeliveryUntracked(real, tracking)
103
+ ? null
104
+ : totals.deliverability,
105
+ problems: problemBreakdown(real).problems,
106
+ latencyMedian: totals.sends ? totals.latencyMedian : null,
107
+ };
108
+ }
109
+
77
110
  /** `previous` is `null` when the user turned the comparison off. */
78
111
  export function buildSendStats(
79
112
  current: SendSummary[],
80
113
  previous: SendSummary[] | null,
81
114
  tracking: ProviderTracking | null = null,
82
115
  ): SendStats {
83
- const real = current.filter((send) => !send.isTest);
116
+ const real = current.filter(isRealSend);
84
117
  const totals = aggregate(real);
85
- const previousTotals = previous
86
- ? aggregate(previous.filter((send) => !send.isTest))
87
- : null;
118
+ const compared = previous ? comparedStats(previous, tracking) : null;
88
119
  return {
89
120
  sends: totals.sends,
90
- sendsDelta: deltaPercent(totals.sends, previousTotals?.sends ?? null),
121
+ sendsDelta: deltaPercent(totals.sends, compared?.sends ?? null),
91
122
  tests: current.length - real.length,
92
123
  delivered: totals.delivered,
93
124
  deliverability: totals.deliverability,
@@ -96,6 +127,7 @@ export function buildSendStats(
96
127
  queued: totals.queued,
97
128
  oldestQueuedAt: oldestQueuedAt(real),
98
129
  latencyMedian: totals.latencyMedian,
130
+ compared,
99
131
  };
100
132
  }
101
133
 
@@ -22,6 +22,8 @@ import {
22
22
  missingSystemVariables,
23
23
  renderPlainText,
24
24
  resolveEmail,
25
+ type ValueFormat,
26
+ valueFormatOf,
25
27
  } from "../engine";
26
28
  import type {
27
29
  SendTemplateParams,
@@ -43,11 +45,12 @@ import { refreshAttentionIfFlagged } from "./template-attention";
43
45
  import {
44
46
  hasDraft,
45
47
  localeContentOf,
46
- parseContent,
47
48
  parseVariables,
48
49
  pickLocale,
50
+ publishedSnapshot,
49
51
  publishedVersionOf,
50
- workingContent,
52
+ type VersionedSnapshot,
53
+ workingSnapshot,
51
54
  } from "./templates";
52
55
 
53
56
  export class SendError extends Error {
@@ -66,6 +69,8 @@ export interface PreparedSend {
66
69
  requestedLocale: string;
67
70
  /** Template version the content belongs to; 0 for a draft. */
68
71
  version: number;
72
+ /** How its typed values are written: the locale, the version's `date` paths. */
73
+ format: ValueFormat;
69
74
  templateId: string;
70
75
  slug: string;
71
76
  tenantId: string;
@@ -165,11 +170,6 @@ const nameOf = (to: EmailAddress): string | undefined =>
165
170
  const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
166
171
  Array.isArray(to) ? to : [to];
167
172
 
168
- interface ResolvedContent {
169
- content: TemplateContent;
170
- version: number;
171
- }
172
-
173
173
  /**
174
174
  * The content a send renders and the version it belongs to. A real send gets
175
175
  * what customers receive; a test send gets the draft when there is one, so a
@@ -178,20 +178,17 @@ interface ResolvedContent {
178
178
  function resolveContent(
179
179
  template: MailingTemplate,
180
180
  params: ModuleSendParams,
181
- ): ResolvedContent {
181
+ ): VersionedSnapshot {
182
182
  if (params.content)
183
183
  return {
184
+ ...workingSnapshot(template),
184
185
  content: params.content,
185
186
  version: params.version ?? DRAFT_VERSION,
186
187
  };
187
- if (params.isTest && hasDraft(template))
188
- return { content: workingContent(template), version: DRAFT_VERSION };
189
- if (params.isTest && !publishedVersionOf(template))
190
- return { content: workingContent(template), version: DRAFT_VERSION };
191
- return {
192
- content: parseContent(template),
193
- version: publishedVersionOf(template),
194
- };
188
+ if (params.isTest && (hasDraft(template) || !publishedVersionOf(template)))
189
+ return { ...workingSnapshot(template), version: DRAFT_VERSION };
190
+ const version = publishedVersionOf(template);
191
+ return { ...publishedSnapshot(template), version };
195
192
  }
196
193
 
197
194
  const isManualRealSend = (params: ModuleSendParams): boolean =>
@@ -247,7 +244,7 @@ async function renderForSend(
247
244
  );
248
245
  }
249
246
  const variables = params.variables ?? {};
250
- const email = resolveEmail(localeContent, variables, "send");
247
+ const email = resolveEmail(localeContent, variables, "send", prepared.format);
251
248
  const refused = refusedMissing(
252
249
  params,
253
250
  isBlocking,
@@ -279,13 +276,15 @@ export async function prepare(
279
276
  if (missing.length) {
280
277
  throw new SendError("missing_variables", missing.join(", "));
281
278
  }
282
- const { content, version } = resolveContent(template, params);
279
+ const { content, version, variables } = resolveContent(template, params);
283
280
  const settings = await getSettings(tenantId);
281
+ const locale = pickLocale(content, params.locale, settings.fallbackLocale);
284
282
  return {
285
283
  content,
286
- locale: pickLocale(content, params.locale, settings.fallbackLocale),
284
+ locale,
287
285
  requestedLocale: params.locale ?? "",
288
286
  version,
287
+ format: valueFormatOf(locale, variables),
289
288
  templateId: template._id,
290
289
  slug,
291
290
  tenantId,
@@ -476,7 +475,7 @@ async function record(
476
475
  outcome.error ? { reason: outcome.error } : {},
477
476
  ),
478
477
  });
479
- publishSendCreated(sendId as string);
478
+ publishSendCreated(tenantId, sendId as string);
480
479
  return sendId as string;
481
480
  }
482
481