@antelopejs/dms-mailing 0.5.0 → 0.6.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.
- 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 +2 -0
- package/dist/db/models/send-events.model.js +11 -0
- package/dist/db/models/send-events.model.js.map +1 -1
- package/dist/db/models/sends.model.d.ts +3 -0
- package/dist/db/models/sends.model.js +13 -0
- package/dist/db/models/sends.model.js.map +1 -1
- 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 +4 -1
- package/dist/db/models/template-versions.model.js +35 -2
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +2 -0
- package/dist/db/models/templates.model.js +22 -0
- package/dist/db/models/templates.model.js.map +1 -1
- 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/settings.table.d.ts +1 -0
- package/dist/db/tables/settings.table.js +4 -0
- package/dist/db/tables/settings.table.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/db/tables/templates.table.d.ts +5 -0
- package/dist/db/tables/templates.table.js +27 -0
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/hooks/database-ready.d.ts +7 -0
- package/dist/hooks/database-ready.js +26 -0
- package/dist/hooks/database-ready.js.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +3 -0
- package/dist/implementations/dms-mailing/index.js +7 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/templates.js +1 -0
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/declaration-error.d.ts +6 -0
- package/dist/services/declaration-error.js +26 -0
- package/dist/services/declaration-error.js.map +1 -0
- package/dist/services/declarations.d.ts +3 -0
- package/dist/services/declarations.js +244 -0
- package/dist/services/declarations.js.map +1 -0
- package/dist/services/events.d.ts +4 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/fingerprint.d.ts +1 -0
- package/dist/services/fingerprint.js +20 -0
- package/dist/services/fingerprint.js.map +1 -0
- package/dist/services/send-import.d.ts +2 -0
- package/dist/services/send-import.js +136 -0
- package/dist/services/send-import.js.map +1 -0
- package/dist/services/templates.d.ts +3 -0
- package/dist/services/templates.js +75 -48
- package/dist/services/templates.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +299 -0
- package/dist/validation/declarations.schema.js +55 -0
- package/dist/validation/declarations.schema.js.map +1 -0
- package/frontend-vue/app/components/CategoriesInput.vue +12 -11
- package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
- package/frontend-vue/app/composables/useSortableList.ts +159 -0
- package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
- 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/editor-block-list.test.ts +46 -6
- package/frontend-vue/tests/sortable-list.test.ts +253 -0
- package/package.json +31 -14
- package/skills/dms-mailing/SKILL.md +101 -0
- package/src/data/templates-table.ts +7 -27
- package/src/db/models/index.ts +1 -0
- package/src/db/models/send-events.model.ts +13 -0
- package/src/db/models/sends.model.ts +22 -0
- package/src/db/models/template-slugs.model.ts +61 -0
- package/src/db/models/template-versions.model.ts +48 -2
- package/src/db/models/templates.model.ts +31 -0
- package/src/db/tables/index.ts +1 -0
- package/src/db/tables/settings.table.ts +8 -0
- package/src/db/tables/template-slugs.table.ts +29 -0
- package/src/db/tables/templates.table.ts +38 -0
- package/src/hooks/database-ready.ts +47 -0
- package/src/hooks/index.ts +1 -0
- package/src/implementations/dms-mailing/index.ts +17 -0
- package/src/index.ts +6 -1
- package/src/routes/templates.ts +2 -0
- package/src/services/declaration-error.ts +39 -0
- package/src/services/declarations.ts +450 -0
- package/src/services/events.ts +8 -2
- package/src/services/fingerprint.ts +22 -0
- package/src/services/send-import.ts +235 -0
- package/src/services/templates.ts +113 -72
- package/src/validation/declarations.schema.ts +77 -0
- package/frontend-vue/pnpm-workspace.yaml +0 -2
|
@@ -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
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-mailing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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.7.
|
|
42
|
-
"@antelopejs/interface-dms-mailing": ">=0.
|
|
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.8.0 <0.9.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,101 @@
|
|
|
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, shipping templates or categories as code with DeclareTemplate / DeclareTemplateCategory, importing past sends with ImportSends, 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.6 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
|
|
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.
|
|
29
|
+
- Module option: `useHtmlRender` (default `true`, DMS html-render chain; `false` = built-in standalone HTML).
|
|
30
|
+
- Set the DMS `clientBaseUrl`: the webhook URL and the e-mail logo are built on it.
|
|
31
|
+
- Per-workspace settings (sender, missing-variable guard, fallback locale, webhook secret, retention,
|
|
32
|
+
categories) live on the Settings page and `GET`/`PUT /api/mailing/settings` (changed fields only).
|
|
33
|
+
|
|
34
|
+
## Send from code
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { SendTemplate, RecordEmailEvent } from "@antelopejs/interface-dms-mailing";
|
|
38
|
+
|
|
39
|
+
const result = await SendTemplate("order-confirmed", {
|
|
40
|
+
tenantId,
|
|
41
|
+
to: [{ email: "ana@example.com", name: "Ana" }], // one address or an array
|
|
42
|
+
locale: "fr",
|
|
43
|
+
variables: { customer: { firstName: "Ana" }, order: { number: "#1042" } },
|
|
44
|
+
source: "checkout", // shows as "Called from" in the template's performance
|
|
45
|
+
});
|
|
46
|
+
// result.status: queued | sent | ... | failed | suppressed; result.results[] per recipient
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- Only the **published** version is sent; drafts go out only as tests (`[TEST]` subject, out of the figures).
|
|
50
|
+
- Variables are typed (`string`, `text`, `number`, `money`, `date`, `url`, `boolean`, `array`) and declared on
|
|
51
|
+
the template; pass every required one.
|
|
52
|
+
- In-process events: `RecordEmailEvent(tenantId, { provider, messageId, type, details })`.
|
|
53
|
+
HTTP: `POST <clientBaseUrl>/api/mailing/events/<provider>/<tenantId>` with header `x-mailing-webhook-secret`.
|
|
54
|
+
- Automation (if `interface-dms-automation` is loaded): action `mailing.send-template`, trigger `mailing.email-event`.
|
|
55
|
+
|
|
56
|
+
## Ship templates as code
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { DeclareTemplate, DeclareTemplateCategory, ImportSends } from "@antelopejs/interface-dms-mailing";
|
|
60
|
+
|
|
61
|
+
export async function declareMailingContent(tenantId: string) {
|
|
62
|
+
await DeclareTemplateCategory({ tenantId, id: "subscriptions", label: "Subscriptions", icon: "i-ph-repeat" }); // before templates naming it
|
|
63
|
+
const { outcome } = await DeclareTemplate({
|
|
64
|
+
tenantId, slug: "subscription-renewed", name: "Subscription renewed", category: "subscriptions",
|
|
65
|
+
content: { locales: { en: { subject: "Your {{plan.name}} plan is renewed", preheader: "", blocks: [] } } },
|
|
66
|
+
variables: [{ path: "plan.name", type: "string", required: true }],
|
|
67
|
+
testData: { plan: { name: "Pro" } },
|
|
68
|
+
}); // created | updated | unchanged | kept
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- Require explicit non-blank `tenantId` for declarations and imports, including an explicitly selected default tenant.
|
|
73
|
+
The application must authorize the target and establish provisioning readiness.
|
|
74
|
+
- For SaaS initialization, follow the consumer reference's **Initialize new SaaS workspaces** section:
|
|
75
|
+
register the durable `created` consumer synchronously in `construct()`, without awaiting declarations,
|
|
76
|
+
before startup reconciliation; deduplicate `operationId`, bind resources
|
|
77
|
+
durably, then return a receipt. Mailing registers no consumer. `start()` is process-global, not per tenant;
|
|
78
|
+
`TENANT_BEING_PROVISIONED` is prepayment and is not the durable `created` transition.
|
|
79
|
+
Deployment updates/backfills require an authorized inventory and `IsWorkspaceProvisioningCommitted`, failing closed.
|
|
80
|
+
- Idempotent on the tenant's slug, published at once (v1, version rows, slug claim, attention refresh: a normal template).
|
|
81
|
+
- Rule: same declaration → `unchanged`; changed declaration → `updated` only if the template is live, has no
|
|
82
|
+
draft and still holds exactly the last declaration (fingerprint on `declaredHash`); otherwise `kept`.
|
|
83
|
+
A dashboard-made template under that slug is never taken over. Deleted → recreated next boot; archive instead.
|
|
84
|
+
- Categories: added once; a category a user relabelled/removed, or a default one, is `kept`.
|
|
85
|
+
- Refusals reject with `error.code`: `invalid_template`, `unknown_category`, `empty_subject`,
|
|
86
|
+
`nothing_to_publish`, `invalid_category`; invalid tenant input rejects before writes. Storage errors can occur after publication.
|
|
87
|
+
- `ImportSends({ tenantId, sends: [{ importId, templateSlug, to, sentAt, events }] })` writes past sends with their own
|
|
88
|
+
dates (demo data, migration): no delivery, no suppression/automation/realtime; skipped when `importId` was
|
|
89
|
+
already imported. Retention still applies (90 days default).
|
|
90
|
+
|
|
91
|
+
## Gotchas
|
|
92
|
+
|
|
93
|
+
- `status: "suppressed"` = address on the suppression list (hard bounce, spam, unsubscribe, manual). Never retry.
|
|
94
|
+
- `marketing` templates whose footer shows unsubscribe/preferences links require `unsubscribeUrl` /
|
|
95
|
+
`preferencesUrl` in `variables`; a link without URL is dropped.
|
|
96
|
+
- A missing locale falls back to the workspace's fallback locale (logged as `DE asked`).
|
|
97
|
+
- The template slug is fixed at creation: code depends on it.
|
|
98
|
+
- `mailing.send` permission gates every route that reaches recipients; writes publish on realtime topic `mailing:sends`.
|
|
99
|
+
- 0.4+ does not read a 0.3 database and moved the webhook path (tenant in the URL).
|
|
100
|
+
- nodemailer `ethereal: true` creates its account online at startup: offline, the whole project fails to
|
|
101
|
+
start (nodemailer's `construct()` throws, before Mailing runs). Use a local SMTP server (Mailpit) offline.
|
|
@@ -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,
|
package/src/db/models/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./send-events.model";
|
|
|
2
2
|
export * from "./sends.model";
|
|
3
3
|
export * from "./settings.model";
|
|
4
4
|
export * from "./suppressions.model";
|
|
5
|
+
export * from "./template-slugs.model";
|
|
5
6
|
export * from "./template-versions.model";
|
|
6
7
|
export * from "./templates.model";
|
|
7
8
|
export * from "./webhook-status.model";
|
|
@@ -23,6 +23,19 @@ export class SendEventModel extends BasicDataModel(
|
|
|
23
23
|
return false;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Writes an imported event under its own id, unless an interrupted run of
|
|
28
|
+
* the same import already did.
|
|
29
|
+
*/
|
|
30
|
+
async insertImported(event: MailingSendEvent): Promise<void> {
|
|
31
|
+
if (await this.get(event._id)) return;
|
|
32
|
+
try {
|
|
33
|
+
await this.insert(event);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (!(await this.get(event._id))) throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/** Keyset pagination remains stable when overlapping sweeps delete earlier rows. */
|
|
27
40
|
listAfter(after: string, size: number): Promise<MailingSendEvent[]> {
|
|
28
41
|
return this.table
|
|
@@ -15,6 +15,12 @@ type NewSend = Omit<
|
|
|
15
15
|
"_id" | "createdAt" | "revision" | "isRetiring" | "lastActivityAt"
|
|
16
16
|
>;
|
|
17
17
|
|
|
18
|
+
/** A send written by an import: its own id and date, as it happened. */
|
|
19
|
+
export type ImportedSendRow = Omit<
|
|
20
|
+
MailingSend,
|
|
21
|
+
"revision" | "isRetiring" | "lastActivityAt"
|
|
22
|
+
>;
|
|
23
|
+
|
|
18
24
|
const MAX_MUTATION_ATTEMPTS = 5;
|
|
19
25
|
|
|
20
26
|
/** A send as the metrics read it: everything but the rendered variables. */
|
|
@@ -82,6 +88,22 @@ export class SendModel extends BasicDataModel(
|
|
|
82
88
|
.run();
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Writes an imported send under its own id; false when that id is already
|
|
93
|
+
* taken, by an earlier run of the same import. Probed by state like the
|
|
94
|
+
* slug claims, since the database exposes no typed conflict error.
|
|
95
|
+
*/
|
|
96
|
+
async insertImported(send: ImportedSendRow): Promise<boolean> {
|
|
97
|
+
if (await this.get(send._id)) return false;
|
|
98
|
+
try {
|
|
99
|
+
await this.table.insert({ ...send, revision: randomUUID() }).run();
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (await this.get(send._id)) return false;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
/** Reads the stored row, including the revision the mutation contract compares. */
|
|
86
108
|
getSnapshot(sendId: string): Promise<MailingSend | undefined> {
|
|
87
109
|
return this.table.get(sendId).run();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { BasicDataModel } from "@antelopejs/interface-database-decorators";
|
|
3
|
+
import {
|
|
4
|
+
MAILING_TEMPLATE_SLUGS_TABLE_NAME,
|
|
5
|
+
MailingTemplateSlug,
|
|
6
|
+
} from "../tables";
|
|
7
|
+
|
|
8
|
+
const ID_LENGTH = 32;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The id of a slug's claim, the same whoever creates the template. The tenant
|
|
12
|
+
* is part of it: a driver may keep every tenant's rows in one collection,
|
|
13
|
+
* where a key made of the slug alone would collide across tenants.
|
|
14
|
+
*/
|
|
15
|
+
export function templateSlugIdOf(tenantId: string, slug: string): string {
|
|
16
|
+
return createHash("sha256")
|
|
17
|
+
.update(`${tenantId}\n${slug}`)
|
|
18
|
+
.digest("hex")
|
|
19
|
+
.slice(0, ID_LENGTH);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class TemplateSlugModel extends BasicDataModel(
|
|
23
|
+
MailingTemplateSlug,
|
|
24
|
+
MAILING_TEMPLATE_SLUGS_TABLE_NAME,
|
|
25
|
+
) {
|
|
26
|
+
/**
|
|
27
|
+
* Inserts the claim; false when the key is taken. Probed by state rather
|
|
28
|
+
* than by error, since the database interface exposes no typed conflict
|
|
29
|
+
* error: a claim present after a failed insert is the lost race.
|
|
30
|
+
*/
|
|
31
|
+
async insertClaim(
|
|
32
|
+
id: string,
|
|
33
|
+
slug: string,
|
|
34
|
+
templateId: string,
|
|
35
|
+
): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
await this.insert({ _id: id, slug, templateId });
|
|
38
|
+
return true;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (await this.get(id)) return false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Drops the claim if `templateId` still holds it, never a newer one. */
|
|
46
|
+
async dropClaimOf(id: string, templateId: string): Promise<void> {
|
|
47
|
+
await this.table
|
|
48
|
+
.atomicMutation(id, {
|
|
49
|
+
type: "deleteIfEqual",
|
|
50
|
+
field: "templateId",
|
|
51
|
+
expectedValue: templateId,
|
|
52
|
+
})
|
|
53
|
+
.run();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Frees the slugs the templates held. */
|
|
57
|
+
async releaseTemplates(templateIds: string[]): Promise<void> {
|
|
58
|
+
if (!templateIds.length) return;
|
|
59
|
+
await this.table.getAll(templateIds, "templateId").delete().run();
|
|
60
|
+
}
|
|
61
|
+
}
|