@antelopejs/dms-mailing 0.4.1 → 0.5.1

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 (86) hide show
  1. package/README.md +61 -339
  2. package/dist/data/templates-table.js +2 -10
  3. package/dist/data/templates-table.js.map +1 -1
  4. package/dist/db/models/index.d.ts +1 -0
  5. package/dist/db/models/index.js +1 -0
  6. package/dist/db/models/index.js.map +1 -1
  7. package/dist/db/models/send-events.model.d.ts +1 -0
  8. package/dist/db/models/sends.model.d.ts +1 -0
  9. package/dist/db/models/settings.model.d.ts +1 -0
  10. package/dist/db/models/suppressions.model.d.ts +1 -0
  11. package/dist/db/models/template-slugs.model.d.ts +27 -0
  12. package/dist/db/models/template-slugs.model.js +43 -0
  13. package/dist/db/models/template-slugs.model.js.map +1 -0
  14. package/dist/db/models/template-versions.model.d.ts +1 -0
  15. package/dist/db/models/templates.model.d.ts +1 -0
  16. package/dist/db/models/webhook-status.model.d.ts +1 -0
  17. package/dist/db/tables/index.d.ts +1 -0
  18. package/dist/db/tables/index.js +1 -0
  19. package/dist/db/tables/index.js.map +1 -1
  20. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  21. package/dist/db/tables/template-slugs.table.js +35 -0
  22. package/dist/db/tables/template-slugs.table.js.map +1 -0
  23. package/dist/routes/templates.js +1 -0
  24. package/dist/routes/templates.js.map +1 -1
  25. package/dist/services/templates.d.ts +2 -0
  26. package/dist/services/templates.js +26 -0
  27. package/dist/services/templates.js.map +1 -1
  28. package/frontend-vue/app/components/CategoriesInput.vue +18 -24
  29. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  30. package/frontend-vue/app/components/Editor.vue +3 -3
  31. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  32. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  33. package/frontend-vue/app/components/PageHeader.vue +8 -12
  34. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  35. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  36. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  37. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  38. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  39. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  40. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  41. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  42. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  43. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  44. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  45. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  46. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  47. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  48. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  49. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  50. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  51. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  52. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  53. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  54. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  55. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  56. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  57. package/frontend-vue/app/utils/gallery.ts +19 -7
  58. package/frontend-vue/app/utils/period.ts +0 -5
  59. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  60. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  61. package/frontend-vue/package.json +4 -3
  62. package/frontend-vue/pnpm-lock.yaml +6 -13
  63. package/frontend-vue/tests/categories-input.test.ts +43 -13
  64. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  65. package/frontend-vue/tests/editor-session.test.ts +4 -0
  66. package/frontend-vue/tests/gallery.test.ts +16 -0
  67. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  68. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  69. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  70. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  71. package/frontend-vue/tests/page-reads.test.ts +157 -0
  72. package/frontend-vue/tests/period.test.ts +0 -9
  73. package/frontend-vue/tests/retention-input.test.ts +30 -9
  74. package/frontend-vue/tests/send-modals.test.ts +8 -1
  75. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  76. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  77. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  78. package/package.json +31 -14
  79. package/skills/dms-mailing/SKILL.md +63 -0
  80. package/src/data/templates-table.ts +7 -27
  81. package/src/db/models/index.ts +1 -0
  82. package/src/db/models/template-slugs.model.ts +61 -0
  83. package/src/db/tables/index.ts +1 -0
  84. package/src/db/tables/template-slugs.table.ts +29 -0
  85. package/src/routes/templates.ts +2 -0
  86. package/src/services/templates.ts +49 -0
@@ -17,6 +17,7 @@ import {
17
17
  typeInto,
18
18
  type NuxtStubs,
19
19
  } from './helpers/nuxt-globals'
20
+ import { mailingReads, stubApiData } from './helpers/api-data'
20
21
 
