@antelopejs/dms-mailing 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +46 -31
  2. package/dist/automation/email-event-trigger.d.ts +2 -2
  3. package/dist/automation/send-template-action.d.ts +11 -3
  4. package/dist/automation/send-template-action.js +1 -1
  5. package/dist/automation/send-template-action.js.map +1 -1
  6. package/dist/crons/jobs.d.ts +1 -1
  7. package/dist/data/body.d.ts +2 -1
  8. package/dist/data/body.js +4 -2
  9. package/dist/data/body.js.map +1 -1
  10. package/dist/data/read-only-routes.d.ts +4 -4
  11. package/dist/data/sends-table.d.ts +4 -4
  12. package/dist/data/suppressions-table.d.ts +4 -4
  13. package/dist/data/templates-table.d.ts +35 -12
  14. package/dist/data/templates-table.js +30 -24
  15. package/dist/data/templates-table.js.map +1 -1
  16. package/dist/data-types/categories-type.d.ts +1 -2
  17. package/dist/data-types/categories-type.js +3 -5
  18. package/dist/data-types/categories-type.js.map +1 -1
  19. package/dist/data-types/category-type.d.ts +1 -2
  20. package/dist/data-types/category-type.js +3 -5
  21. package/dist/data-types/category-type.js.map +1 -1
  22. package/dist/db/models/send-events.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.d.ts +7 -5
  24. package/dist/db/models/sends.model.js.map +1 -1
  25. package/dist/db/models/settings.model.d.ts +7 -5
  26. package/dist/db/models/suppressions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.d.ts +7 -5
  28. package/dist/db/models/template-versions.model.js +4 -3
  29. package/dist/db/models/template-versions.model.js.map +1 -1
  30. package/dist/db/models/templates.model.d.ts +7 -5
  31. package/dist/db/models/templates.model.js +2 -1
  32. package/dist/db/models/templates.model.js.map +1 -1
  33. package/dist/db/models/webhook-status.model.d.ts +7 -5
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -18
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +5 -5
  39. package/dist/engine/conditions.js.map +1 -1
  40. package/dist/engine/coverage.d.ts +2 -1
  41. package/dist/engine/coverage.js.map +1 -1
  42. package/dist/engine/fallback-html.js +8 -8
  43. package/dist/engine/fallback-html.js.map +1 -1
  44. package/dist/engine/formats.d.ts +4 -6
  45. package/dist/engine/formats.js +22 -8
  46. package/dist/engine/formats.js.map +1 -1
  47. package/dist/engine/paths.d.ts +4 -2
  48. package/dist/engine/paths.js +16 -5
  49. package/dist/engine/paths.js.map +1 -1
  50. package/dist/engine/plain-text.js +2 -4
  51. package/dist/engine/plain-text.js.map +1 -1
  52. package/dist/engine/resolve.d.ts +2 -1
  53. package/dist/engine/resolve.js +1 -1
  54. package/dist/engine/resolve.js.map +1 -1
  55. package/dist/engine/system-variables.d.ts +2 -1
  56. package/dist/engine/system-variables.js +1 -1
  57. package/dist/engine/system-variables.js.map +1 -1
  58. package/dist/engine/tokens.d.ts +2 -1
  59. package/dist/engine/tokens.js.map +1 -1
  60. package/dist/hooks/tenant-export.js +10 -9
  61. package/dist/hooks/tenant-export.js.map +1 -1
  62. package/dist/pages/sends.js.map +1 -1
  63. package/dist/pages/templates.js +3 -1
  64. package/dist/pages/templates.js.map +1 -1
  65. package/dist/pages/toolbar.js.map +1 -1
  66. package/dist/routes/events.js +6 -4
  67. package/dist/routes/events.js.map +1 -1
  68. package/dist/routes/index.d.ts +1 -0
  69. package/dist/routes/index.js +1 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/metrics.js +31 -31
  72. package/dist/routes/metrics.js.map +1 -1
  73. package/dist/routes/period.js +5 -5
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +4 -5
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +1 -1
  78. package/dist/routes/settings.js +16 -14
  79. package/dist/routes/settings.js.map +1 -1
  80. package/dist/routes/suppressions.js +9 -6
  81. package/dist/routes/suppressions.js.map +1 -1
  82. package/dist/routes/template-content.d.ts +27 -0
  83. package/dist/routes/template-content.js +3 -0
  84. package/dist/routes/template-content.js.map +1 -0
  85. package/dist/routes/templates.d.ts +7 -30
  86. package/dist/routes/templates.js +7 -6
  87. package/dist/routes/templates.js.map +1 -1
  88. package/dist/routes/zod-json.d.ts +3 -0
  89. package/dist/routes/zod-json.js +21 -0
  90. package/dist/routes/zod-json.js.map +1 -0
  91. package/dist/services/attention.js +16 -8
  92. package/dist/services/attention.js.map +1 -1
  93. package/dist/services/catalog.js +4 -2
  94. package/dist/services/catalog.js.map +1 -1
  95. package/dist/services/categories.js +6 -6
  96. package/dist/services/categories.js.map +1 -1
  97. package/dist/services/checked-send.js +16 -9
  98. package/dist/services/checked-send.js.map +1 -1
  99. package/dist/services/declaration-error.d.ts +1 -1
  100. package/dist/services/declaration-error.js.map +1 -1
  101. package/dist/services/declarations.js +4 -1
  102. package/dist/services/declarations.js.map +1 -1
  103. package/dist/services/events.js +13 -7
  104. package/dist/services/events.js.map +1 -1
  105. package/dist/services/localized-text.d.ts +2 -1
  106. package/dist/services/localized-text.js +2 -1
  107. package/dist/services/localized-text.js.map +1 -1
  108. package/dist/services/metrics.js +2 -5
  109. package/dist/services/metrics.js.map +1 -1
  110. package/dist/services/render.js.map +1 -1
  111. package/dist/services/send-import.js +7 -3
  112. package/dist/services/send-import.js.map +1 -1
  113. package/dist/services/send.js +7 -7
  114. package/dist/services/send.js.map +1 -1
  115. package/dist/services/settings.d.ts +4 -2
  116. package/dist/services/settings.js +5 -4
  117. package/dist/services/settings.js.map +1 -1
  118. package/dist/services/starters.d.ts +3 -2
  119. package/dist/services/starters.js +5 -5
  120. package/dist/services/starters.js.map +1 -1
  121. package/dist/services/template-stats.js +5 -2
  122. package/dist/services/template-stats.js.map +1 -1
  123. package/dist/services/templates.d.ts +2 -1
  124. package/dist/services/templates.js +8 -4
  125. package/dist/services/templates.js.map +1 -1
  126. package/dist/services/webhook-status.d.ts +2 -2
  127. package/dist/services/webhook-status.js +6 -4
  128. package/dist/services/webhook-status.js.map +1 -1
  129. package/dist/types/json.d.ts +1 -0
  130. package/dist/types/json.js +3 -0
  131. package/dist/types/json.js.map +1 -0
  132. package/dist/types/settings.d.ts +2 -2
  133. package/dist/validation/declarations.schema.d.ts +44 -44
  134. package/dist/validation/declarations.schema.js +6 -5
  135. package/dist/validation/declarations.schema.js.map +1 -1
  136. package/dist/validation/guards.d.ts +3 -0
  137. package/dist/validation/guards.js +15 -0
  138. package/dist/validation/guards.js.map +1 -0
  139. package/dist/validation/json.d.ts +5 -0
  140. package/dist/validation/json.js +54 -0
  141. package/dist/validation/json.js.map +1 -0
  142. package/dist/validation/json.schema.d.ts +2 -0
  143. package/dist/validation/json.schema.js +9 -0
  144. package/dist/validation/json.schema.js.map +1 -0
  145. package/dist/validation/send-event-type.schema.d.ts +2 -0
  146. package/dist/validation/send-event-type.schema.js +11 -0
  147. package/dist/validation/send-event-type.schema.js.map +1 -0
  148. package/dist/validation/settings.schema.js +1 -1
  149. package/dist/validation/settings.schema.js.map +1 -1
  150. package/dist/validation/templates.schema.d.ts +44 -42
  151. package/dist/validation/templates.schema.js +4 -3
  152. package/dist/validation/templates.schema.js.map +1 -1
  153. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  154. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  155. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  156. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  157. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  158. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  159. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  160. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  161. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  162. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  163. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  164. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  165. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  166. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  167. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  168. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  169. package/frontend-vue/build/backend-boundary.ts +122 -0
  170. package/frontend-vue/dms.frontend.build.ts +7 -0
  171. package/frontend-vue/eslint.config.mjs +13 -0
  172. package/frontend-vue/package.json +4 -2
  173. package/frontend-vue/pnpm-lock.yaml +250 -61
  174. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  175. package/frontend-vue/tests/dnd.test.ts +1 -2
  176. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  177. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  178. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  179. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  180. package/frontend-vue/tests/page-reads.test.ts +26 -6
  181. package/frontend-vue/tests/send-actions.test.ts +0 -1
  182. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  183. package/frontend-vue/tests/send-modals.test.ts +8 -5
  184. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  185. package/frontend-vue/tests/suppression.test.ts +1 -2
  186. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  187. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  188. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  189. package/package.json +16 -20
  190. package/skills/dms-mailing/SKILL.md +1 -1
  191. package/skills/dms-mailing/cases.json +49 -0
  192. package/src/automation/email-event-trigger.ts +2 -2
  193. package/src/automation/send-template-action.ts +33 -14
  194. package/src/crons/jobs.ts +1 -1
  195. package/src/data/body.ts +7 -3
  196. package/src/data/templates-table.ts +55 -56
  197. package/src/data-types/categories-type.ts +2 -2
  198. package/src/data-types/category-type.ts +2 -2
  199. package/src/db/models/sends.model.ts +2 -2
  200. package/src/db/models/template-versions.model.ts +10 -4
  201. package/src/db/models/templates.model.ts +6 -3
  202. package/src/db/tables/templates.table.ts +1 -1
  203. package/src/engine/changes.ts +11 -28
  204. package/src/engine/conditions.ts +14 -8
  205. package/src/engine/coverage.ts +2 -1
  206. package/src/engine/fallback-html.ts +13 -13
  207. package/src/engine/formats.ts +35 -21
  208. package/src/engine/paths.ts +41 -7
  209. package/src/engine/plain-text.ts +2 -4
  210. package/src/engine/resolve.ts +12 -4
  211. package/src/engine/system-variables.ts +6 -4
  212. package/src/engine/tokens.ts +3 -3
  213. package/src/hooks/tenant-export.ts +11 -13
  214. package/src/pages/sends.ts +5 -3
  215. package/src/pages/templates.ts +4 -2
  216. package/src/pages/toolbar.ts +4 -2
  217. package/src/routes/events.ts +10 -10
  218. package/src/routes/index.ts +1 -0
  219. package/src/routes/metrics.ts +35 -33
  220. package/src/routes/period.ts +5 -5
  221. package/src/routes/sends.ts +23 -26
  222. package/src/routes/settings.ts +21 -17
  223. package/src/routes/suppressions.ts +12 -8
  224. package/src/routes/template-content.ts +45 -0
  225. package/src/routes/templates.ts +16 -49
  226. package/src/routes/zod-json.ts +28 -0
  227. package/src/services/attention.ts +20 -18
  228. package/src/services/catalog.ts +4 -8
  229. package/src/services/categories.ts +6 -6
  230. package/src/services/checked-send.ts +17 -19
  231. package/src/services/declaration-error.ts +1 -1
  232. package/src/services/declarations.ts +7 -6
  233. package/src/services/events.ts +18 -13
  234. package/src/services/localized-text.ts +4 -2
  235. package/src/services/metrics.ts +5 -7
  236. package/src/services/notify.ts +1 -1
  237. package/src/services/render.ts +5 -1
  238. package/src/services/send-import.ts +16 -11
  239. package/src/services/send.ts +10 -13
  240. package/src/services/settings.ts +17 -16
  241. package/src/services/starters.ts +12 -8
  242. package/src/services/template-stats.ts +11 -4
  243. package/src/services/templates.ts +24 -15
  244. package/src/services/webhook-status.ts +13 -8
  245. package/src/types/json.ts +1 -0
  246. package/src/types/settings.ts +2 -2
  247. package/src/validation/declarations.schema.ts +6 -5
  248. package/src/validation/guards.ts +17 -0
  249. package/src/validation/json.schema.ts +7 -0
  250. package/src/validation/json.ts +66 -0
  251. package/src/validation/send-event-type.schema.ts +10 -0
  252. package/src/validation/settings.schema.ts +7 -2
  253. package/src/validation/templates.schema.ts +9 -4
  254. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  255. package/frontend-vue/tests/i18n.test.ts +0 -138
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-mailing",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "E-mail templates for the AntelopeJS DMS: a block editor with drafts and versions, a send log, delivery metrics and a suppression list.",
5
5
  "keywords": [
6
6
  "antelopejs",
@@ -46,21 +46,23 @@
46
46
  "registry": "https://registry.npmjs.org/"
47
47
  },
