@antelopejs/dms-mailing 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +46 -31
  2. package/dist/automation/email-event-trigger.d.ts +2 -2
  3. package/dist/automation/send-template-action.d.ts +11 -3
  4. package/dist/automation/send-template-action.js +1 -1
  5. package/dist/automation/send-template-action.js.map +1 -1
  6. package/dist/crons/jobs.d.ts +1 -1
  7. package/dist/data/body.d.ts +2 -1
  8. package/dist/data/body.js +4 -2
  9. package/dist/data/body.js.map +1 -1
  10. package/dist/data/read-only-routes.d.ts +4 -4
  11. package/dist/data/sends-table.d.ts +4 -4
  12. package/dist/data/suppressions-table.d.ts +4 -4
  13. package/dist/data/templates-table.d.ts +35 -12
  14. package/dist/data/templates-table.js +30 -24
  15. package/dist/data/templates-table.js.map +1 -1
  16. package/dist/data-types/categories-type.d.ts +1 -2
  17. package/dist/data-types/categories-type.js +3 -5
  18. package/dist/data-types/categories-type.js.map +1 -1
  19. package/dist/data-types/category-type.d.ts +1 -2
  20. package/dist/data-types/category-type.js +3 -5
  21. package/dist/data-types/category-type.js.map +1 -1
  22. package/dist/db/models/send-events.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.d.ts +7 -5
  24. package/dist/db/models/sends.model.js.map +1 -1
  25. package/dist/db/models/settings.model.d.ts +7 -5
  26. package/dist/db/models/suppressions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.d.ts +7 -5
  28. package/dist/db/models/template-versions.model.js +4 -3
  29. package/dist/db/models/template-versions.model.js.map +1 -1
  30. package/dist/db/models/templates.model.d.ts +7 -5
  31. package/dist/db/models/templates.model.js +2 -1
  32. package/dist/db/models/templates.model.js.map +1 -1
  33. package/dist/db/models/webhook-status.model.d.ts +7 -5
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -18
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +5 -5
  39. package/dist/engine/conditions.js.map +1 -1
  40. package/dist/engine/coverage.d.ts +2 -1
  41. package/dist/engine/coverage.js.map +1 -1
  42. package/dist/engine/fallback-html.js +8 -8
  43. package/dist/engine/fallback-html.js.map +1 -1
  44. package/dist/engine/formats.d.ts +4 -6
  45. package/dist/engine/formats.js +22 -8
  46. package/dist/engine/formats.js.map +1 -1
  47. package/dist/engine/paths.d.ts +4 -2
  48. package/dist/engine/paths.js +16 -5
  49. package/dist/engine/paths.js.map +1 -1
  50. package/dist/engine/plain-text.js +2 -4
  51. package/dist/engine/plain-text.js.map +1 -1
  52. package/dist/engine/resolve.d.ts +2 -1
  53. package/dist/engine/resolve.js +1 -1
  54. package/dist/engine/resolve.js.map +1 -1
  55. package/dist/engine/system-variables.d.ts +2 -1
  56. package/dist/engine/system-variables.js +1 -1
  57. package/dist/engine/system-variables.js.map +1 -1
  58. package/dist/engine/tokens.d.ts +2 -1
  59. package/dist/engine/tokens.js.map +1 -1
  60. package/dist/hooks/tenant-export.js +10 -9
  61. package/dist/hooks/tenant-export.js.map +1 -1
  62. package/dist/pages/sends.js.map +1 -1
  63. package/dist/pages/templates.js +3 -1
  64. package/dist/pages/templates.js.map +1 -1
  65. package/dist/pages/toolbar.js.map +1 -1
  66. package/dist/routes/events.js +6 -4
  67. package/dist/routes/events.js.map +1 -1
  68. package/dist/routes/index.d.ts +1 -0
  69. package/dist/routes/index.js +1 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/metrics.js +31 -31
  72. package/dist/routes/metrics.js.map +1 -1
  73. package/dist/routes/period.js +5 -5
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +4 -5
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +1 -1
  78. package/dist/routes/settings.js +16 -14
  79. package/dist/routes/settings.js.map +1 -1
  80. package/dist/routes/suppressions.js +9 -6
  81. package/dist/routes/suppressions.js.map +1 -1
  82. package/dist/routes/template-content.d.ts +27 -0
  83. package/dist/routes/template-content.js +3 -0
  84. package/dist/routes/template-content.js.map +1 -0
  85. package/dist/routes/templates.d.ts +7 -30
  86. package/dist/routes/templates.js +7 -6
  87. package/dist/routes/templates.js.map +1 -1
  88. package/dist/routes/zod-json.d.ts +3 -0
  89. package/dist/routes/zod-json.js +21 -0
  90. package/dist/routes/zod-json.js.map +1 -0
  91. package/dist/services/attention.js +16 -8
  92. package/dist/services/attention.js.map +1 -1
  93. package/dist/services/catalog.js +4 -2
  94. package/dist/services/catalog.js.map +1 -1
  95. package/dist/services/categories.js +6 -6
  96. package/dist/services/categories.js.map +1 -1
  97. package/dist/services/checked-send.js +16 -9
  98. package/dist/services/checked-send.js.map +1 -1
  99. package/dist/services/declaration-error.d.ts +1 -1
  100. package/dist/services/declaration-error.js.map +1 -1
  101. package/dist/services/declarations.js +4 -1
  102. package/dist/services/declarations.js.map +1 -1
  103. package/dist/services/events.js +13 -7
  104. package/dist/services/events.js.map +1 -1
  105. package/dist/services/localized-text.d.ts +2 -1
  106. package/dist/services/localized-text.js +2 -1
  107. package/dist/services/localized-text.js.map +1 -1
  108. package/dist/services/metrics.js +2 -5
  109. package/dist/services/metrics.js.map +1 -1
  110. package/dist/services/render.js.map +1 -1
  111. package/dist/services/send-import.js +7 -3
  112. package/dist/services/send-import.js.map +1 -1
  113. package/dist/services/send.js +7 -7
  114. package/dist/services/send.js.map +1 -1
  115. package/dist/services/settings.d.ts +4 -2
  116. package/dist/services/settings.js +5 -4
  117. package/dist/services/settings.js.map +1 -1
  118. package/dist/services/starters.d.ts +3 -2
  119. package/dist/services/starters.js +5 -5
  120. package/dist/services/starters.js.map +1 -1
  121. package/dist/services/template-stats.js +5 -2
  122. package/dist/services/template-stats.js.map +1 -1
  123. package/dist/services/templates.d.ts +2 -1
  124. package/dist/services/templates.js +8 -4
  125. package/dist/services/templates.js.map +1 -1
  126. package/dist/services/webhook-status.d.ts +2 -2
  127. package/dist/services/webhook-status.js +6 -4
  128. package/dist/services/webhook-status.js.map +1 -1
  129. package/dist/types/json.d.ts +1 -0
  130. package/dist/types/json.js +3 -0
  131. package/dist/types/json.js.map +1 -0
  132. package/dist/types/settings.d.ts +2 -2
  133. package/dist/validation/declarations.schema.d.ts +44 -44
  134. package/dist/validation/declarations.schema.js +6 -5
  135. package/dist/validation/declarations.schema.js.map +1 -1
  136. package/dist/validation/guards.d.ts +3 -0
  137. package/dist/validation/guards.js +15 -0
  138. package/dist/validation/guards.js.map +1 -0
  139. package/dist/validation/json.d.ts +5 -0
  140. package/dist/validation/json.js +54 -0
  141. package/dist/validation/json.js.map +1 -0
  142. package/dist/validation/json.schema.d.ts +2 -0
  143. package/dist/validation/json.schema.js +9 -0
  144. package/dist/validation/json.schema.js.map +1 -0
  145. package/dist/validation/send-event-type.schema.d.ts +2 -0
  146. package/dist/validation/send-event-type.schema.js +11 -0
  147. package/dist/validation/send-event-type.schema.js.map +1 -0
  148. package/dist/validation/settings.schema.js +1 -1
  149. package/dist/validation/settings.schema.js.map +1 -1
  150. package/dist/validation/templates.schema.d.ts +44 -42
  151. package/dist/validation/templates.schema.js +4 -3
  152. package/dist/validation/templates.schema.js.map +1 -1
  153. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  154. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  155. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  156. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  157. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  158. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  159. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  160. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  161. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  162. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  163. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  164. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  165. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  166. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  167. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  168. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  169. package/frontend-vue/build/backend-boundary.ts +122 -0
  170. package/frontend-vue/dms.frontend.build.ts +7 -0
  171. package/frontend-vue/eslint.config.mjs +13 -0
  172. package/frontend-vue/package.json +4 -2
  173. package/frontend-vue/pnpm-lock.yaml +250 -61
  174. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  175. package/frontend-vue/tests/dnd.test.ts +1 -2
  176. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  177. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  178. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  179. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  180. package/frontend-vue/tests/page-reads.test.ts +26 -6
  181. package/frontend-vue/tests/send-actions.test.ts +0 -1
  182. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  183. package/frontend-vue/tests/send-modals.test.ts +8 -5
  184. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  185. package/frontend-vue/tests/suppression.test.ts +1 -2
  186. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  187. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  188. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  189. package/package.json +16 -20
  190. package/skills/dms-mailing/SKILL.md +1 -1
  191. package/skills/dms-mailing/cases.json +49 -0
  192. package/src/automation/email-event-trigger.ts +2 -2
  193. package/src/automation/send-template-action.ts +33 -14
  194. package/src/crons/jobs.ts +1 -1
  195. package/src/data/body.ts +7 -3
  196. package/src/data/templates-table.ts +55 -56
  197. package/src/data-types/categories-type.ts +2 -2
  198. package/src/data-types/category-type.ts +2 -2
  199. package/src/db/models/sends.model.ts +2 -2
  200. package/src/db/models/template-versions.model.ts +10 -4
  201. package/src/db/models/templates.model.ts +6 -3
  202. package/src/db/tables/templates.table.ts +1 -1
  203. package/src/engine/changes.ts +11 -28
  204. package/src/engine/conditions.ts +14 -8
  205. package/src/engine/coverage.ts +2 -1
  206. package/src/engine/fallback-html.ts +13 -13
  207. package/src/engine/formats.ts +35 -21
  208. package/src/engine/paths.ts +41 -7
  209. package/src/engine/plain-text.ts +2 -4
  210. package/src/engine/resolve.ts +12 -4
  211. package/src/engine/system-variables.ts +6 -4
  212. package/src/engine/tokens.ts +3 -3
  213. package/src/hooks/tenant-export.ts +11 -13
  214. package/src/pages/sends.ts +5 -3
  215. package/src/pages/templates.ts +4 -2
  216. package/src/pages/toolbar.ts +4 -2
  217. package/src/routes/events.ts +10 -10
  218. package/src/routes/index.ts +1 -0
  219. package/src/routes/metrics.ts +35 -33
  220. package/src/routes/period.ts +5 -5
  221. package/src/routes/sends.ts +23 -26
  222. package/src/routes/settings.ts +21 -17
  223. package/src/routes/suppressions.ts +12 -8
  224. package/src/routes/template-content.ts +45 -0
  225. package/src/routes/templates.ts +16 -49
  226. package/src/routes/zod-json.ts +28 -0
  227. package/src/services/attention.ts +20 -18
  228. package/src/services/catalog.ts +4 -8
  229. package/src/services/categories.ts +6 -6
  230. package/src/services/checked-send.ts +17 -19
  231. package/src/services/declaration-error.ts +1 -1
  232. package/src/services/declarations.ts +7 -6
  233. package/src/services/events.ts +18 -13
  234. package/src/services/localized-text.ts +4 -2
  235. package/src/services/metrics.ts +5 -7
  236. package/src/services/notify.ts +1 -1
  237. package/src/services/render.ts +5 -1
  238. package/src/services/send-import.ts +16 -11
  239. package/src/services/send.ts +10 -13
  240. package/src/services/settings.ts +17 -16
  241. package/src/services/starters.ts +12 -8
  242. package/src/services/template-stats.ts +11 -4
  243. package/src/services/templates.ts +24 -15
  244. package/src/services/webhook-status.ts +13 -8
  245. package/src/types/json.ts +1 -0
  246. package/src/types/settings.ts +2 -2
  247. package/src/validation/declarations.schema.ts +6 -5
  248. package/src/validation/guards.ts +17 -0
  249. package/src/validation/json.schema.ts +7 -0
  250. package/src/validation/json.ts +66 -0
  251. package/src/validation/send-event-type.schema.ts +10 -0
  252. package/src/validation/settings.schema.ts +7 -2
  253. package/src/validation/templates.schema.ts +9 -4
  254. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  255. package/frontend-vue/tests/i18n.test.ts +0 -138
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { SENDS_PATH } from '../utils/pages'
2
+ import { useDrawerStats } from '../build/composables/useDrawerStats'
3
+ import TemplateDrawerFooter from '../build/components/TemplateDrawerFooter.vue'
4
+ import TemplateDrawerPerformance from '../build/components/TemplateDrawerPerformance.vue'
5
+ import TemplateDrawerVariables from '../build/components/TemplateDrawerVariables.vue'
3
6
  import { useElementSize } from '@vueuse/core'
