@antelopejs/dms-mailing 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.js +2 -1
  3. package/dist/constants.js.map +1 -1
  4. package/dist/data/templates-table.d.ts +4 -1
  5. package/dist/data/templates-table.js +55 -13
  6. package/dist/data/templates-table.js.map +1 -1
  7. package/dist/data-types/categories-type.d.ts +5 -5
  8. package/dist/db/models/templates.model.d.ts +10 -1
  9. package/dist/db/models/templates.model.js +27 -1
  10. package/dist/db/models/templates.model.js.map +1 -1
  11. package/dist/db/tables/templates.table.d.ts +7 -1
  12. package/dist/db/tables/templates.table.js +3 -1
  13. package/dist/db/tables/templates.table.js.map +1 -1
  14. package/dist/engine/formats.d.ts +13 -0
  15. package/dist/engine/formats.js +85 -0
  16. package/dist/engine/formats.js.map +1 -0
  17. package/dist/engine/index.d.ts +1 -0
  18. package/dist/engine/index.js +1 -0
  19. package/dist/engine/index.js.map +1 -1
  20. package/dist/engine/resolve.d.ts +2 -1
  21. package/dist/engine/resolve.js +14 -7
  22. package/dist/engine/resolve.js.map +1 -1
  23. package/dist/engine/tokens.d.ts +2 -1
  24. package/dist/engine/tokens.js +3 -5
  25. package/dist/engine/tokens.js.map +1 -1
  26. package/dist/frontend-module.d.ts +2 -0
  27. package/dist/frontend-module.js +24 -0
  28. package/dist/frontend-module.js.map +1 -0
  29. package/dist/hooks/tenant-export.js +4 -1
  30. package/dist/hooks/tenant-export.js.map +1 -1
  31. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  32. package/dist/implementations/dms-mailing/index.js +3 -1
  33. package/dist/implementations/dms-mailing/index.js.map +1 -1
  34. package/dist/index.js +2 -19
  35. package/dist/index.js.map +1 -1
  36. package/dist/pages/overview.d.ts +1 -1
  37. package/dist/pages/overview.js +11 -13
  38. package/dist/pages/overview.js.map +1 -1
  39. package/dist/pages/sends.d.ts +1 -1
  40. package/dist/pages/sends.js +16 -8
  41. package/dist/pages/sends.js.map +1 -1
  42. package/dist/pages/templates.js +3 -8
  43. package/dist/pages/templates.js.map +1 -1
  44. package/dist/pages/toolbar.d.ts +12 -0
  45. package/dist/pages/toolbar.js +41 -0
  46. package/dist/pages/toolbar.js.map +1 -0
  47. package/dist/realtime.d.ts +2 -2
  48. package/dist/realtime.js +8 -8
  49. package/dist/realtime.js.map +1 -1
  50. package/dist/routes/metrics.d.ts +9 -2
  51. package/dist/routes/metrics.js +90 -35
  52. package/dist/routes/metrics.js.map +1 -1
  53. package/dist/routes/period.d.ts +1 -0
  54. package/dist/routes/period.js +19 -0
  55. package/dist/routes/period.js.map +1 -1
  56. package/dist/routes/sends.js +6 -3
  57. package/dist/routes/sends.js.map +1 -1
  58. package/dist/routes/settings.js +6 -7
  59. package/dist/routes/settings.js.map +1 -1
  60. package/dist/routes/templates.d.ts +8 -4
  61. package/dist/routes/templates.js +40 -15
  62. package/dist/routes/templates.js.map +1 -1
  63. package/dist/services/categories.d.ts +2 -1
  64. package/dist/services/categories.js +9 -0
  65. package/dist/services/categories.js.map +1 -1
  66. package/dist/services/declarations.js +2 -2
  67. package/dist/services/declarations.js.map +1 -1
  68. package/dist/services/events.js +1 -1
  69. package/dist/services/events.js.map +1 -1
  70. package/dist/services/localized-text.d.ts +7 -0
  71. package/dist/services/localized-text.js +28 -0
  72. package/dist/services/localized-text.js.map +1 -0
  73. package/dist/services/send-stat-items.js +34 -12
  74. package/dist/services/send-stat-items.js.map +1 -1
  75. package/dist/services/send-stats.d.ts +7 -0
  76. package/dist/services/send-stats.js +17 -5
  77. package/dist/services/send-stats.js.map +1 -1
  78. package/dist/services/send.d.ts +2 -0
  79. package/dist/services/send.js +11 -12
  80. package/dist/services/send.js.map +1 -1
  81. package/dist/services/sender-declaration.d.ts +2 -0
  82. package/dist/services/sender-declaration.js +18 -0
  83. package/dist/services/sender-declaration.js.map +1 -0
  84. package/dist/services/starters.d.ts +2 -2
  85. package/dist/services/starters.js +1 -10
  86. package/dist/services/starters.js.map +1 -1
  87. package/dist/services/templates.d.ts +11 -5
  88. package/dist/services/templates.js +24 -6
  89. package/dist/services/templates.js.map +1 -1
  90. package/dist/services/webhook-status.d.ts +2 -1
  91. package/dist/services/webhook-status.js +6 -5
  92. package/dist/services/webhook-status.js.map +1 -1
  93. package/dist/types/settings.d.ts +6 -0
  94. package/dist/types/settings.js +25 -5
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +25 -7
  97. package/dist/validation/declarations.schema.js +16 -3
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/settings.schema.d.ts +25 -25
  100. package/dist/validation/settings.schema.js +30 -6
  101. package/dist/validation/settings.schema.js.map +1 -1
  102. package/dist/validation/templates.schema.d.ts +13 -0
  103. package/dist/validation/templates.schema.js +6 -1
  104. package/dist/validation/templates.schema.js.map +1 -1
  105. package/frontend-vue/app/components/CategoriesInput.vue +15 -13
  106. package/frontend-vue/app/components/FunnelCard.vue +46 -3
  107. package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
  108. package/frontend-vue/app/components/OverviewGate.vue +0 -8
  109. package/frontend-vue/app/components/ProviderPill.vue +50 -0
  110. package/frontend-vue/app/components/SendDrawer.vue +206 -183
  111. package/frontend-vue/app/components/StarterGallery.vue +1 -1
  112. package/frontend-vue/app/components/TemplateCard.vue +4 -1
  113. package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
  114. package/frontend-vue/app/components/TemplateGallery.vue +57 -17
  115. package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
  116. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
  117. package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
  118. package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
  119. package/frontend-vue/app/composables/useMailingApi.ts +3 -3
  120. package/frontend-vue/app/composables/useMailingReads.ts +2 -2
  121. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
  122. package/frontend-vue/app/types/mailing.ts +18 -0
  123. package/frontend-vue/app/utils/categories.ts +58 -49
  124. package/frontend-vue/app/utils/funnel.ts +25 -0
  125. package/frontend-vue/app/utils/period.ts +0 -7
  126. package/frontend-vue/app/utils/starters.ts +3 -1
  127. package/frontend-vue/app/utils/tokens.ts +6 -1
  128. package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
  129. package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
  130. package/frontend-vue/package.json +1 -1
  131. package/frontend-vue/tests/categories-input.test.ts +4 -10
  132. package/frontend-vue/tests/categories.test.ts +55 -17
  133. package/frontend-vue/tests/conditions.test.ts +1 -4
  134. package/frontend-vue/tests/funnel-card.test.ts +27 -0
  135. package/frontend-vue/tests/funnel.test.ts +25 -0
  136. package/frontend-vue/tests/localized-defaults.test.ts +0 -29
  137. package/frontend-vue/tests/new-template-modal.test.ts +1 -1
  138. package/frontend-vue/tests/overview-gate.test.ts +1 -24
  139. package/frontend-vue/tests/page-reads.test.ts +9 -25
  140. package/frontend-vue/tests/period.test.ts +0 -12
  141. package/frontend-vue/tests/replay-toast.test.ts +2 -1
  142. package/frontend-vue/tests/send-drawer.test.ts +123 -0
  143. package/frontend-vue/tests/template-drawer.test.ts +1 -1
  144. package/frontend-vue/tests/template-gallery.test.ts +90 -3
  145. package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
  146. package/package.json +6 -5
  147. package/skills/dms-mailing/SKILL.md +17 -4
  148. package/src/constants.ts +2 -0
  149. package/src/data/sends-table.ts +1 -1
  150. package/src/data/templates-table.ts +123 -19
  151. package/src/db/models/templates.model.ts +61 -3
  152. package/src/db/tables/templates.table.ts +10 -1
  153. package/src/engine/formats.ts +124 -0
  154. package/src/engine/index.ts +1 -0
  155. package/src/engine/resolve.ts +18 -7
  156. package/src/engine/tokens.ts +7 -5
  157. package/src/frontend-module.ts +36 -0
  158. package/src/hooks/tenant-export.ts +8 -1
  159. package/src/implementations/dms-mailing/index.ts +2 -0
  160. package/src/index.ts +2 -23
  161. package/src/pages/overview.ts +20 -16
  162. package/src/pages/sends.ts +27 -11
  163. package/src/pages/templates.ts +4 -8
  164. package/src/pages/toolbar.ts +77 -0
  165. package/src/realtime.ts +17 -8
  166. package/src/routes/metrics.ts +158 -45
  167. package/src/routes/period.ts +32 -0
  168. package/src/routes/sends.ts +13 -4
  169. package/src/routes/settings.ts +11 -8
  170. package/src/routes/templates.ts +72 -26
  171. package/src/services/categories.ts +14 -1
  172. package/src/services/declarations.ts +4 -3
  173. package/src/services/events.ts +1 -1
  174. package/src/services/localized-text.ts +53 -0
  175. package/src/services/send-stat-items.ts +60 -13
  176. package/src/services/send-stats.ts +37 -5
  177. package/src/services/send.ts +19 -20
  178. package/src/services/sender-declaration.ts +27 -0
  179. package/src/services/starters.ts +3 -15
  180. package/src/services/templates.ts +50 -19
  181. package/src/services/webhook-status.ts +3 -3
  182. package/src/types/settings.ts +35 -5
  183. package/src/validation/declarations.schema.ts +29 -3
  184. package/src/validation/settings.schema.ts +56 -14
  185. package/src/validation/templates.schema.ts +10 -0
  186. package/frontend-vue/app/components/PageHeader.vue +0 -94
  187. package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
