@antelopejs/dms-mailing 0.9.0 → 0.9.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/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/hooks/tenant-export.d.ts +1 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -0
- package/dist/implementations/dms-mailing/index.js +3 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +3 -2
- package/dist/routes/settings.js +8 -4
- package/dist/routes/settings.js.map +1 -1
- package/dist/services/send-import.js +4 -4
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/settings.d.ts +2 -1
- package/dist/services/settings.js +19 -1
- package/dist/services/settings.js.map +1 -1
- package/dist/services/webhook-secret-declaration.d.ts +4 -0
- package/dist/services/webhook-secret-declaration.js +25 -0
- package/dist/services/webhook-secret-declaration.js.map +1 -0
- package/dist/services/webhook-status.js +7 -2
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/settings.d.ts +3 -0
- package/dist/types/settings.js +6 -1
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +13 -0
- package/dist/validation/declarations.schema.js +6 -1
- package/dist/validation/declarations.schema.js.map +1 -1
- package/frontend-vue/app/components/editor/EditorCanvas.vue +8 -4
- package/frontend-vue/app/composables/useSortableList.ts +2 -2
- package/frontend-vue/package.json +37 -36
- package/frontend-vue/pnpm-lock.yaml +25 -5
- package/frontend-vue/tests/editor-canvas.test.ts +142 -0
- package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
- package/frontend-vue/tsconfig.typecheck.json +12 -0
- package/frontend-vue/vitest.config.ts +16 -0
- package/package.json +4 -4
- package/skills/dms-mailing/SKILL.md +2 -0
- package/src/db/tables/settings.table.ts +4 -0
- package/src/hooks/tenant-export.ts +4 -1
- package/src/implementations/dms-mailing/index.ts +1 -0
- package/src/pages/sends.ts +3 -2
- package/src/routes/settings.ts +19 -7
- package/src/services/send-import.ts +10 -8
- package/src/services/settings.ts +40 -2
- package/src/services/webhook-secret-declaration.ts +47 -0
- package/src/services/webhook-status.ts +9 -5
- package/src/types/settings.ts +14 -0
- package/src/validation/declarations.schema.ts +7 -0
|
@@ -1,38 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
2
|
+
"name": "@antelopejs/dms-mailing-frontend-vue",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"packageManager": "pnpm@10.2.0",
|
|
8
|
+
"engines": {
|
|
9
|
+
"@antelopejs/dms-frontend": ">=0.10.0 <0.11.0"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"format": "prettier --write .",
|
|
13
|
+
"lint": "eslint . && node --input-type=module -e \"import { assertApiErrorPresentation } from '@antelopejs/dms/checks/api-error-presentation'; assertApiErrorPresentation(['app']);\"",
|
|
14
|
+
"lint:fix": "eslint . --fix",
|
|
15
|
+
"test": "vitest run"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@vueuse/core": "^14.3.0",
|
|
19
|
+
"@vueuse/integrations": "^14.3.0",
|
|
20
|
+
"sortablejs": "^1.14.0",
|
|
21
|
+
"vue": "^3.5.18"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@antelopejs/tooling-configs": "^0.3.3",
|
|
25
|
+
"@nuxt/ui": "4.10.0",
|
|
26
|
+
"@types/sortablejs": "^1.15.9",
|
|
27
|
+
"@vitejs/plugin-vue": "^6.0.0",
|
|
28
|
+
"@vitest/runner": "^4.1.5",
|
|
29
|
+
"eslint": "^9.0.0",
|
|
30
|
+
"eslint-plugin-vue": "^10.0.0",
|
|
31
|
+
"typescript-eslint": "^8.0.0",
|
|
32
|
+
"vite": "^8.2.2",
|
|
33
|
+
"jsdom": "^26",
|
|
34
|
+
"prettier": "^3.5.3",
|
|
35
|
+
"prettier-plugin-tailwindcss": "^0.6.11",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"vitest": "^4.1.5"
|
|
38
|
+
}
|
|
38
39
|
}
|
|
@@ -22,11 +22,14 @@ importers:
|
|
|
22
22
|
version: 3.5.42(typescript@5.9.3)
|
|
23
23
|
devDependencies:
|
|
24
24
|
'@antelopejs/tooling-configs':
|
|
25
|
-
specifier: ^0.
|
|
26
|
-
version: 0.
|
|
25
|
+
specifier: ^0.3.3
|
|
26
|
+
version: 0.3.3
|
|
27
27
|
'@nuxt/ui':
|
|
28
28
|
specifier: 4.10.0
|
|
29
29
|
version: 4.10.0(9bdbb06d8a0b4accef61070adc217622)
|
|
30
|
+
'@types/sortablejs':
|
|
31
|
+
specifier: ^1.15.9
|
|
32
|
+
version: 1.15.9
|
|
30
33
|
'@vitejs/plugin-vue':
|
|
31
34
|
specifier: ^6.0.0
|
|
32
35
|
version: 6.0.9(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.42(typescript@5.9.3))
|
|
@@ -67,16 +70,20 @@ packages:
|
|
|
67
70
|
resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
|
|
68
71
|
engines: {node: '>=10'}
|
|
69
72
|
|
|
70
|
-
'@antelopejs/tooling-configs@0.
|
|
71
|
-
resolution: {integrity: sha512-
|
|
73
|
+
'@antelopejs/tooling-configs@0.3.3':
|
|
74
|
+
resolution: {integrity: sha512-jQ09zavCF5MMQtfyd8YOqpoxV6l6bp3m9tA5iN0z0XhQpVHM5VWr53yEuQ6Mc+1tseBW85N94ruP4TEfiGg8iQ==}
|
|
72
75
|
engines: {node: '>=22.18.0'}
|
|
73
76
|
hasBin: true
|
|
74
77
|
peerDependencies:
|
|
75
78
|
'@nuxt/eslint-config': '>=1.2.0'
|
|
76
79
|
eslint-plugin-perfectionist: '>=5.0.0'
|
|
77
80
|
knip: '>=6.0.0'
|
|
81
|
+
mongodb-memory-server: '>=10.0.0'
|
|
78
82
|
oxfmt: ^0.66.0
|
|
79
83
|
oxlint: 1.81.0
|
|
84
|
+
pixelmatch: '>=7.0.0'
|
|
85
|
+
playwright-core: '>=1.56.0'
|
|
86
|
+
sharp: '>=0.34.0'
|
|
80
87
|
peerDependenciesMeta:
|
|
81
88
|
'@nuxt/eslint-config':
|
|
82
89
|
optional: true
|
|
@@ -84,10 +91,18 @@ packages:
|
|
|
84
91
|
optional: true
|
|
85
92
|
knip:
|
|
86
93
|
optional: true
|
|
94
|
+
mongodb-memory-server:
|
|
95
|
+
optional: true
|
|
87
96
|
oxfmt:
|
|
88
97
|
optional: true
|
|
89
98
|
oxlint:
|
|
90
99
|
optional: true
|
|
100
|
+
pixelmatch:
|
|
101
|
+
optional: true
|
|
102
|
+
playwright-core:
|
|
103
|
+
optional: true
|
|
104
|
+
sharp:
|
|
105
|
+
optional: true
|
|
91
106
|
|
|
92
107
|
'@antfu/install-pkg@2.0.1':
|
|
93
108
|
resolution: {integrity: sha512-iCKVQcIC0e3oDxEfs3SHQGW+ovhBMZmS1TE+bTk50rVyMCBmCfClv7Qi3HQKlumYwvjb/iIMeWCW2i67q6kFfQ==}
|
|
@@ -1413,6 +1428,9 @@ packages:
|
|
|
1413
1428
|
'@types/json-schema@7.0.15':
|
|
1414
1429
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
|
1415
1430
|
|
|
1431
|
+
'@types/sortablejs@1.15.9':
|
|
1432
|
+
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
|
|
1433
|
+
|
|
1416
1434
|
'@types/web-bluetooth@0.0.20':
|
|
1417
1435
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
|
1418
1436
|
|
|
@@ -3372,7 +3390,7 @@ snapshots:
|
|
|
3372
3390
|
|
|
3373
3391
|
'@alloc/quick-lru@5.3.0': {}
|
|
3374
3392
|
|
|
3375
|
-
'@antelopejs/tooling-configs@0.
|
|
3393
|
+
'@antelopejs/tooling-configs@0.3.3':
|
|
3376
3394
|
dependencies:
|
|
3377
3395
|
'@oxlint/plugins': 1.81.0
|
|
3378
3396
|
dts-bundle-generator: 9.5.1
|
|
@@ -4552,6 +4570,8 @@ snapshots:
|
|
|
4552
4570
|
|
|
4553
4571
|
'@types/json-schema@7.0.15': {}
|
|
4554
4572
|
|
|
4573
|
+
'@types/sortablejs@1.15.9': {}
|
|
4574
|
+
|
|
4555
4575
|
'@types/web-bluetooth@0.0.20': {}
|
|
4556
4576
|
|
|
4557
4577
|
'@types/web-bluetooth@0.0.21': {}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { defineComponent, h } from 'vue'
|
|
4
|
+
import EditorCanvas from '../app/components/editor/EditorCanvas.vue'
|
|
5
|
+
import { useEditorActions } from '../app/composables/useEditorActions'
|
|
6
|
+
import { provideEditorContext } from '../app/composables/useEditorContext'
|
|
7
|
+
import { useEditorSession } from '../app/composables/useEditorSession'
|
|
8
|
+
import { useMailingApi } from '../app/composables/useMailingApi'
|
|
9
|
+
import { useEditorReads } from '../app/composables/useMailingReads'
|
|
10
|
+
import { createEditorState } from '../app/composables/useTemplateEditor'
|
|
11
|
+
import type {
|
|
12
|
+
TemplateContent,
|
|
13
|
+
TemplateContentResponse,
|
|
14
|
+
} from '../app/types/mailing'
|
|
15
|
+
import { stubApiData } from './helpers/api-data'
|
|
16
|
+
import { stubAuthFetch } from './helpers/auth-fetch'
|
|
17
|
+
import { mount, slotStub, stubNuxtGlobals } from './helpers/nuxt-globals'
|
|
18
|
+
import type { Mounted } from './helpers/nuxt-globals'
|
|
19
|
+
|
|
20
|
+
const CLIENT_BASE_URL = 'https://dms.acme.com'
|
|
21
|
+
const DEFAULT_SVG = '/logo.svg'
|
|
22
|
+
const DEFAULT_PNG = 'https://cdn.acme.com/logo.png'
|
|
23
|
+
const EMAIL_PNG = 'https://cdn.acme.com/email-logo.png'
|
|
24
|
+
const DARK_PNG = 'https://cdn.acme.com/email-logo-dark.png'
|
|
25
|
+
|
|
26
|
+
const CONTENT: TemplateContent = {
|
|
27
|
+
locales: { en: { subject: 'Hi', preheader: '', blocks: [] } },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LOADED: TemplateContentResponse = {
|
|
31
|
+
template: {
|
|
32
|
+
_id: 't1',
|
|
33
|
+
slug: 'welcome',
|
|
34
|
+
name: 'Welcome',
|
|
35
|
+
status: 'draft',
|
|
36
|
+
updatedAt: '2026-10-01T10:00:00Z',
|
|
37
|
+
updatedBy: 'camille',
|
|
38
|
+
attention: '',
|
|
39
|
+
},
|
|
40
|
+
content: CONTENT,
|
|
41
|
+
publishedContent: null,
|
|
42
|
+
hasDraft: true,
|
|
43
|
+
version: 0,
|
|
44
|
+
revision: 1,
|
|
45
|
+
changes: [],
|
|
46
|
+
variables: [],
|
|
47
|
+
detectedVariables: [],
|
|
48
|
+
testData: {},
|
|
49
|
+
fallbackLocale: 'en',
|
|
50
|
+
blockOnMissingVariables: false,
|
|
51
|
+
categories: [],
|
|
52
|
+
sender: { name: '', email: '', replyTo: '' },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface LogoBranding {
|
|
56
|
+
default?: { light?: string }
|
|
57
|
+
email?: { light?: string; dark?: string }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The canvas inside an editor built as the workspace builds it. */
|
|
61
|
+
const CanvasInEditor = defineComponent({
|
|
62
|
+
setup() {
|
|
63
|
+
const editor = createEditorState(CONTENT, 'en', ['en'])
|
|
64
|
+
const api = useMailingApi()
|
|
65
|
+
const reads = useEditorReads('t1')
|
|
66
|
+
const deps = { editor, api, reads, templateId: 't1', onError: () => {} }
|
|
67
|
+
const session = useEditorSession({ ...deps, loaded: LOADED })
|
|
68
|
+
const actions = useEditorActions({
|
|
69
|
+
...deps,
|
|
70
|
+
session,
|
|
71
|
+
reload: async () => {},
|
|
72
|
+
})
|
|
73
|
+
provideEditorContext({ editor, session, actions, api, templateId: 't1' })
|
|
74
|
+
return () => h(EditorCanvas)
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
let mounted: Mounted | null = null
|
|
79
|
+
|
|
80
|
+
/** The logo the canvas header shows under `logo`, or null when it shows none. */
|
|
81
|
+
function canvasLogo(logo: LogoBranding): string | null {
|
|
82
|
+
vi.stubGlobal('useDmsAppConfig', () => ({ branding: { logo } }))
|
|
83
|
+
mounted = mount(
|
|
84
|
+
CanvasInEditor,
|
|
85
|
+
{},
|
|
86
|
+
{
|
|
87
|
+
UAlert: slotStub('UAlert'),
|
|
88
|
+
MailingEditorEnvelope: slotStub('MailingEditorEnvelope'),
|
|
89
|
+
MailingEditorMissingLocale: slotStub('MailingEditorMissingLocale'),
|
|
90
|
+
MailingEditorBlockList: slotStub('MailingEditorBlockList'),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
return mounted.host.querySelector('img')?.getAttribute('src') ?? null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
stubNuxtGlobals()
|
|
98
|
+
stubApiData()
|
|
99
|
+
stubAuthFetch()
|
|
100
|
+
vi.stubGlobal('useDmsRuntimeConfig', () => ({
|
|
101
|
+
public: { dms: { clientBaseUrl: CLIENT_BASE_URL } },
|
|
102
|
+
}))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
mounted?.unmount()
|
|
107
|
+
mounted = null
|
|
108
|
+
vi.unstubAllGlobals()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('editor canvas logo header', () => {
|
|
112
|
+
it('shows the e-mail logo over an SVG default logo, as the sent e-mail does', () => {
|
|
113
|
+
expect(
|
|
114
|
+
canvasLogo({
|
|
115
|
+
default: { light: DEFAULT_SVG },
|
|
116
|
+
email: { light: EMAIL_PNG },
|
|
117
|
+
}),
|
|
118
|
+
).toBe(EMAIL_PNG)
|
|
119
|
+
})
|
|
120
|
+
it('prefers the light e-mail logo over a public default logo and the dark one', () => {
|
|
121
|
+
expect(
|
|
122
|
+
canvasLogo({
|
|
123
|
+
default: { light: DEFAULT_PNG },
|
|
124
|
+
email: { light: EMAIL_PNG, dark: DARK_PNG },
|
|
125
|
+
}),
|
|
126
|
+
).toBe(EMAIL_PNG)
|
|
127
|
+
})
|
|
128
|
+
it('falls back to the default logo when no e-mail logo is set', () => {
|
|
129
|
+
expect(canvasLogo({ default: { light: DEFAULT_PNG } })).toBe(DEFAULT_PNG)
|
|
130
|
+
})
|
|
131
|
+
it('shows no logo when the chosen one is an SVG a mail client refuses', () => {
|
|
132
|
+
expect(canvasLogo({ default: { light: DEFAULT_SVG } })).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
it('does not fall back to the default logo when the e-mail logo is refused', () => {
|
|
135
|
+
expect(
|
|
136
|
+
canvasLogo({
|
|
137
|
+
default: { light: DEFAULT_PNG },
|
|
138
|
+
email: { light: DEFAULT_SVG },
|
|
139
|
+
}),
|
|
140
|
+
).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ajs dms verify-source` type-checks the layer against the installed
|
|
3
|
+
* `@vueuse/integrations`: the composable has to compile against its
|
|
4
|
+
* `useSortable` overloads, which take the list as a `MaybeRef`, not a getter.
|
|
5
|
+
*/
|
|
6
|
+
import type { Ref } from 'vue'
|
|
7
|
+
import { describe, expectTypeOf, it } from 'vitest'
|
|
8
|
+
import { useSortableList } from '../app/composables/useSortableList'
|
|
9
|
+
|
|
10
|
+
describe('useSortableList', () => {
|
|
11
|
+
it('takes the list as a getter and compiles against useSortable', () => {
|
|
12
|
+
expectTypeOf(useSortableList<{ id: string }>)
|
|
13
|
+
.parameter(1)
|
|
14
|
+
.toEqualTypeOf<() => { id: string }[]>()
|
|
15
|
+
expectTypeOf(useSortableList<{ id: string }>)
|
|
16
|
+
.parameter(0)
|
|
17
|
+
.toEqualTypeOf<Ref<HTMLElement | null>>()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
1
2
|
import vue from '@vitejs/plugin-vue'
|
|
2
3
|
import { defineConfig } from 'vitest/config'
|
|
3
4
|
|
|
4
5
|
export default defineConfig({
|
|
5
6
|
plugins: [vue()],
|
|
7
|
+
resolve: {
|
|
8
|
+
alias: {
|
|
9
|
+
'#dms-layout': fileURLToPath(
|
|
10
|
+
new URL(
|
|
11
|
+
'../node_modules/@antelopejs/dms/frontend-vue/layers/dms-layout',
|
|
12
|
+
import.meta.url,
|
|
13
|
+
),
|
|
14
|
+
),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
6
17
|
test: {
|
|
7
18
|
// Most suites need no DOM, and setting jsdom up per file is the bulk of
|
|
8
19
|
// the run; the files that mount components opt in with a
|
|
9
20
|
// `// @vitest-environment jsdom` header.
|
|
10
21
|
environment: 'node',
|
|
11
22
|
include: ['tests/**/*.test.ts'],
|
|
23
|
+
typecheck: {
|
|
24
|
+
enabled: true,
|
|
25
|
+
include: ['tests/**/*.test-d.ts'],
|
|
26
|
+
tsconfig: 'tsconfig.typecheck.json',
|
|
27
|
+
},
|
|
12
28
|
},
|
|
13
29
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-mailing",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "E-mail templates for the AntelopeJS DMS: a block editor with drafts and versions, a send log, delivery metrics and a suppression list.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antelopejs",
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
"zod": "~3.24.2"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@antelopejs/core": "^1.
|
|
63
|
-
"@antelopejs/dms": ">=0.
|
|
62
|
+
"@antelopejs/core": "^1.21.0",
|
|
63
|
+
"@antelopejs/dms": ">=0.19.0 <1.0.0",
|
|
64
64
|
"@antelopejs/mongodb": "^1.6.2",
|
|
65
|
-
"@antelopejs/tooling-configs": "^0.
|
|
65
|
+
"@antelopejs/tooling-configs": "^0.3.3",
|
|
66
66
|
"@types/chai": "^4.3.20",
|
|
67
67
|
"@types/mocha": "^10",
|
|
68
68
|
"@types/node": "^24.0.0",
|
|
@@ -83,6 +83,8 @@ export async function declareMailingContent(tenantId: string) {
|
|
|
83
83
|
i18n keys; each user reads, searches and sorts them in their language (`x-content-language`).
|
|
84
84
|
- `DeclareSender({ tenantId, email, name?, replyTo? })` writes the settings' sender (resolves `void`); call it once
|
|
85
85
|
from `Hook.TENANT_BEING_PROVISIONED`, not on every boot, since it replaces what a user set. Refusals use `invalid_sender`.
|
|
86
|
+
- `DeclareWebhookSecret({ tenantId, secret, replaceRotated? })` resolves `{ outcome: "set" | "unchanged" | "kept" }`, never
|
|
87
|
+
the secret; idempotent, and keeps a secret the owner set or rotated unless `replaceRotated`. Refusals use `invalid_webhook_secret`.
|
|
86
88
|
- Refusals reject with `error.code`: `invalid_template`, `unknown_category`, `empty_subject`,
|
|
87
89
|
`nothing_to_publish`, `invalid_category`; invalid tenant input rejects before writes. Storage errors can occur after publication.
|
|
88
90
|
- `ImportSends({ tenantId, sends: [{ importId, templateSlug, to, sentAt, events }] })` writes past sends with their own
|
|
@@ -18,7 +18,10 @@ import {
|
|
|
18
18
|
import { findSettings } from "../services/settings";
|
|
19
19
|
import type { MailingSettingsValues } from "../types";
|
|
20
20
|
|
|
21
|
-
export type ExportedSettings = Omit<
|
|
21
|
+
export type ExportedSettings = Omit<
|
|
22
|
+
MailingSettingsValues,
|
|
23
|
+
"webhookSecret" | "webhookSecretSource"
|
|
24
|
+
>;
|
|
22
25
|
|
|
23
26
|
export type ExportPageLoader<T> = (
|
|
24
27
|
offset: number,
|
|
@@ -4,5 +4,6 @@ export {
|
|
|
4
4
|
} from "../../services/declarations";
|
|
5
5
|
export { importSends as ImportSends } from "../../services/send-import";
|
|
6
6
|
export { declareSender as DeclareSender } from "../../services/sender-declaration";
|
|
7
|
+
export { declareWebhookSecret as DeclareWebhookSecret } from "../../services/webhook-secret-declaration";
|
|
7
8
|
export { recordEmailEvent as RecordEmailEvent } from "../../services/events";
|
|
8
9
|
export { sendUnlessSuppressed as SendTemplate } from "../../services/checked-send";
|
package/src/pages/sends.ts
CHANGED
|
@@ -225,8 +225,9 @@ export class SendsPageController extends PageController(
|
|
|
225
225
|
pageSize: 50,
|
|
226
226
|
rowClick: "single",
|
|
227
227
|
/**
|
|
228
|
-
* New sends wait
|
|
229
|
-
*
|
|
228
|
+
* New sends wait behind a "N new sends" pill while the log is on screen,
|
|
229
|
+
* so the rows do not move under the user. A send open in the drawer, a
|
|
230
|
+
* scroll down the log or a search also names the hold above the rows.
|
|
230
231
|
*/
|
|
231
232
|
liveHold: {
|
|
232
233
|
whileRowOpen: true,
|
package/src/routes/settings.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
mergeSettingsPatch,
|
|
34
34
|
missingVariablesPolicy,
|
|
35
35
|
saveSettings,
|
|
36
|
+
saveWebhookSecret,
|
|
36
37
|
WEBHOOK_SECRET_BYTES,
|
|
37
38
|
} from "../services/settings";
|
|
38
39
|
import {
|
|
@@ -78,7 +79,15 @@ const retentionDaysSchema = z.coerce
|
|
|
78
79
|
* The DMS form reads and writes localized values whole
|
|
79
80
|
* (`x-content-language: *`): category labels travel with every language.
|
|
80
81
|
*/
|
|
81
|
-
|
|
82
|
+
/** The settings as the form reads and writes them: who set the secret stays inside the module. */
|
|
83
|
+
type FormSettings = Omit<MailingSettingsValues, "webhookSecretSource">;
|
|
84
|
+
|
|
85
|
+
function formSettings(values: MailingSettingsValues): FormSettings {
|
|
86
|
+
const { webhookSecretSource: _source, ...form } = values;
|
|
87
|
+
return form;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface MailingSettingsResponse extends FormSettings {
|
|
82
91
|
/** Where the provider posts its events; read-only. */
|
|
83
92
|
webhookUrl: string;
|
|
84
93
|
/** The configured provider, as the read-only provider row shows it. */
|
|
@@ -94,7 +103,7 @@ export interface SettingsNotice {
|
|
|
94
103
|
params?: Record<string, number | string>;
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
export interface SettingsWriteResponse extends
|
|
106
|
+
export interface SettingsWriteResponse extends FormSettings {
|
|
98
107
|
notice?: SettingsNotice;
|
|
99
108
|
}
|
|
100
109
|
|
|
@@ -211,7 +220,7 @@ export class MailingSettingsController extends Controller(
|
|
|
211
220
|
readCapabilities(),
|
|
212
221
|
]);
|
|
213
222
|
return {
|
|
214
|
-
...values,
|
|
223
|
+
...formSettings(values),
|
|
215
224
|
webhookUrl: await this.providerWebhookUrl(capabilities?.name),
|
|
216
225
|
provider: capabilities?.name ?? "",
|
|
217
226
|
missingVariables: missingVariablesPolicy(values),
|
|
@@ -234,7 +243,7 @@ export class MailingSettingsController extends Controller(
|
|
|
234
243
|
? await this.reassignCategories(categories, categoryMoves(entries))
|
|
235
244
|
: 0;
|
|
236
245
|
const [notice] = [...reassignedNotice(reassigned), ...senderNotice(saved)];
|
|
237
|
-
return { ...saved, ...(notice && { notice }) };
|
|
246
|
+
return { ...formSettings(saved), ...(notice && { notice }) };
|
|
238
247
|
}
|
|
239
248
|
|
|
240
249
|
/** The body as a checked patch; 400 for anything else. */
|
|
@@ -249,12 +258,15 @@ export class MailingSettingsController extends Controller(
|
|
|
249
258
|
);
|
|
250
259
|
}
|
|
251
260
|
|
|
252
|
-
/**
|
|
261
|
+
/**
|
|
262
|
+
* Issues a new webhook secret, the workspace owner's from now on; the old
|
|
263
|
+
* one stops working at once.
|
|
264
|
+
*/
|
|
253
265
|
@Post("/webhook-secret/rotate")
|
|
254
266
|
async rotateSecret(): Promise<{ value: string }> {
|
|
255
|
-
|
|
267
|
+
await getSettings(this.tenantId);
|
|
256
268
|
const value = randomBytes(WEBHOOK_SECRET_BYTES).toString("hex");
|
|
257
|
-
await
|
|
269
|
+
await saveWebhookSecret(this.tenantId, value, "operator");
|
|
258
270
|
return { value };
|
|
259
271
|
}
|
|
260
272
|
|
|
@@ -172,10 +172,12 @@ interface EntryOutcome {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
175
|
-
* Writes one send:
|
|
176
|
-
* the send
|
|
177
|
-
*
|
|
178
|
-
*
|
|
175
|
+
* Writes one send: the send itself first, then its events under ids derived
|
|
176
|
+
* from the send's. An event is written only after its send, so the
|
|
177
|
+
* retention's orphan-event pass never finds an import's events before their
|
|
178
|
+
* send. A run stopped half-way leaves a send with part of its history, which
|
|
179
|
+
* the next run completes: each write skips the row an earlier run wrote, and
|
|
180
|
+
* the events are written whether or not the send was already there.
|
|
179
181
|
*/
|
|
180
182
|
async function importOne(
|
|
181
183
|
tenantId: string,
|
|
@@ -184,8 +186,10 @@ async function importOne(
|
|
|
184
186
|
fallbackLocale: string,
|
|
185
187
|
): Promise<EntryOutcome> {
|
|
186
188
|
const id = importedSendIdOf(tenantId, send.importId);
|
|
187
|
-
const sends = GetModel(SendModel, tenantId);
|
|
188
189
|
const timeline = timelineOf(send);
|
|
190
|
+
const locale = send.locale ?? fallbackLocale;
|
|
191
|
+
const row = sendRow(id, send, template, locale, foldEvents(send, timeline));
|
|
192
|
+
const isImported = await GetModel(SendModel, tenantId).insertImported(row);
|
|
189
193
|
const events = GetModel(SendEventModel, tenantId);
|
|
190
194
|
for (const [index, entry] of timeline.entries()) {
|
|
191
195
|
await events.insertImported({
|
|
@@ -196,9 +200,7 @@ async function importOne(
|
|
|
196
200
|
json_details: JSON.stringify(entry.details ?? {}),
|
|
197
201
|
});
|
|
198
202
|
}
|
|
199
|
-
|
|
200
|
-
const row = sendRow(id, send, template, locale, foldEvents(send, timeline));
|
|
201
|
-
return { id, isImported: await sends.insertImported(row) };
|
|
203
|
+
return { id, isImported };
|
|
202
204
|
}
|
|
203
205
|
|
|
204
206
|
/**
|
package/src/services/settings.ts
CHANGED
|
@@ -5,12 +5,17 @@ import {
|
|
|
5
5
|
MODULE_DEFAULT_SETTINGS,
|
|
6
6
|
type MailingSettingsValues,
|
|
7
7
|
type TemplateCategory,
|
|
8
|
+
WEBHOOK_SECRET_SOURCES,
|
|
9
|
+
type WebhookSecretSource,
|
|
8
10
|
} from "../types";
|
|
9
11
|
import type { JsonObject } from "../types/json";
|
|
10
12
|
import type { SettingsPatch } from "../validation/settings.schema";
|
|
11
13
|
|
|
12
14
|
export const WEBHOOK_SECRET_BYTES = 24;
|
|
13
15
|
|
|
16
|
+
const GENERATED_SECRET: WebhookSecretSource = "generated";
|
|
17
|
+
const OPERATOR_SECRET: WebhookSecretSource = "operator";
|
|
18
|
+
|
|
14
19
|
function defaults(): MailingSettingsValues {
|
|
15
20
|
return {
|
|
16
21
|
...MODULE_DEFAULT_SETTINGS,
|
|
@@ -18,9 +23,21 @@ function defaults(): MailingSettingsValues {
|
|
|
18
23
|
senderEmail: "",
|
|
19
24
|
replyTo: "",
|
|
20
25
|
webhookSecret: randomBytes(WEBHOOK_SECRET_BYTES).toString("hex"),
|
|
26
|
+
webhookSecretSource: GENERATED_SECRET,
|
|
21
27
|
};
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The stored source, or `operator` for a row written before the source was
|
|
32
|
+
* kept: a secret of unknown origin is the workspace's until proven otherwise.
|
|
33
|
+
*/
|
|
34
|
+
function storedSecretSource(stored: string | undefined): WebhookSecretSource {
|
|
35
|
+
return (
|
|
36
|
+
WEBHOOK_SECRET_SOURCES.find((source) => source === stored) ??
|
|
37
|
+
OPERATOR_SECRET
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
function toValues(row: MailingSettings): MailingSettingsValues {
|
|
25
42
|
return {
|
|
26
43
|
fallbackLocale: row.fallbackLocale,
|
|
@@ -30,6 +47,7 @@ function toValues(row: MailingSettings): MailingSettingsValues {
|
|
|
30
47
|
senderEmail: row.senderEmail,
|
|
31
48
|
replyTo: row.replyTo,
|
|
32
49
|
webhookSecret: row.webhookSecret,
|
|
50
|
+
webhookSecretSource: storedSecretSource(row.webhookSecretSource),
|
|
33
51
|
// SAFETY: json_categories is written only by toRow as the JSON.stringify of categories that categoriesSchema
|
|
34
52
|
// validated (settingsPatchSchema, applyCategory) or that the module defaults supplied.
|
|
35
53
|
categories: JSON.parse(row.json_categories) as TemplateCategory[],
|
|
@@ -94,6 +112,22 @@ export async function saveSettings(
|
|
|
94
112
|
return values;
|
|
95
113
|
}
|
|
96
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Writes the webhook secret and its source alone, so a concurrent writer of
|
|
117
|
+
* the other settings keeps its fields. The tenant's row must exist: read it
|
|
118
|
+
* with `getSettings` first.
|
|
119
|
+
*/
|
|
120
|
+
export async function saveWebhookSecret(
|
|
121
|
+
tenantId: string,
|
|
122
|
+
webhookSecret: string,
|
|
123
|
+
webhookSecretSource: WebhookSecretSource,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
await GetModel(SettingsModel, tenantId).update(tenantId, {
|
|
126
|
+
webhookSecret,
|
|
127
|
+
webhookSecretSource,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
97
131
|
export function findCategory(
|
|
98
132
|
categories: TemplateCategory[],
|
|
99
133
|
id: string,
|
|
@@ -125,7 +159,8 @@ export function clearedToEmpty(patch: JsonObject): JsonObject {
|
|
|
125
159
|
/**
|
|
126
160
|
* The settings once `patch` (the fields a form changed, cleared ones already
|
|
127
161
|
* stored as empty) is laid over the stored ones, the missing-variables choice
|
|
128
|
-
* folded back into its boolean.
|
|
162
|
+
* folded back into its boolean. A secret the patch changes becomes the
|
|
163
|
+
* workspace owner's.
|
|
129
164
|
*/
|
|
130
165
|
export function mergeSettingsPatch(
|
|
131
166
|
current: MailingSettingsValues,
|
|
@@ -137,5 +172,8 @@ export function mergeSettingsPatch(
|
|
|
137
172
|
isChoice && !("blockOnMissingVariables" in patch)
|
|
138
173
|
? { blockOnMissingVariables: policy === REFUSE_POLICY }
|
|
139
174
|
: {};
|
|
140
|
-
|
|
175
|
+
const merged = { ...current, ...written, ...guard };
|
|
176
|
+
return merged.webhookSecret === current.webhookSecret
|
|
177
|
+
? merged
|
|
178
|
+
: { ...merged, webhookSecretSource: OPERATOR_SECRET };
|
|
141
179
|
}
|