@antelopejs/dms-mailing 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -43
- package/dist/automation/email-event-trigger.d.ts +2 -2
- package/dist/automation/send-template-action.d.ts +11 -3
- package/dist/automation/send-template-action.js +1 -1
- package/dist/automation/send-template-action.js.map +1 -1
- package/dist/data/body.d.ts +2 -1
- package/dist/data/body.js +2 -1
- package/dist/data/body.js.map +1 -1
- package/dist/data/read-only-routes.d.ts +4 -4
- package/dist/data/sends-table.d.ts +4 -4
- package/dist/data/suppressions-table.d.ts +4 -4
- package/dist/data/templates-table.d.ts +35 -12
- package/dist/data/templates-table.js +29 -24
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +1 -2
- package/dist/data-types/categories-type.js +3 -5
- package/dist/data-types/categories-type.js.map +1 -1
- package/dist/data-types/category-type.d.ts +1 -2
- package/dist/data-types/category-type.js +3 -5
- package/dist/data-types/category-type.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +7 -5
- package/dist/db/models/sends.model.d.ts +7 -5
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +7 -5
- package/dist/db/models/suppressions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.js +4 -3
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +7 -5
- package/dist/db/models/webhook-status.model.d.ts +7 -5
- 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/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +8 -19
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.d.ts +2 -1
- package/dist/engine/conditions.js +3 -2
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/coverage.d.ts +2 -1
- package/dist/engine/coverage.js.map +1 -1
- package/dist/engine/formats.d.ts +4 -6
- package/dist/engine/formats.js +17 -3
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.d.ts +4 -2
- package/dist/engine/paths.js +15 -3
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/plain-text.js +2 -4
- package/dist/engine/plain-text.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +1 -1
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/system-variables.d.ts +2 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/tenant-export.d.ts +1 -1
- package/dist/hooks/tenant-export.js +10 -9
- 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/pages/templates.js +3 -1
- package/dist/pages/templates.js.map +1 -1
- package/dist/routes/events.js +4 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/metrics.js +27 -28
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.js +5 -5
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +3 -4
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +4 -3
- package/dist/routes/settings.js +20 -14
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/suppressions.js +9 -6
- package/dist/routes/suppressions.js.map +1 -1
- package/dist/routes/template-content.d.ts +27 -0
- package/dist/routes/template-content.js +3 -0
- package/dist/routes/template-content.js.map +1 -0
- package/dist/routes/templates.d.ts +7 -30
- package/dist/routes/templates.js +3 -2
- package/dist/routes/templates.js.map +1 -1
- package/dist/routes/zod-json.d.ts +3 -0
- package/dist/routes/zod-json.js +21 -0
- package/dist/routes/zod-json.js.map +1 -0
- package/dist/services/attention.js +14 -7
- package/dist/services/attention.js.map +1 -1
- package/dist/services/catalog.js +4 -2
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/declaration-error.d.ts +1 -1
- package/dist/services/declaration-error.js.map +1 -1
- package/dist/services/declarations.js +4 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +3 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/metrics.js +2 -5
- 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/send.js +3 -4
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +6 -3
- package/dist/services/settings.js +24 -5
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +3 -2
- package/dist/services/starters.js +1 -1
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +2 -1
- package/dist/services/templates.js +5 -5
- package/dist/services/templates.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.d.ts +2 -2
- package/dist/services/webhook-status.js +13 -6
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/json.d.ts +1 -0
- package/dist/types/json.js +3 -0
- package/dist/types/json.js.map +1 -0
- package/dist/types/settings.d.ts +5 -2
- package/dist/types/settings.js +6 -1
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +41 -28
- package/dist/validation/declarations.schema.js +10 -4
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +0 -1
- package/dist/validation/guards.js +0 -4
- package/dist/validation/guards.js.map +1 -1
- package/dist/validation/json.d.ts +5 -0
- package/dist/validation/json.js +54 -0
- package/dist/validation/json.js.map +1 -0
- package/dist/validation/json.schema.d.ts +2 -0
- package/dist/validation/json.schema.js +9 -0
- package/dist/validation/json.schema.js.map +1 -0
- package/dist/validation/templates.schema.d.ts +25 -24
- package/dist/validation/templates.schema.js +4 -3
- package/dist/validation/templates.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 +65 -5
- package/frontend-vue/tests/editor-canvas.test.ts +142 -0
- package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
- package/frontend-vue/tsconfig.typecheck.json +12 -0
- package/frontend-vue/vitest.config.ts +16 -0
- package/package.json +16 -15
- package/skills/dms-mailing/SKILL.md +5 -3
- package/src/automation/email-event-trigger.ts +2 -2
- package/src/automation/send-template-action.ts +33 -14
- package/src/data/body.ts +5 -2
- package/src/data/templates-table.ts +46 -52
- package/src/data-types/categories-type.ts +2 -2
- package/src/data-types/category-type.ts +2 -2
- package/src/db/models/sends.model.ts +2 -2
- package/src/db/models/template-versions.model.ts +5 -3
- package/src/db/tables/settings.table.ts +4 -0
- package/src/db/tables/templates.table.ts +1 -1
- package/src/engine/changes.ts +11 -29
- package/src/engine/conditions.ts +12 -4
- package/src/engine/coverage.ts +2 -1
- package/src/engine/formats.ts +31 -17
- package/src/engine/paths.ts +39 -6
- package/src/engine/plain-text.ts +2 -4
- package/src/engine/resolve.ts +12 -4
- package/src/engine/system-variables.ts +2 -1
- package/src/engine/tokens.ts +2 -1
- package/src/hooks/tenant-export.ts +15 -14
- package/src/implementations/dms-mailing/index.ts +1 -0
- package/src/pages/sends.ts +3 -2
- package/src/pages/templates.ts +3 -1
- package/src/routes/events.ts +7 -4
- package/src/routes/index.ts +1 -0
- package/src/routes/metrics.ts +30 -30
- package/src/routes/period.ts +5 -5
- package/src/routes/sends.ts +13 -8
- package/src/routes/settings.ts +34 -17
- package/src/routes/suppressions.ts +12 -8
- package/src/routes/template-content.ts +45 -0
- package/src/routes/templates.ts +11 -44
- package/src/routes/zod-json.ts +28 -0
- package/src/services/attention.ts +16 -15
- package/src/services/catalog.ts +4 -8
- package/src/services/declaration-error.ts +1 -1
- package/src/services/declarations.ts +4 -1
- package/src/services/events.ts +4 -2
- package/src/services/metrics.ts +3 -5
- package/src/services/send-import.ts +10 -8
- package/src/services/send.ts +3 -6
- package/src/services/settings.ts +55 -16
- package/src/services/starters.ts +8 -4
- package/src/services/templates.ts +16 -9
- package/src/services/webhook-secret-declaration.ts +47 -0
- package/src/services/webhook-status.ts +18 -9
- package/src/types/json.ts +1 -0
- package/src/types/settings.ts +16 -2
- package/src/validation/declarations.schema.ts +11 -3
- package/src/validation/guards.ts +0 -8
- package/src/validation/json.schema.ts +7 -0
- package/src/validation/json.ts +66 -0
- package/src/validation/templates.schema.ts +7 -3
package/src/engine/paths.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TemplateVariables,
|
|
3
|
+
TemplateVariableValue,
|
|
4
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
1
5
|
import { isObject } from "../validation/guards";
|
|
2
6
|
|
|
3
7
|
const PATH_SEPARATOR = ".";
|
|
4
8
|
const ARRAY_SUFFIX = "[]";
|
|
9
|
+
const ARRAY_LENGTH = "length";
|
|
10
|
+
const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
|
|
5
11
|
|
|
6
12
|
export function normalizePath(path: string): string {
|
|
7
13
|
const trimmed = path.trim();
|
|
@@ -17,18 +23,45 @@ export function normalizePath(path: string): string {
|
|
|
17
23
|
*/
|
|
18
24
|
const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
/** Whether a path can step into `value`: a record or a money value, not a list or a `Date`. */
|
|
27
|
+
export function isRecord(
|
|
28
|
+
value: TemplateVariableValue | undefined,
|
|
29
|
+
): value is TemplateVariables {
|
|
30
|
+
return isObject(value) && !Array.isArray(value) && !(value instanceof Date);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readKey(record: TemplateVariables, key: string) {
|
|
34
|
+
return Object.hasOwn(record, key) ? record[key] : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readSegment(
|
|
38
|
+
current: TemplateVariableValue | undefined,
|
|
39
|
+
segment: string,
|
|
40
|
+
): TemplateVariableValue | undefined {
|
|
22
41
|
if (RESERVED_SEGMENTS.includes(segment)) return undefined;
|
|
23
|
-
|
|
42
|
+
if (Array.isArray(current)) {
|
|
43
|
+
if (segment === ARRAY_LENGTH) return current.length;
|
|
44
|
+
return ARRAY_INDEX.test(segment) ? current[Number(segment)] : undefined;
|
|
45
|
+
}
|
|
46
|
+
return isRecord(current) ? readKey(current, segment) : undefined;
|
|
24
47
|
}
|
|
25
48
|
|
|
26
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The value `path` names in `data`: a key a record owns, a list's element by
|
|
51
|
+
* index or its length. `undefined` when the path leaves the data.
|
|
52
|
+
*/
|
|
53
|
+
export function getPath(
|
|
54
|
+
data: TemplateVariableValue | undefined,
|
|
55
|
+
path: string,
|
|
56
|
+
): TemplateVariableValue | undefined {
|
|
27
57
|
return normalizePath(path)
|
|
28
58
|
.split(PATH_SEPARATOR)
|
|
29
|
-
.reduce<
|
|
59
|
+
.reduce<TemplateVariableValue | undefined>(readSegment, data);
|
|
30
60
|
}
|
|
31
61
|
|
|
32
|
-
|
|
62
|
+
/** Whether `value` is neither missing nor `null`. */
|
|
63
|
+
export function isPresent(
|
|
64
|
+
value: TemplateVariableValue | undefined,
|
|
65
|
+
): value is Exclude<TemplateVariableValue, null> {
|
|
33
66
|
return value !== undefined && value !== null;
|
|
34
67
|
}
|
package/src/engine/plain-text.ts
CHANGED
|
@@ -13,14 +13,12 @@ const FOOTER_TYPE = "footer";
|
|
|
13
13
|
* Languages whose typography spaces a colon off its label, with a no-break
|
|
14
14
|
* space so the colon never starts a line ("Total : 64,00 €").
|
|
15
15
|
*/
|
|
16
|
-
const LABEL_SEPARATORS
|
|
17
|
-
fr: " : ",
|
|
18
|
-
};
|
|
16
|
+
const LABEL_SEPARATORS = new Map([["fr", " : "]]);
|
|
19
17
|
|
|
20
18
|
/** What goes between a label and its value in the e-mail's language. */
|
|
21
19
|
export function labelSeparatorOf(locale = ""): string {
|
|
22
20
|
const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
|
|
23
|
-
return LABEL_SEPARATORS
|
|
21
|
+
return LABEL_SEPARATORS.get(language) ?? DEFAULT_LABEL_SEPARATOR;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
const linked = (label: string, href: string, separator: string): string =>
|
package/src/engine/resolve.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TemplateVariables,
|
|
3
|
+
TemplateVariableValue,
|
|
4
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
1
5
|
import {
|
|
2
6
|
PREFERENCES_URL_PATH,
|
|
3
7
|
SYSTEM_VARIABLE_PATHS,
|
|
@@ -15,12 +19,12 @@ import type {
|
|
|
15
19
|
} from "../types";
|
|
16
20
|
import { evaluateCondition } from "./conditions";
|
|
17
21
|
import { safeUrl } from "./fallback-html";
|
|
18
|
-
import { formatValue, type ValueFormat } from "./formats";
|
|
22
|
+
import { formatValue, plainText, type ValueFormat } from "./formats";
|
|
19
23
|
import { getPath, isPresent } from "./paths";
|
|
20
24
|
import { footerLinkPaths } from "./system-variables";
|
|
21
25
|
import { type InterpolationMode, interpolate } from "./tokens";
|
|
22
26
|
|
|
23
|
-
type Data =
|
|
27
|
+
type Data = TemplateVariables;
|
|
24
28
|
|
|
25
29
|
interface ResolveState {
|
|
26
30
|
data: Data;
|
|
@@ -60,11 +64,15 @@ function systemUrl(
|
|
|
60
64
|
): string {
|
|
61
65
|
if (!footerLinkPaths(block).includes(path)) return "";
|
|
62
66
|
const value = getPath(state.data, path);
|
|
63
|
-
return isPresent(value) ? safeUrl(
|
|
67
|
+
return isPresent(value) ? safeUrl(plainText(value)) : "";
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
/** A list cell: a money value or a date in it is written for the locale. */
|
|
67
|
-
function cell(
|
|
71
|
+
function cell(
|
|
72
|
+
item: TemplateVariableValue,
|
|
73
|
+
path: string,
|
|
74
|
+
state: ResolveState,
|
|
75
|
+
): string {
|
|
68
76
|
const value = getPath(item, path);
|
|
69
77
|
return isPresent(value) ? formatValue(value, "", state.format) : "";
|
|
70
78
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
|
|
1
2
|
import {
|
|
2
3
|
PREFERENCES_URL_PATH,
|
|
3
4
|
SYSTEM_VARIABLE_PATHS,
|
|
@@ -93,7 +94,7 @@ export function systemVariables(
|
|
|
93
94
|
export function missingSystemVariables(
|
|
94
95
|
content: LocaleContent,
|
|
95
96
|
category: string | null | undefined,
|
|
96
|
-
data:
|
|
97
|
+
data: TemplateVariables,
|
|
97
98
|
): string[] {
|
|
98
99
|
return systemVariables(localeSystemPaths(content), category)
|
|
99
100
|
.filter((variable) => variable.required)
|
package/src/engine/tokens.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
|
|
1
2
|
import { formatValue, type ValueFormat } from "./formats";
|
|
2
3
|
import { getPath, isPresent } from "./paths";
|
|
3
4
|
|
|
@@ -31,7 +32,7 @@ export function extractTokens(text: string): string[] {
|
|
|
31
32
|
*/
|
|
32
33
|
export function interpolate(
|
|
33
34
|
text: string,
|
|
34
|
-
data:
|
|
35
|
+
data: TemplateVariables,
|
|
35
36
|
mode: InterpolationMode = "preview",
|
|
36
37
|
format?: ValueFormat,
|
|
37
38
|
): InterpolationResult {
|
|
@@ -11,14 +11,17 @@ import {
|
|
|
11
11
|
import { MODULE_ID } from "../constants";
|
|
12
12
|
import {
|
|
13
13
|
type MailingTemplate,
|
|
14
|
-
SettingsModel,
|
|
15
14
|
SuppressionModel,
|
|
16
15
|
TemplateModel,
|
|
17
16
|
TemplateVersionModel,
|
|
18
17
|
} from "../db";
|
|
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,
|
|
@@ -26,7 +29,7 @@ export type ExportPageLoader<T> = (
|
|
|
26
29
|
) => Promise<T[]>;
|
|
27
30
|
|
|
28
31
|
interface RowCodec<T> {
|
|
29
|
-
fromDatabase(row:
|
|
32
|
+
fromDatabase(row: T): T | undefined;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export interface ExportedCollection {
|
|
@@ -72,18 +75,16 @@ export async function* jsonArrayChunks<T>(
|
|
|
72
75
|
async function exportedSettings(
|
|
73
76
|
tenantId: string,
|
|
74
77
|
): Promise<ExportedSettings | null> {
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
78
|
+
const settings = await findSettings(tenantId);
|
|
79
|
+
if (!settings) return null;
|
|
77
80
|
return {
|
|
78
|
-
fallbackLocale:
|
|
79
|
-
logRetentionDays:
|
|
80
|
-
blockOnMissingVariables:
|
|
81
|
-
senderName:
|
|
82
|
-
senderEmail:
|
|
83
|
-
replyTo:
|
|
84
|
-
categories:
|
|
85
|
-
row.json_categories,
|
|
86
|
-
) as ExportedSettings["categories"],
|
|
81
|
+
fallbackLocale: settings.fallbackLocale,
|
|
82
|
+
logRetentionDays: settings.logRetentionDays,
|
|
83
|
+
blockOnMissingVariables: settings.blockOnMissingVariables,
|
|
84
|
+
senderName: settings.senderName,
|
|
85
|
+
senderEmail: settings.senderEmail,
|
|
86
|
+
replyTo: settings.replyTo,
|
|
87
|
+
categories: settings.categories,
|
|
87
88
|
};
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -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/pages/templates.ts
CHANGED
|
@@ -109,7 +109,9 @@ export class TemplatesPageController extends PageController(
|
|
|
109
109
|
},
|
|
110
110
|
async edit(ctx, { id, body, current }) {
|
|
111
111
|
await assertEditable(getRequestTenantId(ctx), body, current);
|
|
112
|
-
return
|
|
112
|
+
return body.name === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: afterEdit(this.model, id, localeOfRequest(ctx));
|
|
113
115
|
},
|
|
114
116
|
},
|
|
115
117
|
rowActions: {
|
package/src/routes/events.ts
CHANGED
|
@@ -18,13 +18,16 @@ import {
|
|
|
18
18
|
WEBHOOK_SECRET_HEADER,
|
|
19
19
|
} from "../services/webhook-status";
|
|
20
20
|
import type { WebhookRefusal } from "../types";
|
|
21
|
+
import type { JsonValue } from "../types/json";
|
|
22
|
+
import { parseJson } from "../validation/json";
|
|
23
|
+
import { jsonObjectSchema } from "../validation/json.schema";
|
|
21
24
|
import { sendEventTypeSchema } from "../validation/send-event-type.schema";
|
|
22
25
|
|
|
23
26
|
const eventSchema = z.object({
|
|
24
27
|
messageId: z.string().min(1),
|
|
25
28
|
type: sendEventTypeSchema,
|
|
26
29
|
at: z.coerce.date().optional(),
|
|
27
|
-
details:
|
|
30
|
+
details: jsonObjectSchema.optional(),
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
type ParsedEvent = z.infer<typeof eventSchema>;
|
|
@@ -38,7 +41,7 @@ const STATUS_OF_REFUSAL: Record<WebhookRefusal, number> = {
|
|
|
38
41
|
interface ReceivedEvent {
|
|
39
42
|
provider: string;
|
|
40
43
|
secret: string | undefined;
|
|
41
|
-
body:
|
|
44
|
+
body: JsonValue | undefined;
|
|
42
45
|
/** The tenant the URL names. */
|
|
43
46
|
tenantId: string;
|
|
44
47
|
}
|
|
@@ -54,9 +57,9 @@ export interface WebhookResponse {
|
|
|
54
57
|
* The body as JSON, `undefined` when it is not: a malformed body is refused
|
|
55
58
|
* as an invalid event, after the secret, never answered with a parser error.
|
|
56
59
|
*/
|
|
57
|
-
function parseBody(raw: Buffer):
|
|
60
|
+
function parseBody(raw: Buffer): JsonValue | undefined {
|
|
58
61
|
try {
|
|
59
|
-
return
|
|
62
|
+
return parseJson(raw.toString("utf8"));
|
|
60
63
|
} catch {
|
|
61
64
|
return undefined;
|
|
62
65
|
}
|
package/src/routes/index.ts
CHANGED
package/src/routes/metrics.ts
CHANGED
|
@@ -63,16 +63,16 @@ const TEMPLATE_VOLUME_KEY = "$dms_mailing.metrics.template_volume";
|
|
|
63
63
|
const FIRST_ROW_OFFSET = 0;
|
|
64
64
|
const SINGLE_ROW = 1;
|
|
65
65
|
|
|
66
|
-
const KPI_EXTRACTORS
|
|
67
|
-
sends
|
|
68
|
-
deliverability
|
|
69
|
-
"open-rate"
|
|
70
|
-
"click-rate"
|
|
71
|
-
bounces
|
|
72
|
-
unsubscribes
|
|
73
|
-
queued
|
|
74
|
-
latency
|
|
75
|
-
|
|
66
|
+
const KPI_EXTRACTORS = new Map<string, TotalsExtractor>([
|
|
67
|
+
["sends", (totals) => totals.sends],
|
|
68
|
+
["deliverability", (totals) => totals.deliverability],
|
|
69
|
+
["open-rate", (totals) => totals.openRate],
|
|
70
|
+
["click-rate", (totals) => totals.clickRate],
|
|
71
|
+
["bounces", (totals) => totals.bounced],
|
|
72
|
+
["unsubscribes", (totals) => totals.unsubscribed],
|
|
73
|
+
["queued", (totals) => totals.queued],
|
|
74
|
+
["latency", (totals) => totals.latencyMedian],
|
|
75
|
+
]);
|
|
76
76
|
|
|
77
77
|
/** The KPIs read from delivery events: unmeasured when nothing reports them. */
|
|
78
78
|
const DELIVERY_METRICS = new Set(["deliverability", "open-rate", "click-rate"]);
|
|
@@ -88,14 +88,17 @@ const OPEN_RATE_METRIC: TopMetric = {
|
|
|
88
88
|
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
-
const TOP_METRICS
|
|
92
|
-
"open-rate"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
91
|
+
const TOP_METRICS = new Map<string, TopMetric>([
|
|
92
|
+
["open-rate", OPEN_RATE_METRIC],
|
|
93
|
+
[
|
|
94
|
+
"click-rate",
|
|
95
|
+
{
|
|
96
|
+
extract: (totals) => totals.clickRate,
|
|
97
|
+
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
["sends", { extract: (totals) => totals.sends, minDeliveries: 0 }],
|
|
101
|
+
]);
|
|
99
102
|
const DEFAULT_TOP_METRIC = OPEN_RATE_METRIC;
|
|
100
103
|
|
|
101
104
|
interface FunnelStepDefinition {
|
|
@@ -225,7 +228,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
225
228
|
async kpi(
|
|
226
229
|
@Parameter("metric", "param") metric: string,
|
|
227
230
|
): Promise<KpiCardData> {
|
|
228
|
-
const extract = KPI_EXTRACTORS
|
|
231
|
+
const extract = KPI_EXTRACTORS.get(metric);
|
|
229
232
|
assert(extract, HTTP_NOT_FOUND, UNKNOWN_METRIC);
|
|
230
233
|
const range = currentRange(this.ctx);
|
|
231
234
|
const [current, previous, tracking] = await Promise.all([
|
|
@@ -254,7 +257,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
254
257
|
@Get("/top-templates")
|
|
255
258
|
async topTemplates() {
|
|
256
259
|
const metric =
|
|
257
|
-
TOP_METRICS
|
|
260
|
+
TOP_METRICS.get(this.ctx.url.searchParams.get("metric") ?? "") ??
|
|
258
261
|
DEFAULT_TOP_METRIC;
|
|
259
262
|
const range = currentRange(this.ctx);
|
|
260
263
|
const [sends, previous, templates] = await Promise.all([
|
|
@@ -469,22 +472,19 @@ export function comparedSteps(
|
|
|
469
472
|
/** Which sends the funnel counts: all, or one side of the marketing split. */
|
|
470
473
|
export type FunnelKind = "all" | "marketing" | "transactional";
|
|
471
474
|
|
|
472
|
-
const FUNNEL_KINDS: Record<string, FunnelKind> = {
|
|
473
|
-
all: "all",
|
|
474
|
-
marketing: "marketing",
|
|
475
|
-
transactional: "transactional",
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
export function readFunnelKind(raw: string | null): FunnelKind {
|
|
479
|
-
return FUNNEL_KINDS[raw ?? ""] ?? "all";
|
|
480
|
-
}
|
|
481
|
-
|
|
482
475
|
const KIND_KEEPS: Record<FunnelKind, (category: string) => boolean> = {
|
|
483
476
|
all: () => true,
|
|
484
477
|
marketing: (category) => isMarketingCategory(category),
|
|
485
478
|
transactional: (category) => !isMarketingCategory(category),
|
|
486
479
|
};
|
|
487
480
|
|
|
481
|
+
const isFunnelKind = (raw: string): raw is FunnelKind =>
|
|
482
|
+
Object.hasOwn(KIND_KEEPS, raw);
|
|
483
|
+
|
|
484
|
+
export function readFunnelKind(raw: string | null): FunnelKind {
|
|
485
|
+
return raw !== null && isFunnelKind(raw) ? raw : "all";
|
|
486
|
+
}
|
|
487
|
+
|
|
488
488
|
/**
|
|
489
489
|
* Sends of the templates on one side of the split. A template's category
|
|
490
490
|
* decides: "marketing" is the campaign side, everything else transactional.
|
package/src/routes/period.ts
CHANGED
|
@@ -18,10 +18,10 @@ const PREVIOUS_YEAR = "previous-year";
|
|
|
18
18
|
* What the comparison series is called, by the selector's `comparison`. A
|
|
19
19
|
* custom window, or a caller naming none, is neither of the two.
|
|
20
20
|
*/
|
|
21
|
-
const COMPARISON_SERIES_LABELS
|
|
22
|
-
"previous-period"
|
|
23
|
-
[PREVIOUS_YEAR
|
|
24
|
-
|
|
21
|
+
const COMPARISON_SERIES_LABELS = new Map([
|
|
22
|
+
["previous-period", "$dms_mailing.metrics.previous"],
|
|
23
|
+
[PREVIOUS_YEAR, "$dms_mailing.metrics.previous_year"],
|
|
24
|
+
]);
|
|
25
25
|
const COMPARED_SERIES_LABEL = "$dms_mailing.metrics.compared";
|
|
26
26
|
|
|
27
27
|
function readQuery(ctx: RangeContext, key: string): string | undefined {
|
|
@@ -67,7 +67,7 @@ export function isComparisonWanted(ctx: RangeContext): boolean {
|
|
|
67
67
|
/** The label of the comparison series, for the comparison the caller asks. */
|
|
68
68
|
export function comparisonLabel(ctx: RangeContext): string {
|
|
69
69
|
const comparison = readQuery(ctx, "comparison") ?? "previous-period";
|
|
70
|
-
return COMPARISON_SERIES_LABELS
|
|
70
|
+
return COMPARISON_SERIES_LABELS.get(comparison) ?? COMPARED_SERIES_LABEL;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function yearBefore(date: Date): Date {
|
package/src/routes/sends.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
snapshotOfVersion,
|
|
70
70
|
} from "../services/templates";
|
|
71
71
|
import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
|
|
72
|
+
import type { JsonObject } from "../types/json";
|
|
72
73
|
import {
|
|
73
74
|
type previewVersionSchema,
|
|
74
75
|
realSendSchema,
|
|
@@ -184,10 +185,10 @@ const sameAddress = (left: string, right: string): boolean =>
|
|
|
184
185
|
normalizeAddress(left) === normalizeAddress(right);
|
|
185
186
|
|
|
186
187
|
/** A bad address gets its own message; any other invalid field is the content's. */
|
|
187
|
-
function sendInputError(error:
|
|
188
|
-
const isRecipient =
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
function sendInputError(error: ZodError): string {
|
|
189
|
+
const isRecipient = error.issues.some(
|
|
190
|
+
(issue) => issue.path[0] === RECIPIENT_FIELD,
|
|
191
|
+
);
|
|
191
192
|
return isRecipient ? INVALID_RECIPIENT : INVALID_CONTENT;
|
|
192
193
|
}
|
|
193
194
|
|
|
@@ -255,7 +256,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
255
256
|
const input = assertValidation(
|
|
256
257
|
body,
|
|
257
258
|
(value) => testSendSchema.parse(value),
|
|
258
|
-
|
|
259
|
+
(error) =>
|
|
260
|
+
error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
|
|
259
261
|
);
|
|
260
262
|
const template = await requireTemplate(this.templates, id);
|
|
261
263
|
const ownAddressSuppressed = await this.ownSuppression(input.to);
|
|
@@ -301,7 +303,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
301
303
|
const input = assertValidation(
|
|
302
304
|
body,
|
|
303
305
|
(value) => realSendSchema.parse(value),
|
|
304
|
-
|
|
306
|
+
(error) =>
|
|
307
|
+
error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
|
|
305
308
|
);
|
|
306
309
|
const template = await requireTemplate(this.templates, id);
|
|
307
310
|
const result = await this.send(
|
|
@@ -574,8 +577,10 @@ function requestedVersionOf(
|
|
|
574
577
|
return version === "draft" || version === "published" ? undefined : version;
|
|
575
578
|
}
|
|
576
579
|
|
|
577
|
-
function variablesOf(send: MailingSend):
|
|
578
|
-
|
|
580
|
+
function variablesOf(send: MailingSend): JsonObject {
|
|
581
|
+
// SAFETY: json_variables is written only as JSON.stringify(params.variables ?? {}) (services/send.ts) or
|
|
582
|
+
// JSON.stringify(send.variables) (services/send-import.ts), both a JsonObject.
|
|
583
|
+
return JSON.parse(send.json_variables || "{}") as JsonObject;
|
|
579
584
|
}
|
|
580
585
|
|
|
581
586
|
function summarize(template: MailingTemplate): SendTemplateSummary {
|
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 {
|
|
@@ -48,7 +49,8 @@ import {
|
|
|
48
49
|
settingsPatchSchema,
|
|
49
50
|
} from "../validation/settings.schema";
|
|
50
51
|
import { retentionLimit } from "../crons/retention";
|
|
51
|
-
import { isJsonObject } from "../validation/
|
|
52
|
+
import { isJsonObject } from "../validation/json";
|
|
53
|
+
import { zodIssuesJson } from "./zod-json";
|
|
52
54
|
|
|
53
55
|
const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
|
|
54
56
|
const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
|
|
@@ -77,7 +79,15 @@ const retentionDaysSchema = z.coerce
|
|
|
77
79
|
* The DMS form reads and writes localized values whole
|
|
78
80
|
* (`x-content-language: *`): category labels travel with every language.
|
|
79
81
|
*/
|
|
80
|
-
|
|
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 {
|
|
81
91
|
/** Where the provider posts its events; read-only. */
|
|
82
92
|
webhookUrl: string;
|
|
83
93
|
/** The configured provider, as the read-only provider row shows it. */
|
|
@@ -90,10 +100,10 @@ export interface MailingSettingsResponse extends MailingSettingsValues {
|
|
|
90
100
|
export interface SettingsNotice {
|
|
91
101
|
title: string;
|
|
92
102
|
color?: string;
|
|
93
|
-
params?: Record<string,
|
|
103
|
+
params?: Record<string, number | string>;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
export interface SettingsWriteResponse extends
|
|
106
|
+
export interface SettingsWriteResponse extends FormSettings {
|
|
97
107
|
notice?: SettingsNotice;
|
|
98
108
|
}
|
|
99
109
|
|
|
@@ -147,9 +157,11 @@ function formIssue(issue: z.ZodIssue): z.ZodIssue {
|
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/** A refused write: the generic message, and each issue for its field. */
|
|
150
|
-
function invalidSettings(error:
|
|
151
|
-
|
|
152
|
-
|
|
160
|
+
function invalidSettings(error: z.ZodError) {
|
|
161
|
+
return {
|
|
162
|
+
message: INVALID_SETTINGS,
|
|
163
|
+
issues: zodIssuesJson(error.issues.map(formIssue)),
|
|
164
|
+
};
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
function senderNotice(values: MailingSettingsValues): SettingsNotice[] {
|
|
@@ -208,7 +220,7 @@ export class MailingSettingsController extends Controller(
|
|
|
208
220
|
readCapabilities(),
|
|
209
221
|
]);
|
|
210
222
|
return {
|
|
211
|
-
...values,
|
|
223
|
+
...formSettings(values),
|
|
212
224
|
webhookUrl: await this.providerWebhookUrl(capabilities?.name),
|
|
213
225
|
provider: capabilities?.name ?? "",
|
|
214
226
|
missingVariables: missingVariablesPolicy(values),
|
|
@@ -231,7 +243,7 @@ export class MailingSettingsController extends Controller(
|
|
|
231
243
|
? await this.reassignCategories(categories, categoryMoves(entries))
|
|
232
244
|
: 0;
|
|
233
245
|
const [notice] = [...reassignedNotice(reassigned), ...senderNotice(saved)];
|
|
234
|
-
return { ...saved, ...(notice && { notice }) };
|
|
246
|
+
return { ...formSettings(saved), ...(notice && { notice }) };
|
|
235
247
|
}
|
|
236
248
|
|
|
237
249
|
/** The body as a checked patch; 400 for anything else. */
|
|
@@ -241,16 +253,20 @@ export class MailingSettingsController extends Controller(
|
|
|
241
253
|
return assertValidation(
|
|
242
254
|
clearedToEmpty(body),
|
|
243
255
|
(value) => settingsPatchSchema.parse(value),
|
|
244
|
-
|
|
256
|
+
(error) =>
|
|
257
|
+
error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
|
|
245
258
|
);
|
|
246
259
|
}
|
|
247
260
|
|
|
248
|
-
/**
|
|
261
|
+
/**
|
|
262
|
+
* Issues a new webhook secret, the workspace owner's from now on; the old
|
|
263
|
+
* one stops working at once.
|
|
264
|
+
*/
|
|
249
265
|
@Post("/webhook-secret/rotate")
|
|
250
266
|
async rotateSecret(): Promise<{ value: string }> {
|
|
251
|
-
|
|
267
|
+
await getSettings(this.tenantId);
|
|
252
268
|
const value = randomBytes(WEBHOOK_SECRET_BYTES).toString("hex");
|
|
253
|
-
await
|
|
269
|
+
await saveWebhookSecret(this.tenantId, value, "operator");
|
|
254
270
|
return { value };
|
|
255
271
|
}
|
|
256
272
|
|
|
@@ -295,7 +311,8 @@ export class MailingSettingsController extends Controller(
|
|
|
295
311
|
return assertValidation(
|
|
296
312
|
raw,
|
|
297
313
|
(value) => retentionDaysSchema.parse(value),
|
|
298
|
-
|
|
314
|
+
(error) =>
|
|
315
|
+
error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
|
|
299
316
|
);
|
|
300
317
|
}
|
|
301
318
|
|
|
@@ -306,12 +323,12 @@ export class MailingSettingsController extends Controller(
|
|
|
306
323
|
@Get("/category-usage")
|
|
307
324
|
async categoryUsage(): Promise<{ counts: Record<string, number> }> {
|
|
308
325
|
const templates = await this.templates.getAll();
|
|
309
|
-
const counts
|
|
326
|
+
const counts = new Map<string, number>();
|
|
310
327
|
for (const template of templates) {
|
|
311
328
|
if (!template.category || template.status === ARCHIVED_STATUS) continue;
|
|
312
|
-
counts
|
|
329
|
+
counts.set(template.category, (counts.get(template.category) ?? 0) + 1);
|
|
313
330
|
}
|
|
314
|
-
return { counts };
|
|
331
|
+
return { counts: Object.fromEntries(counts) };
|
|
315
332
|
}
|
|
316
333
|
|
|
317
334
|
/**
|
|
@@ -18,6 +18,7 @@ import { API_BASE_PATH, HTTP_CONFLICT, HTTP_NOT_FOUND } from "../constants";
|
|
|
18
18
|
import { SuppressionModel } from "../db";
|
|
19
19
|
import { SuppressionsPageController } from "../pages/suppressions";
|
|
20
20
|
import { suppressAddress } from "../services/suppressions";
|
|
21
|
+
import { zodIssuesJson } from "./zod-json";
|
|
21
22
|
|
|
22
23
|
const INVALID_SUPPRESSION = "$dms_mailing.suppressions.errors.invalid";
|
|
23
24
|
const ALREADY_SUPPRESSED = "$dms_mailing.suppressions.errors.already";
|
|
@@ -32,12 +33,6 @@ const manualSuppressionSchema = z.object({
|
|
|
32
33
|
note: z.string().trim().max(MAX_NOTE_LENGTH).optional(),
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
/** A refused entry: the generic message, and each issue under its field. */
|
|
36
|
-
function invalidSuppression(error: unknown) {
|
|
37
|
-
if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
|
|
38
|
-
return { message: INVALID_SUPPRESSION, issues: error.issues };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
36
|
/** An address already on the list, told under the address field. */
|
|
42
37
|
const alreadySuppressed = () => ({
|
|
43
38
|
message: ALREADY_SUPPRESSED,
|
|
@@ -56,13 +51,22 @@ export class SuppressionsController extends Controller(
|
|
|
56
51
|
@TenantScopedModel(SuppressionModel)
|
|
57
52
|
declare suppressions: SuppressionModel;
|
|
58
53
|
|
|
59
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Puts an address on the list by hand: nothing is sent to it any more. A
|
|
56
|
+
* refused entry answers the generic message, and each issue under its field.
|
|
57
|
+
*/
|
|
60
58
|
@Post("")
|
|
61
59
|
async add(@JSONBody() body: unknown): Promise<{ added: true }> {
|
|
62
60
|
const input = assertValidation(
|
|
63
61
|
body ?? {},
|
|
64
62
|
(value) => manualSuppressionSchema.parse(value),
|
|
65
|
-
|
|
63
|
+
(error) => {
|
|
64
|
+
if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
|
|
65
|
+
return {
|
|
66
|
+
message: INVALID_SUPPRESSION,
|
|
67
|
+
issues: zodIssuesJson(error.issues),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
66
70
|
);
|
|
67
71
|
const isAdded = await suppressAddress(getRequestTenantId(this.ctx), {
|
|
68
72
|
email: input.email,
|