@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.
- package/README.md +61 -339
- package/dist/data/templates-table.js +2 -10
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +1 -0
- package/dist/db/models/sends.model.d.ts +1 -0
- package/dist/db/models/settings.model.d.ts +1 -0
- package/dist/db/models/suppressions.model.d.ts +1 -0
- package/dist/db/models/template-slugs.model.d.ts +27 -0
- package/dist/db/models/template-slugs.model.js +43 -0
- package/dist/db/models/template-slugs.model.js.map +1 -0
- package/dist/db/models/template-versions.model.d.ts +1 -0
- package/dist/db/models/templates.model.d.ts +1 -0
- package/dist/db/models/webhook-status.model.d.ts +1 -0
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/template-slugs.table.d.ts +7 -0
- package/dist/db/tables/template-slugs.table.js +35 -0
- package/dist/db/tables/template-slugs.table.js.map +1 -0
- package/dist/routes/templates.js +1 -0
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/templates.d.ts +2 -0
- package/dist/services/templates.js +26 -0
- package/dist/services/templates.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +18 -24
- package/frontend-vue/app/components/DuplicateModal.vue +9 -3
- package/frontend-vue/app/components/Editor.vue +3 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
- package/frontend-vue/app/components/OverviewGate.vue +20 -29
- package/frontend-vue/app/components/PageHeader.vue +8 -12
- package/frontend-vue/app/components/RealSendModal.vue +3 -1
- package/frontend-vue/app/components/RenderingModal.vue +5 -7
- package/frontend-vue/app/components/RetentionInput.vue +13 -23
- package/frontend-vue/app/components/SendDrawer.vue +5 -2
- package/frontend-vue/app/components/StarterGallery.vue +11 -9
- package/frontend-vue/app/components/TemplateCard.vue +19 -4
- package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
- package/frontend-vue/app/components/TemplateGallery.vue +25 -20
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
- package/frontend-vue/app/components/TestSendModal.vue +3 -1
- package/frontend-vue/app/components/VersionHistory.vue +3 -3
- package/frontend-vue/app/components/WebhookStatus.vue +20 -17
- package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
- package/frontend-vue/app/composables/useEditorActions.ts +3 -1
- package/frontend-vue/app/composables/useEditorSession.ts +6 -4
- package/frontend-vue/app/composables/useMailingApi.ts +5 -58
- package/frontend-vue/app/composables/useMailingReads.ts +229 -0
- package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
- package/frontend-vue/app/composables/useSortableList.ts +159 -0
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
- package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
- package/frontend-vue/app/utils/gallery.ts +19 -7
- package/frontend-vue/app/utils/period.ts +0 -5
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +6 -13
- package/frontend-vue/tests/categories-input.test.ts +43 -13
- package/frontend-vue/tests/editor-block-list.test.ts +46 -6
- package/frontend-vue/tests/editor-session.test.ts +4 -0
- package/frontend-vue/tests/gallery.test.ts +16 -0
- package/frontend-vue/tests/helpers/api-data.ts +173 -0
- package/frontend-vue/tests/new-template-modal.test.ts +17 -5
- package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
- package/frontend-vue/tests/overview-gate.test.ts +59 -21
- package/frontend-vue/tests/page-reads.test.ts +157 -0
- package/frontend-vue/tests/period.test.ts +0 -9
- package/frontend-vue/tests/retention-input.test.ts +30 -9
- package/frontend-vue/tests/send-modals.test.ts +8 -1
- package/frontend-vue/tests/sortable-list.test.ts +253 -0
- package/frontend-vue/tests/template-drawer.test.ts +30 -14
- package/frontend-vue/tests/template-gallery.test.ts +45 -24
- package/package.json +31 -14
- package/skills/dms-mailing/SKILL.md +63 -0
- package/src/data/templates-table.ts +7 -27
- package/src/db/models/index.ts +1 -0
- package/src/db/models/template-slugs.model.ts +61 -0
- package/src/db/tables/index.ts +1 -0
- package/src/db/tables/template-slugs.table.ts +29 -0
- package/src/routes/templates.ts +2 -0
- 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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
15
|
+
import {
|
|
16
|
+
stubApiData,
|
|
17
|
+
stubRegionalFormat,
|
|
18
|
+
type ApiDataStub,
|
|
19
|
+
} from './helpers/api-data'
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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(
|
|
236
|
+
expect(readsOf(LIST_URL)).toHaveLength(1)
|
|
216
237
|
ctx.items = [LIVE]
|
|
217
238
|
await flush()
|
|
218
|
-
expect(
|
|
219
|
-
expect(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
36
|
-
"@antelopejs/interface-api-util": ">=0.1.
|
|
37
|
-
"@antelopejs/interface-core": ">=0.
|
|
38
|
-
"@antelopejs/interface-data-api": ">=0.2.
|
|
39
|
-
"@antelopejs/interface-database": ">=0.1.
|
|
40
|
-
"@antelopejs/interface-database-decorators": ">=0.1.
|
|
41
|
-
"@antelopejs/interface-dms": ">=0.
|
|
42
|
-
"@antelopejs/interface-dms-mailing": ">=0.7.
|
|
43
|
-
"@antelopejs/interface-email": ">=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.
|
|
49
|
-
"@antelopejs/tooling-configs": ">=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
|
|
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
|
|
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
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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,
|