@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.
Files changed (49) hide show
  1. package/dist/db/tables/settings.table.d.ts +1 -0
  2. package/dist/db/tables/settings.table.js +4 -0
  3. package/dist/db/tables/settings.table.js.map +1 -1
  4. package/dist/hooks/tenant-export.d.ts +1 -1
  5. package/dist/hooks/tenant-export.js.map +1 -1
  6. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  7. package/dist/implementations/dms-mailing/index.js +3 -1
  8. package/dist/implementations/dms-mailing/index.js.map +1 -1
  9. package/dist/pages/sends.js.map +1 -1
  10. package/dist/routes/settings.d.ts +3 -2
  11. package/dist/routes/settings.js +8 -4
  12. package/dist/routes/settings.js.map +1 -1
  13. package/dist/services/send-import.js +4 -4
  14. package/dist/services/send-import.js.map +1 -1
  15. package/dist/services/settings.d.ts +2 -1
  16. package/dist/services/settings.js +19 -1
  17. package/dist/services/settings.js.map +1 -1
  18. package/dist/services/webhook-secret-declaration.d.ts +4 -0
  19. package/dist/services/webhook-secret-declaration.js +25 -0
  20. package/dist/services/webhook-secret-declaration.js.map +1 -0
  21. package/dist/services/webhook-status.js +7 -2
  22. package/dist/services/webhook-status.js.map +1 -1
  23. package/dist/types/settings.d.ts +3 -0
  24. package/dist/types/settings.js +6 -1
  25. package/dist/types/settings.js.map +1 -1
  26. package/dist/validation/declarations.schema.d.ts +13 -0
  27. package/dist/validation/declarations.schema.js +6 -1
  28. package/dist/validation/declarations.schema.js.map +1 -1
  29. package/frontend-vue/app/components/editor/EditorCanvas.vue +8 -4
  30. package/frontend-vue/app/composables/useSortableList.ts +2 -2
  31. package/frontend-vue/package.json +37 -36
  32. package/frontend-vue/pnpm-lock.yaml +25 -5
  33. package/frontend-vue/tests/editor-canvas.test.ts +142 -0
  34. package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
  35. package/frontend-vue/tsconfig.typecheck.json +12 -0
  36. package/frontend-vue/vitest.config.ts +16 -0
  37. package/package.json +4 -4
  38. package/skills/dms-mailing/SKILL.md +2 -0
  39. package/src/db/tables/settings.table.ts +4 -0
  40. package/src/hooks/tenant-export.ts +4 -1
  41. package/src/implementations/dms-mailing/index.ts +1 -0
  42. package/src/pages/sends.ts +3 -2
  43. package/src/routes/settings.ts +19 -7
  44. package/src/services/send-import.ts +10 -8
  45. package/src/services/settings.ts +40 -2
  46. package/src/services/webhook-secret-declaration.ts +47 -0
  47. package/src/services/webhook-status.ts +9 -5
  48. package/src/types/settings.ts +14 -0
  49. package/src/validation/declarations.schema.ts +7 -0
@@ -1,38 +1,39 @@
1
1
  {
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.2.12",
25
- "@nuxt/ui": "4.10.0",
26
- "@vitejs/plugin-vue": "^6.0.0",
27
- "@vitest/runner": "^4.1.5",
28
- "eslint": "^9.0.0",
29
- "eslint-plugin-vue": "^10.0.0",
30
- "typescript-eslint": "^8.0.0",
31
- "vite": "^8.2.2",
32
- "jsdom": "^26",
33
- "prettier": "^3.5.3",
34
- "prettier-plugin-tailwindcss": "^0.6.11",
35
- "typescript": "^5.9.3",
36
- "vitest": "^4.1.5"
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.2.12
26
- version: 0.2.17
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.2.17':
71
- resolution: {integrity: sha512-8fwh7xQPzazP/l/ivEwRKpfN8qShV9+PRizpI9cBewe2TwoRPu4n95w6sYSlygiRmm0HxLJYZFhmSPRgtiNo1Q==}
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.2.17':
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
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["esnext", "dom"],
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["tests/**/*.test-d.ts"]
12
+ }
@@ -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.0",
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.20.0",
63
- "@antelopejs/dms": ">=0.16.1 <1.0.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.2.18",
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
@@ -46,4 +46,8 @@ export class MailingSettings extends Table {
46
46
 
47
47
  @Field("string")
48
48
  declare webhookSecret: string;
49
+
50
+ /** Who set `webhookSecret`; absent on rows written before it was kept. */
51
+ @Field("string")
52
+ declare webhookSecretSource?: string;
49
53
  }
@@ -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<MailingSettingsValues, "webhookSecret">;
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";
@@ -225,8 +225,9 @@ export class SendsPageController extends PageController(
225
225
  pageSize: 50,
226
226
  rowClick: "single",
227
227
  /**
228
- * New sends wait in a "N new sends" strip while the user reads a send,
229
- * scrolls down the log or searches it, so the rows do not move under them.
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,
@@ -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
- export interface MailingSettingsResponse extends MailingSettingsValues {
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 MailingSettingsValues {
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
- /** Issues a new webhook secret; the old one stops working at once. */
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
- const current = await getSettings(this.tenantId);
267
+ await getSettings(this.tenantId);
256
268
  const value = randomBytes(WEBHOOK_SECRET_BYTES).toString("hex");
257
- await saveSettings(this.tenantId, { ...current, webhookSecret: value });
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: its events first, under ids derived from the send's, then
176
- * the send itself. A run stopped half-way leaves events no send shows yet,
177
- * which the next run completes; each write skips the row an earlier run
178
- * wrote, and a send present means it was fully imported.
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
- const locale = send.locale ?? fallbackLocale;
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
  /**
@@ -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
- return { ...current, ...written, ...guard };
175
+ const merged = { ...current, ...written, ...guard };
176
+ return merged.webhookSecret === current.webhookSecret
177
+ ? merged
178
+ : { ...merged, webhookSecretSource: OPERATOR_SECRET };
141
179
  }