4
7
  import {
5
8
  useMailingApi,
@@ -10,11 +13,9 @@ import {
10
13
  useTemplatePerformanceRead,
11
14
  } from '../composables/useMailingReads'
12
15
  import { useTemplateDrawer } from '../composables/useTemplateDrawer'
13
- import { apiErrorMessage } from '../utils/api-error'
14
16
  import { formatCardDate } from '../utils/gallery'
15
17
  import { localeCoverage } from '../utils/locales'
16
18
  import { sendSnippet } from '../utils/send-snippet'
17
- import { variableTypeLabelKey } from '../utils/variable-types'
18
19
  import { validateName } from '../utils/slug'
19
20
  import {
20
21
  validTransitions,
@@ -66,14 +67,8 @@ interface LifecycleSpec {
66
67
  confirmColor?: ConfirmTone
67
68
  }
68
69
 
69
- /** The send log reads these: `?template=` narrows it, `?view=` opens a tab. */
70
- const SENDS_TEMPLATE_KEY = 'template'
71
- const SENDS_VIEW_KEY = 'view'
72
- const PROBLEMS_VIEW = 'problems'
73
70
  const PREVIEW_WIDTH = 600
74
71
  const PREVIEW_FALLBACK_SCALE = 0.72
75
- const OPEN_RATE_DIGITS = 1
76
- const PERCENT = 100
77
72
  const DEFAULT_VARIABLE_TYPE: VariableType = 'string'
78
73
 
79
74
  const STATUS_TONES: Record<TemplateStatus, string> = {
@@ -88,9 +83,9 @@ defineEmits<{ success: [changed?: boolean] }>()
88
83
 
89
84
  const api = useMailingApi()
90
85
  const toast = useToast()
86
+ const { presentError } = useApiErrorPresenter()
91
87
  const { confirm } = useConfirm()
92
88
  const { t, locale: uiLocale } = useI18n()
93
- const { processApiMessage } = useTranslation()
94
89
  const { uniqueLocales } = useUniqueLocales()
95
90
  const overlays = useTemplateDrawer()
96
91
 
@@ -142,10 +137,7 @@ async function load(): Promise<void> {
142
137
  performance.value = stats
143
138
  locale.value ||= content.fallbackLocale
144
139
  } catch (error) {
145
- toast.add({
146
- color: 'error',
147
- title: processApiMessage(apiErrorMessage(error)),
148
- })
140
+ presentError(error, { fallbackKey: 'error.500.description' })
149
141
  } finally {
150
142
  loading.value = false
151
143
  }
@@ -261,60 +253,12 @@ const versionLabel = computed(() =>
261
253
  : t('dms_mailing.templates.drawer.never_published'),
262
254
  )
263
255
 
264
- const formatNumber = (value: number) =>
265
- new Intl.NumberFormat(uiLocale.value, {
266
- maximumFractionDigits: OPEN_RATE_DIGITS,
267
- }).format(value)
268
-
269
- /** A rate in percent, written the reader's way ("20.8%", "20,8 %"). */
270
- const formatPercent = (value: number) =>
271
- new Intl.NumberFormat(uiLocale.value, {
272
- style: 'percent',
273
- maximumFractionDigits: OPEN_RATE_DIGITS,
274
- }).format(value / PERCENT)
275
-
276
- /** The send log narrowed to this template, on one of its tabs if given. */
277
- function sendLogRoute(view?: string) {
278
- return {
279
- path: SENDS_PATH,
280
- query: {
281
- [SENDS_TEMPLATE_KEY]: template.value?.slug ?? '',
282
- ...(view ? { [SENDS_VIEW_KEY]: view } : {}),
283
- },
284
- }
285
- }
286
-
287
- function sendLogUrl(view?: string): string {
288
- const { path, query } = sendLogRoute(view)
289
- return `${path}?${new URLSearchParams(query).toString()}`
290
- }
291
-
292
- const hasSends = computed(() => Boolean(performance.value?.sends))
293
-
294
- const stats = computed(() => [
295
- {
296
- id: 'sends',
297
- eyebrow: t('dms_mailing.templates.drawer.stats_sends'),
298
- value: formatNumber(performance.value?.sends ?? 0),
299
- },
300
- {
301
- id: 'open',
302
- eyebrow: t('dms_mailing.templates.drawer.stats_open'),
303
- value: performance.value?.sends
304
- ? formatPercent(performance.value.openRate)
305
- : '—',
306
- },
307
- {
308
- id: 'problems',
309
- eyebrow: t('dms_mailing.templates.drawer.stats_problems'),
310
- value: formatNumber(performance.value?.problems ?? 0),
311
- detail: performance.value?.problems
312
- ? t('dms_mailing.templates.drawer.problems_detail')
313
- : undefined,
314
- detailTone: 'error' as const,
315
- to: performance.value?.problems ? sendLogUrl(PROBLEMS_VIEW) : undefined,
316
- },
317
- ])
256
+ const { formatNumber, sendLogRoute, hasSends, stats } = useDrawerStats(
257
+ template,
258
+ performance,
259
+ t,
260
+ uiLocale,
261
+ )
318
262
 
319
263
  const snippet = computed(() =>
320
264
  sendSnippet({
@@ -363,10 +307,7 @@ async function saveDetails(changes: Partial<TemplateDetails>): Promise<void> {
363
307
  })
364
308
  reloadInPlace()
365
309
  } catch (error) {
366
- toast.add({
367
- color: 'error',
368
- title: processApiMessage(apiErrorMessage(error)),
369
- })
310
+ presentError(error, { fallbackKey: 'error.500.description' })
370
311
  }
371
312
  }
372
313
 
@@ -424,10 +365,7 @@ async function runLifecycle(action: LifecycleAction): Promise<void> {
424
365
  })
425
366
  settle()
426
367
  } catch (error) {
427
- toast.add({
428
- color: 'error',
429
- title: processApiMessage(apiErrorMessage(error)),
430
- })
368
+ presentError(error, { fallbackKey: 'error.500.description' })
431
369
  }
432
370
  }