48
48
  "dependencies": {
49
- "@antelopejs/interface-api": ">=0.0.15 <1.0.0",
50
- "@antelopejs/interface-api-util": ">=0.1.3 <1.0.0",
51
- "@antelopejs/interface-core": ">=0.2.4 <1.0.0",
52
- "@antelopejs/interface-data-api": ">=0.4.0 <1.0.0",
53
- "@antelopejs/interface-database": ">=0.1.11 <1.0.0",
54
- "@antelopejs/interface-database-decorators": ">=0.1.10 <1.0.0",
55
- "@antelopejs/interface-dms": ">=0.9.0 <1.0.0",
56
- "@antelopejs/interface-dms-mailing": ">=0.9.0 <0.10.0",
49
+ "@antelopejs/interface-api": ">=0.3.0 <1.0.0",
50
+ "@antelopejs/interface-api-util": ">=0.3.0 <1.0.0",
51
+ "@antelopejs/interface-core": ">=0.4.0 <1.0.0",
52
+ "@antelopejs/interface-data-api": ">=0.6.1 <1.0.0",
53
+ "@antelopejs/interface-database": ">=0.3.0 <1.0.0",
54
+ "@antelopejs/interface-database-decorators": ">=0.2.0 <1.0.0",
55
+ "@antelopejs/interface-dms": ">=0.12.0 <1.0.0",
56
+ "@antelopejs/interface-dms-mailing": ">=0.10.0 <0.11.0",
57
57
  "@antelopejs/interface-email": ">=0.0.6 <1.0.0",
58
58
  "node-cron": "^4.2.1",
59
59
  "zod": "~3.24.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@antelopejs/core": ">=1.17.1 <2",
63
- "@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
62
+ "@antelopejs/core": "^1.20.0",
63
+ "@antelopejs/dms": ">=0.16.1 <1.0.0",
64
+ "@antelopejs/mongodb": "^1.6.2",
65
+ "@antelopejs/tooling-configs": "^0.2.18",
64
66
  "@types/chai": "^4.3.20",
65
67
  "@types/mocha": "^10",
66
68
  "@types/node": "^24.0.0",
@@ -69,16 +71,13 @@
69
71
  "chai": "^4.5.0",
70
72
  "knip": "^6.34.0",
71
73
  "mongodb-memory-server-core": "^11.0.1",
72
- "oxfmt": "^0.66.0",
73
- "oxlint": "1.81.0",
74
- "oxlint-tsgolint": "^7.0.2001",
75
74
  "release-it": "^19.2.4",
76
75
  "release-it-changelogen": "^0.1.0",
77
76
  "rimraf": "^6.1.3",
78
77
  "typescript": "^6.0.3"
79
78
  },
