@antelopejs/dms-mailing 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/dist/antelope-modules.d.ts +5 -5
- package/dist/antelope-modules.js +5 -5
- package/dist/antelope-modules.js.map +1 -1
- package/dist/constants.d.ts +0 -3
- package/dist/constants.js +1 -4
- package/dist/constants.js.map +1 -1
- package/dist/crons/retention.js +1 -1
- package/dist/crons/retention.js.map +1 -1
- package/dist/data/templates-table.d.ts +1 -1
- package/dist/data/templates-table.js +21 -0
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +5 -5
- package/dist/db/models/sends.model.d.ts +2 -1
- package/dist/db/models/sends.model.js +1 -0
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +1 -1
- package/dist/db/models/settings.model.js +4 -1
- package/dist/db/models/settings.model.js.map +1 -1
- package/dist/db/tables/send-events.table.js +0 -1
- package/dist/db/tables/send-events.table.js.map +1 -1
- package/dist/db/tables/sends.table.js +0 -1
- package/dist/db/tables/sends.table.js.map +1 -1
- package/dist/db/tables/templates.table.js +0 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/hooks/tenant-export.js +1 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/pages/editor.js +0 -1
- package/dist/pages/editor.js.map +1 -1
- package/dist/pages/module.d.ts +1 -1
- package/dist/pages/overview.d.ts +5 -5
- package/dist/pages/overview.js +0 -1
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/sends.d.ts +3 -3
- package/dist/pages/sends.js +0 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/settings/form.d.ts +1 -1
- package/dist/pages/settings/page.d.ts +2 -2
- package/dist/pages/settings/page.js +0 -2
- package/dist/pages/settings/page.js.map +1 -1
- package/dist/pages/templates.d.ts +1 -1
- package/dist/pages/templates.js +0 -1
- package/dist/pages/templates.js.map +1 -1
- package/dist/permissions.js +10 -32
- package/dist/permissions.js.map +1 -1
- package/dist/quick-actions.d.ts +2 -2
- package/dist/routes/events.js +2 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/period.js +7 -4
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/templates.d.ts +2 -2
- package/dist/services/attention.d.ts +3 -3
- package/dist/services/categories.d.ts +5 -5
- package/dist/services/events.d.ts +1 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/metrics.d.ts +4 -4
- package/dist/services/settings.d.ts +1 -0
- package/dist/services/settings.js +25 -8
- package/dist/services/settings.js.map +1 -1
- package/dist/test/antelope.test.js +1 -0
- package/dist/test/antelope.test.js.map +1 -1
- package/dist/test/integration/events.test.js +11 -0
- package/dist/test/integration/events.test.js.map +1 -1
- package/dist/test/integration/sends.test.js +53 -0
- package/dist/test/integration/sends.test.js.map +1 -1
- package/dist/test/integration/settings.test.js +92 -0
- package/dist/test/integration/settings.test.js.map +1 -1
- package/dist/test/integration/templates.test.js +68 -2
- package/dist/test/integration/templates.test.js.map +1 -1
- package/dist/test/unit/period.test.js +17 -0
- package/dist/test/unit/period.test.js.map +1 -1
- package/dist/test/unit/permissions.test.d.ts +1 -0
- package/dist/test/unit/permissions.test.js +27 -0
- package/dist/test/unit/permissions.test.js.map +1 -0
- package/dist/test/unit/webhook-secret.test.js +1 -0
- package/dist/test/unit/webhook-secret.test.js.map +1 -1
- package/dist/validation/settings.schema.d.ts +6 -6
- package/dist/validation/templates.schema.d.ts +14 -14
- package/frontend-vue/app/components/editor/EditorDataPanel.vue +3 -1
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -0
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -0
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/pnpm-lock.yaml +2 -28
- package/package.json +10 -12
- package/src/antelope-modules.ts +5 -5
- package/src/constants.ts +0 -3
- package/src/crons/retention.ts +3 -1
- package/src/data/templates-table.ts +46 -0
- package/src/db/models/sends.model.ts +6 -2
- package/src/db/models/settings.model.ts +7 -1
- package/src/db/tables/send-events.table.ts +0 -1
- package/src/db/tables/sends.table.ts +0 -1
- package/src/db/tables/templates.table.ts +0 -1
- package/src/hooks/tenant-export.ts +1 -1
- package/src/pages/editor.ts +1 -2
- package/src/pages/overview.ts +0 -2
- package/src/pages/sends.ts +1 -7
- package/src/pages/settings/page.ts +1 -3
- package/src/pages/templates.ts +1 -7
- package/src/permissions.ts +14 -42
- package/src/routes/events.ts +3 -3
- package/src/routes/metrics.ts +3 -3
- package/src/routes/period.ts +9 -4
- package/src/routes/templates.ts +1 -1
- package/src/services/attention.ts +5 -5
- package/src/services/events.ts +4 -4
- package/src/services/metrics.ts +9 -9
- package/src/services/settings.ts +36 -7
- package/src/test/antelope.test.ts +2 -0
- package/src/test/integration/events.test.ts +24 -1
- package/src/test/integration/sends.test.ts +33 -0
- package/src/test/integration/settings.test.ts +78 -0
- package/src/test/integration/templates.test.ts +49 -6
- package/src/test/unit/period.test.ts +27 -0
- package/src/test/unit/permissions.test.ts +30 -0
- package/src/test/unit/webhook-secret.test.ts +1 -0
- package/frontend-vue/tests/email-render-cases.json +0 -77
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-mailing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"LICENSE",
|
|
12
12
|
"README.md",
|
|
13
13
|
"dist",
|
|
14
|
+
"!dist/.tsbuildinfo",
|
|
14
15
|
"frontend-vue",
|
|
15
16
|
"src"
|
|
16
17
|
],
|
|
@@ -31,20 +32,18 @@
|
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@antelopejs/interface-api": ">=0.0.13 <1.0.0",
|
|
33
34
|
"@antelopejs/interface-api-util": ">=0.1.2 <1.0.0",
|
|
34
|
-
"@antelopejs/interface-core": ">=0.
|
|
35
|
+
"@antelopejs/interface-core": ">=0.1.1 <1.0.0",
|
|
35
36
|
"@antelopejs/interface-data-api": ">=0.1.9 <1.0.0",
|
|
36
37
|
"@antelopejs/interface-database": "0.1.7",
|
|
37
38
|
"@antelopejs/interface-database-decorators": ">=0.1.6 <1.0.0",
|
|
38
|
-
"@antelopejs/interface-dms": ">=0.1
|
|
39
|
-
"@antelopejs/interface-dms-mailing": ">=0.
|
|
39
|
+
"@antelopejs/interface-dms": ">=0.2.1 <1.0.0",
|
|
40
|
+
"@antelopejs/interface-dms-mailing": ">=0.4.0 <1.0.0",
|
|
40
41
|
"@antelopejs/interface-email": ">=0.0.5 <1.0.0",
|
|
41
42
|
"node-cron": "^4.2.1",
|
|
42
43
|
"zod": "~3.24.2"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@antelopejs/core": ">=1.
|
|
46
|
-
"@antelopejs/dms": ">=0.3.5 <1.0.0",
|
|
47
|
-
"@antelopejs/dms-frontend": ">=0.2.0 <1.0.0",
|
|
46
|
+
"@antelopejs/core": ">=1.9.1 <2",
|
|
48
47
|
"@antelopejs/tooling-configs": ">=0.0.6 <1.0.0",
|
|
49
48
|
"@types/chai": "^4.3.20",
|
|
50
49
|
"@types/mocha": "^10",
|
|
@@ -60,10 +59,10 @@
|
|
|
60
59
|
"release-it": "^19.2.4",
|
|
61
60
|
"release-it-changelogen": "^0.1.0",
|
|
62
61
|
"rimraf": "^6.1.3",
|
|
63
|
-
"typescript": "^
|
|
62
|
+
"typescript": "^6.0.3"
|
|
64
63
|
},
|
|
65
64
|
"optionalDependencies": {
|
|
66
|
-
"@antelopejs/interface-dms-automation": ">=0.0
|
|
65
|
+
"@antelopejs/interface-dms-automation": ">=0.1.0 <1.0.0"
|
|
67
66
|
},
|
|
68
67
|
"antelopeJs": {
|
|
69
68
|
"baseUrl": "dist",
|
|
@@ -73,19 +72,18 @@
|
|
|
73
72
|
"test": "src/test/antelope.test.ts"
|
|
74
73
|
},
|
|
75
74
|
"scripts": {
|
|
76
|
-
"build": "rimraf dist && tsc
|
|
75
|
+
"build": "rimraf dist && tsc",
|
|
77
76
|
"dev": "ajs project run -w -p playground",
|
|
78
77
|
"dev:frontend": "cd playground && pnpm frontend:dev",
|
|
79
78
|
"frontend:dev": "cd playground && pnpm frontend:dev",
|
|
80
79
|
"lint": "oxlint && oxfmt --check . && cd frontend-vue && pnpm lint",
|
|
81
80
|
"lint:fix": "oxlint --fix --max-warnings=1000000 && oxfmt . && cd frontend-vue && pnpm lint:fix",
|
|
82
81
|
"format": "oxfmt . && cd frontend-vue && pnpm format",
|
|
83
|
-
"typecheck": "tsc
|
|
82
|
+
"typecheck": "tsc --noEmit",
|
|
84
83
|
"knip": "knip --dependencies",
|
|
85
84
|
"knip:all": "knip",
|
|
86
85
|
"release": "pnpm --filter @antelopejs/interface-dms-mailing build && pnpm --dir frontend-vue install --frozen-lockfile && release-it",
|
|
87
86
|
"test": "pnpm run test:backend && pnpm run test:layer",
|
|
88
|
-
"test:frontend": "node scripts/check-frontend.mjs",
|
|
89
87
|
"test:backend": "pnpm run build && ajs module test .",
|
|
90
88
|
"test:layer": "cd frontend-vue && pnpm test",
|
|
91
89
|
"test:unit": "pnpm run build && DMS_MAILING_TEST_DIR=dist/test/unit ajs module test .",
|
package/src/antelope-modules.ts
CHANGED
|
@@ -18,12 +18,12 @@ export const SHARED_MODULE_SOURCES = {
|
|
|
18
18
|
dms: {
|
|
19
19
|
type: "package",
|
|
20
20
|
package: "@antelopejs/dms",
|
|
21
|
-
version: ">=0.
|
|
21
|
+
version: ">=0.4.3 <1.0.0",
|
|
22
22
|
},
|
|
23
23
|
mongodb: {
|
|
24
24
|
type: "package",
|
|
25
25
|
package: "@antelopejs/mongodb",
|
|
26
|
-
version: "^1.3.
|
|
26
|
+
version: "^1.3.1",
|
|
27
27
|
},
|
|
28
28
|
"auth-jwt": {
|
|
29
29
|
type: "package",
|
|
@@ -33,16 +33,16 @@ export const SHARED_MODULE_SOURCES = {
|
|
|
33
33
|
api: {
|
|
34
34
|
type: "package",
|
|
35
35
|
package: "@antelopejs/api",
|
|
36
|
-
version: "1.
|
|
36
|
+
version: "^1.3.0",
|
|
37
37
|
},
|
|
38
38
|
"file-storage-local": {
|
|
39
39
|
type: "package",
|
|
40
40
|
package: "@antelopejs/file-storage-local",
|
|
41
|
-
version: "^0.1.
|
|
41
|
+
version: "^0.1.5",
|
|
42
42
|
},
|
|
43
43
|
nodemailer: {
|
|
44
44
|
type: "package",
|
|
45
45
|
package: "@antelopejs/nodemailer",
|
|
46
|
-
version: "^0.0.
|
|
46
|
+
version: "^0.0.5",
|
|
47
47
|
},
|
|
48
48
|
} as const;
|
package/src/constants.ts
CHANGED
|
@@ -4,10 +4,7 @@ export const TABLES_BASE_PATH = `${API_BASE_PATH}/tables`;
|
|
|
4
4
|
export const FRONTEND_MODULE_NAME = "@antelopejs/dms-mailing-frontend-vue";
|
|
5
5
|
export const EMAIL_TEMPLATE_NAME = "EmailMailingTemplate";
|
|
6
6
|
|
|
7
|
-
export const MAILING_ACCESS_PERMISSION = "mailing.access";
|
|
8
|
-
export const MAILING_TEMPLATES_MANAGE_PERMISSION = "mailing.templates.manage";
|
|
9
7
|
export const MAILING_SEND_PERMISSION = "mailing.send";
|
|
10
|
-
export const MAILING_SETTINGS_MANAGE_PERMISSION = "mailing.settings.manage";
|
|
11
8
|
|
|
12
9
|
export const OVERVIEW_PERIOD_SCOPE = "mailing-overview";
|
|
13
10
|
export const SENDS_PERIOD_SCOPE = "mailing-sends";
|
package/src/crons/retention.ts
CHANGED
|
@@ -92,7 +92,9 @@ export async function purgeOrphanEvents(
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
async function purgeTenant(tenantId: string, now: Date): Promise<number> {
|
|
95
|
-
const settings = await GetModel(SettingsModel, tenantId).getSingleton(
|
|
95
|
+
const settings = await GetModel(SettingsModel, tenantId).getSingleton(
|
|
96
|
+
tenantId,
|
|
97
|
+
);
|
|
96
98
|
if (!settings || settings.logRetentionDays < MIN_RETENTION_DAYS) return 0;
|
|
97
99
|
const limit = retentionLimit(settings.logRetentionDays, now);
|
|
98
100
|
const purged = await purgeInPages(tenantPass(tenantId, limit));
|
|
@@ -39,6 +39,7 @@ import { createTemplateSchema } from "../validation/templates.schema";
|
|
|
39
39
|
import { parseBody } from "./body";
|
|
40
40
|
|
|
41
41
|
const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
|
|
42
|
+
const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
|
|
42
43
|
const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
|
|
43
44
|
const CREATE_ONLY = { edit: ReadonlyBehaviorType.disabled };
|
|
44
45
|
// Read-only columns render as empty dashes on the create form; the four fields
|
|
@@ -99,9 +100,54 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
interface EditParams {
|
|
104
|
+
id: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The body an edit may apply. Sends resolve templates by slug and nothing
|
|
109
|
+
* enforces its uniqueness but the create checks, so a slug never changes once
|
|
110
|
+
* set: a different one is refused, a missing one (which the data-api edit
|
|
111
|
+
* would null) is kept.
|
|
112
|
+
*/
|
|
113
|
+
async function withStoredSlug(
|
|
114
|
+
tenantId: string,
|
|
115
|
+
templateId: string,
|
|
116
|
+
body: Buffer | string,
|
|
117
|
+
): Promise<Buffer | string> {
|
|
118
|
+
const stored = await GetModel(TemplateModel, tenantId).get(templateId);
|
|
119
|
+
if (!stored) return body;
|
|
120
|
+
const edit = parseBody(body);
|
|
121
|
+
if (edit.slug !== undefined && edit.slug !== stored.slug) {
|
|
122
|
+
throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
|
|
123
|
+
}
|
|
124
|
+
return JSON.stringify({ ...edit, slug: stored.slug });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function withFixedSlug(base: DataControllerCallback): DataControllerCallback {
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
func: async function (
|
|
131
|
+
this: unknown,
|
|
132
|
+
ctx: RequestContext,
|
|
133
|
+
params: EditParams,
|
|
134
|
+
body: Buffer | string,
|
|
135
|
+
...rest: unknown[]
|
|
136
|
+
) {
|
|
137
|
+
const guarded = await withStoredSlug(
|
|
138
|
+
getRequestTenantId(ctx),
|
|
139
|
+
params.id,
|
|
140
|
+
body,
|
|
141
|
+
);
|
|
142
|
+
return base.func.call(this, ctx, params, guarded, ...rest);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
102
147
|
const templateRoutes = {
|
|
103
148
|
...TableViewRoutes.All,
|
|
104
149
|
new: withSeeding(TableViewRoutes.New),
|
|
150
|
+
edit: withFixedSlug(TableViewRoutes.Edit),
|
|
105
151
|
};
|
|
106
152
|
|
|
107
153
|
@RegisterDataController()
|
|
@@ -14,6 +14,9 @@ type NewSend = Omit<
|
|
|
14
14
|
"_id" | "createdAt" | "revision" | "isRetiring" | "lastActivityAt"
|
|
15
15
|
>;
|
|
16
16
|
|
|
17
|
+
/** A send as the metrics read it: everything but the rendered variables. */
|
|
18
|
+
export type SendSummary = Omit<MailingSend, "json_variables">;
|
|
19
|
+
|
|
17
20
|
type SendActivity = Pick<
|
|
18
21
|
MailingSend,
|
|
19
22
|
"status" | "opens" | "clicks" | "lastEventAt" | "provider"
|
|
@@ -74,11 +77,12 @@ export class SendModel extends BasicDataModel(
|
|
|
74
77
|
return rows[0];
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
listBetween(from: Date, to: Date): Promise<
|
|
80
|
+
listBetween(from: Date, to: Date): Promise<SendSummary[]> {
|
|
78
81
|
return this.table
|
|
79
82
|
.filter((row) => row.key("createdAt").ge(from))
|
|
80
83
|
.filter((row) => row.key("createdAt").lt(to))
|
|
81
|
-
.
|
|
84
|
+
.without("json_variables")
|
|
85
|
+
.run() as Promise<SendSummary[]>;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
listOlderThan(limit: Date, pageSize: number): Promise<MailingSend[]> {
|
|
@@ -5,7 +5,13 @@ export class SettingsModel extends BasicDataModel(
|
|
|
5
5
|
MailingSettings,
|
|
6
6
|
MAILING_SETTINGS_TABLE_NAME,
|
|
7
7
|
) {
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The tenant's settings row, keyed by the tenant id. Rows written before the
|
|
10
|
+
* key existed carry a random id and are still found by a scan.
|
|
11
|
+
*/
|
|
12
|
+
async getSingleton(tenantId: string): Promise<MailingSettings | undefined> {
|
|
13
|
+
const keyed = await this.get(tenantId);
|
|
14
|
+
if (keyed) return keyed;
|
|
9
15
|
const rows = await this.getAll();
|
|
10
16
|
return rows[0];
|
|
11
17
|
}
|
|
@@ -64,7 +64,7 @@ export async function* jsonArrayChunks<T>(
|
|
|
64
64
|
async function exportedSettings(
|
|
65
65
|
tenantId: string,
|
|
66
66
|
): Promise<ExportedSettings | null> {
|
|
67
|
-
const row = await GetModel(SettingsModel, tenantId).getSingleton();
|
|
67
|
+
const row = await GetModel(SettingsModel, tenantId).getSingleton(tenantId);
|
|
68
68
|
if (!row) return null;
|
|
69
69
|
return {
|
|
70
70
|
fallbackLocale: row.fallbackLocale,
|
package/src/pages/editor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PageController, RegisterPage } from "@antelopejs/interface-dms/page";
|
|
2
2
|
import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
|
|
3
3
|
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
|
|
4
|
-
import {
|
|
4
|
+
import { MODULE_ID } from "../constants";
|
|
5
5
|
|
|
6
6
|
const EDITOR_PAGE_ORDER = 2;
|
|
7
7
|
|
|
@@ -15,7 +15,6 @@ export class MailingEditorController extends PageController(
|
|
|
15
15
|
module: MODULE_ID,
|
|
16
16
|
order: EDITOR_PAGE_ORDER,
|
|
17
17
|
hidden: true,
|
|
18
|
-
permission: { id: MAILING_TEMPLATES_MANAGE_PERMISSION },
|
|
19
18
|
},
|
|
20
19
|
DefaultLayout({ fullWidth: true }),
|
|
21
20
|
) {
|
package/src/pages/overview.ts
CHANGED
|
@@ -13,7 +13,6 @@ import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
|
|
|
13
13
|
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
|
|
14
14
|
import {
|
|
15
15
|
API_BASE_PATH,
|
|
16
|
-
MAILING_ACCESS_PERMISSION,
|
|
17
16
|
MAILING_SENDS_TOPIC,
|
|
18
17
|
MODULE_ID,
|
|
19
18
|
OVERVIEW_PERIOD_SCOPE,
|
|
@@ -50,7 +49,6 @@ export class OverviewPageController extends PageController(
|
|
|
50
49
|
icon: "i-ph-chart-pie-slice",
|
|
51
50
|
module: MODULE_ID,
|
|
52
51
|
order: OVERVIEW_ORDER,
|
|
53
|
-
permission: { id: MAILING_ACCESS_PERMISSION },
|
|
54
52
|
},
|
|
55
53
|
DefaultLayout({ fullWidth: true }),
|
|
56
54
|
) {
|
package/src/pages/sends.ts
CHANGED
|
@@ -10,12 +10,7 @@ import {
|
|
|
10
10
|
import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
|
|
11
11
|
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
|
|
12
12
|
import { TableView } from "@antelopejs/interface-dms/base/table-view";
|
|
13
|
-
import {
|
|
14
|
-
API_BASE_PATH,
|
|
15
|
-
MAILING_ACCESS_PERMISSION,
|
|
16
|
-
MODULE_ID,
|
|
17
|
-
SENDS_PERIOD_SCOPE,
|
|
18
|
-
} from "../constants";
|
|
13
|
+
import { API_BASE_PATH, MODULE_ID, SENDS_PERIOD_SCOPE } from "../constants";
|
|
19
14
|
import { SendsTableAPI } from "../data/sends-table";
|
|
20
15
|
import {
|
|
21
16
|
OPERATIONAL_AUDIENCE,
|
|
@@ -58,7 +53,6 @@ export class SendsPageController extends PageController(
|
|
|
58
53
|
icon: "i-ph-paper-plane-tilt",
|
|
59
54
|
module: MODULE_ID,
|
|
60
55
|
order: SENDS_ORDER,
|
|
61
|
-
permission: { id: MAILING_ACCESS_PERMISSION },
|
|
62
56
|
},
|
|
63
57
|
DefaultLayout({ fullWidth: true }),
|
|
64
58
|
) {
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
settingsCategory,
|
|
6
6
|
} from "@antelopejs/interface-dms/page";
|
|
7
7
|
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
|
|
8
|
-
import {
|
|
8
|
+
import { MODULE_ID } from "../../constants";
|
|
9
9
|
import { mailingSettingsForm } from "./form";
|
|
10
10
|
|
|
11
11
|
const SETTINGS_ORDER = 3;
|
|
@@ -15,7 +15,6 @@ export const mailingSettingsCategory = Category(MODULE_ID, {
|
|
|
15
15
|
displayName: "$dms_mailing.title",
|
|
16
16
|
icon: "i-ph-envelope-simple",
|
|
17
17
|
order: SETTINGS_ORDER,
|
|
18
|
-
authOnly: true,
|
|
19
18
|
});
|
|
20
19
|
|
|
21
20
|
@RegisterPage()
|
|
@@ -26,7 +25,6 @@ export class MailingSettingsPage extends PageController(
|
|
|
26
25
|
description: "$dms_mailing.settings.description",
|
|
27
26
|
icon: "i-ph-gear",
|
|
28
27
|
category: mailingSettingsCategory,
|
|
29
|
-
permission: { id: MAILING_SETTINGS_MANAGE_PERMISSION },
|
|
30
28
|
},
|
|
31
29
|
DefaultLayout(),
|
|
32
30
|
) {
|
package/src/pages/templates.ts
CHANGED
|
@@ -3,12 +3,7 @@ import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
|
|
|
3
3
|
import type { TableViewTab } from "@antelopejs/interface-dms/base";
|
|
4
4
|
import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
|
|
5
5
|
import { TableView } from "@antelopejs/interface-dms/base/table-view";
|
|
6
|
-
import {
|
|
7
|
-
API_BASE_PATH,
|
|
8
|
-
GALLERY_DISPLAY_ID,
|
|
9
|
-
MAILING_ACCESS_PERMISSION,
|
|
10
|
-
MODULE_ID,
|
|
11
|
-
} from "../constants";
|
|
6
|
+
import { API_BASE_PATH, GALLERY_DISPLAY_ID, MODULE_ID } from "../constants";
|
|
12
7
|
import { TemplatesTableAPI } from "../data/templates-table";
|
|
13
8
|
import "./module";
|
|
14
9
|
|
|
@@ -43,7 +38,6 @@ export class TemplatesPageController extends PageController(
|
|
|
43
38
|
icon: "i-ph-envelope-simple",
|
|
44
39
|
module: MODULE_ID,
|
|
45
40
|
order: TEMPLATES_ORDER,
|
|
46
|
-
permission: { id: MAILING_ACCESS_PERMISSION },
|
|
47
41
|
},
|
|
48
42
|
DefaultLayout({ fullWidth: true }),
|
|
49
43
|
) {
|
package/src/permissions.ts
CHANGED
|
@@ -1,44 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "
|
|
5
|
-
import {
|
|
6
|
-
MAILING_ACCESS_PERMISSION,
|
|
7
|
-
MAILING_SEND_PERMISSION,
|
|
8
|
-
MAILING_SETTINGS_MANAGE_PERMISSION,
|
|
9
|
-
MAILING_TEMPLATES_MANAGE_PERMISSION,
|
|
10
|
-
} from "./constants";
|
|
1
|
+
import { GetPermissionId } from "@antelopejs/interface-dms/page";
|
|
2
|
+
import { RegisterPermission } from "@antelopejs/interface-dms/permissions";
|
|
3
|
+
import { MAILING_SEND_PERMISSION } from "./constants";
|
|
4
|
+
import { SendsPageController } from "./pages/sends";
|
|
11
5
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
title: "Access mailing",
|
|
16
|
-
icon: "i-ph-envelope-simple",
|
|
17
|
-
description: "See templates, sends and mailing metrics",
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
id: MAILING_TEMPLATES_MANAGE_PERMISSION,
|
|
21
|
-
title: "Manage e-mail templates",
|
|
22
|
-
icon: "i-ph-note-pencil",
|
|
23
|
-
description: "Create, edit, publish, duplicate and archive templates",
|
|
24
|
-
dependencies: [MAILING_ACCESS_PERMISSION],
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
id: MAILING_SEND_PERMISSION,
|
|
28
|
-
title: "Send e-mails and tests",
|
|
29
|
-
icon: "i-ph-paper-plane-tilt",
|
|
30
|
-
description: "Trigger test sends and replay a previous send",
|
|
31
|
-
dependencies: [MAILING_ACCESS_PERMISSION],
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
id: MAILING_SETTINGS_MANAGE_PERMISSION,
|
|
35
|
-
title: "Manage mailing settings",
|
|
36
|
-
icon: "i-ph-sliders",
|
|
37
|
-
description: "Edit sender identity, retention and template categories",
|
|
38
|
-
dependencies: [MAILING_ACCESS_PERMISSION],
|
|
39
|
-
},
|
|
40
|
-
];
|
|
6
|
+
// `@RegisterPage` publishes the page synchronously when the class is defined,
|
|
7
|
+
// so the sends page's default permission id is known once it is imported.
|
|
8
|
+
const sendsPagePermissionId = GetPermissionId(SendsPageController);
|
|
41
9
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
10
|
+
RegisterPermission(MAILING_SEND_PERMISSION, {
|
|
11
|
+
id: MAILING_SEND_PERMISSION,
|
|
12
|
+
title: "Send e-mails and tests",
|
|
13
|
+
icon: "i-ph-paper-plane-tilt",
|
|
14
|
+
description: "Trigger test sends and replay a previous send",
|
|
15
|
+
dependencies: sendsPagePermissionId ? [sendsPagePermissionId] : [],
|
|
16
|
+
});
|
package/src/routes/events.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { API_BASE_PATH, HTTP_FORBIDDEN } from "../constants";
|
|
13
13
|
import { recordEmailEvent, webhookSecretMatches } from "../services/events";
|
|
14
|
-
import {
|
|
14
|
+
import { findSettings } from "../services/settings";
|
|
15
15
|
import { SEND_STATUSES, type SendStatus } from "../types";
|
|
16
16
|
|
|
17
17
|
const SECRET_HEADER = "x-mailing-webhook-secret";
|
|
@@ -39,9 +39,9 @@ export class MailingEventsController extends Controller(
|
|
|
39
39
|
) {
|
|
40
40
|
const event = assertValidation(body, (value) => eventSchema.parse(value));
|
|
41
41
|
const tenantId = event.tenantId ?? getRequestTenantId(this.ctx);
|
|
42
|
-
const settings = await
|
|
42
|
+
const settings = await findSettings(tenantId);
|
|
43
43
|
assert(
|
|
44
|
-
webhookSecretMatches(secret, settings
|
|
44
|
+
webhookSecretMatches(secret, settings?.webhookSecret),
|
|
45
45
|
HTTP_FORBIDDEN,
|
|
46
46
|
BAD_SECRET,
|
|
47
47
|
);
|
package/src/routes/metrics.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { TopListItem } from "@antelopejs/interface-dms/base";
|
|
|
12
12
|
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
13
13
|
import { API_BASE_PATH, HTTP_NOT_FOUND } from "../constants";
|
|
14
14
|
import {
|
|
15
|
-
type
|
|
15
|
+
type SendSummary,
|
|
16
16
|
type MailingTemplate,
|
|
17
17
|
SendModel,
|
|
18
18
|
TemplateModel,
|
|
@@ -91,7 +91,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
91
91
|
return readAudience(this.ctx.url.searchParams.get(SEND_AUDIENCE_QUERY_KEY));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
private async load(range: DateRange): Promise<
|
|
94
|
+
private async load(range: DateRange): Promise<SendSummary[]> {
|
|
95
95
|
const rows = await this.sends.listBetween(range.from, range.to);
|
|
96
96
|
return forAudience(rows, this.audience);
|
|
97
97
|
}
|
|
@@ -228,7 +228,7 @@ async function readTracking(): Promise<ProviderTracking | null> {
|
|
|
228
228
|
|
|
229
229
|
function toTemplateItem(
|
|
230
230
|
slug: string,
|
|
231
|
-
rows:
|
|
231
|
+
rows: SendSummary[],
|
|
232
232
|
templates: MailingTemplate[],
|
|
233
233
|
extract: (totals: Totals) => number,
|
|
234
234
|
): TopListItem {
|
package/src/routes/period.ts
CHANGED
|
@@ -21,14 +21,19 @@ function parseDate(raw: string | undefined): Date | undefined {
|
|
|
21
21
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Every window is read from the send log in full, so none may exceed the cap. */
|
|
25
|
+
function clampToMaxWindow(requested: Date, to: Date): DateRange {
|
|
26
|
+
const earliest = to.getTime() - MAX_WINDOW_DAYS * DAY_MS;
|
|
27
|
+
const from = requested.getTime() < earliest ? new Date(earliest) : requested;
|
|
28
|
+
return { from, to };
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
export function currentRange(ctx: RangeContext): DateRange {
|
|
25
32
|
const to = parseDate(readQuery(ctx, "to")) ?? new Date();
|
|
26
33
|
const requested =
|
|
27
34
|
parseDate(readQuery(ctx, "from")) ??
|
|
28
35
|
new Date(to.getTime() - DEFAULT_WINDOW_DAYS * DAY_MS);
|
|
29
|
-
|
|
30
|
-
const from = requested.getTime() < earliest ? new Date(earliest) : requested;
|
|
31
|
-
return { from, to };
|
|
36
|
+
return clampToMaxWindow(requested, to);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export function comparisonRange(
|
|
@@ -37,7 +42,7 @@ export function comparisonRange(
|
|
|
37
42
|
): DateRange {
|
|
38
43
|
const from = parseDate(readQuery(ctx, "compareFrom"));
|
|
39
44
|
const to = parseDate(readQuery(ctx, "compareTo"));
|
|
40
|
-
if (from && to) return
|
|
45
|
+
if (from && to) return clampToMaxWindow(from, to);
|
|
41
46
|
const span = current.to.getTime() - current.from.getTime();
|
|
42
47
|
return {
|
|
43
48
|
from: new Date(current.from.getTime() - span),
|
package/src/routes/templates.ts
CHANGED
|
@@ -96,7 +96,7 @@ export class TemplatesController extends Controller(
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* The tenant's categories for a category picker. Separate from `/settings`,
|
|
99
|
-
* which is guarded by the settings
|
|
99
|
+
* which is guarded by the settings page permission a template editor need
|
|
100
100
|
* not hold.
|
|
101
101
|
*/
|
|
102
102
|
@Get("/categories")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SendSummary, MailingTemplate } from "../db";
|
|
2
2
|
import { PROBLEM_STATUSES } from "../types";
|
|
3
3
|
|
|
4
4
|
export type AttentionTone = "error" | "warning" | "neutral";
|
|
@@ -24,7 +24,7 @@ const isStaleDraft = (template: MailingTemplate, now: number): boolean =>
|
|
|
24
24
|
!template.publishedAt &&
|
|
25
25
|
now - new Date(template.updatedAt).getTime() > STALE_DRAFT_DAYS * DAY_MS;
|
|
26
26
|
|
|
27
|
-
function problemsItem(sends:
|
|
27
|
+
function problemsItem(sends: SendSummary[]): AttentionItem[] {
|
|
28
28
|
const problems = sends.filter((send) =>
|
|
29
29
|
PROBLEM_STATUSES.includes(send.status),
|
|
30
30
|
);
|
|
@@ -62,7 +62,7 @@ function staleDraftsItem(templates: MailingTemplate[]): AttentionItem[] {
|
|
|
62
62
|
|
|
63
63
|
function neverSentItem(
|
|
64
64
|
templates: MailingTemplate[],
|
|
65
|
-
sends:
|
|
65
|
+
sends: SendSummary[],
|
|
66
66
|
): AttentionItem[] {
|
|
67
67
|
const used = new Set(sends.map((send) => send.templateSlug));
|
|
68
68
|
const unused = templates.filter(
|
|
@@ -89,8 +89,8 @@ function neverSentItem(
|
|
|
89
89
|
*/
|
|
90
90
|
export interface AttentionWindow {
|
|
91
91
|
templates: MailingTemplate[];
|
|
92
|
-
sends:
|
|
93
|
-
excluded:
|
|
92
|
+
sends: SendSummary[];
|
|
93
|
+
excluded: SendSummary[];
|
|
94
94
|
/** What the configured provider can report; `null` when it is unreachable. */
|
|
95
95
|
tracking: ProviderTracking | null;
|
|
96
96
|
}
|
package/src/services/events.ts
CHANGED
|
@@ -104,14 +104,14 @@ export async function recordEmailEvent(
|
|
|
104
104
|
/**
|
|
105
105
|
* Whether a webhook request carries the tenant's secret.
|
|
106
106
|
*
|
|
107
|
-
* Fails closed on a blank stored secret: the event endpoint is the
|
|
108
|
-
* only unauthenticated route, so "no secret configured" must refuse
|
|
109
|
-
* rather than accept an empty header. The comparison itself is constant-time;
|
|
107
|
+
* Fails closed on a blank or missing stored secret: the event endpoint is the
|
|
108
|
+
* module's only unauthenticated route, so "no secret configured" must refuse
|
|
109
|
+
* everything rather than accept an empty header. The comparison itself is constant-time;
|
|
110
110
|
* only the length is observable.
|
|
111
111
|
*/
|
|
112
112
|
export function webhookSecretMatches(
|
|
113
113
|
provided: string | undefined,
|
|
114
|
-
stored: string,
|
|
114
|
+
stored: string | undefined,
|
|
115
115
|
): boolean {
|
|
116
116
|
if (!stored || !provided) return false;
|
|
117
117
|
const left = Buffer.from(provided);
|
package/src/services/metrics.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SendSummary } from "../db";
|
|
2
2
|
import {
|
|
3
3
|
BUSINESS_AUDIENCE,
|
|
4
4
|
OPERATIONAL_AUDIENCE,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type SendStatus,
|
|
7
7
|
} from "../types";
|
|
8
8
|
|
|
9
|
-
const AUDIENCE_KEEPS: Record<SendAudience, (send:
|
|
9
|
+
const AUDIENCE_KEEPS: Record<SendAudience, (send: SendSummary) => boolean> = {
|
|
10
10
|
[BUSINESS_AUDIENCE]: (send) => !send.isTest,
|
|
11
11
|
[OPERATIONAL_AUDIENCE]: () => true,
|
|
12
12
|
};
|
|
@@ -22,9 +22,9 @@ export function readAudience(raw: string | null | undefined): SendAudience {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function forAudience(
|
|
25
|
-
sends:
|
|
25
|
+
sends: SendSummary[],
|
|
26
26
|
audience: SendAudience,
|
|
27
|
-
):
|
|
27
|
+
): SendSummary[] {
|
|
28
28
|
return sends.filter(AUDIENCE_KEEPS[audience]);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -72,13 +72,13 @@ export const ratio = (part: number, whole: number): number =>
|
|
|
72
72
|
whole ? (part / whole) * PERCENT : 0;
|
|
73
73
|
|
|
74
74
|
const countIf = (
|
|
75
|
-
sends:
|
|
76
|
-
predicate: (send:
|
|
75
|
+
sends: SendSummary[],
|
|
76
|
+
predicate: (send: SendSummary) => boolean,
|
|
77
77
|
): number => sends.filter(predicate).length;
|
|
78
78
|
|
|
79
79
|
const byStatus =
|
|
80
80
|
(status: SendStatus) =>
|
|
81
|
-
(send:
|
|
81
|
+
(send: SendSummary): boolean =>
|
|
82
82
|
send.status === status;
|
|
83
83
|
|
|
84
84
|
function median(values: number[]): number {
|
|
@@ -87,7 +87,7 @@ function median(values: number[]): number {
|
|
|
87
87
|
return sorted[Math.floor(sorted.length / 2)] as number;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
export function aggregate(sends:
|
|
90
|
+
export function aggregate(sends: SendSummary[]): Totals {
|
|
91
91
|
const delivered = countIf(sends, (send) =>
|
|
92
92
|
DELIVERED_LIKE.includes(send.status),
|
|
93
93
|
);
|
|
@@ -138,7 +138,7 @@ const dayKey = (date: Date): string =>
|
|
|
138
138
|
* still counted it.
|
|
139
139
|
*/
|
|
140
140
|
export function dailySeries(
|
|
141
|
-
sends:
|
|
141
|
+
sends: SendSummary[],
|
|
142
142
|
from: Date,
|
|
143
143
|
to: Date,
|
|
144
144
|
): SeriesPoint[] {
|