21
22
  const api = {
22
23
  templateContent: vi.fn(),
@@ -25,7 +26,8 @@ const api = {
25
26
  realSend: vi.fn(),
26
27
  }
27
28
 
28
- vi.mock('../app/composables/useMailingApi', () => ({
29
+ vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
30
+ ...(await importOriginal<object>()),
29
31
  useMailingApi: () => api,
30
32
  }))
31
33
 
@@ -102,6 +104,11 @@ let stubs: NuxtStubs
102
104
 
103
105
  beforeEach(() => {
104
106
  stubs = stubNuxtGlobals('camille@acme.com')
107
+ stubApiData().respond(
108
+ mailingReads({ templateContent: api.templateContent }, ({ url }) => {
109
+ throw new Error(`unexpected read ${url}`)
110
+ }),
111
+ )
105
112
  api.preview.mockResolvedValue({
106
113
  html: '',
107
114
  subject: '20% off every chair',
@@ -0,0 +1,253 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The drag and drop of the categories, the canvas blocks and the palette:
4
+ * SortableJS moves the dragged element in the DOM, the list puts it back and
5
+ * changes its array, and Vue renders the new order. The drags are replayed as
6
+ * SortableJS reports them, DOM moves included.
7
+ */
8
+ import { readFileSync } from 'node:fs'
9
+ import { resolve } from 'node:path'
10
+ import {
11
+ createApp,
12
+ defineComponent,
13
+ h,
14
+ nextTick,
15
+ reactive,
16
+ ref,
17
+ type App,
18
+ } from 'vue'
19
+ import { afterEach, describe, expect, it, vi } from 'vitest'
20
+
21
+ type Options = Record<string, unknown>
22
+
23
+ interface FakeSortable {
24
+ el: HTMLElement
25
+ options: Options
26
+ }
27
+
28
+ const instances: FakeSortable[] = []
29
+
30
+ // SortableJS needs a pointer to drag with: what the test drives is the
31
+ // options the list hands it, called as SortableJS calls them.
32
+ vi.mock('@vueuse/integrations/useSortable', async () => {
33
+ const { onMounted, unref } = await import('vue')
34
+ return {
35
+ useSortable: (
36
+ target: { value: HTMLElement },
37
+ _list: unknown,
38
+ options: Options,
39
+ ) => {
40
+ const sortable = { el: undefined as unknown as HTMLElement, options }
41
+ onMounted(() => {
42
+ sortable.el = unref(target)
43
+ instances.push(sortable)
44
+ })
45
+ return {
46
+ option: (name: string, value: unknown) => {
47
+ sortable.options[name] = value
48
+ },
49
+ }
50
+ },
51
+ }
52
+ })
53
+
54
+ const { useSortableList } = await import('../app/composables/useSortableList')
55
+
56
+ interface Item {
57
+ id: string
58
+ }
59
+
60
+ let app: App | undefined
61
+
62
+ afterEach(() => {
63
+ app?.unmount()
64
+ app = undefined
65
+ instances.length = 0
66
+ })
67
+
68
+ const List = defineComponent({
69
+ props: {
70
+ items: { type: Array as () => Item[], required: true },
71
+ options: { type: Object, default: () => ({}) },
72
+ },
73
+ setup(props) {
74
+ const root = ref<HTMLElement | null>(null)
75
+ useSortableList(root, () => props.items, props.options)
76
+ return () =>
77
+ h(
78
+ 'div',
79
+ { ref: root },
80
+ props.items.map((item) =>
81
+ h('p', { key: item.id, 'data-id': item.id }, item.id),
82
+ ),
83
+ )
84
+ },
85
+ })
86
+
87
+ function mountLists(lists: { items: Item[]; options: Options }[]) {
88
+ const host = document.createElement('div')
89
+ document.body.append(host)
90
+ app = createApp({
91
+ render: () => lists.map((list) => h(List, list)),
92
+ })
93
+ app.mount(host)
94
+ return host
95
+ }
96
+
97
+ const ids = (items: Item[]) => items.map((item) => item.id)
98
+ const domIds = (sortable: FakeSortable) =>
99
+ [...sortable.el.children].map((child) => child.getAttribute('data-id'))
100
+ const call = (sortable: FakeSortable, name: string, event: unknown) =>
101
+ (sortable.options[name] as (event: unknown) => void)(event)
102
+
103
+ describe('useSortableList', () => {
104
+ it('reorders a list in place and renders the new order', async () => {
105
+ const items = reactive([{ id: 'a' }, { id: 'b' }, { id: 'c' }])
106
+ const changes: unknown[] = []
107
+ const ended = vi.fn()
108
+ mountLists([
109
+ {
110
+ items,
111
+ options: { onChange: (c: unknown) => changes.push(c), onEnd: ended },
112
+ },
113
+ ])
114
+ const [list] = instances
115
+ const item = list!.el.children[0] as HTMLElement
116
+ call(list!, 'onStart', { item, oldIndex: 0 })
117
+ // SortableJS has moved the element already when it reports the update.
118
+ list!.el.append(item)
119
+ call(list!, 'onUpdate', { item, oldIndex: 0, newIndex: 2 })
120
+ call(list!, 'onEnd', {})
121
+ await nextTick()
122
+ expect(ids(items)).toEqual(['b', 'c', 'a'])
123
+ expect(domIds(list!)).toEqual(['b', 'c', 'a'])
124
+ expect(changes).toEqual([
125
+ { moved: { element: { id: 'a' }, oldIndex: 0, newIndex: 2 } },
126
+ ])
127
+ expect(ended).toHaveBeenCalledOnce()
128
+ })
129
+
130
+ it('moves an item to another list of its group', async () => {
131
+ const source = reactive([{ id: 'a' }, { id: 'b' }])
132
+ const target = reactive([{ id: 'x' }])
133
+ const sourceChanges: unknown[] = []
134
+ const targetChanges: unknown[] = []
135
+ mountLists([
136
+ {
137
+ items: source,
138
+ options: {
139
+ group: 'g',
140
+ onChange: (c: unknown) => sourceChanges.push(c),
141
+ },
142
+ },
143
+ {
144
+ items: target,
145
+ options: {
146
+ group: 'g',
147
+ onChange: (c: unknown) => targetChanges.push(c),
148
+ },
149
+ },
150
+ ])
151
+ const [from, to] = instances
152
+ const item = from!.el.children[1] as HTMLElement
153
+ call(from!, 'onStart', { item, oldIndex: 1 })
154
+ to!.el.prepend(item)
155
+ // SortableJS reports the add on the target first, then the remove.
156
+ call(to!, 'onAdd', { item, newIndex: 0 })
157
+ call(from!, 'onRemove', { item, oldIndex: 1 })
158
+ call(from!, 'onEnd', {})
159
+ await nextTick()
160
+ expect(ids(source)).toEqual(['a'])
161
+ expect(ids(target)).toEqual(['b', 'x'])
162
+ expect(domIds(from!)).toEqual(['a'])
163
+ expect(domIds(to!)).toEqual(['b', 'x'])
164
+ expect(sourceChanges).toEqual([
165
+ { removed: { element: { id: 'b' }, oldIndex: 1 } },
166
+ ])
167
+ expect(targetChanges).toEqual([
168
+ { added: { element: { id: 'b' }, newIndex: 0 } },
169
+ ])
170
+ })
171
+
172
+ it('hands a copy to the other list and keeps its own item (pull: clone)', async () => {
173
+ const palette = reactive([{ id: 'tile' }])
174
+ const canvas = reactive<Item[]>([])
175
+ const group = { name: 'g', pull: 'clone', put: false }
176
+ mountLists([
177
+ {
178
+ items: palette,
179
+ options: {
180
+ group,
181
+ sort: false,
182
+ clone: (item: Item) => ({ id: `${item.id}-copy` }),
183
+ },
184
+ },
185
+ { items: canvas, options: { group: { name: 'g' } } },
186
+ ])
187
+ const [from, to] = instances
188
+ expect(from!.options.group).toBe(group)
189
+ expect(from!.options.sort).toBe(false)
190
+ const item = from!.el.children[0] as HTMLElement
191
+ call(from!, 'onStart', { item, oldIndex: 0 })
192
+ // SortableJS leaves a stand-in where the tile was and drags the tile.
193
+ const stand = item.cloneNode(true) as HTMLElement
194
+ from!.el.insertBefore(stand, item)
195
+ to!.el.append(item)
196
+ call(to!, 'onAdd', { item, newIndex: 0 })
197
+ call(from!, 'onRemove', {
198
+ item,
199
+ clone: stand,
200
+ oldIndex: 0,
201
+ pullMode: 'clone',
202
+ })
203
+ call(from!, 'onEnd', {})
204
+ await nextTick()
205
+ expect(ids(palette)).toEqual(['tile'])
206
+ expect(ids(canvas)).toEqual(['tile-copy'])
207
+ expect(domIds(from!)).toEqual(['tile'])
208
+ expect(domIds(to!)).toEqual(['tile-copy'])
209
+ })
210
+
211
+ it('passes the handle, the item selector and a disabled state it follows', async () => {
212
+ const disabled = ref(false)
213
+ mountLists([
214
+ {
215
+ items: reactive([{ id: 'a' }]),
216
+ options: {
217
+ handle: '[data-drag-handle]',
218
+ draggable: '>[data-drag-item]',
219
+ ghostClass: 'opacity-30',
220
+ disabled: () => disabled.value,
221
+ },
222
+ },
223
+ ])
224
+ const [list] = instances
225
+ expect(list!.options).toMatchObject({
226
+ handle: '[data-drag-handle]',
227
+ draggable: '>[data-drag-item]',
228
+ ghostClass: 'opacity-30',
229
+ disabled: false,
230
+ sort: true,
231
+ })
232
+ disabled.value = true
233
+ await nextTick()
234
+ expect(list!.options.disabled).toBe(true)
235
+ })
236
+
237
+ it('leaves SortableJS its defaults for what the list does not set', () => {
238
+ mountLists([{ items: reactive([{ id: 'a' }]), options: {} }])
239
+ const [list] = instances
240
+ // A key present with `undefined` would replace the default: an
241
+ // undefined `draggable` makes no child an item, and nothing drags.
242
+ for (const name of ['group', 'handle', 'draggable', 'ghostClass'])
243
+ expect(list!.options).not.toHaveProperty(name)
244
+ })
245
+
246
+ it('no longer ships vuedraggable, which bundled the Vue compiler', () => {
247
+ const manifest = JSON.parse(
248
+ readFileSync(resolve(__dirname, '../package.json'), 'utf8'),
249
+ ) as { dependencies: Record<string, string> }
250
+ expect(manifest.dependencies).not.toHaveProperty('vuedraggable')
251
+ expect(manifest.dependencies).toHaveProperty('sortablejs')
252
+ })
253
+ })
@@ -17,6 +17,7 @@ import {
17
17
  typeInto,
18
18
  type NuxtStubs,
19
19
  } from './helpers/nuxt-globals'
20
+ import { mailingReads, stubApiData } from './helpers/api-data'
20
21
 
21
22
  const api = {
22
23
  templateContent: vi.fn(),
@@ -29,12 +30,12 @@ const api = {
29
30
  listTemplates: vi.fn(),
30
31
  duplicate: vi.fn(),
31
32
  starters: vi.fn(),
32
- listCategories: vi.fn(),
33
33
  createTemplate: vi.fn(),
34
34
  saveTemplateDetails: vi.fn(),
35
35
  }
36
36
 
37
- vi.mock('../app/composables/useMailingApi', () => ({
37
+ vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
38
+ ...(await importOriginal<object>()),
38
39
  useMailingApi: () => api,
39
40
  }))
40
41
 
@@ -108,9 +109,27 @@ const globals = {
108
109
 
109
110
  let stubs: NuxtStubs
110
111
 
112
+ let starters: unknown[] = []
113
+
111
114
  beforeEach(() => {
112
115
  stubs = stubNuxtGlobals()
113
116
  menuGroups = []
117
+ starters = []
118
+ stubApiData().respond(
119
+ mailingReads(
120
+ {
121
+ templateContent: api.templateContent,
122
+ performance: api.performance,
123
+ listTemplates: api.listTemplates,
124
+ },
125
+ ({ url }) => {
126
+ if (url === '/api/mailing/templates/categories')
127
+ return { categories: [] }
128
+ if (url === '/api/mailing/templates/starters') return { starters }
129
+ throw new Error(`unexpected read ${url}`)
130
+ },
131
+ ),
132
+ )
114
133
  api.performance.mockResolvedValue({
115
134
  id: 't1',
116
135
  slug: 'order-confirmed',
@@ -403,18 +422,15 @@ describe('DuplicateModal', () => {
403
422
 
404
423
  describe('StarterGallery', () => {
405
424
  beforeEach(() => {
406
- api.starters.mockResolvedValue({
407
- starters: [
408
- {
409
- id: 'order-confirmed',
410
- name: 'Order confirmation',
411
- slug: 'order-confirmed',
412
- category: 'orders',
413
- locales: ['en'],
414
- },
415
- ],
416
- })
417
- api.listCategories.mockResolvedValue({ categories: [] })
425
+ starters = [
426
+ {
427
+ id: 'order-confirmed',
428
+ name: 'Order confirmation',
429
+ slug: 'order-confirmed',
430
+ category: 'orders',
431
+ locales: ['en'],
432
+ },
433
+ ]
418
434
  })
419
435
 
420
436
  it('creates a starter as a draft and opens it', async () => {
@@ -12,15 +12,15 @@ import {
12
12
  type NuxtStubs,
13
13
  } from './helpers/nuxt-globals'
14
14
 
15
- const api = {
16
- listCategories: vi.fn(),
17
- templatesOverview: vi.fn(),
18
- listTemplates: vi.fn(),
19
- }
15
+ import {
16
+ stubApiData,
17
+ stubRegionalFormat,
18
+ type ApiDataStub,
19
+ } from './helpers/api-data'
20
20
 
21
- vi.mock('../app/composables/useMailingApi', () => ({
22
- useMailingApi: () => api,
23
- }))
21
+ const CATEGORIES_URL = '/api/mailing/templates/categories'
22
+ const OVERVIEW_URL = '/api/mailing/templates/overview'
23
+ const LIST_URL = '/api/mailing/tables/templates/list'
24
24
 
25
25
  const RECENT = new Date(Date.now() - 3_600_000).toISOString()
26
26
 
@@ -114,22 +114,32 @@ const globals = {
114
114
  }
115
115
 
116
116
  let stubs: NuxtStubs
117
+ let api: ApiDataStub
117
118
  let routeQuery: Record<string, unknown> = {}
119
+ let templateTotal = 3
120
+
121
+ const readsOf = (url: string) => api.reads.filter((read) => read.url === url)
118
122
 
119
123
  beforeEach(() => {
120
124
  stubs = stubNuxtGlobals()
125
+ stubRegionalFormat()
121
126
  routeQuery = {}
127
+ templateTotal = 3
122
128
  vi.stubGlobal('useDmsRoute', () => ({ query: routeQuery }))
123
- api.listCategories.mockResolvedValue({
124
- categories: [
125
- { id: 'orders', label: 'Orders', icon: 'i-ph-package' },
126
- { id: 'billing', label: 'Billing', icon: 'i-ph-file-text' },
127
- ],
128
- })
129
- api.templatesOverview.mockResolvedValue({
130
- items: [stats('live', 6812), stats('missing', 12)],
129
+ api = stubApiData()
130
+ api.respond(({ url }) => {
131
+ if (url === CATEGORIES_URL)
132
+ return {
133
+ categories: [
134
+ { id: 'orders', label: 'Orders', icon: 'i-ph-package' },
135
+ { id: 'billing', label: 'Billing', icon: 'i-ph-file-text' },
136
+ ],
137
+ }
138
+ if (url === OVERVIEW_URL)
139
+ return { items: [stats('live', 6812), stats('missing', 12)] }
140
+ if (url === LIST_URL) return { results: [LIVE], total: templateTotal }
141
+ throw new Error(`unexpected read ${url}`)
131
142
  })
132
- api.listTemplates.mockResolvedValue({ results: [], total: 3 })
133
143
  })
134
144
 
135
145
  afterEach(() => {
@@ -206,21 +216,32 @@ describe('TemplateGallery', () => {
206
216
  cleared.unmount()
207
217
  })
208
218
 
219
+ it('reads the template count alone, not the rows', async () => {
220
+ const view = mount(
221
+ TemplateGallery,
222
+ { context: context({ items: [LIVE] }) },
223
+ globals,
224
+ )
225
+ await flush()
226
+ expect(readsOf(LIST_URL)).toEqual([
227
+ { url: LIST_URL, query: { limit: '1' } },
228
+ ])
229
+ view.unmount()
230
+ })
231
+
209
232
  it('reloads the figures when the table refreshes, not when it pages', async () => {
210
- api.listTemplates.mockClear()
211
- api.templatesOverview.mockClear()
212
233
  const ctx = reactive(context({ items: [LIVE] }))
213
234
  const view = mount(TemplateGallery, { context: ctx }, globals)
214
235
  await flush()
215
- expect(api.listTemplates).toHaveBeenCalledTimes(1)
236
+ expect(readsOf(LIST_URL)).toHaveLength(1)
216
237
  ctx.items = [LIVE]
217
238
  await flush()
218
- expect(api.listTemplates).toHaveBeenCalledTimes(2)
219
- expect(api.templatesOverview).toHaveBeenCalledTimes(2)
239
+ expect(readsOf(LIST_URL)).toHaveLength(2)
240
+ expect(readsOf(OVERVIEW_URL)).toHaveLength(2)
220
241
  ctx.pagination.pageIndex = 1
221
242
  ctx.items = [MISSING_DE]
222
243
  await flush()
223
- expect(api.listTemplates).toHaveBeenCalledTimes(2)
244
+ expect(readsOf(LIST_URL)).toHaveLength(2)
224
245
  view.unmount()
225
246
  })
226
247
 
@@ -262,7 +283,7 @@ describe('TemplateGallery', () => {
262
283
  })
263
284
 
264
285
  it('shows the starters on a first run', async () => {
265
- api.listTemplates.mockResolvedValue({ results: [], total: 0 })
286
+ templateTotal = 0
266
287
  const view = mount(
267
288
  TemplateGallery,
268
289
  { context: context({ items: [] }) },
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-mailing",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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
+ "keywords": [
6
+ "antelopejs",
7
+ "dms",
8
+ "dms-module",
9
+ "email",
10
+ "mailing",
11
+ "templates"
12
+ ],
13
+ "homepage": "https://dms.antelopejs.com/docs/modules/mailing",
14
+ "bugs": {
15
+ "url": "https://github.com/AntelopeJS/dms-mailing/issues"
16
+ },
4
17
  "license": "Apache-2.0",
5
18
  "repository": {
6
19
  "type": "git",
@@ -14,6 +27,7 @@
14
27
  "!dist/.tsbuildinfo",
15
28
  "!dist/test",
16
29
  "frontend-vue",
30
+ "skills",
17
31
  "src",
18
32
  "!src/test"
19
33
  ],
@@ -32,21 +46,21 @@
32
46
  "registry": "https://registry.npmjs.org/"
33
47
  },
34
48
  "dependencies": {
35
- "@antelopejs/interface-api": ">=0.0.13 <1.0.0",
36
- "@antelopejs/interface-api-util": ">=0.1.2 <1.0.0",
37
- "@antelopejs/interface-core": ">=0.1.1 <1.0.0",
38
- "@antelopejs/interface-data-api": ">=0.2.1 <1.0.0",
39
- "@antelopejs/interface-database": ">=0.1.9 <1.0.0",
40
- "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
41
- "@antelopejs/interface-dms": ">=0.6.0 <1.0.0",
42
- "@antelopejs/interface-dms-mailing": ">=0.7.0 <0.8.0",
43
- "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
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.2.2 <1.0.0",
53
+ "@antelopejs/interface-database": ">=0.1.10 <1.0.0",
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.7.1 <0.8.0",
57
+ "@antelopejs/interface-email": ">=0.0.6 <1.0.0",
44
58
  "node-cron": "^4.2.1",
45
59
  "zod": "~3.24.2"
46
60
  },
47
61
  "devDependencies": {
48
- "@antelopejs/core": ">=1.13.4 <2",
49
- "@antelopejs/tooling-configs": ">=0.0.7 <1.0.0",
62
+ "@antelopejs/core": ">=1.16.0 <2",
63
+ "@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
50
64
  "@types/chai": "^4.3.20",
51
65
  "@types/mocha": "^10",
52
66
  "@types/node": "^24.0.0",
@@ -64,14 +78,17 @@
64
78
  "typescript": "^6.0.3"
65
79
  },
66
80
  "optionalDependencies": {
67
- "@antelopejs/interface-dms-automation": ">=0.1.0 <1.0.0"
81
+ "@antelopejs/interface-dms-automation": ">=0.2.1 <1.0.0"
68
82
  },
69
83
  "antelopeJs": {
70
84
  "baseUrl": "dist",
71
85
  "implements": [
72
86
  "@antelopejs/interface-dms-mailing"
73
87
  ],
74
- "test": "src/test/antelope.test.ts"
88
+ "test": "src/test/antelope.test.ts",
89
+ "skills": [
90
+ "./skills"
91
+ ]
75
92
  },
76
93
  "scripts": {
77
94
  "build": "rimraf dist && tsc",
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: dms-mailing
3
+ description: Sends and manages transactional e-mail with the @antelopejs/dms-mailing module of the AntelopeJS DMS. Use when installing or configuring dms-mailing, sending a template from code with SendTemplate, reporting provider events with RecordEmailEvent or the webhook, designing templates (blocks, variables, locales, versions), or debugging suppressed, refused, missing-variable or fallback-locale sends.
4
+ icon: i-lucide-mail
5
+ category: messaging
6
+ tags: [email, templates, transactional, webhook, dms-module]
7
+ ---
8
+
9
+ # dms-mailing
10
+
11
+ Templates edited in the dashboard, sent by slug from code, logged with provider events.
12
+ Pages under `/modules/mailing` (owner-only): Overview, Templates, editor (`templates/:id`), Sends,
13
+ Settings, Suppression list. User docs: `docs/` of this package, published at
14
+ https://dms.antelopejs.com/docs/modules/mailing (sending from code and the webhook format: `extending`).
15
+
16
+ ## When to reach for it
17
+
18
+ - Code must send an e-mail a non-developer can reword, translate or restyle: create a template, call it by slug.
19
+ Hard-coding HTML in a route is the path this module replaces.
20
+ - Delivery must be traceable (bounces, opens, clicks) or addresses must stop receiving after a hard bounce.
21
+
22
+ ## Setup
23
+
24
+ - `ajs project modules add @antelopejs/dms-mailing`, plus a module implementing `@antelopejs/interface-email`
25
+ (`@antelopejs/nodemailer`, `config: { ethereal: true }` in dev).
26
+ - Mailing 0.5 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
27
+ 0.4 is the DMS 0.8 line. `@antelopejs/interface-dms-mailing` stays 0.7, so callers do not change.
28
+ - Module option: `useHtmlRender` (default `true`, DMS html-render chain; `false` = built-in standalone HTML).
29
+ - Set the DMS `clientBaseUrl`: the webhook URL and the e-mail logo are built on it.
30
+ - Per-workspace settings (sender, missing-variable guard, fallback locale, webhook secret, retention,
31
+ categories) live on the Settings page and `GET`/`PUT /api/mailing/settings` (changed fields only).
32
+
33
+ ## Send from code
34
+
35
+ ```typescript
36
+ import { SendTemplate, RecordEmailEvent } from "@antelopejs/interface-dms-mailing";
37
+
38
+ const result = await SendTemplate("order-confirmed", {
39
+ tenantId,
40
+ to: [{ email: "ana@example.com", name: "Ana" }], // one address or an array
41
+ locale: "fr",
42
+ variables: { customer: { firstName: "Ana" }, order: { number: "#1042" } },
43
+ source: "checkout", // shows as "Called from" in the template's performance
44
+ });
45
+ // result.status: queued | sent | ... | failed | suppressed; result.results[] per recipient
46
+ ```
47
+
48
+ - Only the **published** version is sent; drafts go out only as tests (`[TEST]` subject, out of the figures).
49
+ - Variables are typed (`string`, `text`, `number`, `money`, `date`, `url`, `boolean`, `array`) and declared on
50
+ the template; pass every required one.
51
+ - In-process events: `RecordEmailEvent(tenantId, { provider, messageId, type, details })`.
52
+ HTTP: `POST <clientBaseUrl>/api/mailing/events/<provider>/<tenantId>` with header `x-mailing-webhook-secret`.
53
+ - Automation (if `interface-dms-automation` is loaded): action `mailing.send-template`, trigger `mailing.email-event`.
54
+
55
+ ## Gotchas
56
+
57
+ - `status: "suppressed"` = address on the suppression list (hard bounce, spam, unsubscribe, manual). Never retry.
58
+ - `marketing` templates whose footer shows unsubscribe/preferences links require `unsubscribeUrl` /
59
+ `preferencesUrl` in `variables`; a link without URL is dropped.
60
+ - A missing locale falls back to the workspace's fallback locale (logged as `DE asked`).
61
+ - The template slug is fixed at creation: code depends on it.
62
+ - `mailing.send` permission gates every route that reaches recipients; writes publish on realtime topic `mailing:sends`.
63
+ - 0.4+ does not read a 0.3 database and moved the webhook path (tenant in the URL).
@@ -55,11 +55,13 @@ import { findCategory, getSettings } from "../services/settings";
55
55
  import { findStarter, starterFields } from "../services/starters";
56
56
  import { refreshTemplateAttention } from "../services/template-attention";
57
57
  import {
58
+ assertSlugClaimed,
58
59
  blankContent,
59
60
  blankFields,
60
61
  contentFieldsOf,
61
62
  displayName,
62
63
  initializeTemplate,
64
+ releaseTemplateSlugs,
63
65
  } from "../services/templates";
64
66
  import type { TemplateCategory } from "../types";
65
67
 
@@ -142,29 +144,6 @@ function assertKnownCategory(
142
144
  throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
143
145
  }
144
146
 
145
- const creationTime = (template: MailingTemplate) =>
146
- new Date(template.createdAt).getTime();
147
-
148
- /**
149
- * Nothing in the database keeps slugs unique, so two creations racing past
150
- * the free-slug check both land: the earliest one stays, a later one is
151
- * removed again and refused like any taken slug.
152
- */
153
- async function assertFirstOfSlug(
154
- model: TemplateModel,
155
- slug: string,
156
- templateId: string,
157
- ): Promise<void> {
158
- const [first] = (await model.getBy("slug", slug)).sort(
159
- (left, right) =>
160
- creationTime(left) - creationTime(right) ||
161
- left._id.localeCompare(right._id),
162
- );
163
- if (!first || first._id === templateId) return;
164
- await model.delete(templateId);
165
- throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
166
- }
167
-
168
147
  /** The creation's seed, and the body to store: slug and name trimmed. */
169
148
  function parseCreation(body: Buffer | string) {
170
149
  const raw = parseBody(body);
@@ -209,7 +188,7 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
209
188
  ...rest,
210
189
  )) as string[];
211
190
  const templateId = ids[0] as string;
212
- await assertFirstOfSlug(model, seed.slug, templateId);
191
+ await assertSlugClaimed(tenantId, seed.slug, templateId);
213
192
  const user = await authenticateTenantRequest(ctx);
214
193
  await initializeTemplate(tenantId, templateId, displayName(user), fields);
215
194
  return ids;
@@ -270,14 +249,15 @@ export function afterDelete(tenantId: string, ids: string[]): AfterWrite {
270
249
  await Promise.all(
271
250
  ids.map((id) => versions.getAll(id, "templateId").delete().run()),
272
251
  );
252
+ await releaseTemplateSlugs(tenantId, ids);
273
253
  await refreshTemplateAttention(tenantId);
274
254
  };
275
255
  }
276
256
 
277
257
  /**
278
- * Sends resolve templates by slug and nothing enforces its uniqueness but the
279
- * create checks, so a slug never changes once set; a category must be one the
280
- * settings declare, and a name holds more than spaces.
258
+ * Sends resolve templates by slug and only the create paths claim it, so a
259
+ * slug never changes once set; a category must be one the settings declare,
260
+ * and a name holds more than spaces.
281
261
  */
282
262
  export async function assertEditable(
283
263
  tenantId: string,