80
79
  "optionalDependencies": {
81
- "@antelopejs/interface-dms-automation": ">=0.2.1 <1.0.0"
80
+ "@antelopejs/interface-dms-automation": ">=0.6.0 <1.0.0"
82
81
  },
83
82
  "antelopeJs": {
84
83
  "baseUrl": "dist",
@@ -92,17 +91,14 @@
92
91
  },
93
92
  "dmsFrontend": "dist/frontend-module.js",
94
93
  "scripts": {
95
- "build": "rimraf dist && tsc",
94
+ "build": "pnpm --filter @antelopejs/interface-dms-mailing build && rimraf dist && tsc",
96
95
  "dev": "ajs project dev -w -p playground",
97
96
  "dev:frontend": "cd playground && pnpm frontend:dev",
98
97
  "frontend:dev": "cd playground && pnpm frontend:dev",
99
- "lint": "oxlint && oxfmt --check . && cd frontend-vue && pnpm lint && antelopejs-check-interface-ranges",
100
- "lint:fix": "oxlint --fix --max-warnings=1000000 && oxfmt . && cd frontend-vue && pnpm lint:fix",
101
- "format": "oxfmt . && cd frontend-vue && pnpm format",
102
98
  "typecheck": "tsc --noEmit",
103
99
  "knip": "knip --dependencies",
104
100
  "knip:all": "knip",
105
- "release": "pnpm --filter @antelopejs/interface-dms-mailing build && pnpm --dir frontend-vue install --frozen-lockfile && release-it",
101
+ "release": "pnpm --filter @antelopejs/interface-dms-mailing build && pnpm --dir ../.. install --frozen-lockfile && release-it",
106
102
  "test": "pnpm run test:backend && pnpm run test:layer",
107
103
  "test:backend": "([ -n \"$DMS_PREBUILT\" ] || pnpm run build) && ajs module test .",
108
104
  "test:layer": "cd frontend-vue && pnpm test",
@@ -23,7 +23,7 @@ https://dms.antelopejs.com/docs/modules/mailing (sending from code and the webho
23
23
 
24
24
  - `ajs project modules add @antelopejs/dms-mailing`, plus a module implementing `@antelopejs/interface-email`
25
25
  (`@antelopejs/nodemailer`, `config: { ethereal: true }` in dev).
26
- - Mailing 0.6 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
26
+ - The DMS backend and renderer releases the module runs on are in the README compatibility block and in `engines["@antelopejs/dms-frontend"]` of `frontend-vue/package.json`;
27
27
  0.4 is the DMS 0.8 line. Mailing 0.6 implements `@antelopejs/interface-dms-mailing` `>=0.8.0 <0.9.0`, adding declaration and import calls;
28
28
  interface 0.9 adds `DeclareSender`; `SendTemplate` / `RecordEmailEvent` callers do not change.
29
29
  - Module option: `useHtmlRender` (default `true`, DMS html-render chain; `false` = built-in standalone HTML).
@@ -0,0 +1,49 @@
1
+ {
2
+ "version": 1,
3
+ "cases": [
4
+ {
5
+ "id": "mailing-send-template",
6
+ "area": "modules",
7
+ "style": "clean",
8
+ "prompt": "Send the welcome e-mail template when a user signs up.",
9
+ "expect": {
10
+ "skills": ["dms-mailing"],
11
+ "acceptable": ["dms", "dms-auth"],
12
+ "mustMention": ["SendTemplate"]
13
+ }
14
+ },
15
+ {
16
+ "id": "mailing-bounces",
17
+ "area": "modules",
18
+ "style": "clean",
19
+ "prompt": "Our e-mail provider reports bounces through a webhook. How do we record them?",
20
+ "expect": {
21
+ "skills": ["dms-mailing"],
22
+ "acceptable": ["dms"],
23
+ "mustMention": ["RecordEmailEvent"]
24
+ }
25
+ },
26
+ {
27
+ "id": "mailing-missing-variable-casual",
28
+ "area": "modules",
29
+ "style": "casual",
30
+ "prompt": "mail send says missing variable firstName??",
31
+ "expect": {
32
+ "skills": ["dms-mailing"],
33
+ "acceptable": ["dms"]
34
+ }
35
+ },
36
+ {
37
+ "id": "dms-mailing-avoid-tracker",
38
+ "area": "modules",
39
+ "style": "clean",
40
+ "prompt": "Put the tracking script on our public website and count how many visitors click the signup button.",
41
+ "expect": {
42
+ "skills": ["dms-marketing"],
43
+ "acceptable": ["dms"],
44
+ "mustMention": ["data-website-id"],
45
+ "avoid": ["dms-mailing"]
46
+ }
47
+ }
48
+ ]
49
+ }
@@ -5,13 +5,13 @@ import type {
5
5
  } from "@antelopejs/interface-dms-automation";
6
6
  import { SEND_EVENT_TYPES, type SendEventType } from "../types";
7
7
 
8
- export interface EmailEventPayload {
8
+ export type EmailEventPayload = {
9
9
  tenantId: string;
10
10
  sendId: string;
11
11
  templateSlug: string;
12
12
  type: SendEventType;
13
13
  recipientEmail: string;
14
- }
14
+ };
15
15
 
16
16
  export interface EmailEventTriggerConfig {
17
17
  types?: SendEventType[];
@@ -3,7 +3,10 @@ import type {
3
3
  JsonSchema,
4
4
  } from "@antelopejs/interface-dms-automation";
5
5
  import type { EmailAddress } from "@antelopejs/interface-email";
6
- import type { SendTemplateResult } from "@antelopejs/interface-dms-mailing";
6
+ import type {
7
+ SendTemplateResult,
8
+ TemplateVariables,
9
+ } from "@antelopejs/interface-dms-mailing";
7
10
  import { sendUnlessSuppressed } from "../services/checked-send";
8
11
  import { SEND_STATUSES } from "../types";
9
12
 
@@ -12,7 +15,7 @@ export interface SendTemplateActionInput {
12
15
  slug: string;
13
16
  to: EmailAddress | EmailAddress[];
14
17
  locale?: string;
15
- variables?: Record<string, unknown>;
18
+ variables?: TemplateVariables;
16
19
  source?: string;
17
20
  }
18
21
 
@@ -42,12 +45,12 @@ const INPUT_SCHEMA: JsonSchema = {
42
45
  required: ["tenantId", "slug", "to"],
43
46
  };
44
47
 
45
- const RESULT_PROPERTIES: JsonSchema = {
48
+ const RESULT_PROPERTIES = {
46
49
  sendId: { type: "string" },
47
50
  status: { type: "string", enum: SEND_STATUSES },
48
51
  messageId: { type: "string" },
49
52
  error: { type: "string" },
50
- };
53
+ } satisfies { [name: string]: JsonSchema };
51
54
 
52
55
  const OUTPUT_SCHEMA: JsonSchema = {
53
56
  type: "object",
@@ -61,12 +64,21 @@ const OUTPUT_SCHEMA: JsonSchema = {
61
64
  },
62
65
  };
63
66
 
64
- const failedRecipients = (result: SendTemplateResult): SendTemplateResult[] =>
67
+ /** What the action hands the graph: the send's result, as JSON. */
68
+ type SendTemplateOutput = {
69
+ sendId: string;
70
+ status: SendTemplateResult["status"];
71
+ messageId?: string;
72
+ error?: string;
73
+ results?: SendTemplateOutput[];
74
+ };
75
+
76
+ const failedRecipients = (result: SendTemplateOutput): SendTemplateOutput[] =>
65
77
  (result.results ?? [result]).filter((entry) => entry.status === "failed");
66
78
 
67
79
  export const sendTemplateAction: ActionType<
68
80
  SendTemplateActionInput,
69
- SendTemplateResult
81
+ SendTemplateOutput
70
82
  > = {
71
83
  id: SEND_TEMPLATE_ACTION_ID,
72
84
  name: "Send e-mail template",
@@ -78,13 +90,16 @@ export const sendTemplateAction: ActionType<
78
90
  const source =
79
91
  input.source ?? `${AUTOMATION_SOURCE_PREFIX}${ctx.procedureId}`;
80
92
  try {
81
- const result = await sendUnlessSuppressed(input.slug, {
82
- tenantId: input.tenantId,
83
- to: input.to,
84
- locale: input.locale,
85
- variables: input.variables,
86
- source,
87
- });
93
+ const result: SendTemplateOutput = await sendUnlessSuppressed(
94
+ input.slug,
95
+ {
96
+ tenantId: input.tenantId,
97
+ to: input.to,
98
+ locale: input.locale,
99
+ variables: input.variables,
100
+ source,
101
+ },
102
+ );
88
103
  const failures = failedRecipients(result);
89
104
  if (failures.length > 0) {
90
105
  ctx.log(
@@ -95,7 +110,11 @@ export const sendTemplateAction: ActionType<
95
110
  }
96
111
  return result;
97
112
  } catch (error) {
98
- ctx.log("error", `Template "${input.slug}" could not be sent`, error);
113
+ ctx.log(
114
+ "error",
115
+ `Template "${input.slug}" could not be sent`,
116
+ error instanceof Error ? error.message : String(error),
117
+ );
99
118
  throw error;
100
119
  }
101
120
  },
package/src/crons/jobs.ts CHANGED
@@ -5,7 +5,7 @@ import cron, { type ScheduledTask } from "node-cron";
5
5
  export interface CronJob {
6
6
  name: string;
7
7
  schedule: string;
8
- run: () => Promise<unknown>;
8
+ run: () => Promise<number | void>;
9
9
  }
10
10
 
11
11
  /** A job as it runs once scheduled. */
package/src/data/body.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import { HTTPResult } from "@antelopejs/interface-api";
2
2
  import { HTTP_BAD_REQUEST } from "../constants";
3
+ import type { JsonValue } from "../types/json";
4
+ import { isText } from "../validation/guards";
5
+ import { parseJson } from "../validation/json";
3
6
 
4
7
  const INVALID_BODY = "$dms_mailing.errors.invalid_body";
5
8
 
6
- export function parseBody(body: Buffer | string): Record<string, unknown> {
7
- const raw = typeof body === "string" ? body : body.toString("utf8");
9
+ /** The JSON a request body carries, `{}` for an empty one; 400 when it is not JSON. */
10
+ export function parseBody(body: Buffer | string): JsonValue {
11
+ const raw = isText(body) ? body : body.toString("utf8");
8
12
  if (!raw) return {};
9
13
  try {
10
- return JSON.parse(raw) as Record<string, unknown>;
14
+ return parseJson(raw);
11
15
  } catch {
12
16
  throw new HTTPResult(HTTP_BAD_REQUEST, INVALID_BODY);
13
17
  }
@@ -4,13 +4,17 @@ import {
4
4
  Parameter,
5
5
  type RequestContext,
6
6
  } from "@antelopejs/interface-api";
7
- import { assertValidation } from "@antelopejs/interface-api-util";
7
+ import { assert, assertValidation } from "@antelopejs/interface-api-util";
8
+ import { z } from "zod";
8
9
  import {
9
10
  DataController,
10
11
  type DataControllerCallback,
11
12
  RegisterDataController,
12
13
  } from "@antelopejs/interface-data-api";
13
- import type { Validation } from "@antelopejs/interface-data-api/components";
14
+ import type {
15
+ Parameters,
16
+ Validation,
17
+ } from "@antelopejs/interface-data-api/components";
14
18
  import {
15
19
  Access,
16
20
  AccessMode,
@@ -79,18 +83,23 @@ import {
79
83
  refuseTakenSlug,
80
84
  } from "../services/templates";
81
85
  import type { TemplateCategory } from "../types";
86
+ import type { JsonObject } from "../types/json";
82
87
 
83
88
  import {
84
89
  createTemplateSchema,
85
- templateDetailsSchema,
90
+ type TemplateDetails,
86
91
  } from "../validation/templates.schema";
92
+ import { isObject, isText } from "../validation/guards";
93
+ import { isJsonObject } from "../validation/json";
87
94
  import { parseBody } from "./body";
88
95
  import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
89
96
 
90
97
  const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
91
98
  const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
92
99
  const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
93
- const INVALID_TEMPLATE = "$dms_mailing.errors.invalid_template";
100
+ export type { TemplateDetails };
101
+
102
+ export const INVALID_TEMPLATE = "$dms_mailing.errors.invalid_template";
94
103
  const UNKNOWN_CATEGORY = "$dms_mailing.errors.unknown_category";
95
104
  const TEMPLATE_IN_USE = "$dms_mailing.errors.template_in_use";
96
105
  const TEMPLATE_WAS_SENT = "$dms_mailing.errors.template_was_sent";
@@ -146,42 +155,40 @@ async function seedFields(
146
155
  return starterFields(starter, fallbackLocale);
147
156
  }
148
157
 
149
- const isBlank = (value: unknown) =>
150
- value === undefined || value === null || value === "";
151
-
152
158
  /** A category is left empty or picked among the ones the settings declare. */
153
159
  function assertKnownCategory(
154
160
  categories: TemplateCategory[],
155
- category: unknown,
161
+ category: string | undefined,
156
162
  ): void {
157
- if (isBlank(category)) return;
158
- if (typeof category === "string" && findCategory(categories, category)) {
159
- return;
160
- }
163
+ if (!category || findCategory(categories, category)) return;
161
164
  throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
162
165
  }
163
166
 
164
- /** The row `new` answers since interface-data-api 0.3: its key and fields. */
165
- interface CreatedRow {
166
- _id: string;
167
- [field: string]: unknown;
167
+ /** The key of the row `new` answers since interface-data-api 0.3. */
168
+ const createdRowSchema = z.object({ _id: z.string() });
169
+
170
+ /** The JSON object a creation sends; 400 for any other JSON. */
171
+ function parseCreationBody(body: Buffer): JsonObject {
172
+ const parsed = parseBody(body);
173
+ assert(isJsonObject(parsed), HTTP_BAD_REQUEST, INVALID_TEMPLATE);
174
+ return parsed;
168
175
  }
169
176
 
170
177
  /**
171
178
  * The creation's seed, and the body to store: slug trimmed, and the name
172
179
  * typed in the reader's language as every language of the localized field,
173
180
  * the fallback one included until someone translates it. A body a guard
174
- * already parsed arrives as an object.
181
+ * already parsed arrives as an object. The body to store is handed on as an
182
+ * object: interface-data-api 0.3 refuses a JSON string.
175
183
  */
176
184
  function parseCreation(body: Validation.WrittenBody, locale: string) {
177
- const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
185
+ const raw = Buffer.isBuffer(body) ? parseCreationBody(body) : body;
178
186
  const seed = assertValidation(
179
187
  raw,
180
188
  (value) => createTemplateSchema.parse(value),
181
189
  () => INVALID_TEMPLATE,
182
190
  HTTP_BAD_REQUEST,
183
191
  );
184
- // Handed as an object: interface-data-api 0.3 refuses a JSON string.
185
192
  const normalized = {
186
193
  ...raw,
187
194
  slug: seed.slug,
@@ -190,21 +197,22 @@ function parseCreation(body: Validation.WrittenBody, locale: string) {
190
197
  return { seed, normalized };
191
198
  }
192
199
 
193
- /** What the data API locks a localized field with: the controller's key. */
194
- interface LocalizedController {
195
- language: string;
196
- }
197
-
198
200
  /** The key that writes every language of a localized field at once. */
199
201
  const ALL_LANGUAGES = "*";
200
202
 
203
+ /**
204
+ * The data API's `new` callback, seeding the template it creates. The
205
+ * module's creation dialogs send one typed name, and the stored name holds
206
+ * every language, written at once: the controller's language key is set to
207
+ * all languages before the creation.
208
+ */
201
209
  function withSeeding(base: DataControllerCallback): DataControllerCallback {
202
210
  return {
203
211
  ...base,
204
212
  func: async function (
205
- this: unknown,
213
+ this: TemplatesTableAPI,
206
214
  ctx: RequestContext,
207
- params: unknown,
215
+ params: Parameters.NewParameters,
208
216
  body: Validation.WrittenBody,
209
217
  ...rest: unknown[]
210
218
  ) {
@@ -213,13 +221,11 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
213
221
  const settings = await getSettings(tenantId);
214
222
  assertKnownCategory(settings.categories, seed.category);
215
223
  const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
216
- // The module's creation dialogs send one typed name; the stored name
217
- // holds every language, written at once.
218
- (this as LocalizedController).language = ALL_LANGUAGES;
219
- const created = (await refuseTakenSlug(
220
- base.func.call(this, ctx, params, normalized, ...rest),
221
- )) as CreatedRow;
222
- const templateId = created._id;
224
+ this.language = ALL_LANGUAGES;
225
+ const created: unknown = await refuseTakenSlug(
226
+ Promise.resolve(base.func.call(this, ctx, params, normalized, ...rest)),
227
+ );
228
+ const templateId = createdRowSchema.parse(created)._id;
223
229
  const user = await authenticateTenantRequest(ctx);
224
230
  await initializeTemplate(tenantId, templateId, displayName(user), fields);
225
231
  return created;
@@ -296,10 +302,17 @@ export async function assertEditable(
296
302
  ): Promise<void> {
297
303
  if (body.slug !== undefined && body.slug !== current.slug)
298
304
  throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
299
- if (body.name !== undefined && isBlankName(body.name))
300
- throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
305
+ if (body.name !== undefined) {
306
+ const texts = isObject(body.name) ? Object.values(body.name) : [body.name];
307
+ if (!texts.some((text) => isText(text) && text.trim()))
308
+ throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
309
+ }
301
310
  const { categories } = await getSettings(tenantId);
302
- assertKnownCategory(categories, body.category);
311
+ const { category } = body;
312
+ if (category === undefined || category === null || category === "") return;
313
+ if (!isText(category))
314
+ throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
315
+ assertKnownCategory(categories, category);
303
316
  }
304
317
 
305
318
  /**
@@ -309,14 +322,9 @@ export async function assertEditable(
309
322
  export async function saveDetails(
310
323
  ctx: RequestContext,
311
324
  template: MailingTemplate,
312
- body: unknown,
325
+ details: TemplateDetails,
313
326
  author: string,
314
327
  ): Promise<void> {
315
- const details = assertValidation(
316
- body,
317
- (value) => templateDetailsSchema.parse(value),
318
- () => INVALID_TEMPLATE,
319
- );
320
328
  const tenantId = getRequestTenantId(ctx);
321
329
  await assertEditable(tenantId, details, template);
322
330
  const names = TemplateModel.namesOf(template);
@@ -329,15 +337,6 @@ export async function saveDetails(
329
337
  });
330
338
  }
331
339
 
332
- /**
333
- * Whether an edited name holds no text: the text of the reader's language,
334
- * or, from a form editing every language, all of them.
335
- */
336
- function isBlankName(name: unknown): boolean {
337
- const texts = typeof name === "object" && name ? Object.values(name) : [name];
338
- return !texts.some((text) => typeof text === "string" && text.trim());
339
- }
340
-
341
340
  /** The names trimmed, blank ones left out, the fallback language filled. */
342
341
  function tidyNames(names: LocalizedText, locale: string): LocalizedText {
343
342
  const tidy = Object.fromEntries(
@@ -357,10 +356,8 @@ function tidyNames(names: LocalizedText, locale: string): LocalizedText {
357
356
  export function afterEdit(
358
357
  model: TemplateModel,
359
358
  id: string,
360
- name: unknown,
361
359
  locale: string,
362
- ): AfterWrite | undefined {
363
- if (name === undefined) return undefined;
360
+ ): AfterWrite {
364
361
  return async () => {
365
362
  const template = await model.get(id);
366
363
  if (!template) return;
@@ -372,8 +369,10 @@ export function afterEdit(
372
369
  }
373
370
 
374
371
  /** The templates the attention refresh flagged. */
375
- const isFlagged: FilterFunction<Record<string, unknown>> = (_context, proxy) =>
376
- proxy.default(false).eq(true);
372
+ const isFlagged: FilterFunction<TemplatesTableAPI, TemplatesTableAPI> = (
373
+ _context,
374
+ proxy,
375
+ ) => proxy.default(false).eq(true);
377
376
 
378
377
  const templateRoutes = {
379
378
  ...TableViewRoutes.All,
@@ -12,8 +12,8 @@ import { categoriesSchema } from "../validation/settings.schema";
12
12
  */
13
13
  @RegisterDataType(TEMPLATE_CATEGORIES_TYPE_ID)
14
14
  export class TemplateCategoriesType extends DataType {
15
- constructor(public readonly options: Record<string, unknown> = {}) {
16
- super([], undefined, options);
15
+ constructor() {
16
+ super([], undefined, {});
17
17
  }
18
18
 
19
19
  protected defaultInputComponent() {
@@ -19,7 +19,7 @@ import { TEMPLATE_CATEGORY_TYPE_ID } from "../constants";
19
19
  */
20
20
  @RegisterDataType(TEMPLATE_CATEGORY_TYPE_ID)
21
21
  export class TemplateCategoryType extends DataType {
22
- constructor(public readonly options: Record<string, unknown> = {}) {
22
+ constructor() {
23
23
  super(
24
24
  [
25
25
  DefaultDataCompareTypes.Is,
@@ -28,7 +28,7 @@ export class TemplateCategoryType extends DataType {
28
28
  DefaultDataCompareTypes.IsNotEmpty,
29
29
  ],
30
30
  DefaultDataCompareTypes.Is,
31
- options,
31
+ {},
32
32
  );
33
33
  }
34
34
 
@@ -123,7 +123,7 @@ export class SendModel extends BasicDataModel(
123
123
  .filter((row) => row.key("createdAt").ge(from))
124
124
  .filter((row) => row.key("createdAt").lt(to))
125
125
  .without("json_variables")
126
- .run() as Promise<SendSummary[]>;
126
+ .run();
127
127
  }
128
128
 
129
129
  listOlderThan(limit: Date, pageSize: number): Promise<MailingSend[]> {
@@ -260,7 +260,7 @@ export class SendModel extends BasicDataModel(
260
260
  .getAll(templateSlug, "templateSlug")
261
261
  .filter((row) => row.key("createdAt").ge(from))
262
262
  .without("json_variables")
263
- .run() as Promise<SendSummary[]>;
263
+ .run();
264
264
  }
265
265
 
266
266
  /** How many sends the retention would delete with this cutoff. */
@@ -11,6 +11,8 @@ export function committedVersionOf(
11
11
  template: Pick<MailingTemplate, "json_published_version">,
12
12
  ): MailingTemplateVersion | undefined {
13
13
  if (!template.json_published_version) return undefined;
14
+ // SAFETY: json_published_version is written only by publishSnapshot and publishedAtCreation as the
15
+ // JSON.stringify of the version row they insert; its publishedAt reads back as a string, and the next line restores the Date.
14
16
  const row = JSON.parse(
15
17
  template.json_published_version,
16
18
  ) as MailingTemplateVersion;
@@ -33,20 +35,24 @@ export class TemplateVersionModel extends BasicDataModel(
33
35
  await this.insert(version);
34
36
  } catch (error) {
35
37
  const stored = await this.get(version._id);
38
+ if (!stored) throw error;
39
+ const storedFields = new Map(Object.entries(stored));
36
40
  if (
37
- !stored ||
38
41
  Object.entries(version).some(
39
42
  ([key, value]) =>
40
- JSON.stringify(stored[key as keyof MailingTemplateVersion]) !==
41
- JSON.stringify(value),
43
+ JSON.stringify(storedFields.get(key)) !== JSON.stringify(value),
42
44
  )
43
45
  )
44
46
  throw error;
45
47
  }
46
48
  }
47
49
 
50
+ /**
51
+ * The published versions of a template, newest first. It reads the
52
+ * template's publication before the version rows: any later publication
53
+ * must have materialized the version that read names.
54
+ */
48
55
  async listForTemplate(templateId: string): Promise<MailingTemplateVersion[]> {
49
- // Read the publication first: any later publication must have materialized it.
50
56
  const template = await new TemplateModel(this.database).get(templateId);
51
57
  if (!template) return [];
52
58
  const rows = await this.getBy("templateId", templateId);
@@ -5,6 +5,7 @@ import {
5
5
  } from "@antelopejs/interface-database-decorators";
6
6
  import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
7
7
  import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
8
+ import { isText } from "../../validation/guards";
8
9
 
9
10
  /** The attention fields of a template, as the refresh writes them. */
10
11
  export type TemplateAttentionFields = Pick<
@@ -15,7 +16,7 @@ export type TemplateAttentionFields = Pick<
15
16
  const isLocalizedText = (value: unknown): value is LocalizedText =>
16
17
  typeof value === "object" &&
17
18
  value !== null &&
18
- Object.values(value).every((text) => typeof text === "string");
19
+ Object.values(value).every(isText);
19
20
 
20
21
  /** Where a row keeps its modifier-held fields, the localized name among them. */
21
22
  const MODIFIER_CONTAINER = "_internal";
@@ -37,9 +38,11 @@ export class TemplateModel extends BasicDataModel(
37
38
  return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
38
39
  }
39
40
 
40
- /** The names of `template`, one per language. */
41
+ /**
42
+ * The names of `template`, one per language: unlocked with `"*"`, the
43
+ * localized field holds the whole per-language record.
44
+ */
41
45
  static namesOf(template: MailingTemplate): LocalizedText {
42
- // Unlocked with "*", the localized field holds the whole per-language record.
43
46
  const names: unknown = template.localize("*", ["name"]).name;
44
47
  return isLocalizedText(names) ? names : {};
45
48
  }
@@ -156,7 +156,7 @@ export class MailingTemplate extends Table.with(LocalizationModifier) {
156
156
 
157
157
  /** Covers model updates and generated data-api edits in the same database write. */
158
158
  class TemplateRevisionModifier extends Modifier {
159
- update(object: Record<string, unknown>, field: string): void {
159
+ update(object: Pick<MailingTemplate, "revision">, field: "revision"): void {
160
160
  object[field] = randomUUID();
161
161
  }
162
162
  }