@antelopejs/dms-mailing 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +28 -10
  2. package/dist/constants.d.ts +0 -2
  3. package/dist/constants.js +1 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/crons/jobs.d.ts +1 -1
  6. package/dist/data/body.js +2 -1
  7. package/dist/data/body.js.map +1 -1
  8. package/dist/data/templates-table.js +5 -11
  9. package/dist/data/templates-table.js.map +1 -1
  10. package/dist/db/models/index.d.ts +0 -1
  11. package/dist/db/models/index.js +0 -1
  12. package/dist/db/models/index.js.map +1 -1
  13. package/dist/db/models/send-events.model.js +2 -3
  14. package/dist/db/models/send-events.model.js.map +1 -1
  15. package/dist/db/models/sends.model.js +2 -3
  16. package/dist/db/models/sends.model.js.map +1 -1
  17. package/dist/db/models/template-versions.model.js.map +1 -1
  18. package/dist/db/models/templates.model.js +4 -10
  19. package/dist/db/models/templates.model.js.map +1 -1
  20. package/dist/db/tables/index.d.ts +0 -1
  21. package/dist/db/tables/index.js +0 -1
  22. package/dist/db/tables/index.js.map +1 -1
  23. package/dist/db/tables/settings.table.d.ts +0 -1
  24. package/dist/db/tables/settings.table.js +0 -4
  25. package/dist/db/tables/settings.table.js.map +1 -1
  26. package/dist/db/tables/templates.table.d.ts +0 -3
  27. package/dist/db/tables/templates.table.js +1 -13
  28. package/dist/db/tables/templates.table.js.map +1 -1
  29. package/dist/engine/changes.js +2 -1
  30. package/dist/engine/changes.js.map +1 -1
  31. package/dist/engine/conditions.js +2 -3
  32. package/dist/engine/conditions.js.map +1 -1
  33. package/dist/engine/fallback-html.js +8 -8
  34. package/dist/engine/fallback-html.js.map +1 -1
  35. package/dist/engine/formats.js +6 -6
  36. package/dist/engine/formats.js.map +1 -1
  37. package/dist/engine/paths.js +2 -3
  38. package/dist/engine/paths.js.map +1 -1
  39. package/dist/engine/system-variables.js +1 -1
  40. package/dist/engine/system-variables.js.map +1 -1
  41. package/dist/engine/tokens.js.map +1 -1
  42. package/dist/hooks/index.d.ts +0 -1
  43. package/dist/hooks/index.js +0 -1
  44. package/dist/hooks/index.js.map +1 -1
  45. package/dist/implementations/dms-mailing/index.d.ts +3 -4
  46. package/dist/implementations/dms-mailing/index.js +8 -9
  47. package/dist/implementations/dms-mailing/index.js.map +1 -1
  48. package/dist/index.js +0 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/pages/sends.js.map +1 -1
  51. package/dist/pages/settings/form.js +8 -0
  52. package/dist/pages/settings/form.js.map +1 -1
  53. package/dist/pages/toolbar.js.map +1 -1
  54. package/dist/routes/events.js +2 -2
  55. package/dist/routes/events.js.map +1 -1
  56. package/dist/routes/metrics.js +6 -5
  57. package/dist/routes/metrics.js.map +1 -1
  58. package/dist/routes/sends.js +1 -1
  59. package/dist/routes/sends.js.map +1 -1
  60. package/dist/routes/settings.js +6 -6
  61. package/dist/routes/settings.js.map +1 -1
  62. package/dist/routes/templates.js +6 -10
  63. package/dist/routes/templates.js.map +1 -1
  64. package/dist/services/attention.js +2 -1
  65. package/dist/services/attention.js.map +1 -1
  66. package/dist/services/categories.js +6 -6
  67. package/dist/services/categories.js.map +1 -1
  68. package/dist/services/checked-send.js +16 -9
  69. package/dist/services/checked-send.js.map +1 -1
  70. package/dist/services/declarations.js +42 -184
  71. package/dist/services/declarations.js.map +1 -1
  72. package/dist/services/events.js +11 -7
  73. package/dist/services/events.js.map +1 -1
  74. package/dist/services/localized-text.d.ts +2 -1
  75. package/dist/services/localized-text.js +2 -1
  76. package/dist/services/localized-text.js.map +1 -1
  77. package/dist/services/metrics.js.map +1 -1
  78. package/dist/services/render.js.map +1 -1
  79. package/dist/services/send-import.js +7 -5
  80. package/dist/services/send-import.js.map +1 -1
  81. package/dist/services/send.js +4 -3
  82. package/dist/services/send.js.map +1 -1
  83. package/dist/services/settings.d.ts +1 -1
  84. package/dist/services/settings.js +1 -5
  85. package/dist/services/settings.js.map +1 -1
  86. package/dist/services/starters.js +5 -5
  87. package/dist/services/starters.js.map +1 -1
  88. package/dist/services/template-stats.js +5 -2
  89. package/dist/services/template-stats.js.map +1 -1
  90. package/dist/services/templates.d.ts +2 -2
  91. package/dist/services/templates.js +54 -29
  92. package/dist/services/templates.js.map +1 -1
  93. package/dist/types/settings.d.ts +2 -0
  94. package/dist/types/settings.js +8 -1
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +16 -16
  97. package/dist/validation/declarations.schema.js +2 -2
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/guards.d.ts +4 -0
  100. package/dist/validation/guards.js +19 -0
  101. package/dist/validation/guards.js.map +1 -0
  102. package/dist/validation/send-event-type.schema.d.ts +2 -0
  103. package/dist/validation/send-event-type.schema.js +11 -0
  104. package/dist/validation/send-event-type.schema.js.map +1 -0
  105. package/dist/validation/settings.schema.d.ts +16 -16
  106. package/dist/validation/settings.schema.js +1 -1
  107. package/dist/validation/settings.schema.js.map +1 -1
  108. package/dist/validation/templates.schema.d.ts +19 -18
  109. package/dist/validation/templates.schema.js.map +1 -1
  110. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  111. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  112. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  113. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  114. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  115. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  116. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  117. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  118. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  119. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  120. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  121. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  122. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  123. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  124. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  125. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  126. package/frontend-vue/build/backend-boundary.ts +122 -0
  127. package/frontend-vue/dms.frontend.build.ts +7 -0
  128. package/frontend-vue/eslint.config.mjs +13 -0
  129. package/frontend-vue/package.json +4 -2
  130. package/frontend-vue/pnpm-lock.yaml +210 -61
  131. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  132. package/frontend-vue/tests/dnd.test.ts +1 -2
  133. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  134. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  135. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  136. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  137. package/frontend-vue/tests/page-reads.test.ts +26 -6
  138. package/frontend-vue/tests/send-actions.test.ts +0 -1
  139. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  140. package/frontend-vue/tests/send-modals.test.ts +8 -5
  141. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  142. package/frontend-vue/tests/suppression.test.ts +1 -2
  143. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  144. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  145. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  146. package/package.json +8 -13
  147. package/skills/dms-mailing/SKILL.md +11 -17
  148. package/skills/dms-mailing/cases.json +49 -0
  149. package/src/constants.ts +0 -2
  150. package/src/crons/jobs.ts +1 -1
  151. package/src/data/body.ts +2 -1
  152. package/src/data/templates-table.ts +15 -22
  153. package/src/db/models/index.ts +0 -1
  154. package/src/db/models/send-events.model.ts +2 -2
  155. package/src/db/models/sends.model.ts +7 -8
  156. package/src/db/models/template-versions.model.ts +5 -1
  157. package/src/db/models/templates.model.ts +9 -16
  158. package/src/db/tables/index.ts +0 -1
  159. package/src/db/tables/settings.table.ts +0 -8
  160. package/src/db/tables/templates.table.ts +2 -16
  161. package/src/engine/changes.ts +3 -2
  162. package/src/engine/conditions.ts +2 -4
  163. package/src/engine/fallback-html.ts +13 -13
  164. package/src/engine/formats.ts +6 -6
  165. package/src/engine/paths.ts +4 -3
  166. package/src/engine/system-variables.ts +4 -3
  167. package/src/engine/tokens.ts +1 -2
  168. package/src/hooks/index.ts +0 -1
  169. package/src/implementations/dms-mailing/index.ts +5 -18
  170. package/src/index.ts +1 -6
  171. package/src/pages/sends.ts +5 -3
  172. package/src/pages/settings/form.ts +16 -0
  173. package/src/pages/templates.ts +1 -1
  174. package/src/pages/toolbar.ts +4 -2
  175. package/src/routes/events.ts +3 -6
  176. package/src/routes/metrics.ts +7 -5
  177. package/src/routes/sends.ts +10 -18
  178. package/src/routes/settings.ts +8 -9
  179. package/src/routes/suppressions.ts +1 -1
  180. package/src/routes/templates.ts +16 -18
  181. package/src/services/attention.ts +4 -3
  182. package/src/services/categories.ts +6 -6
  183. package/src/services/checked-send.ts +17 -19
  184. package/src/services/declarations.ts +62 -318
  185. package/src/services/events.ts +16 -13
  186. package/src/services/localized-text.ts +4 -2
  187. package/src/services/metrics.ts +2 -2
  188. package/src/services/notify.ts +1 -1
  189. package/src/services/render.ts +5 -1
  190. package/src/services/send-import.ts +18 -13
  191. package/src/services/send.ts +7 -7
  192. package/src/services/settings.ts +3 -10
  193. package/src/services/starters.ts +6 -6
  194. package/src/services/template-stats.ts +11 -4
  195. package/src/services/templates.ts +69 -52
  196. package/src/types/settings.ts +20 -0
  197. package/src/validation/declarations.schema.ts +2 -2
  198. package/src/validation/guards.ts +25 -0
  199. package/src/validation/send-event-type.schema.ts +10 -0
  200. package/src/validation/settings.schema.ts +7 -2
  201. package/src/validation/templates.schema.ts +2 -1
  202. package/dist/db/models/template-slugs.model.d.ts +0 -27
  203. package/dist/db/models/template-slugs.model.js +0 -43
  204. package/dist/db/models/template-slugs.model.js.map +0 -1
  205. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  206. package/dist/db/tables/template-slugs.table.js +0 -35
  207. package/dist/db/tables/template-slugs.table.js.map +0 -1
  208. package/dist/hooks/database-ready.d.ts +0 -7
  209. package/dist/hooks/database-ready.js +0 -26
  210. package/dist/hooks/database-ready.js.map +0 -1
  211. package/dist/services/fingerprint.d.ts +0 -1
  212. package/dist/services/fingerprint.js +0 -20
  213. package/dist/services/fingerprint.js.map +0 -1
  214. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  215. package/frontend-vue/tests/i18n.test.ts +0 -138
  216. package/src/db/models/template-slugs.model.ts +0 -61
  217. package/src/db/tables/template-slugs.table.ts +0 -29
  218. package/src/hooks/database-ready.ts +0 -47
  219. package/src/services/fingerprint.ts +0 -22
