@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.
Files changed (117) hide show
  1. package/README.md +7 -8
  2. package/dist/antelope-modules.d.ts +5 -5
  3. package/dist/antelope-modules.js +5 -5
  4. package/dist/antelope-modules.js.map +1 -1
  5. package/dist/constants.d.ts +0 -3
  6. package/dist/constants.js +1 -4
  7. package/dist/constants.js.map +1 -1
  8. package/dist/crons/retention.js +1 -1
  9. package/dist/crons/retention.js.map +1 -1
  10. package/dist/data/templates-table.d.ts +1 -1
  11. package/dist/data/templates-table.js +21 -0
  12. package/dist/data/templates-table.js.map +1 -1
  13. package/dist/data-types/categories-type.d.ts +5 -5
  14. package/dist/db/models/sends.model.d.ts +2 -1
  15. package/dist/db/models/sends.model.js +1 -0
  16. package/dist/db/models/sends.model.js.map +1 -1
  17. package/dist/db/models/settings.model.d.ts +1 -1
  18. package/dist/db/models/settings.model.js +4 -1
  19. package/dist/db/models/settings.model.js.map +1 -1
  20. package/dist/db/tables/send-events.table.js +0 -1
  21. package/dist/db/tables/send-events.table.js.map +1 -1
  22. package/dist/db/tables/sends.table.js +0 -1
  23. package/dist/db/tables/sends.table.js.map +1 -1
  24. package/dist/db/tables/templates.table.js +0 -1
  25. package/dist/db/tables/templates.table.js.map +1 -1
  26. package/dist/hooks/tenant-export.js +1 -1
  27. package/dist/hooks/tenant-export.js.map +1 -1
  28. package/dist/pages/editor.js +0 -1
  29. package/dist/pages/editor.js.map +1 -1
  30. package/dist/pages/module.d.ts +1 -1
  31. package/dist/pages/overview.d.ts +5 -5
  32. package/dist/pages/overview.js +0 -1
  33. package/dist/pages/overview.js.map +1 -1
  34. package/dist/pages/sends.d.ts +3 -3
  35. package/dist/pages/sends.js +0 -1
  36. package/dist/pages/sends.js.map +1 -1
  37. package/dist/pages/settings/form.d.ts +1 -1
  38. package/dist/pages/settings/page.d.ts +2 -2
  39. package/dist/pages/settings/page.js +0 -2
  40. package/dist/pages/settings/page.js.map +1 -1
  41. package/dist/pages/templates.d.ts +1 -1
  42. package/dist/pages/templates.js +0 -1
  43. package/dist/pages/templates.js.map +1 -1
  44. package/dist/permissions.js +10 -32
  45. package/dist/permissions.js.map +1 -1
  46. package/dist/quick-actions.d.ts +2 -2
  47. package/dist/routes/events.js +2 -2
  48. package/dist/routes/events.js.map +1 -1
  49. package/dist/routes/period.js +7 -4
  50. package/dist/routes/period.js.map +1 -1
  51. package/dist/routes/templates.d.ts +2 -2
  52. package/dist/services/attention.d.ts +3 -3
  53. package/dist/services/categories.d.ts +5 -5
  54. package/dist/services/events.d.ts +1 -1
  55. package/dist/services/events.js.map +1 -1
  56. package/dist/services/metrics.d.ts +4 -4
  57. package/dist/services/settings.d.ts +1 -0
  58. package/dist/services/settings.js +25 -8
  59. package/dist/services/settings.js.map +1 -1
  60. package/dist/test/antelope.test.js +1 -0
  61. package/dist/test/antelope.test.js.map +1 -1
  62. package/dist/test/integration/events.test.js +11 -0
  63. package/dist/test/integration/events.test.js.map +1 -1
  64. package/dist/test/integration/sends.test.js +53 -0
  65. package/dist/test/integration/sends.test.js.map +1 -1
  66. package/dist/test/integration/settings.test.js +92 -0
  67. package/dist/test/integration/settings.test.js.map +1 -1
  68. package/dist/test/integration/templates.test.js +68 -2
  69. package/dist/test/integration/templates.test.js.map +1 -1
  70. package/dist/test/unit/period.test.js +17 -0
  71. package/dist/test/unit/period.test.js.map +1 -1
  72. package/dist/test/unit/permissions.test.d.ts +1 -0
  73. package/dist/test/unit/permissions.test.js +27 -0
  74. package/dist/test/unit/permissions.test.js.map +1 -0
  75. package/dist/test/unit/webhook-secret.test.js +1 -0
  76. package/dist/test/unit/webhook-secret.test.js.map +1 -1
  77. package/dist/validation/settings.schema.d.ts +6 -6
  78. package/dist/validation/templates.schema.d.ts +14 -14
  79. package/frontend-vue/app/components/editor/EditorDataPanel.vue +3 -1
  80. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -0
  81. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -0
  82. package/frontend-vue/package.json +1 -1
  83. package/frontend-vue/pnpm-lock.yaml +2 -28
  84. package/package.json +10 -12
  85. package/src/antelope-modules.ts +5 -5
  86. package/src/constants.ts +0 -3
  87. package/src/crons/retention.ts +3 -1
  88. package/src/data/templates-table.ts +46 -0
  89. package/src/db/models/sends.model.ts +6 -2
  90. package/src/db/models/settings.model.ts +7 -1
  91. package/src/db/tables/send-events.table.ts +0 -1
  92. package/src/db/tables/sends.table.ts +0 -1
  93. package/src/db/tables/templates.table.ts +0 -1
  94. package/src/hooks/tenant-export.ts +1 -1
  95. package/src/pages/editor.ts +1 -2
  96. package/src/pages/overview.ts +0 -2
  97. package/src/pages/sends.ts +1 -7
  98. package/src/pages/settings/page.ts +1 -3
  99. package/src/pages/templates.ts +1 -7
  100. package/src/permissions.ts +14 -42
  101. package/src/routes/events.ts +3 -3
  102. package/src/routes/metrics.ts +3 -3
  103. package/src/routes/period.ts +9 -4
  104. package/src/routes/templates.ts +1 -1
  105. package/src/services/attention.ts +5 -5
  106. package/src/services/events.ts +4 -4
  107. package/src/services/metrics.ts +9 -9
  108. package/src/services/settings.ts +36 -7
  109. package/src/test/antelope.test.ts +2 -0
  110. package/src/test/integration/events.test.ts +24 -1
  111. package/src/test/integration/sends.test.ts +33 -0
  112. package/src/test/integration/settings.test.ts +78 -0
  113. package/src/test/integration/templates.test.ts +49 -6
  114. package/src/test/unit/period.test.ts +27 -0
  115. package/src/test/unit/permissions.test.ts +30 -0
  116. package/src/test/unit/webhook-secret.test.ts +1 -0
  117. 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.2.3",
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.0.13 <1.0.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.0 <1.0.0",
39
- "@antelopejs/interface-dms-mailing": ">=0.2.0 <1.0.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.8.0 <2",
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": "^5.9.3"
62
+ "typescript": "^6.0.3"
64
63
  },