@@ -42,7 +42,8 @@ describe('useReplayToast', () => {
42
42
  const [toast] = stubs.toasts
43
43
  expect(toast).toMatchObject({
44
44
  color: 'warning',
45
- title: 'dms_mailing.suppressions.notice.not_sent.toast_title email=a@x.test',
45
+ title:
46
+ 'dms_mailing.suppressions.notice.not_sent.toast_title email=a@x.test',
46
47
  description:
47
48
  'dms_mailing.suppressions.notice.not_sent.reason reason=dms_mailing.suppressions.reasons.unsubscribe',
48
49
  })
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import SendDrawer from '../app/components/SendDrawer.vue'
4
+ import type { SendRow } from '../app/types/mailing'
5
+ import { flush, mount, slotStub, stubNuxtGlobals } from './helpers/nuxt-globals'
6
+ import { mailingReads, stubApiData } from './helpers/api-data'
7
+
8
+ vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
9
+ ...(await importOriginal<object>()),
10
+ useMailingApi: () => ({ replay: vi.fn() }),
11
+ }))
12
+
13
+ const SEND: SendRow = {
14
+ _id: '041f125a00000000',
15
+ templateId: 't1',
16
+ templateSlug: 'invoice-reminder',
17
+ locale: 'fr',
18
+ templateVersion: 1,
19
+ recipientEmail: 'marc@boulangerie-lejeune.example',
20
+ recipientName: 'Marc Lejeune',
21
+ status: 'sent',
22
+ providerMessageId: '<f1c836b9-226e-1c5a-ab39-10b4beb00a4c@ethereal.email>',
23
+ latencyMs: 822,
24
+ source: 'automation.overdue-reminders',
25
+ json_variables: '{"customer":{"name":"Marc"}}',
26
+ opens: 0,
27
+ clicks: 0,
28
+ createdAt: '2026-10-20T08:00:02Z',
29
+ isTest: false,
30
+ }
31
+
32
+ const STUB_NAMES = [
33
+ 'USkeleton',
34
+ 'UButton',
35
+ 'UIcon',
36
+ 'DmsEyebrow',
37
+ 'DmsStatusPill',
38
+ 'DmsStatGroup',
39
+ 'DmsBanner',
40
+ 'DmsActivityItem',
41
+ 'DmsListRow',
42
+ 'DmsKeyValueList',
43
+ 'DmsContainerFooter',
44
+ ]
45
+ const STUBS = Object.fromEntries(
46
+ STUB_NAMES.map((name) => [name, slotStub(name)]),
47
+ )
48
+
49
+ async function render(): Promise<HTMLElement> {
50
+ const { host } = mount(SendDrawer, { rowData: SEND }, STUBS)
51
+ await flush()
52
+ return host
53
+ }
54
+
55
+ describe('send drawer layout', () => {
56
+ beforeEach(() => {
57
+ stubNuxtGlobals()
58
+ vi.stubGlobal('useApiError', () => undefined)
59
+ stubApiData().respond(
60
+ mailingReads({ send: () => ({ send: SEND, events: [] }) }),
61
+ )
62
+ })
63
+
64
+ afterEach(() => {
65
+ vi.unstubAllGlobals()
66
+ document.body.innerHTML = ''
67
+ })
68
+
69
+ it('adds no inset or scroller of its own inside the drawer body', async () => {
70
+ const host = await render()
71
+ const root = host.firstElementChild as HTMLElement
72
+ expect(root.className).not.toMatch(/(^|\s)(p|px|py|m)-\d/)
73
+ expect(root.className).not.toContain('h-full')
74
+ expect(host.querySelector('.overflow-y-auto')).toBeNull()
75
+ expect(host.querySelector('header')?.className).not.toMatch(/px-\d/)
76
+ })
77
+
78
+ // The DMS drawer body is a flex column: a root that grows keeps the
79
+ // DmsContainerFooter, which sticks and sits flush, at the drawer's bottom.
80
+ it('grows in the drawer body and puts its actions in the DMS footer', async () => {
81
+ const host = await render()
82
+ const root = host.firstElementChild as HTMLElement
83
+ expect(root.className).toContain('flex-1')
84
+ expect(root.className).toContain('flex-col')
85
+ expect(root.style.minHeight).toBe('')
86
+ const footer = host.querySelector('[data-send-footer]')
87
+ expect(footer?.getAttribute('data-stub')).toBe('DmsContainerFooter')
88
+ expect(
89
+ footer?.querySelectorAll('[data-stub="UButton"]').length,
90
+ ).toBeGreaterThan(0)
91
+ })
92
+
93
+ it('keeps the template / locale / latency trio on one row in a 600px drawer', async () => {
94
+ const group = (await render()).querySelector('[data-stub="DmsStatGroup"]')
95
+ expect(group?.getAttribute('columns-from')).toBe('md')
96
+ expect(group?.className).not.toContain('grid-cols')
97
+ })
98
+
99
+ it('wraps the timeline so a Message-ID is never clipped', async () => {
100
+ const items = (await render()).querySelectorAll(
101
+ '[data-stub="DmsActivityItem"]',
102
+ )
103
+ expect(items.length).toBeGreaterThan(0)
104
+ items.forEach((item) => expect(item.hasAttribute('wrap')).toBe(true))
105
+ })
106
+
107
+ it('lets the address give way before the date on a phone', async () => {
108
+ const line = (await render()).querySelector('[data-send-recipient]')
109
+ const [address, date] = Array.from(line?.children ?? [])
110
+ expect(line?.className).toContain('flex-wrap')
111
+ expect(line?.className).not.toContain('truncate')
112
+ expect(address?.className).toContain('truncate')
113
+ expect(address?.getAttribute('title')).toBe(SEND.recipientEmail)
114
+ expect(date?.className).toContain('whitespace-nowrap')
115
+ })
116
+
117
+ it('puts the "source" label under the source, not beside it', async () => {
118
+ const row = (await render()).querySelector('[data-send-source]')
119
+ expect(row?.hasAttribute('trailing')).toBe(false)
120
+ expect(row?.getAttribute('meta')).toContain('source')
121
+ expect(row?.textContent).toContain(SEND.source)
122
+ })
123
+ })
@@ -434,7 +434,7 @@ describe('StarterGallery', () => {
434
434
  })