@@ -1,27 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
- DeclarationOutcome,
4
- LocalizedText,
5
3
  DeclareTemplateCategoryResult,
6
4
  DeclareTemplateResult,
7
5
  TemplateCategoryDeclaration,
8
6
  TemplateDeclaration,
9
7
  } from "@antelopejs/interface-dms-mailing";
10
- import { Logging } from "@antelopejs/interface-core/logging";
8
+ import { IsDuplicateKeyError } from "@antelopejs/interface-database";
11
9
  import { GetModel } from "@antelopejs/interface-database-decorators";
12
10
  import {
13
11
  type MailingTemplate,
14
- SettingsModel,
15
12
  TemplateModel,
16
- TemplateSlugModel,
17
13
  TemplateVersionModel,
18
- templateSlugIdOf,
19
14
  } from "../db";
20
- import type {
21
- TemplateCategory,
22
- TemplateContent,
23
- VariableDefinition,
24
- } from "../types";
15
+ import type { TemplateCategory, TemplateContent } from "../types";
25
16
  import {
26
17
  categoryDeclarationSchema,
27
18
  type ParsedCategoryDeclaration,
@@ -30,54 +21,18 @@ import {
30
21
  } from "../validation/declarations.schema";
31
22
  import { categoriesSchema } from "../validation/settings.schema";
32
23
  import { DeclarationError, parseDeclared } from "./declaration-error";
33
- import { fingerprintOf } from "./fingerprint";
34
24
  import { findCategory, getSettings, saveSettings } from "./settings";
25
+ import { refreshTemplateAttention } from "./template-attention";
35
26
  import {
36
- assertSlugClaimed,
37
- blankFields,
38
- hasDraft,
39
27
  localesWithoutSubject,
40
- parseContent,
41
- parseTestData,
42
- parseVariables,
43
28
  publishedVersionOf,
44
- publishSnapshot,
29
+ publishedAtCreation,
45
30
  requireTemplate,
46
- workingContent,
47
- workingVariables,
48
31
  } from "./templates";
49
32
 
50
33
  /** Who a declaration's writes are shown as by default. */
51
34
  const DECLARATION_AUTHOR = "system";
52
35
 
53
- const LIVE_STATUS = "live";
54
- const LOG_PREFIX = "[dms-mailing]";
55
- const MAX_DECLARATION_ATTEMPTS = 5;
56
-
57
- /** What a declaration fingerprints: everything a user may change after it. */
58
- interface DeclaredTemplateState {
59
- name: LocalizedText;
60
- category: string;
61
- content: TemplateContent;
62
- variables: VariableDefinition[];
63
- testData: Record<string, unknown>;
64
- }
65
-
66
- /** A declaration ready to write: validated, defaulted and fingerprinted. */
67
- interface TemplatePlan {
68
- tenantId: string;
69
- slug: string;
70
- state: DeclaredTemplateState;
71
- author: string;
72
- hash: string;
73
- }
74
-
75
- /**
76
- * The values as the database gives them back: a `Date` in the test data is
77
- * stored as its ISO string, and the fingerprint must compare alike.
78
- */
79
- const asStored = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
80
-
81
36
  function assertPublishable(content: TemplateContent, slug: string): void {
82
37
  if (!Object.keys(content.locales).length)
83
38
  throw new DeclarationError(
@@ -105,212 +60,60 @@ function assertKnownCategory(
105
60
  );
106
61
  }
107
62
 
108
- function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
109
- const { tenantId } = declaration;
110
- const { slug } = declaration;
111
- const state = asStored<DeclaredTemplateState>({
112
- name: declaration.name,
113
- category: declaration.category,
114
- content: declaration.content as TemplateContent,
115
- variables: declaration.variables,
116
- testData: declaration.testData,
117
- });
118
- assertPublishable(state.content, slug);
119
- return {
120
- tenantId,
121
- slug,
122
- state,
123
- author: declaration.author ?? DECLARATION_AUTHOR,
124
- hash: fingerprintOf(state),
125
- };
126
- }
127
-
128
- /**
129
- * The fingerprint of what the template holds now, `null` when a user took it
130
- * out of the state a declaration leaves it in: live, with no draft waiting.
131
- */
132
- function currentFingerprint(template: MailingTemplate): string | null {
133
- if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
134
- return fingerprintOf({
135
- name: TemplateModel.namesOf(template),
136
- category: template.category ?? "",
137
- content: parseContent(template),
138
- variables: parseVariables(template),
139
- testData: parseTestData(template),
140
- });
141
- }
142
-
143
63
  function resultOf(
144
64
  template: MailingTemplate,
145
- outcome: DeclarationOutcome,
146
- version = publishedVersionOf(template),
65
+ outcome: DeclareTemplateResult["outcome"],
147
66
  ): DeclareTemplateResult {
148
67
  return {
149
68
  templateId: template._id,
150
69
  slug: template.slug,
151
70
  outcome,
152
- version,
71
+ version: publishedVersionOf(template),
153
72
  };
154
73
  }
155
74
 
156
75
  /**
157
- * Inserts the template as a fresh draft of the declared content, claims its
158
- * slug like the dashboard's creation does. The seed is not an applied
159
- * declaration: any caller can finish its first publication after a crash.
76
+ * Inserts the declared template already published, as version 1, in one
77
+ * write. `undefined` when a concurrent call created the slug first (the slug
78
+ * index is unique): that call's row is published too.
160
79
  */
161
80
  async function createDeclared(
162
- plan: TemplatePlan,
81
+ declaration: ParsedTemplateDeclaration,
163
82
  ): Promise<MailingTemplate | undefined> {
164
- const { tenantId, slug, state, author } = plan;
83
+ const { tenantId, slug } = declaration;
165
84
  const model = GetModel(TemplateModel, tenantId);
166
- const settings = await getSettings(tenantId);
167
- assertKnownCategory(settings.categories, state.category, slug);
168
- const revision = randomUUID();
169
- const [id] = await model.insertFields({
170
- slug,
171
- name: state.name,
172
- category: state.category,
173
- status: "draft",
174
- ...blankFields(state.content),
175
- json_variables: JSON.stringify(state.variables),
176
- json_test_data: JSON.stringify(state.testData),
177
- updatedBy: author,
178
- revision,
179
- declarationSeedRevision: revision,
180
- pendingDeclaredHash: plan.hash,
181
- });
182
- const templateId = id as string;
85
+ const author = declaration.author ?? DECLARATION_AUTHOR;
86
+ let template: MailingTemplate;
183
87
  try {
184
- await assertSlugClaimed(tenantId, slug, templateId);
88
+ const [id] = await model.insertFields({
89
+ slug,
90
+ name: declaration.name,
91
+ category: declaration.category,
92
+ json_test_data: JSON.stringify(declaration.testData),
93
+ ...publishedAtCreation(
94
+ randomUUID(),
95
+ {
96
+ content: declaration.content,
97
+ variables: declaration.variables,
98
+ },
99
+ author,
100
+ ),
101
+ });
102
+ template = await requireTemplate(model, id);
185
103
  } catch (error) {
186
- if (await model.getBySlug(slug)) return undefined;
104
+ if (IsDuplicateKeyError(error)) return undefined;
187
105
  throw error;
188
106
  }
189
- return requireTemplate(model, templateId);
107
+ await GetModel(TemplateVersionModel, tenantId).materialize(template);
108
+ await refreshTemplateAttention(tenantId);
109
+ return template;
190
110
  }
191
111
 
192
112
  /**
193
- * A slug claim, not an arbitrary candidate row, decides which template won
194
- * concurrent creation. An unclaimed seed can be claimed by its next caller.
195
- */
196
- async function declaredTemplate(
197
- tenantId: string,
198
- slug: string,
199
- ): Promise<MailingTemplate | undefined> {
200
- const model = GetModel(TemplateModel, tenantId);
201
- const claim = await GetModel(TemplateSlugModel, tenantId).get(
202
- templateSlugIdOf(tenantId, slug),
203
- );
204
- const winner = claim && (await model.get(claim.templateId));
205
- return winner || model.getBySlug(slug);
206
- }
207
-
208
- function isUnmodifiedSeed(template: MailingTemplate): boolean {
209
- return Boolean(
210
- template.pendingDeclaredHash &&
211
- template.declarationSeedRevision &&
212
- template.revision === template.declarationSeedRevision &&
213
- template.status === "draft" &&
214
- template.publishedVersion === 0,
215
- );
216
- }
217
-
218
- /** Finishes the stored seed, never substituting a later caller's declaration. */
219
- async function finishSeed(
220
- tenantId: string,
221
- template: MailingTemplate,
222
- ): Promise<boolean> {
223
- try {
224
- await assertSlugClaimed(tenantId, template.slug, template._id);
225
- } catch (error) {
226
- const winner = await declaredTemplate(tenantId, template.slug);
227
- if (winner && winner._id !== template._id) return false;
228
- throw error;
229
- }
230
- const settings = await getSettings(tenantId);
231
- assertKnownCategory(settings.categories, template.category, template.slug);
232
- return Boolean(
233
- await publishSnapshot(
234
- tenantId,
235
- template,
236
- {
237
- content: workingContent(template),
238
- variables: workingVariables(template),
239
- },
240
- template.updatedBy,
241
- {
242
- declaredHash: template.pendingDeclaredHash,
243
- pendingDeclaredHash: "",
244
- declarationSeedRevision: "",
245
- },
246
- ),
247
- );
248
- }
249
-
250
- /** Why an existing template is left alone, `null` when it may be refreshed. */
251
- function keepReason(template: MailingTemplate): string | null {
252
- if (!template.declaredHash) return "it was created in the dashboard";
253
- if (currentFingerprint(template) !== template.declaredHash)
254
- return "a user changed it since it was declared";
255
- return null;
256
- }
257
-
258
- async function applyToExisting(
259
- plan: TemplatePlan,
260
- template: MailingTemplate,
261
- ): Promise<DeclareTemplateResult> {
262
- const { tenantId, state, author } = plan;
263
- for (let attempt = 0; attempt < MAX_DECLARATION_ATTEMPTS; attempt++) {
264
- if (isUnmodifiedSeed(template)) {
265
- const created = await finishSeed(tenantId, template);
266
- if (created && template.pendingDeclaredHash === plan.hash)
267
- return resultOf(template, "created", 1);
268
- } else {
269
- await GetModel(TemplateVersionModel, tenantId).materialize(template);
270
- if (template.publishedVersion > 0 && template.declaredHash === plan.hash)
271
- return resultOf(template, "unchanged");
272
- const reason = keepReason(template);
273
- if (reason) {
274
- Logging.Info(
275
- `${LOG_PREFIX} Kept template "${plan.slug}" as it is: ${reason}`,
276
- );
277
- return resultOf(template, "kept");
278
- }
279
- const settings = await getSettings(tenantId);
280
- assertKnownCategory(settings.categories, state.category, plan.slug);
281
- const outcome = await publishSnapshot(tenantId, template, state, author, {
282
- name: state.name,
283
- category: state.category,
284
- json_test_data: JSON.stringify(state.testData),
285
- declaredHash: plan.hash,
286
- });
287
- if (outcome) return resultOf(template, "updated", outcome.version);
288
- }
289
- const current = await declaredTemplate(tenantId, plan.slug);
290
- if (!current) break;
291
- template = current;
292
- }
293
- throw new DeclarationError(
294
- "declaration_conflict",
295
- `Template "${plan.slug}" changed repeatedly; retry the declaration`,
296
- );
297
- }
298
-
299
- /**
300
- * Creates or refreshes the template a project declares from code. The rule,
301
- * keyed on the slug:
302
- *
303
- * - no template has the slug: it is created and published (`created`);
304
- * - the declaration is the one last applied: nothing is written
305
- * (`unchanged`), whatever a user did to the template since;
306
- * - the declaration changed and the template is still live, with no draft,
307
- * and holds exactly what the last declaration wrote (name, category,
308
- * content, variables, test data): the new declaration is published
309
- * (`updated`);
310
- * - otherwise (edited, unpublished, archived, a draft pending, or made in the
311
- * dashboard): it is left as it is (`kept`).
312
- *
313
- * A declared template a user deleted is created again by the next call.
113
+ * Writes a template a project ships as code, once: call it when the tenant
114
+ * is provisioned. A new slug is created and published (`created`). A slug the
115
+ * tenant already has is left as it is (`kept`): from its creation on, the
116
+ * template belongs to the tenant, and a later declaration never changes it.
314
117
  */
315
118
  export async function declareTemplate(
316
119
  input: TemplateDeclaration,
@@ -321,23 +124,18 @@ export async function declareTemplate(
321
124
  "invalid_template",
322
125
  `template declaration "${String(input?.slug)}"`,
323
126
  );
324
- const plan = planTemplate(declaration);
325
- const existing = await declaredTemplate(plan.tenantId, plan.slug);
326
- if (existing) return applyToExisting(plan, existing);
327
- const created = await createDeclared(plan);
328
- if (created) return applyToExisting(plan, created);
329
- const winner = (await declaredTemplate(
330
- plan.tenantId,
331
- plan.slug,
332
- )) as MailingTemplate;
333
- return applyToExisting(plan, winner);
127
+ const { tenantId, slug } = declaration;
128
+ assertPublishable(declaration.content, slug);
129
+ const model = GetModel(TemplateModel, tenantId);
130
+ const existing = await model.getBySlug(slug);
131
+ if (existing) return resultOf(existing, "kept");
132
+ const settings = await getSettings(tenantId);
133
+ assertKnownCategory(settings.categories, declaration.category, slug);
134
+ const created = await createDeclared(declaration);
135
+ if (created) return resultOf(created, "created");
136
+ return resultOf((await model.getBySlug(slug)) as MailingTemplate, "kept");
334
137
  }
335
138
 
336
- type DeclaredCategories = Record<string, string>;
337
-
338
- const categoryFingerprint = (category: Omit<TemplateCategory, "id">) =>
339
- fingerprintOf({ label: category.label, icon: category.icon });
340
-
341
139
  /** Category declarations of one tenant run one after the other. */
342
140
  const categoryQueues = new Map<string, Promise<unknown>>();
343
141
 
@@ -359,84 +157,30 @@ function inTenantQueue<T>(
359
157
  return next;
360
158
  }
361
159
 
362
- async function readDeclaredCategories(
363
- tenantId: string,
364
- ): Promise<DeclaredCategories> {
365
- const row = await GetModel(SettingsModel, tenantId).get(tenantId);
366
- return JSON.parse(
367
- row?.json_declared_categories || "{}",
368
- ) as DeclaredCategories;
369
- }
370
-
371
- /** The category list once the declared category is added or relabelled. */
372
- function withCategory(
373
- categories: TemplateCategory[],
374
- declared: TemplateCategory,
375
- ): TemplateCategory[] {
376
- const isPresent = Boolean(findCategory(categories, declared.id));
377
- const next = isPresent
378
- ? categories.map((category) =>
379
- category.id === declared.id ? declared : category,
380
- )
381
- : [...categories, declared];
382
- return parseDeclared(
383
- categoriesSchema,
384
- next,
385
- "invalid_category",
386
- `category declaration "${declared.id}"`,
387
- ).map(({ id, label, icon }) => ({ id, label, icon }));
388
- }
389
-
390
- /** What a category declaration does to the stored list, by its state. */
391
- function categoryOutcome(
392
- current: TemplateCategory | undefined,
393
- previousHash: string | undefined,
394
- hash: string,
395
- ): DeclarationOutcome {
396
- if (!current) return previousHash ? "kept" : "created";
397
- if (!previousHash) return "kept";
398
- if (previousHash === hash) return "unchanged";
399
- return categoryFingerprint(current) === previousHash ? "updated" : "kept";
400
- }
401
-
402
- const WRITING_OUTCOMES = new Set<DeclarationOutcome>(["created", "updated"]);
403
-
404
160
  async function applyCategory(
405
161
  declaration: ParsedCategoryDeclaration,
406
162
  ): Promise<DeclareTemplateCategoryResult> {
407
- const { tenantId } = declaration;
408
- const category = {
409
- id: declaration.id,
410
- label: declaration.label,
411
- icon: declaration.icon,
412
- };
413
- const hash = categoryFingerprint(category);
163
+ const { tenantId, id, label, icon } = declaration;
414
164
  const settings = await getSettings(tenantId);
415
- const declared = await readDeclaredCategories(tenantId);
416
- const outcome = categoryOutcome(
417
- findCategory(settings.categories, category.id),
418
- declared[category.id],
419
- hash,
420
- );
421
- if (!WRITING_OUTCOMES.has(outcome)) return { id: category.id, outcome };
422
- const categories = withCategory(settings.categories, category);
165
+ if (findCategory(settings.categories, id)) return { id, outcome: "kept" };
166
+ const categories = parseDeclared(
167
+ categoriesSchema,
168
+ [...settings.categories, { id, label, icon }],
169
+ "invalid_category",
170
+ `category declaration "${id}"`,
171
+ ).map((category) => ({
172
+ id: category.id,
173
+ label: category.label,
174
+ icon: category.icon,
175
+ }));
423
176
  await saveSettings(tenantId, { ...settings, categories });
424
- await GetModel(SettingsModel, tenantId).update(tenantId, {
425
- json_declared_categories: JSON.stringify({
426
- ...declared,
427
- [category.id]: hash,
428
- }),
429
- });
430
- return { id: category.id, outcome };
177
+ return { id, outcome: "created" };
431
178
  }
432
179
 
433
180
  /**
434
- * Adds or refreshes a template category a project declares from code, keyed
435
- * on its id: a new id is added (`created`); the declaration last applied is
436
- * left alone (`unchanged`); a changed declaration relabels a category still
437
- * as last declared (`updated`); a category a user relabelled or removed
438
- * since, or one that existed before any declaration (the defaults among
439
- * them), is left as it is (`kept`).
181
+ * Adds a template category a project ships as code, once: call it when the
182
+ * tenant is provisioned. A new id is added (`created`); an id the workspace
183
+ * settings already hold, the defaults among them, is left as it is (`kept`).
440
184
  */
441
185
  export function declareTemplateCategory(
442
186
  input: TemplateCategoryDeclaration,
@@ -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(