433
371
 
@@ -472,10 +410,7 @@ async function declareVariable(path: string): Promise<void> {
472
410
  detail.value.variables = response.variables
473
411
  detail.value.revision = response.revision
474
412
  } catch (error) {
475
- toast.add({
476
- color: 'error',
477
- title: processApiMessage(apiErrorMessage(error)),
478
- })
413
+ presentError(error, { fallbackKey: 'error.500.description' })
479
414
  reloadInPlace()
480
415
  }
481
416
  }
@@ -499,10 +434,7 @@ async function restoreVersion(version: number): Promise<void> {
499
434
  ],
500
435
  })
501
436
  } catch (error) {
502
- toast.add({
503
- color: 'error',
504
- title: processApiMessage(apiErrorMessage(error)),
505
- })
437
+ presentError(error, { fallbackKey: 'error.500.description' })
506
438
  }
507
439
  reloadInPlace()
508
440
  }
@@ -701,172 +633,26 @@ function onResolved(preview: PreviewResponse): void {
701
633
  </div>
702
634
 
703
635
  <div v-show="tab === 'variables'" class="flex flex-col gap-4 py-4">
704
- <div class="flex flex-col gap-1">
705
- <DmsEyebrow
706
- :label="t('dms_mailing.templates.drawer.declared_detected')"
707
- />
708
- <p v-if="!variables.length" class="text-muted text-[12.5px]">
709
- {{ t('dms_mailing.templates.drawer.no_variables') }}
710
- </p>
711
- <div
712
- v-for="variable in variables"
713
- :key="variable.path"
714
- class="border-default flex min-h-9 items-center gap-2 border-b last:border-b-0"
715
- >
716
- <code
717
- class="truncate font-mono text-[12px]"
718
- :class="
719
- variable.usage === 'unused' ? 'text-dimmed' : 'text-toned'
720
- "
721
- >
722
- {{ variable.path }}
723
- </code>
724
- <span
725
- v-if="variable.required && variable.usage !== 'detected'"
726
- class="text-error"
727
- :title="t('dms_mailing.templates.drawer.required')"
728
- >
729
- *
730
- </span>
731
- <DmsStatusPill
732
- v-if="variable.usage === 'detected'"
733
- tone="warning"
734
- size="sm"
735
- dot="none"
736
- :label="t('dms_mailing.templates.drawer.not_declared')"
737
- />
738
- <DmsStatusPill
739
- v-if="variable.usage === 'unused'"
740
- tone="neutral"
741
- size="sm"
742
- dot="none"
743
- :label="t('dms_mailing.templates.drawer.unused')"
744
- />
745
- <UButton
746
- v-if="variable.usage === 'detected' && !isLive"
747
- class="ml-auto"
748
- color="neutral"
749
- variant="ghost"
750
- size="xs"
751
- :label="t('dms_mailing.templates.drawer.declare')"
752
- @click="declareVariable(variable.path)"
753
- />
754
- <span
755
- v-else-if="variable.usage !== 'detected'"
756
- class="text-dimmed ml-auto font-mono text-[11px]"
757
- >
758
- {{ t(variableTypeLabelKey(variable.type)) }}
759
- </span>
760
- </div>
761
- </div>
762
- <DmsBanner
763
- v-if="hasDraftDeclarations"
764
- tone="info"
765
- size="sm"
766
- icon="i-ph-brackets-curly"
767
- :title="t('dms_mailing.templates.drawer.draft_declarations')"
768
- :description="
769
- t('dms_mailing.templates.drawer.draft_declarations_description')
770
- "
771
- >
772
- <template #actions>
773
- <UButton
774
- color="neutral"
775
- variant="outline"
776
- size="xs"
777
- :label="t('dms_mailing.templates.actions.open_editor')"
778
- @click="openEditor()"
779
- />
780
- </template>
781
- </DmsBanner>
782
- <MailingSystemVariables :variables="systemVars" />
783
- <div class="flex flex-col gap-1.5">
784
- <div class="flex items-center justify-between">
785
- <DmsEyebrow
786
- :label="t('dms_mailing.templates.drawer.call_from_code')"
787
- />
788
- <DmsCopyButton :value="snippet" />
789
- </div>
790
- <pre
791
- class="bg-elevated text-toned overflow-x-auto rounded-md p-3 font-mono text-[11.5px] leading-relaxed"
792
- >{{ snippet }}</pre>
793
- </div>
636
+ <TemplateDrawerVariables
637
+ :variables="variables"
638
+ :is-live="isLive"
639
+ :has-draft-declarations="hasDraftDeclarations"
640
+ :system-vars="systemVars"
641
+ :snippet="snippet"
642
+ :declare-variable="declareVariable"
643
+ :open-editor="openEditor"
644
+ />
794
645
  </div>
795
646
 
796
647
  <div v-show="tab === 'performance'" class="flex flex-col gap-4 py-4">
797
- <div class="flex flex-col gap-2">
798
- <div class="flex items-center justify-between">
799
- <DmsEyebrow
800
- :label="t('dms_mailing.templates.drawer.last_30_days')"
801
- />
802
- <UButton
803
- variant="link"
804
- size="xs"
805
- class="p-0"
806
- :label="t('dms_mailing.templates.drawer.open_send_log')"
807
- @click="openSendLog"
808
- />
809
- </div>
810
- <DmsStatGroup
811
- layout="joined"
812
- :columns="3"
813
- :items="stats"
814
- :loading="loading"
815
- />
816
- </div>
817
- <div class="flex flex-col gap-1">
818
- <DmsEyebrow :label="t('dms_mailing.templates.drawer.called_from')" />
819
- <p
820
- v-if="!hasSends || !performance?.sources.length"
821
- class="text-muted text-[12.5px]"
822
- >
823
- {{ t('dms_mailing.templates.drawer.no_sources') }}
824
- </p>
825
- <DmsListRow
826
- v-for="source in performance?.sources ?? []"
827
- :key="source.source"
828
- icon="i-ph-code"
829
- size="sm"
830
- mono
831
- :title="
832
- source.source || t('dms_mailing.templates.drawer.unknown_source')
833
- "
834
- :trailing="
835
- t(
836
- 'dms_mailing.templates.drawer.sends_count',
837
- { count: formatNumber(source.count) },
838
- source.count,
839
- )
840
- "
841
- />
842
- </div>
843
- <div class="flex flex-col gap-1">
844
- <DmsEyebrow :label="t('dms_mailing.templates.drawer.fallbacks')" />
845
- <p v-if="!hasSends" class="text-muted text-[12.5px]">
846
- {{ t('dms_mailing.templates.drawer.no_sources') }}
847
- </p>
848
- <p
849
- v-else-if="!performance?.fallbacks.length"
850
- class="text-muted text-[12.5px]"
851
- >
852
- {{ t('dms_mailing.templates.drawer.no_fallbacks') }}
853
- </p>
854
- <DmsListRow
855
- v-for="fallback in performance?.fallbacks ?? []"
856
- :key="`${fallback.requested}-${fallback.used}`"
857
- icon="i-ph-translate"
858
- tone="warning"
859
- size="sm"
860
- :title="`${fallback.requested.toUpperCase()} → ${fallback.used.toUpperCase()}`"
861
- :trailing="
862
- t(
863
- 'dms_mailing.templates.drawer.sends_count',
864
- { count: formatNumber(fallback.count) },
865
- fallback.count,
866
- )
867
- "
868
- />
869
- </div>
648
+ <TemplateDrawerPerformance
649
+ :stats="stats"
650
+ :loading="loading"
651
+ :has-sends="hasSends"
652
+ :performance="performance"
653
+ :open-send-log="openSendLog"
654
+ :format-number="formatNumber"
655
+ />
870
656
  </div>
871
657
 
872
658
  <div v-if="tab === 'history'" class="flex flex-col gap-2 py-4">
@@ -882,28 +668,13 @@ function onResolved(preview: PreviewResponse): void {
882
668
  />
883
669
  </div>
884
670
 
885
- <DmsContainerFooter data-template-footer>
886
- <UButton
887
- icon="i-ph-flask"
888
- color="neutral"
889
- variant="ghost"
890
- :label="t('dms_mailing.templates.card.test')"
891
- @click="overlays.openTestSend(templateId, { locale })"
892
- />
893
- <UButton
894
- class="ml-auto"
895
- icon="i-ph-copy"
896
- color="neutral"
897
- variant="outline"
898
- :label="t('dms_mailing.templates.actions.duplicate')"
899
- @click="overlays.openDuplicate(templateId, { rowData: template })"
900
- />
901
- <UButton
902
- icon="i-ph-pencil-simple"
903
- :label="t('dms_mailing.templates.actions.open_editor')"
904
- @click="openEditor(locale)"
905
- />
906
- </DmsContainerFooter>
671
+ <TemplateDrawerFooter
672
+ :template-id="templateId"
673
+ :locale="locale"
674
+ :template="template"
675
+ :overlays="overlays"
676
+ :open-editor="openEditor"
677
+ />
907
678
  </template>
908
679
  </div>
909
680
  </template>
@@ -4,7 +4,6 @@ import FormRows from '../build/components/FormRows.vue'
4
4
  import { useMailingApi } from '../composables/useMailingApi'
5
5
  import { useTemplateContentRead } from '../composables/useMailingReads'
6
6
  import type { SendResult, TestSendInput } from '../composables/useMailingApi'
7
- import { apiErrorMessage } from '../utils/api-error'
8
7
  import { sendLogRoute } from '../utils/send-link'
9
8
  import { reasonKey, type SuppressionSummary } from '../utils/suppression'
10
9
  import {
@@ -49,8 +48,8 @@ const emit = defineEmits<{ success: [sent?: boolean] }>()
49
48
 
50
49
  const api = useMailingApi()
51
50
  const toast = useToast()
51
+ const { presentError } = useApiErrorPresenter()
52
52
  const { t, locale: uiLocale } = useI18n()
53
- const { processApiMessage } = useTranslation()
54
53
  const { user } = useCurrentUser()
55
54
  const { uniqueLocales } = useUniqueLocales()
56
55
 
@@ -70,10 +69,7 @@ onMounted(async () => {
70
69
  detail.value = await readContent()
71
70
  locale.value ||= detail.value.fallbackLocale
72
71
  } catch (error) {
73
- toast.add({
74
- color: 'error',
75
- title: processApiMessage(apiErrorMessage(error)),
76
- })
72
+ presentError(error, { fallbackKey: 'error.500.description' })
77
73
  }
78
74
  })
79
75
 
@@ -154,14 +150,10 @@ function contentOverride(): Pick<TestSendInput, 'content' | 'version'> {
154
150
  return {}
155
151
  }
156
152
 
157
- function reportFailures(results: SendResult[], to: string[]): void {
158
- results.forEach((result, index) => {
153
+ function reportFailures(results: SendResult[]): void {
154
+ results.forEach((result) => {
159
155
  if (!result.error) return
160
- toast.add({
161
- color: 'error',
162
- title: to[index] ?? result.sendId,
163
- description: processApiMessage(result.error),
164
- })
156
+ presentError(result.error, { fallbackKey: 'error.500.description' })
165
157
  })
166
158
  const sent = results.filter((result) => !result.error).length
167
159
  if (sent) {
@@ -213,14 +205,11 @@ async function submit(): Promise<void> {
213
205
  ...contentOverride(),
214
206
  data: DATA_SETS[dataChoice.value](),
215
207
  })
216
- reportFailures(response.results, to)
208
+ reportFailures(response.results)
217
209
  warnOwnSuppression(response.ownAddressSuppressed)
218
210
  close(true)
219
211
  } catch (error) {
220
- toast.add({
221
- color: 'error',
222
- title: processApiMessage(apiErrorMessage(error)),
223
- })
212
+ presentError(error, { fallbackKey: 'error.500.description' })
224
213
  } finally {
225
214
  sending.value = false
226
215
  }
@@ -2,7 +2,6 @@
2
2
  import type { Component } from 'vue'
3
3
  import { useTemplateVersionsRead } from '../composables/useMailingReads'
4
4
  import type { TemplateStatus, TemplateVersionSummary } from '../types/mailing'
5
- import { apiErrorMessage } from '../utils/api-error'
6
5
  import {
7
6
  changeSummary,
8
7
  formatVersionDate,
@@ -32,10 +31,9 @@ const props = withDefaults(defineProps<VersionHistoryProps>(), {
32
31
  })
33
32
 
34
33
  const modal = useModal()
35
- const toast = useToast()
34
+ const { presentError } = useApiErrorPresenter()
36
35
  const { confirm } = useConfirm()
37
36
  const { t, locale: uiLocale } = useI18n()
38
- const { processApiMessage } = useTranslation()
39
37
  const previewComponent = resolveComponent(PREVIEW_COMPONENT) as Component
40
38
 
41
39
  const readVersions = useTemplateVersionsRead(() => props.templateId)
@@ -47,10 +45,7 @@ async function load(): Promise<void> {
47
45
  try {
48
46
  versions.value = (await readVersions()).versions
49
47
  } catch (error) {
50
- toast.add({
51
- color: 'error',
52
- title: processApiMessage(apiErrorMessage(error)),
53
- })
48
+ presentError(error, { fallbackKey: 'error.500.description' })
54
49
  } finally {
55
50
  loading.value = false
56
51
  }
@@ -35,6 +35,7 @@ const api = useMailingApi()
35
35
  const toast = useToast()
36
36
  const { t, locale } = useI18n()
37
37
  const { dateTimeFormat } = useRegionalFormat()
38
+ const { presentError } = useApiErrorPresenter()
38
39
 
39
40
  const read = useWebhookStatus()
40
41
  /** The status read, `null` when the read failed. */
@@ -84,7 +85,7 @@ async function sendTest(): Promise<void> {
84
85
  : undefined,
85
86
  })
86
87
  } catch (error) {
87
- useApiError(error)
88
+ presentError(error, { fallbackKey: 'error.500.description' })
88
89
  } finally {
89
90
  isTesting.value = false
90
91
  }
@@ -11,7 +11,6 @@ import {
11
11
  useEditorShortcuts,
12
12
  } from '../../composables/useEditorShortcuts'
13
13
  import { createEditorState } from '../../composables/useTemplateEditor'
14
- import { apiErrorMessage } from '../../utils/api-error'
15
14
 
16
15
  interface Props {
17
16
  loaded: TemplateContentResponse
@@ -23,8 +22,7 @@ const props = defineProps<Props>()
23
22
  const locale = defineModel<string | null>('locale', { default: null })
24
23
  const emit = defineEmits<{ renamed: [string] }>()
25
24
 
26
- const toast = useToast()
27
- const { processApiMessage } = useTranslation()
25
+ const { presentError } = useApiErrorPresenter()
28
26
  const { uniqueLocales } = useUniqueLocales()
29
27
  const api = useMailingApi()
30
28
 
@@ -44,10 +42,7 @@ const editor = createEditorState(
44
42
  )
45
43
 
46
44
  function reportFailure(error: unknown): void {
47
- toast.add({
48
- color: 'error',
49
- title: processApiMessage(apiErrorMessage(error)),
50
- })
45
+ presentError(error, { fallbackKey: 'error.500.description' })
51
46
  }
52
47
 
53
48
  const reads = useEditorReads(props.templateId)
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path'
3
+ import type { DmsVitePluginEntry } from '#dms/frontend-build'
4
+
5
+ type VitePlugin = Extract<DmsVitePluginEntry, { name: string }>
6
+
7
+ const BACKEND_PACKAGE = '@antelopejs/dms-mailing'
8
+ const BACKEND_SOURCE = 'src'
9
+ const QUERY_OR_HASH = /[?#].*$/
10
+
11
+ const backendRoots = new Map<string, string | null>()
12
+
13
+ /** Whether a parsed manifest names its package. */
14
+ function isNamedManifest(value: unknown): value is { name: string } {
15
+ return (
16
+ typeof value === 'object' &&
17
+ value !== null &&
18
+ 'name' in value &&
19
+ typeof value.name === 'string'
20
+ )
21
+ }
22
+
23
+ /** The package name a `package.json` declares, or `undefined`. */
24
+ function packageName(manifest: string): string | undefined {
25
+ try {
26
+ const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
27
+ return isNamedManifest(parsed) ? parsed.name : undefined
28
+ } catch {
29
+ return undefined
30
+ }
31
+ }
32
+
33
+ /** The directory of the backend package that holds `directory`, or null. */
34
+ function backendRoot(directory: string): string | null {
35
+ const known = backendRoots.get(directory)
36
+ if (known !== undefined) {
37
+ return known
38
+ }
39
+ const manifest = join(directory, 'package.json')
40
+ const parent = dirname(directory)
41
+ let root: string | null = null
42
+ if (existsSync(manifest)) {
43
+ root = packageName(manifest) === BACKEND_PACKAGE ? directory : null
44
+ } else if (parent !== directory) {
45
+ root = backendRoot(parent)
46
+ }
47
+ backendRoots.set(directory, root)
48
+ return root
49
+ }
50
+
51
+ /**
52
+ * The real path of a resolved module id when it lies under the `src`
53
+ * directory of the backend package, or `undefined`. The package that owns the
54
+ * file decides, not a path, so the answer is the same in the repository and in
55
+ * the build workspace. Virtual ids and ids that are not files are ignored.
56
+ */
57
+ function backendSourceFile(id: string): string | undefined {
58
+ const path = id.replace(QUERY_OR_HASH, '')
59
+ if (!isAbsolute(path)) {
60
+ return undefined
61
+ }
62
+ let real: string
63
+ try {
64
+ real = realpathSync(path)
65
+ } catch {
66
+ return undefined
67
+ }
68
+ const root = backendRoot(dirname(real))
69
+ if (root === null) {
70
+ return undefined
71
+ }
72
+ return relative(root, real).split(sep)[0] === BACKEND_SOURCE
73
+ ? real
74
+ : undefined
75
+ }
76
+
77
+ /**
78
+ * Fails the build and the dev server when a module of the frontend resolves to
79
+ * a file under the `src` of the backend package: the browser reaches the
80
+ * backend only through its HTTP interface. It checks what the whole resolution
81
+ * chain returns, an id marked external included, so an alias is seen through
82
+ * and a symlink the resolver returns is followed to its real path, and it runs
83
+ * before the other plugins (`enforce: "pre"`). Vite builds workers with their
84
+ * own plugin list, so the plugin adds itself there too.
85
+ *
86
+ * antelopejs-sync writes this file from `@antelopejs/tooling-configs` and
87
+ * restores it on every run: change the plugin in the pack, not here.
88
+ */
89
+ export function backendBoundary(): VitePlugin {
90
+ return {
91
+ ...boundaryCheck(),
92
+ config: () => ({ worker: { plugins: () => [boundaryCheck()] } }),
93
+ }
94
+ }
95
+
96
+ /** The check alone, as a worker build runs it. */
97
+ function boundaryCheck(): VitePlugin {
98
+ return {
99
+ name: `${BACKEND_PACKAGE}:backend-boundary`,
100
+ enforce: 'pre',
101
+ async resolveId(source, importer, options) {
102
+ const resolved = await this.resolve(source, importer, {
103
+ ...options,
104
+ skipSelf: true,
105
+ })
106
+ if (resolved === null) {
107
+ return resolved
108
+ }
109
+ const file = backendSourceFile(resolved.id)
110
+ if (file === undefined) {
111
+ return resolved
112
+ }
113
+ const from =
114
+ importer === undefined
115
+ ? 'an entry point'
116
+ : importer.replace(QUERY_OR_HASH, '')
117
+ return this.error(
118
+ `Backend boundary: ${from} imports ${file}, which is under the src of ${BACKEND_PACKAGE}. The frontend reaches the backend only through its HTTP interface.`,
119
+ )
120
+ },
121
+ }
122
+ }
@@ -0,0 +1,7 @@
1
+ import { defineDmsFrontendBuild } from '#dms/frontend-build'
2
+ import { backendBoundary } from './build/backend-boundary.ts'
3
+
4
+ /** The build of the frontend module: the backend boundary in every build. */
5
+ export default defineDmsFrontendBuild((build) => {
6
+ build.registerVitePlugins(() => [backendBoundary()])
7
+ })
@@ -1,3 +1,5 @@
1
+ import antiSlop from '@antelopejs/tooling-configs/oxc/anti-slop'
2
+ import guardrails from '@antelopejs/tooling-configs/oxc/guardrails'
1
3
  import vue from 'eslint-plugin-vue'
2
4
  import ts from 'typescript-eslint'
3
5
 
@@ -10,4 +12,15 @@ export default [
10
12
  rules: { 'vue/multi-word-component-names': 'off' },
11
13
  },
12
14
  { files: ['**/*.ts'], languageOptions: { parser: ts.parser } },
15
+ {
16
+ files: ['tests/**/*.ts'],
17
+ plugins: { antelopejs: guardrails, 'anti-slop': antiSlop },
18
+ rules: {
19
+ 'antelopejs/no-fs-read-in-tests': 'error',
20
+ 'antelopejs/no-constant-value-assertion': 'error',
21
+ 'antelopejs/no-typeof-function-only-test': 'error',
22
+ 'antelopejs/no-self-import-in-tests': 'error',
23
+ 'anti-slop/no-module-mocking': 'error',
24
+ },
25
+ },
13
26
  ]