@antelopejs/dms-mailing 0.7.1 → 0.9.0
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 +46 -31
- 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/crons/jobs.d.ts +1 -1
- package/dist/data/body.d.ts +2 -1
- package/dist/data/body.js +4 -2
- 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 +30 -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/templates.model.js +2 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/models/webhook-status.model.d.ts +7 -5
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +8 -18
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.d.ts +2 -1
- package/dist/engine/conditions.js +5 -5
- 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/fallback-html.js +8 -8
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/formats.d.ts +4 -6
- package/dist/engine/formats.js +22 -8
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.d.ts +4 -2
- package/dist/engine/paths.js +16 -5
- 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 +1 -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.js +10 -9
- package/dist/hooks/tenant-export.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/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +6 -4
- 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 +31 -31
- 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 +4 -5
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +1 -1
- package/dist/routes/settings.js +16 -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 +7 -6
- 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 +16 -8
- 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/categories.js +6 -6
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.js +16 -9
- package/dist/services/checked-send.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 +13 -7
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +2 -1
- package/dist/services/localized-text.js +2 -1
- package/dist/services/localized-text.js.map +1 -1
- package/dist/services/metrics.js +2 -5
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-import.js +7 -3
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +7 -7
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +4 -2
- package/dist/services/settings.js +5 -4
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +3 -2
- package/dist/services/starters.js +5 -5
- package/dist/services/starters.js.map +1 -1
- package/dist/services/template-stats.js +5 -2
- package/dist/services/template-stats.js.map +1 -1
- package/dist/services/templates.d.ts +2 -1
- package/dist/services/templates.js +8 -4
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -2
- package/dist/services/webhook-status.js +6 -4
- 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 +2 -2
- package/dist/validation/declarations.schema.d.ts +44 -44
- package/dist/validation/declarations.schema.js +6 -5
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +3 -0
- package/dist/validation/guards.js +15 -0
- package/dist/validation/guards.js.map +1 -0
- 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/send-event-type.schema.d.ts +2 -0
- package/dist/validation/send-event-type.schema.js +11 -0
- package/dist/validation/send-event-type.schema.js.map +1 -0
- package/dist/validation/settings.schema.js +1 -1
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +44 -42
- package/dist/validation/templates.schema.js +4 -3
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
- package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
- package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
- package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
- package/frontend-vue/app/components/DuplicateModal.vue +3 -11
- package/frontend-vue/app/components/FixAddressModal.vue +2 -1
- package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
- package/frontend-vue/app/components/RealSendModal.vue +6 -17
- package/frontend-vue/app/components/RenderingModal.vue +2 -1
- package/frontend-vue/app/components/SendDrawer.vue +5 -4
- package/frontend-vue/app/components/StarterGallery.vue +3 -11
- package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
- package/frontend-vue/app/components/TestSendModal.vue +7 -18
- package/frontend-vue/app/components/VersionHistory.vue +2 -7
- package/frontend-vue/app/components/WebhookStatus.vue +2 -1
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
- package/frontend-vue/build/backend-boundary.ts +122 -0
- package/frontend-vue/dms.frontend.build.ts +7 -0
- package/frontend-vue/eslint.config.mjs +13 -0
- package/frontend-vue/package.json +4 -2
- package/frontend-vue/pnpm-lock.yaml +250 -61
- package/frontend-vue/tests/backend-boundary.test.ts +203 -0
- package/frontend-vue/tests/dnd.test.ts +1 -2
- package/frontend-vue/tests/editor-block-list.test.ts +136 -53
- package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
- package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
- package/frontend-vue/tests/new-template-modal.test.ts +41 -6
- package/frontend-vue/tests/page-reads.test.ts +26 -6
- package/frontend-vue/tests/send-actions.test.ts +0 -1
- package/frontend-vue/tests/send-drawer.test.ts +2 -6
- package/frontend-vue/tests/send-modals.test.ts +8 -5
- package/frontend-vue/tests/sortable-list.test.ts +54 -62
- package/frontend-vue/tests/suppression.test.ts +1 -2
- package/frontend-vue/tests/template-drawer.test.ts +27 -18
- package/frontend-vue/tests/template-gallery.test.ts +7 -5
- package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
- package/package.json +16 -20
- package/skills/dms-mailing/SKILL.md +1 -1
- package/skills/dms-mailing/cases.json +49 -0
- package/src/automation/email-event-trigger.ts +2 -2
- package/src/automation/send-template-action.ts +33 -14
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +7 -3
- package/src/data/templates-table.ts +55 -56
- 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 +10 -4
- package/src/db/models/templates.model.ts +6 -3
- package/src/db/tables/templates.table.ts +1 -1
- package/src/engine/changes.ts +11 -28
- package/src/engine/conditions.ts +14 -8
- package/src/engine/coverage.ts +2 -1
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +35 -21
- package/src/engine/paths.ts +41 -7
- package/src/engine/plain-text.ts +2 -4
- package/src/engine/resolve.ts +12 -4
- package/src/engine/system-variables.ts +6 -4
- package/src/engine/tokens.ts +3 -3
- package/src/hooks/tenant-export.ts +11 -13
- package/src/pages/sends.ts +5 -3
- package/src/pages/templates.ts +4 -2
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +10 -10
- package/src/routes/index.ts +1 -0
- package/src/routes/metrics.ts +35 -33
- package/src/routes/period.ts +5 -5
- package/src/routes/sends.ts +23 -26
- package/src/routes/settings.ts +21 -17
- package/src/routes/suppressions.ts +12 -8
- package/src/routes/template-content.ts +45 -0
- package/src/routes/templates.ts +16 -49
- package/src/routes/zod-json.ts +28 -0
- package/src/services/attention.ts +20 -18
- package/src/services/catalog.ts +4 -8
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declaration-error.ts +1 -1
- package/src/services/declarations.ts +7 -6
- package/src/services/events.ts +18 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +5 -7
- package/src/services/notify.ts +1 -1
- package/src/services/render.ts +5 -1
- package/src/services/send-import.ts +16 -11
- package/src/services/send.ts +10 -13
- package/src/services/settings.ts +17 -16
- package/src/services/starters.ts +12 -8
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +24 -15
- package/src/services/webhook-status.ts +13 -8
- package/src/types/json.ts +1 -0
- package/src/types/settings.ts +2 -2
- package/src/validation/declarations.schema.ts +6 -5
- package/src/validation/guards.ts +17 -0
- package/src/validation/json.schema.ts +7 -0
- package/src/validation/json.ts +66 -0
- package/src/validation/send-event-type.schema.ts +10 -0
- package/src/validation/settings.schema.ts +7 -2
- package/src/validation/templates.schema.ts +9 -4
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
package/src/engine/changes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import type {
|
|
2
3
|
Block,
|
|
3
4
|
BlockType,
|
|
@@ -40,36 +41,21 @@ export const CONTRACT_LOCALE = "";
|
|
|
40
41
|
|
|
41
42
|
interface BlockSnapshot {
|
|
42
43
|
type: BlockType;
|
|
43
|
-
|
|
44
|
+
fields: object;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
const NESTED_KEYS = new Set(["children", "elseChildren"]);
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
* A value with its object keys sorted at every depth, so two blocks equal but
|
|
50
|
-
* for the order their fields were written in read the same.
|
|
51
|
-
*/
|
|
52
|
-
function canonical(value: unknown): unknown {
|
|
53
|
-
if (Array.isArray(value)) return value.map(canonical);
|
|
54
|
-
if (!value || typeof value !== "object") return value;
|
|
49
|
+
function ownFields(block: Block): object {
|
|
55
50
|
return Object.fromEntries(
|
|
56
|
-
Object.
|
|
57
|
-
.sort()
|
|
58
|
-
.map((key) => [key, canonical((value as Record<string, unknown>)[key])]),
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function ownFields(block: Block): string {
|
|
63
|
-
const entries = Object.entries(block).filter(
|
|
64
|
-
([key]) => !NESTED_KEYS.has(key),
|
|
51
|
+
Object.entries(block).filter(([key]) => !NESTED_KEYS.has(key)),
|
|
65
52
|
);
|
|
66
|
-
return JSON.stringify(canonical(Object.fromEntries(entries)));
|
|
67
53
|
}
|
|
68
54
|
|
|
69
55
|
function snapshotBlocks(blocks: Block[]): Map<string, BlockSnapshot> {
|
|
70
56
|
const snapshots = new Map<string, BlockSnapshot>();
|
|
71
57
|
walkBlocks(blocks, (block) => {
|
|
72
|
-
snapshots.set(block.id, { type: block.type,
|
|
58
|
+
snapshots.set(block.id, { type: block.type, fields: ownFields(block) });
|
|
73
59
|
});
|
|
74
60
|
return snapshots;
|
|
75
61
|
}
|
|
@@ -93,7 +79,7 @@ function blockChanges(
|
|
|
93
79
|
const touched = [...after].flatMap(([id, snapshot]) => {
|
|
94
80
|
const previous = before.get(id);
|
|
95
81
|
if (!previous) return [blockChange(locale, "block_added", id, snapshot)];
|
|
96
|
-
if (previous.
|
|
82
|
+
if (isDeepStrictEqual(previous.fields, snapshot.fields)) return [];
|
|
97
83
|
return [blockChange(locale, "block_changed", id, snapshot)];
|
|
98
84
|
});
|
|
99
85
|
return [...touched, ...removed];
|
|
@@ -161,13 +147,8 @@ export function diffContents(
|
|
|
161
147
|
|
|
162
148
|
function declarationsByPath(
|
|
163
149
|
variables: VariableDefinition[],
|
|
164
|
-
): Map<string,
|
|
165
|
-
return new Map(
|
|
166
|
-
variables.map((variable) => [
|
|
167
|
-
variable.path,
|
|
168
|
-
JSON.stringify(canonical(variable)),
|
|
169
|
-
]),
|
|
170
|
-
);
|
|
150
|
+
): Map<string, VariableDefinition> {
|
|
151
|
+
return new Map(variables.map((variable) => [variable.path, variable]));
|
|
171
152
|
}
|
|
172
153
|
|
|
173
154
|
function variableChange(
|
|
@@ -193,6 +174,8 @@ export function diffVariables(
|
|
|
193
174
|
const next = after.get(path);
|
|
194
175
|
if (previous === undefined) return [variableChange("variable_added", path)];
|
|
195
176
|
if (next === undefined) return [variableChange("variable_removed", path)];
|
|
196
|
-
return previous
|
|
177
|
+
return isDeepStrictEqual(previous, next)
|
|
178
|
+
? []
|
|
179
|
+
: [variableChange("variable_changed", path)];
|
|
197
180
|
});
|
|
198
181
|
}
|
package/src/engine/conditions.ts
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TemplateVariables,
|
|
3
|
+
TemplateVariableValue,
|
|
4
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
1
5
|
import type { Condition, ConditionOperator } from "../types";
|
|
6
|
+
import { plainText } from "./formats";
|
|
2
7
|
import { getPath } from "./paths";
|
|
3
8
|
|
|
4
|
-
type Evaluator = (
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
type Evaluator = (
|
|
10
|
+
actual: TemplateVariableValue | undefined,
|
|
11
|
+
expected: string,
|
|
12
|
+
) => boolean;
|
|
7
13
|
|
|
8
14
|
const EVALUATORS: Record<ConditionOperator, Evaluator> = {
|
|
9
15
|
truthy: (actual) => Boolean(actual),
|
|
10
16
|
falsy: (actual) => !actual,
|
|
11
|
-
eq: (actual, expected) =>
|
|
12
|
-
ne: (actual, expected) =>
|
|
13
|
-
gt: (actual, expected) =>
|
|
14
|
-
lt: (actual, expected) =>
|
|
17
|
+
eq: (actual, expected) => plainText(actual) === expected,
|
|
18
|
+
ne: (actual, expected) => plainText(actual) !== expected,
|
|
19
|
+
gt: (actual, expected) => Number(actual) > Number(expected),
|
|
20
|
+
lt: (actual, expected) => Number(actual) < Number(expected),
|
|
15
21
|
};
|
|
16
22
|
|
|
17
23
|
/** Whether `data` passes the condition a block is shown under. */
|
|
18
24
|
export function evaluateCondition(
|
|
19
25
|
condition: Condition,
|
|
20
|
-
data:
|
|
26
|
+
data: TemplateVariables,
|
|
21
27
|
): boolean {
|
|
22
28
|
const evaluate = EVALUATORS[condition.operator];
|
|
23
29
|
return evaluate(getPath(data, condition.path), condition.value);
|
package/src/engine/coverage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TemplateVariables } from "@antelopejs/interface-dms-mailing";
|
|
1
2
|
import { SYSTEM_VARIABLE_PATHS } from "../constants";
|
|
2
3
|
import type {
|
|
3
4
|
Block,
|
|
@@ -63,7 +64,7 @@ export function collectContentVariablePaths(
|
|
|
63
64
|
|
|
64
65
|
export function missingRequiredVariables(
|
|
65
66
|
declared: VariableDefinition[],
|
|
66
|
-
data:
|
|
67
|
+
data: TemplateVariables,
|
|
67
68
|
): string[] {
|
|
68
69
|
return declared
|
|
69
70
|
.filter((variable) => variable.required)
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type { ResolvedBlock, ResolvedEmail } from "../types";
|
|
2
2
|
|
|
3
|
-
const ESCAPES
|
|
4
|
-
"&"
|
|
5
|
-
"<"
|
|
6
|
-
">"
|
|
7
|
-
'"'
|
|
8
|
-
"'"
|
|
9
|
-
|
|
3
|
+
const ESCAPES = new Map([
|
|
4
|
+
["&", "&"],
|
|
5
|
+
["<", "<"],
|
|
6
|
+
[">", ">"],
|
|
7
|
+
['"', """],
|
|
8
|
+
["'", "'"],
|
|
9
|
+
]);
|
|
10
10
|
|
|
11
11
|
export const escapeHtml = (value: string): string =>
|
|
12
|
-
value.replace(/[&<>"']/g, (char) => ESCAPES
|
|
12
|
+
value.replace(/[&<>"']/g, (char) => ESCAPES.get(char) ?? char);
|
|
13
13
|
|
|
14
14
|
const LINE_BREAK = /\r?\n/g;
|
|
15
15
|
|
|
@@ -77,7 +77,7 @@ const row = (label: string, value: string, weight: string): string =>
|
|
|
77
77
|
const table = (rows: string): string =>
|
|
78
78
|
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BLOCK_SPACING}">${rows}</table>`;
|
|
79
79
|
|
|
80
|
-
const TEXT_RENDERERS
|
|
80
|
+
const TEXT_RENDERERS = {
|
|
81
81
|
heading: (block) =>
|
|
82
82
|
block.type === "heading"
|
|
83
83
|
? `<h1 style="${FONT};font-size:${block.size}px;font-weight:600;color:${HEADING_COLOR};text-align:${block.align};margin:0 0 24px;${WRAP}">${escapeLines(block.text)}</h1>`
|
|
@@ -94,7 +94,7 @@ const TEXT_RENDERERS: Record<string, BlockHtml> = {
|
|
|
94
94
|
block.type === "button"
|
|
95
95
|
? `<div style="text-align:${block.align};margin:24px 0"><a href="${escapeHtml(safeUrl(block.href))}" style="${FONT};${BUTTON_STYLE}">${escapeHtml(block.text)}</a></div>`
|
|
96
96
|
: "",
|
|
97
|
-
}
|
|
97
|
+
} satisfies Record<string, BlockHtml>;
|
|
98
98
|
|
|
99
99
|
const footerHtml = (block: ResolvedBlock): string => {
|
|
100
100
|
if (block.type !== "footer") return "";
|
|
@@ -109,7 +109,7 @@ const footerHtml = (block: ResolvedBlock): string => {
|
|
|
109
109
|
return `${DIVIDER}<p style="${FOOTER_STYLE}">${escapeLines(block.text)}${linkLine}</p>`;
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
-
const LAYOUT_RENDERERS
|
|
112
|
+
const LAYOUT_RENDERERS = {
|
|
113
113
|
hero: (block) =>
|
|
114
114
|
block.type === "hero" && block.imageUrl
|
|
115
115
|
? `<img src="${escapeHtml(safeUrl(block.imageUrl))}" alt="${escapeHtml(block.alt)}" style="display:block;width:100%;border-radius:8px;margin:0 0 20px">`
|
|
@@ -124,12 +124,12 @@ const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
|
|
|
124
124
|
block.type === "total" ? table(row(block.label, block.value, "600")) : "",
|
|
125
125
|
divider: () => DIVIDER,
|
|
126
126
|
footer: footerHtml,
|
|
127
|
-
}
|
|
127
|
+
} satisfies Record<string, BlockHtml>;
|
|
128
128
|
|
|
129
129
|
const RENDERERS: Record<ResolvedBlock["type"], BlockHtml> = {
|
|
130
130
|
...TEXT_RENDERERS,
|
|
131
131
|
...LAYOUT_RENDERERS,
|
|
132
|
-
}
|
|
132
|
+
};
|
|
133
133
|
|
|
134
134
|
const FOOTER_TYPE = "footer";
|
|
135
135
|
|
package/src/engine/formats.ts
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MoneyValue,
|
|
3
|
+
TemplateVariableValue,
|
|
4
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
1
5
|
import type { VariableDefinition } from "../types";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* An amount in the minor unit of its currency (cents for `EUR`), like the
|
|
5
|
-
* DMS's composed-text `money` parameter: `{ value: 987420, currency: "EUR" }`
|
|
6
|
-
* reads "€9,874.20" in English, "9 874,20 €" in French.
|
|
7
|
-
*/
|
|
8
|
-
export interface MoneyValue {
|
|
9
|
-
value: number;
|
|
10
|
-
/** ISO 4217 code. An unknown code writes the amount as a plain number. */
|
|
11
|
-
currency: string;
|
|
12
|
-
}
|
|
6
|
+
import { isText } from "../validation/guards";
|
|
7
|
+
import { isPresent, isRecord } from "./paths";
|
|
13
8
|
|
|
14
9
|
/** How a send writes its typed values: its locale, and the paths typed `date`. */
|
|
15
10
|
export interface ValueFormat {
|
|
@@ -54,13 +49,14 @@ export function valueFormatOf(
|
|
|
54
49
|
};
|
|
55
50
|
}
|
|
56
51
|
|
|
57
|
-
export function isMoneyValue(
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
export function isMoneyValue(
|
|
53
|
+
value: TemplateVariableValue | undefined,
|
|
54
|
+
): value is MoneyValue {
|
|
55
|
+
if (!isRecord(value)) return false;
|
|
60
56
|
return (
|
|
61
|
-
typeof
|
|
62
|
-
Number.isFinite(
|
|
63
|
-
typeof
|
|
57
|
+
typeof value.value === "number" &&
|
|
58
|
+
Number.isFinite(value.value) &&
|
|
59
|
+
typeof value.currency === "string"
|
|
64
60
|
);
|
|
65
61
|
}
|
|
66
62
|
|
|
@@ -101,6 +97,24 @@ function formatIsoDate(value: string, locale: string): string | null {
|
|
|
101
97
|
: formatDate(date, options, locale);
|
|
102
98
|
}
|
|
103
99
|
|
|
100
|
+
/** What `String()` writes for a record, which has no text of its own. */
|
|
101
|
+
const RECORD_TEXT = "[object Object]";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `String(value)` spelled out for each kind a variable can be: a list joins
|
|
105
|
+
* its items with commas, a missing item writing nothing, and a record writes
|
|
106
|
+
* {@link RECORD_TEXT}.
|
|
107
|
+
*/
|
|
108
|
+
export function plainText(value: TemplateVariableValue | undefined): string {
|
|
109
|
+
if (Array.isArray(value))
|
|
110
|
+
return value
|
|
111
|
+
.map((item) => (isPresent(item) ? plainText(item) : ""))
|
|
112
|
+
.join(",");
|
|
113
|
+
if (value instanceof Date) return value.toString();
|
|
114
|
+
if (isRecord(value)) return RECORD_TEXT;
|
|
115
|
+
return String(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
104
118
|
/**
|
|
105
119
|
* A value as a recipient reads it. A money value and a `Date` format
|
|
106
120
|
* themselves in the send's locale wherever they appear; an ISO string is
|
|
@@ -108,7 +122,7 @@ function formatIsoDate(value: string, locale: string): string | null {
|
|
|
108
122
|
* else, a string already formatted by the caller among them, as given.
|
|
109
123
|
*/
|
|
110
124
|
export function formatValue(
|
|
111
|
-
value:
|
|
125
|
+
value: TemplateVariableValue | undefined,
|
|
112
126
|
path = "",
|
|
113
127
|
format?: ValueFormat,
|
|
114
128
|
): string {
|
|
@@ -116,9 +130,9 @@ export function formatValue(
|
|
|
116
130
|
if (isMoneyValue(value)) return formatMoney(value, locale);
|
|
117
131
|
if (value instanceof Date)
|
|
118
132
|
return Number.isNaN(value.getTime())
|
|
119
|
-
?
|
|
133
|
+
? value.toString()
|
|
120
134
|
: formatDate(value, DATE_STYLE, locale);
|
|
121
|
-
if (
|
|
135
|
+
if (isText(value) && format?.datePaths.has(path))
|
|
122
136
|
return formatIsoDate(value, locale) ?? value;
|
|
123
|
-
return
|
|
137
|
+
return plainText(value);
|
|
124
138
|
}
|
package/src/engine/paths.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TemplateVariables,
|
|
3
|
+
TemplateVariableValue,
|
|
4
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
5
|
+
import { isObject } from "../validation/guards";
|
|
6
|
+
|
|
1
7
|
const PATH_SEPARATOR = ".";
|
|
2
8
|
const ARRAY_SUFFIX = "[]";
|
|
9
|
+
const ARRAY_LENGTH = "length";
|
|
10
|
+
const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
|
|
3
11
|
|
|
4
12
|
export function normalizePath(path: string): string {
|
|
5
13
|
const trimmed = path.trim();
|
|
@@ -15,19 +23,45 @@ export function normalizePath(path: string): string {
|
|
|
15
23
|
*/
|
|
16
24
|
const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
21
41
|
if (RESERVED_SEGMENTS.includes(segment)) return undefined;
|
|
22
|
-
|
|
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;
|
|
23
47
|
}
|
|
24
48
|
|
|
25
|
-
|
|
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 {
|
|
26
57
|
return normalizePath(path)
|
|
27
58
|
.split(PATH_SEPARATOR)
|
|
28
|
-
.reduce<
|
|
59
|
+
.reduce<TemplateVariableValue | undefined>(readSegment, data);
|
|
29
60
|
}
|
|
30
61
|
|
|
31
|
-
|
|
62
|
+
/** Whether `value` is neither missing nor `null`. */
|
|
63
|
+
export function isPresent(
|
|
64
|
+
value: TemplateVariableValue | undefined,
|
|
65
|
+
): value is Exclude<TemplateVariableValue, null> {
|
|
32
66
|
return value !== undefined && value !== null;
|
|
33
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,
|
|
@@ -22,7 +23,10 @@ export const MARKETING_CATEGORY = "marketing";
|
|
|
22
23
|
|
|
23
24
|
const SYSTEM_VARIABLE_TYPE = "url";
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
/** The footer fields that label a link, in the order the links show. */
|
|
27
|
+
const LABEL_KEYS = ["unsubscribeLabel", "preferencesLabel"] as const;
|
|
28
|
+
|
|
29
|
+
type FooterLabelKey = (typeof LABEL_KEYS)[number];
|
|
26
30
|
|
|
27
31
|
/** Each footer link: the label that shows it and the URL the caller passes. */
|
|
28
32
|
const FOOTER_LINKS: Record<FooterLabelKey, string> = {
|
|
@@ -30,8 +34,6 @@ const FOOTER_LINKS: Record<FooterLabelKey, string> = {
|
|
|
30
34
|
preferencesLabel: PREFERENCES_URL_PATH,
|
|
31
35
|
};
|
|
32
36
|
|
|
33
|
-
const LABEL_KEYS = Object.keys(FOOTER_LINKS) as FooterLabelKey[];
|
|
34
|
-
|
|
35
37
|
export function isMarketingCategory(category?: string | null): boolean {
|
|
36
38
|
return category === MARKETING_CATEGORY;
|
|
37
39
|
}
|
|
@@ -92,7 +94,7 @@ export function systemVariables(
|
|
|
92
94
|
export function missingSystemVariables(
|
|
93
95
|
content: LocaleContent,
|
|
94
96
|
category: string | null | undefined,
|
|
95
|
-
data:
|
|
97
|
+
data: TemplateVariables,
|
|
96
98
|
): string[] {
|
|
97
99
|
return systemVariables(localeSystemPaths(content), category)
|
|
98
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
|
|
|
@@ -21,8 +22,7 @@ const MISSING_TOKEN: Record<InterpolationMode, (token: string) => string> = {
|
|
|
21
22
|
|
|
22
23
|
export function extractTokens(text: string): string[] {
|
|
23
24
|
const found = new Set<string>();
|
|
24
|
-
for (const match of text.matchAll(TOKEN_PATTERN))
|
|
25
|
-
found.add(match[1] as string);
|
|
25
|
+
for (const match of text.matchAll(TOKEN_PATTERN)) found.add(match[1]);
|
|
26
26
|
return [...found];
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ export function extractTokens(text: string): string[] {
|
|
|
32
32
|
*/
|
|
33
33
|
export function interpolate(
|
|
34
34
|
text: string,
|
|
35
|
-
data:
|
|
35
|
+
data: TemplateVariables,
|
|
36
36
|
mode: InterpolationMode = "preview",
|
|
37
37
|
format?: ValueFormat,
|
|
38
38
|
): InterpolationResult {
|
|
@@ -11,11 +11,11 @@ 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
21
|
export type ExportedSettings = Omit<MailingSettingsValues, "webhookSecret">;
|
|
@@ -26,7 +26,7 @@ export type ExportPageLoader<T> = (
|
|
|
26
26
|
) => Promise<T[]>;
|
|
27
27
|
|
|
28
28
|
interface RowCodec<T> {
|
|
29
|
-
fromDatabase(row:
|
|
29
|
+
fromDatabase(row: T): T | undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface ExportedCollection {
|
|
@@ -72,18 +72,16 @@ export async function* jsonArrayChunks<T>(
|
|
|
72
72
|
async function exportedSettings(
|
|
73
73
|
tenantId: string,
|
|
74
74
|
): Promise<ExportedSettings | null> {
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
75
|
+
const settings = await findSettings(tenantId);
|
|
76
|
+
if (!settings) return null;
|
|
77
77
|
return {
|
|
78
|
-
fallbackLocale:
|
|
79
|
-
logRetentionDays:
|
|
80
|
-
blockOnMissingVariables:
|
|
81
|
-
senderName:
|
|
82
|
-
senderEmail:
|
|
83
|
-
replyTo:
|
|
84
|
-
categories:
|
|
85
|
-
row.json_categories,
|
|
86
|
-
) as ExportedSettings["categories"],
|
|
78
|
+
fallbackLocale: settings.fallbackLocale,
|
|
79
|
+
logRetentionDays: settings.logRetentionDays,
|
|
80
|
+
blockOnMissingVariables: settings.blockOnMissingVariables,
|
|
81
|
+
senderName: settings.senderName,
|
|
82
|
+
senderEmail: settings.senderEmail,
|
|
83
|
+
replyTo: settings.replyTo,
|
|
84
|
+
categories: settings.categories,
|
|
87
85
|
};
|
|
88
86
|
}
|
|
89
87
|
|
package/src/pages/sends.ts
CHANGED
|
@@ -313,11 +313,13 @@ export class SendsPageController extends PageController(
|
|
|
313
313
|
permissionMeta("send_drawer", "i-ph-info"),
|
|
314
314
|
),
|
|
315
315
|
title: "$dms_mailing.sends.actions.details",
|
|
316
|
-
|
|
316
|
+
/** Names the open send: its recipient and the template it used. */
|
|
317
317
|
rowTitle: "$dms_mailing.sends.drawer.heading",
|
|
318
318
|
positionLabel: "$dms_mailing.sends.drawer.position",
|
|
319
|
-
|
|
320
|
-
|
|
319
|
+
/**
|
|
320
|
+
* A side panel keeps the open send in the URL and words a missing
|
|
321
|
+
* one by default; a drawer does both only when asked.
|
|
322
|
+
*/
|
|
321
323
|
deepLink: true,
|
|
322
324
|
notFound: {},
|
|
323
325
|
},
|
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: {
|
|
@@ -146,7 +148,7 @@ export class TemplatesPageController extends PageController(
|
|
|
146
148
|
deepLink: true,
|
|
147
149
|
target: {
|
|
148
150
|
type: "drawer",
|
|
149
|
-
|
|
151
|
+
/** On the right, as the gallery opens it (`useTemplateDrawer`). */
|
|
150
152
|
direction: "right",
|
|
151
153
|
component: templateDrawer,
|
|
152
154
|
title: "$dms_mailing.templates.actions.details",
|
package/src/pages/toolbar.ts
CHANGED
|
@@ -65,8 +65,10 @@ export function mailingToolbar(
|
|
|
65
65
|
id: period.scope,
|
|
66
66
|
presets: period.presets,
|
|
67
67
|
defaultPreset: period.defaultPreset,
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* More than one comparison: `pageToolbar()` draws them as a "vs …" menu
|
|
70
|
+
* on the first one, the previous period.
|
|
71
|
+
*/
|
|
70
72
|
comparisons: MAILING_COMPARISONS,
|
|
71
73
|
},
|
|
72
74
|
}).meta({
|
package/src/routes/events.ts
CHANGED
|
@@ -17,17 +17,17 @@ import {
|
|
|
17
17
|
secretRefusal,
|
|
18
18
|
WEBHOOK_SECRET_HEADER,
|
|
19
19
|
} from "../services/webhook-status";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} from "../
|
|
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";
|
|
24
|
+
import { sendEventTypeSchema } from "../validation/send-event-type.schema";
|
|
25
25
|
|
|
26
26
|
const eventSchema = z.object({
|
|
27
27
|
messageId: z.string().min(1),
|
|
28
|
-
type:
|
|
28
|
+
type: sendEventTypeSchema,
|
|
29
29
|
at: z.coerce.date().optional(),
|
|
30
|
-
details:
|
|
30
|
+
details: jsonObjectSchema.optional(),
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
type ParsedEvent = z.infer<typeof eventSchema>;
|
|
@@ -41,7 +41,7 @@ const STATUS_OF_REFUSAL: Record<WebhookRefusal, number> = {
|
|
|
41
41
|
interface ReceivedEvent {
|
|
42
42
|
provider: string;
|
|
43
43
|
secret: string | undefined;
|
|
44
|
-
body:
|
|
44
|
+
body: JsonValue | undefined;
|
|
45
45
|
/** The tenant the URL names. */
|
|
46
46
|
tenantId: string;
|
|
47
47
|
}
|
|
@@ -57,9 +57,9 @@ export interface WebhookResponse {
|
|
|
57
57
|
* The body as JSON, `undefined` when it is not: a malformed body is refused
|
|
58
58
|
* as an invalid event, after the secret, never answered with a parser error.
|
|
59
59
|
*/
|
|
60
|
-
function parseBody(raw: Buffer):
|
|
60
|
+
function parseBody(raw: Buffer): JsonValue | undefined {
|
|
61
61
|
try {
|
|
62
|
-
return
|
|
62
|
+
return parseJson(raw.toString("utf8"));
|
|
63
63
|
} catch {
|
|
64
64
|
return undefined;
|
|
65
65
|
}
|