65
64
  "optionalDependencies": {
66
- "@antelopejs/interface-dms-automation": ">=0.0.1 <1.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 -p tsconfig.build.json",
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 -p tsconfig.json",
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 .",
@@ -18,12 +18,12 @@ export const SHARED_MODULE_SOURCES = {
18
18
  dms: {
19
19
  type: "package",
20
20
  package: "@antelopejs/dms",
21
- version: ">=0.0.1 <1.0.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.0",
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.2.5",
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.4",
41
+ version: "^0.1.5",
42
42
  },
43
43
  nodemailer: {
44
44
  type: "package",
45
45
  package: "@antelopejs/nodemailer",
46
- version: "^0.0.4",
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";
@@ -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<MailingSend[]> {
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
- .run();
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
- async getSingleton(): Promise<MailingSettings | undefined> {
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
  }
@@ -21,7 +21,6 @@ export class MailingSendEvent extends Table {
21
21
  @Field("string")
22
22
  declare type: SendEventType;
23
23
 
24
- @Index()
25
24
  @Field("date")
26
25
  declare at: Date;
27
26
 
@@ -20,7 +20,6 @@ export class MailingSend extends Table {
20
20
  @Field("date")
21
21
  declare createdAt: Date;
22
22
 
23
- @Index()
24
23
  @Field("string")
25
24
  declare templateId: string;
26
25
 
@@ -16,7 +16,6 @@ export class MailingTemplate extends Table {
16
16
  @Field("string")
17
17
  declare _id: string;
18
18
 
19
- @Index()
20
19
  @CreationTime()
21
20
  @Field("date")
22
21
  declare createdAt: Date;
@@ -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,
@@ -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 { MAILING_TEMPLATES_MANAGE_PERMISSION, MODULE_ID } from "../constants";
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
  ) {
@@ -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
  ) {
@@ -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 { MAILING_SETTINGS_MANAGE_PERMISSION, MODULE_ID } from "../../constants";
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
  ) {
@@ -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
  ) {
@@ -1,44 +1,16 @@
1
- import {
2
- type Permission,
3
- RegisterPermission,
4
- } from "@antelopejs/interface-dms/permissions";
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
- const MAILING_PERMISSIONS: Permission[] = [
13
- {
14
- id: MAILING_ACCESS_PERMISSION,
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
- for (const permission of MAILING_PERMISSIONS) {
43
- RegisterPermission(permission.id, permission);
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
+ });
@@ -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 { getSettings } from "../services/settings";
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 getSettings(tenantId);
42
+ const settings = await findSettings(tenantId);
43
43
  assert(
44
- webhookSecretMatches(secret, settings.webhookSecret),
44
+ webhookSecretMatches(secret, settings?.webhookSecret),
45
45
  HTTP_FORBIDDEN,
46
46
  BAD_SECRET,
47
47
  );
@@ -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 MailingSend,
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<MailingSend[]> {
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: MailingSend[],
231
+ rows: SendSummary[],
232
232
  templates: MailingTemplate[],
233
233
  extract: (totals: Totals) => number,
234
234
  ): TopListItem {
@@ -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
- const earliest = to.getTime() - MAX_WINDOW_DAYS * DAY_MS;
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 { from, to };
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),
@@ -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-manage permission a template editor need
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 { MailingSend, MailingTemplate } from "../db";
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: MailingSend[]): AttentionItem[] {
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: MailingSend[],
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: MailingSend[];
93
- excluded: MailingSend[];
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
  }
@@ -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 module's
108
- * only unauthenticated route, so "no secret configured" must refuse everything
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);
@@ -1,4 +1,4 @@
1
- import type { MailingSend } from "../db";
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: MailingSend) => boolean> = {
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: MailingSend[],
25
+ sends: SendSummary[],
26
26
  audience: SendAudience,
27
- ): MailingSend[] {
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: MailingSend[],
76
- predicate: (send: MailingSend) => boolean,
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: MailingSend): boolean =>
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: MailingSend[]): Totals {
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: MailingSend[],
141
+ sends: SendSummary[],
142
142
  from: Date,
143
143
  to: Date,
144
144
  ): SeriesPoint[] {