435
435
 
436
436
  it('creates a starter as a draft and opens it', async () => {
437
- api.createTemplate.mockResolvedValue(['new-id'])
437
+ api.createTemplate.mockResolvedValue({ _id: 'new-id' })
438
438
  const view = mount(StarterGallery, {}, globals)
439
439
  await flush()
440
440
  ;(view.host.querySelector('[data-stub="DmsCard"]') as HTMLElement).click()
@@ -2,7 +2,6 @@
2
2
  import { reactive } from 'vue'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import TemplateGallery from '../app/components/TemplateGallery.vue'
5
- import TemplateCard from '../app/components/TemplateCard.vue'
6
5
  import type { TemplateRow, TemplateStats } from '../app/types/mailing'
7
6
  import {
8
7
  flush,
@@ -22,6 +21,11 @@ const CATEGORIES_URL = '/api/mailing/templates/categories'
22
21
  const OVERVIEW_URL = '/api/mailing/templates/overview'
23
22
  const LIST_URL = '/api/mailing/tables/templates/list'
24
23
 
24
+ vi.mock('../app/components/TemplatePreviewFrame.vue', async () => {
25
+ const { slotStub } = await import('./helpers/nuxt-globals')
26
+ return { default: slotStub('MailingTemplatePreviewFrame') }
27
+ })
28
+
25
29
  const RECENT = new Date(Date.now() - 3_600_000).toISOString()
26
30
 
27
31
  function row(overrides: Partial<TemplateRow>): TemplateRow {
@@ -107,8 +111,6 @@ function context({ items, query = {} }: ContextOptions) {
107
111
  }
108
112
 
109
113
  const globals = {
110
- MailingTemplateCard: TemplateCard,
111
- MailingTemplatePreviewFrame: slotStub('MailingTemplatePreviewFrame'),
112
114
  MailingStarterGallery: slotStub('MailingStarterGallery'),
113
115
  DmsEmptyState: slotStub('DmsEmptyState'),
114
116
  }
@@ -371,6 +373,91 @@ describe('TemplateGallery', () => {
371
373
  view.unmount()
372
374
  })
373
375
 
376
+ it('keeps its skeleton until the categories answered, then draws the cards in their group', async () => {
377
+ let answerCategories: (value: unknown) => void = () => {}
378
+ api.respond(({ url }) => {
379
+ if (url === CATEGORIES_URL)
380
+ return new Promise((resolve) => (answerCategories = resolve))
381
+ if (url === OVERVIEW_URL) return { items: [] }
382
+ return { results: [LIVE], total: 1 }
383
+ })
384
+ const view = mount(
385
+ TemplateGallery,
386
+ { context: context({ items: [LIVE] }) },
387
+ globals,
388
+ )
389
+ await flush()
390
+ expect(view.host.querySelector('[data-gallery-skeleton]')).not.toBeNull()
391
+ expect(cardNames(view.host)).toEqual([])
392
+ answerCategories({
393
+ categories: [{ id: 'orders', label: 'Orders', icon: 'i-ph-package' }],
394
+ })
395
+ await flush()
396
+ expect(view.host.querySelector('[data-gallery-skeleton]')).toBeNull()
397
+ expect(view.host.querySelector('h2')?.textContent?.trim()).toBe('Orders')
398
+ view.unmount()
399
+ })
400
+
401
+ it('draws no skeleton once the cards are on screen, a refresh included', async () => {
402
+ const ctx = reactive(context({ items: [LIVE] }))
403
+ const view = mount(TemplateGallery, { context: ctx }, globals)
404
+ await flush()
405
+ ctx.loading = true
406
+ ctx.items = [LIVE]
407
+ await flush()
408
+ expect(view.host.querySelector('uskeleton')).toBeNull()
409
+ expect(cardNames(view.host)).toEqual(['Order confirmation'])
410
+ view.unmount()
411
+ })
412
+
413
+ it('lays its skeleton out like a group of cards, so they replace it in place', async () => {
414
+ api.respond(({ url }) => {
415
+ if (url === CATEGORIES_URL) return new Promise(() => {})
416
+ return { items: [], results: [LIVE], total: 1 }
417
+ })
418
+ const loading = mount(
419
+ TemplateGallery,
420
+ { context: context({ items: [LIVE] }) },
421
+ globals,
422
+ )
423
+ await flush()
424
+ const skeleton = loading.host.querySelector(
425
+ '[data-gallery-skeleton]',
426
+ ) as HTMLElement
427
+ const skeletonCard = skeleton.querySelector('dmscard') as HTMLElement
428
+ const skeletonHeading = skeleton.firstElementChild as HTMLElement
429
+ const skeletonBody = skeletonCard.lastElementChild as HTMLElement
430
+
431
+ api.respond(({ url }) =>
432
+ url === CATEGORIES_URL
433
+ ? { categories: [] }
434
+ : { items: [], results: [LIVE], total: 1 },
435
+ )
436
+ const loaded = mount(
437
+ TemplateGallery,
438
+ { context: context({ items: [MISSING_DE] }) },
439
+ globals,
440
+ )
441
+ await flush()
442
+ expect(loaded.host.querySelector('[data-gallery-skeleton]')).toBeNull()
443
+ const section = loaded.host.querySelector('section') as HTMLElement
444
+ const card = section.querySelector('article, dmscard') as HTMLElement
445
+ const body = card.lastElementChild as HTMLElement
446
+
447
+ expect(skeleton.className).toBe(section.className)
448
+ expect(skeletonHeading.className).toContain('flex items-center gap-2')
449
+ expect(skeletonCard.querySelector('uskeleton')?.className).toContain(
450
+ 'h-[172px]',
451
+ )
452
+ expect(skeletonBody.className).toBe(body.className)
453
+ expect(skeletonBody.children).toHaveLength(body.children.length)
454
+ expect(skeletonBody.lastElementChild?.className).toBe(
455
+ body.lastElementChild?.className.replace(' text-muted', ''),
456
+ )
457
+ loading.unmount()
458
+ loaded.unmount()
459
+ })
460
+
374
461
  it('opens the editor on a missing locale chip', async () => {
375
462
  const view = mount(
376
463
  TemplateGallery,
@@ -0,0 +1,92 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import TemplatePreviewFrame from '../app/components/TemplatePreviewFrame.vue'
4
+ import { flush, mount, stubNuxtGlobals } from './helpers/nuxt-globals'
5
+ import { stubApiData } from './helpers/api-data'
6
+
7
+ /** Past the frame's debounce before it reads. */
8
+ const DEBOUNCE_WAIT_MS = 400
9
+
10
+ const api = { preview: vi.fn() }
11
+
12
+ vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
13
+ ...(await importOriginal<object>()),
14
+ useMailingApi: () => api,
15
+ }))
16
+
17
+ const skeleton = (host: HTMLElement) => host.querySelector('uskeleton')
18
+ const placeholder = (host: HTMLElement) =>
19
+ host.querySelector('[data-preview-placeholder]')
20
+ const fallback = (host: HTMLElement) =>
21
+ host.querySelector('[data-preview-fallback]')
22
+
23
+ async function settle(): Promise<void> {
24
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS)
25
+ await flush()
26
+ }
27
+
28
+ beforeEach(() => {
29
+ vi.useFakeTimers()
30
+ stubNuxtGlobals()
31
+ stubApiData()
32
+ api.preview.mockReset()
33
+ })
34
+
35
+ afterEach(() => {
36
+ vi.useRealTimers()
37
+ vi.unstubAllGlobals()
38
+ document.body.innerHTML = ''
39
+ })
40
+
41
+ describe('TemplatePreviewFrame', () => {
42
+ it('holds a skeleton from its first render until the HTML is in', async () => {
43
+ api.preview.mockResolvedValue({ html: '<p>Hello</p>' })
44
+ const view = mount(TemplatePreviewFrame, { templateId: 't1', width: 600 })
45
+ expect(skeleton(view.host)).not.toBeNull()
46
+ expect(view.host.querySelector('iframe')).toBeNull()
47
+ await settle()
48
+ expect(skeleton(view.host)).toBeNull()
49
+ expect(view.host.querySelector('iframe')?.getAttribute('srcdoc')).toContain(
50
+ 'Hello',
51
+ )
52
+ view.unmount()
53
+ })
54
+
55
+ it('waits on a blank sheet, never a skeleton, as a lazy thumbnail', async () => {
56
+ api.preview.mockResolvedValue({ html: '<p>Hello</p>' })
57
+ const view = mount(TemplatePreviewFrame, {
58
+ templateId: 't1',
59
+ width: 600,
60
+ lazy: true,
61
+ })
62
+ await settle()
63
+ expect(skeleton(view.host)).toBeNull()
64
+ expect(placeholder(view.host)).not.toBeNull()
65
+ expect(view.host.querySelector('iframe')).toBeNull()
66
+ view.unmount()
67
+ })
68
+
69
+ it('shows its fallback when the preview cannot be read, not a skeleton for ever', async () => {
70
+ api.preview.mockRejectedValue(new Error('500'))
71
+ const view = mount(TemplatePreviewFrame, { templateId: 't1', width: 600 })
72
+ await settle()
73
+ expect(skeleton(view.host)).toBeNull()
74
+ expect(fallback(view.host)?.textContent).toContain(
75
+ 'dms_mailing.common.preview_unavailable',
76
+ )
77
+ view.unmount()
78
+ })
79
+
80
+ it('keeps the box its size while it waits', () => {
81
+ const view = mount(TemplatePreviewFrame, {
82
+ templateId: 't1',
83
+ width: 600,
84
+ scale: 0.4,
85
+ lazy: true,
86
+ })
87
+ const box = view.host.firstElementChild as HTMLElement
88
+ expect(box.style.width).toBe('240px')
89
+ expect(box.style.height).toBe('192px')
90
+ view.unmount()
91
+ })
92
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-mailing",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -49,17 +49,17 @@
49
49
  "@antelopejs/interface-api": ">=0.0.15 <1.0.0",
50
50
  "@antelopejs/interface-api-util": ">=0.1.3 <1.0.0",
51
51
  "@antelopejs/interface-core": ">=0.2.4 <1.0.0",
52
- "@antelopejs/interface-data-api": ">=0.2.2 <1.0.0",
52
+ "@antelopejs/interface-data-api": ">=0.3.0 <1.0.0",
53
53
  "@antelopejs/interface-database": ">=0.1.10 <1.0.0",
54
54
  "@antelopejs/interface-database-decorators": ">=0.1.9 <1.0.0",
55
- "@antelopejs/interface-dms": ">=0.7.2 <1.0.0",
56
- "@antelopejs/interface-dms-mailing": ">=0.8.0 <0.9.0",
55
+ "@antelopejs/interface-dms": ">=0.8.0 <1.0.0",
56
+ "@antelopejs/interface-dms-mailing": ">=0.9.0 <0.10.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.16.0 <2",
62
+ "@antelopejs/core": ">=1.17.0 <2",
63
63
  "@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
64
64
  "@types/chai": "^4.3.20",
65
65
  "@types/mocha": "^10",
@@ -90,6 +90,7 @@
90
90
  "./skills"
91
91
  ]
92
92
  },
93
+ "dmsFrontend": "dist/frontend-module.js",
93
94
  "scripts": {
94
95
  "build": "rimraf dist && tsc",
95
96
  "dev": "ajs project dev -w -p playground",
@@ -25,7 +25,7 @@ https://dms.antelopejs.com/docs/modules/mailing (sending from code and the webho
25
25
  (`@antelopejs/nodemailer`, `config: { ethereal: true }` in dev).
26
26
  - Mailing 0.6 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
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
- `SendTemplate` / `RecordEmailEvent` callers do not change.
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).
30
30
  - Set the DMS `clientBaseUrl`: the webhook URL and the e-mail logo are built on it.
31
31
  - Per-workspace settings (sender, missing-variable guard, fallback locale, webhook secret, retention,
@@ -49,6 +49,8 @@ const result = await SendTemplate("order-confirmed", {
49
49
  - Only the **published** version is sent; drafts go out only as tests (`[TEST]` subject, out of the figures).
50
50
  - Variables are typed (`string`, `text`, `number`, `money`, `date`, `url`, `boolean`, `array`) and declared on
51
51
  the template; pass every required one.
52
+ - Don't pre-format amounts and dates per locale: pass `{ value: 987420, currency: "EUR" }` (minor units) or a
53
+ `Date`, or an ISO string at a `date` path; the send locale formats them. Plain strings print as given.
52
54
  - In-process events: `RecordEmailEvent(tenantId, { provider, messageId, type, details })`.
53
55
  HTTP: `POST <clientBaseUrl>/api/mailing/events/<provider>/<tenantId>` with header `x-mailing-webhook-secret`.
54
56
  - Automation (if `interface-dms-automation` is loaded): action `mailing.send-template`, trigger `mailing.email-event`.
@@ -56,12 +58,13 @@ const result = await SendTemplate("order-confirmed", {
56
58
  ## Ship templates as code
57
59
 
58
60
  ```typescript
59
- import { DeclareTemplate, DeclareTemplateCategory, ImportSends } from "@antelopejs/interface-dms-mailing";
61
+ import { DeclareSender, DeclareTemplate, DeclareTemplateCategory, ImportSends } from "@antelopejs/interface-dms-mailing";
60
62
 
61
63
  export async function declareMailingContent(tenantId: string) {
62
- await DeclareTemplateCategory({ tenantId, id: "subscriptions", label: "Subscriptions", icon: "i-ph-repeat" }); // before templates naming it
64
+ await DeclareSender({ tenantId, name: "Acme", email: "hello@acme.example" }); // a plain write: call it once, at provisioning
65
+ await DeclareTemplateCategory({ tenantId, id: "subscriptions", label: { en: "Subscriptions", fr: "Abonnements" }, icon: "i-ph-repeat" }); // before templates naming it
63
66
  const { outcome } = await DeclareTemplate({
64
- tenantId, slug: "subscription-renewed", name: "Subscription renewed", category: "subscriptions",
67
+ tenantId, slug: "subscription-renewed", name: { en: "Subscription renewed", fr: "Abonnement renouvelé" }, category: "subscriptions",
65
68
  content: { locales: { en: { subject: "Your {{plan.name}} plan is renewed", preheader: "", blocks: [] } } },
66
69
  variables: [{ path: "plan.name", type: "string", required: true }],
67
70
  testData: { plan: { name: "Pro" } },
@@ -82,6 +85,10 @@ export async function declareMailingContent(tenantId: string) {
82
85
  draft and still holds exactly the last declaration (fingerprint on `declaredHash`); otherwise `kept`.
83
86
  A dashboard-made template under that slug is never taken over. Deleted → recreated next boot; archive instead.
84
87
  - Categories: added once; a category a user relabelled/removed, or a default one, is `kept`.
88
+ - `name` / `label` are localized texts `{ en, fr, … }` (stored `@Localized`, `en` required as fallback), never `$`
89
+ i18n keys; each user reads, searches and sorts them in their language (`x-content-language`).
90
+ - `DeclareSender({ tenantId, email, name?, replyTo? })` writes the settings' sender (resolves `void`); call it once
91
+ from `Hook.TENANT_BEING_PROVISIONED`, not on every boot, since it replaces what a user set. Refusals use `invalid_sender`.
85
92
  - Refusals reject with `error.code`: `invalid_template`, `unknown_category`, `empty_subject`,
86
93
  `nothing_to_publish`, `invalid_category`; invalid tenant input rejects before writes. Storage errors can occur after publication.
87
94
  - `ImportSends({ tenantId, sends: [{ importId, templateSlug, to, sentAt, events }] })` writes past sends with their own
@@ -96,6 +103,12 @@ export async function declareMailingContent(tenantId: string) {
96
103
  - A missing locale falls back to the workspace's fallback locale (logged as `DE asked`).
97
104
  - The template slug is fixed at creation: code depends on it.
98
105
  - `mailing.send` permission gates every route that reaches recipients; writes publish on realtime topic `mailing:sends`.
106
+ - Overview and Sends are live pages (`live: { maxRate: 5, rememberKey: "mailing" }`) with the DMS `PageToolbar`
107
+ (provider pill, Direct / Pause, period) under their `header` static; the send log `liveHold`s its rows.
108
+ The period offers a comparison menu (`previous-period` default, `previous-year`, `none`); routes read
109
+ `compareFrom` / `compareTo`, and `comparison=none` drops every delta and comparison series. Every period
110
+ figure renders it: KPI deltas, the volume chart's dashed series, funnel steps (`previousCount`, `delta`),
111
+ the top lists' row `delta` and the Sends stat strip (`compared`); period blocks carry `realtimeTopic: "mailing:sends"`.
99
112
  - 0.4+ does not read a 0.3 database and moved the webhook path (tenant in the URL).
100
113
  - nodemailer `ethereal: true` creates its account online at startup: offline, the whole project fails to
101
114
  start (nodemailer's `construct()` throws, before Mailing runs). Use a local SMTP server (Mailpit) offline.
package/src/constants.ts CHANGED
@@ -8,6 +8,8 @@ export const SETTINGS_PATH = `${MODULE_BASE_PATH}/settings`;
8
8
  export const NEW_TEMPLATE_BUTTON_ID = "create";
9
9
  export const NEW_TEMPLATE_ACTION_ID = "mailing.new-template";
10
10
  export const FRONTEND_MODULE_NAME = "@antelopejs/dms-mailing-frontend-vue";
11
+ /** The header a provider webhook carries the secret its route checks in. */
12
+ export const WEBHOOK_SECRET_HEADER = "x-mailing-webhook-secret";
11
13
  export const EMAIL_TEMPLATE_NAME = "EmailMailingTemplate";
12
14
 
13
15
  export const MAILING_SEND_PERMISSION = "mailing.send";
@@ -231,7 +231,7 @@ export class SendsTableAPI extends DataController(
231
231
  declare requestedLocale: string;
232
232
 
233
233
  /**
234
- * Hidden by default: the side panel shows it under "Origin", and the room
234
+ * Hidden by default: the send drawer shows it under "Origin", and the room
235
235
  * goes to the recipient and the provider's error.
236
236
  */
237
237
  @Listable()