@antelopejs/dms-mailing 0.9.0 → 0.9.2
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 +2 -2
- package/dist/db/models/sends.model.d.ts +3 -1
- package/dist/db/models/sends.model.js +26 -2
- package/dist/db/models/sends.model.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/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/metrics.js +1 -2
- package/dist/routes/metrics.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/routes/templates.js +2 -2
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/attention.d.ts +1 -1
- package/dist/services/attention.js +3 -3
- package/dist/services/attention.js.map +1 -1
- package/dist/services/metrics.js +5 -1
- package/dist/services/metrics.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/template-attention.d.ts +1 -1
- package/dist/services/template-attention.js +2 -4
- package/dist/services/template-attention.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 +11 -0
- package/frontend-vue/vitest.config.ts +16 -0
- package/package.json +5 -5
- package/skills/dms-mailing/SKILL.md +2 -0
- package/src/db/models/sends.model.ts +34 -4
- 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/metrics.ts +1 -2
- package/src/routes/settings.ts +19 -7
- package/src/routes/templates.ts +6 -7
- package/src/services/attention.ts +6 -6
- package/src/services/metrics.ts +3 -1
- package/src/services/send-import.ts +10 -8
- package/src/services/settings.ts +40 -2
- package/src/services/template-attention.ts +4 -5
- 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
|
@@ -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.2",
|
|
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.5.1",
|
|
66
66
|
"@types/chai": "^4.3.20",
|
|
67
67
|
"@types/mocha": "^10",
|
|
68
68
|
"@types/node": "^24.0.0",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"typecheck": "tsc --noEmit",
|
|
99
99
|
"knip": "knip --dependencies",
|
|
100
100
|
"knip:all": "knip",
|
|
101
|
-
"release": "pnpm --filter @antelopejs/interface-dms-mailing build && pnpm --dir ../.. install --frozen-lockfile && release-it",
|
|
101
|
+
"release": "pnpm --dir ../.. run typecheck && pnpm --filter @antelopejs/interface-dms-mailing build && pnpm --dir ../.. install --frozen-lockfile && release-it",
|
|
102
102
|
"test": "pnpm run test:backend && pnpm run test:layer",
|
|
103
103
|
"test:backend": "([ -n \"$DMS_PREBUILT\" ] || pnpm run build) && ajs module test .",
|
|
104
104
|
"test:layer": "cd frontend-vue && pnpm test",
|
|
@@ -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
|
|
@@ -24,8 +24,28 @@ export type ImportedSendRow = Omit<
|
|
|
24
24
|
|
|
25
25
|
const MAX_MUTATION_ATTEMPTS = 5;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const SEND_SUMMARY_FIELDS = [
|
|
28
|
+
"_id",
|
|
29
|
+
"createdAt",
|
|
30
|
+
"templateSlug",
|
|
31
|
+
"recipientEmail",
|
|
32
|
+
"status",
|
|
33
|
+
"stage",
|
|
34
|
+
"latencyMs",
|
|
35
|
+
"opens",
|
|
36
|
+
"clicks",
|
|
37
|
+
"isTest",
|
|
38
|
+
"error",
|
|
39
|
+
"source",
|
|
40
|
+
"locale",
|
|
41
|
+
"requestedLocale",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
/** A send as the figures read it: only the fields they count. */
|
|
45
|
+
export type SendSummary = Pick<
|
|
46
|
+
MailingSend,
|
|
47
|
+
(typeof SEND_SUMMARY_FIELDS)[number]
|
|
48
|
+
>;
|
|
29
49
|
|
|
30
50
|
type SendActivity = Pick<
|
|
31
51
|
MailingSend,
|
|
@@ -122,7 +142,7 @@ export class SendModel extends BasicDataModel(
|
|
|
122
142
|
return this.table
|
|
123
143
|
.filter((row) => row.key("createdAt").ge(from))
|
|
124
144
|
.filter((row) => row.key("createdAt").lt(to))
|
|
125
|
-
.
|
|
145
|
+
.pluck(...SEND_SUMMARY_FIELDS)
|
|
126
146
|
.run();
|
|
127
147
|
}
|
|
128
148
|
|
|
@@ -259,7 +279,7 @@ export class SendModel extends BasicDataModel(
|
|
|
259
279
|
return this.table
|
|
260
280
|
.getAll(templateSlug, "templateSlug")
|
|
261
281
|
.filter((row) => row.key("createdAt").ge(from))
|
|
262
|
-
.
|
|
282
|
+
.pluck(...SEND_SUMMARY_FIELDS)
|
|
263
283
|
.run();
|
|
264
284
|
}
|
|
265
285
|
|
|
@@ -270,4 +290,14 @@ export class SendModel extends BasicDataModel(
|
|
|
270
290
|
.count()
|
|
271
291
|
.run();
|
|
272
292
|
}
|
|
293
|
+
|
|
294
|
+
/** The slugs of the real sends made from `from` until `to`. */
|
|
295
|
+
listSentSlugs(from: Date, to: Date): Promise<string[]> {
|
|
296
|
+
return this.table
|
|
297
|
+
.filter((row) => row.key("createdAt").ge(from))
|
|
298
|
+
.filter((row) => row.key("createdAt").lt(to))
|
|
299
|
+
.filter((row) => row.key("isTest").ne(true))
|
|
300
|
+
.distinct("templateSlug")
|
|
301
|
+
.run();
|
|
302
|
+
}
|
|
273
303
|
}
|
|
@@ -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/metrics.ts
CHANGED
|
@@ -324,11 +324,10 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
324
324
|
findSettings(this.tenantId),
|
|
325
325
|
]);
|
|
326
326
|
const sends = forAudience(rows, this.audience);
|
|
327
|
-
const excluded = rows.filter((row) => !sends.includes(row));
|
|
328
327
|
const items = buildAttentionItems({
|
|
329
328
|
templates,
|
|
330
329
|
sends,
|
|
331
|
-
|
|
330
|
+
excludedCount: rows.length - sends.length,
|
|
332
331
|
windowEnd: range.to,
|
|
333
332
|
tracking,
|
|
334
333
|
isSenderSet: Boolean(settings?.senderEmail),
|
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
|
|
package/src/routes/templates.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "../db";
|
|
33
33
|
import { INVALID_TEMPLATE, saveDetails } from "../data/templates-table";
|
|
34
34
|
import { TemplatesPageController } from "../pages/templates";
|
|
35
|
-
import { forAudience, statsWindowStart } from "../services/metrics";
|
|
35
|
+
import { forAudience, groupBy, statsWindowStart } from "../services/metrics";
|
|
36
36
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
37
37
|
import { renderEmailHtml } from "../services/render";
|
|
38
38
|
import { categoriesIn } from "../services/categories";
|
|
@@ -234,14 +234,13 @@ export class TemplatesController extends Controller(
|
|
|
234
234
|
this.templates.getAll(),
|
|
235
235
|
this.sends.listBetween(statsWindowStart(), new Date()),
|
|
236
236
|
]);
|
|
237
|
-
const
|
|
237
|
+
const bySlug = groupBy(
|
|
238
|
+
forAudience(rows, BUSINESS_AUDIENCE),
|
|
239
|
+
(send) => send.templateSlug,
|
|
240
|
+
);
|
|
238
241
|
return {
|
|
239
242
|
items: templates.map((template) =>
|
|
240
|
-
statsOf(
|
|
241
|
-
template._id,
|
|
242
|
-
template.slug,
|
|
243
|
-
sends.filter((send) => send.templateSlug === template.slug),
|
|
244
|
-
),
|
|
243
|
+
statsOf(template._id, template.slug, bySlug.get(template.slug) ?? []),
|
|
245
244
|
),
|
|
246
245
|
};
|
|
247
246
|
}
|
|
@@ -124,13 +124,13 @@ function unusedLiveItem(window: AttentionWindow): AttentionItem[] {
|
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
126
|
* Every send the Overview window holds, split the way the business view counts
|
|
127
|
-
* them: `sends` are the ones behind the figures, `
|
|
128
|
-
* business audience drops.
|
|
127
|
+
* them: `sends` are the ones behind the figures, `excludedCount` the test sends
|
|
128
|
+
* the business audience drops.
|
|
129
129
|
*/
|
|
130
130
|
export interface AttentionWindow {
|
|
131
131
|
templates: MailingTemplate[];
|
|
132
132
|
sends: SendSummary[];
|
|
133
|
-
|
|
133
|
+
excludedCount: number;
|
|
134
134
|
/** When the window closes. */
|
|
135
135
|
windowEnd: Date;
|
|
136
136
|
/** What the configured provider can report; `null` when it is unreachable. */
|
|
@@ -140,7 +140,7 @@ export interface AttentionWindow {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
function testOnlyWindowItem(window: AttentionWindow): AttentionItem[] {
|
|
143
|
-
if (window.sends.length || !window.
|
|
143
|
+
if (window.sends.length || !window.excludedCount) return [];
|
|
144
144
|
return [
|
|
145
145
|
{
|
|
146
146
|
id: "test-only-window",
|
|
@@ -150,8 +150,8 @@ function testOnlyWindowItem(window: AttentionWindow): AttentionItem[] {
|
|
|
150
150
|
description: "$dms_mailing.attention.test_only_window.description",
|
|
151
151
|
action: "$dms_mailing.attention.test_only_window.action",
|
|
152
152
|
to: SENDS_PAGE,
|
|
153
|
-
count: window.
|
|
154
|
-
params: { count: window.
|
|
153
|
+
count: window.excludedCount,
|
|
154
|
+
params: { count: window.excludedCount },
|
|
155
155
|
},
|
|
156
156
|
];
|
|
157
157
|
}
|
package/src/services/metrics.ts
CHANGED
|
@@ -276,7 +276,9 @@ export function groupBy<T>(
|
|
|
276
276
|
const groups = new Map<string, T[]>();
|
|
277
277
|
for (const item of items) {
|
|
278
278
|
const id = key(item);
|
|
279
|
-
|
|
279
|
+
const group = groups.get(id);
|
|
280
|
+
if (group) group.push(item);
|
|
281
|
+
else groups.set(id, [item]);
|
|
280
282
|
}
|
|
281
283
|
return groups;
|
|
282
284
|
}
|
|
@@ -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
|
}
|