@antelopejs/dms-mailing 0.7.1 → 0.8.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 +21 -4
- package/dist/crons/jobs.d.ts +1 -1
- package/dist/data/body.js +2 -1
- package/dist/data/body.js.map +1 -1
- package/dist/data/templates-table.js +4 -3
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.js +2 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/engine/changes.js +2 -1
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.js +2 -3
- package/dist/engine/conditions.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.js +6 -6
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.js +2 -3
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/system-variables.js +1 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +2 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/metrics.js +6 -5
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/sends.js +1 -1
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -6
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.js +4 -4
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/attention.js +2 -1
- package/dist/services/attention.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/declarations.js.map +1 -1
- package/dist/services/events.js +11 -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.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 +4 -3
- package/dist/services/send.js.map +1 -1
- 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.js +6 -2
- package/dist/services/templates.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +16 -16
- package/dist/validation/declarations.schema.js +2 -2
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +4 -0
- package/dist/validation/guards.js +19 -0
- package/dist/validation/guards.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 +19 -18
- 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 +210 -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 +3 -8
- package/skills/dms-mailing/SKILL.md +3 -3
- package/skills/dms-mailing/cases.json +49 -0
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +2 -1
- package/src/data/templates-table.ts +12 -7
- package/src/db/models/template-versions.model.ts +5 -1
- package/src/db/models/templates.model.ts +6 -3
- package/src/engine/changes.ts +3 -2
- package/src/engine/conditions.ts +2 -4
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +6 -6
- package/src/engine/paths.ts +4 -3
- package/src/engine/system-variables.ts +4 -3
- package/src/engine/tokens.ts +1 -2
- package/src/pages/sends.ts +5 -3
- package/src/pages/templates.ts +1 -1
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +3 -6
- package/src/routes/metrics.ts +7 -5
- package/src/routes/sends.ts +10 -18
- package/src/routes/settings.ts +8 -9
- package/src/routes/suppressions.ts +1 -1
- package/src/routes/templates.ts +5 -5
- package/src/services/attention.ts +4 -3
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declarations.ts +3 -5
- package/src/services/events.ts +16 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +2 -2
- 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 +7 -7
- package/src/services/starters.ts +6 -6
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +10 -8
- package/src/validation/declarations.schema.ts +2 -2
- package/src/validation/guards.ts +25 -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 +2 -1
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"id": "mailing-send-template",
|
|
6
|
+
"area": "modules",
|
|
7
|
+
"style": "clean",
|
|
8
|
+
"prompt": "Send the welcome e-mail template when a user signs up.",
|
|
9
|
+
"expect": {
|
|
10
|
+
"skills": ["dms-mailing"],
|
|
11
|
+
"acceptable": ["dms", "dms-auth"],
|
|
12
|
+
"mustMention": ["SendTemplate"]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "mailing-bounces",
|
|
17
|
+
"area": "modules",
|
|
18
|
+
"style": "clean",
|
|
19
|
+
"prompt": "Our e-mail provider reports bounces through a webhook. How do we record them?",
|
|
20
|
+
"expect": {
|
|
21
|
+
"skills": ["dms-mailing"],
|
|
22
|
+
"acceptable": ["dms"],
|
|
23
|
+
"mustMention": ["RecordEmailEvent"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "mailing-missing-variable-casual",
|
|
28
|
+
"area": "modules",
|
|
29
|
+
"style": "casual",
|
|
30
|
+
"prompt": "mail send says missing variable firstName??",
|
|
31
|
+
"expect": {
|
|
32
|
+
"skills": ["dms-mailing"],
|
|
33
|
+
"acceptable": ["dms"]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "dms-mailing-avoid-tracker",
|
|
38
|
+
"area": "modules",
|
|
39
|
+
"style": "clean",
|
|
40
|
+
"prompt": "Put the tracking script on our public website and count how many visitors click the signup button.",
|
|
41
|
+
"expect": {
|
|
42
|
+
"skills": ["dms-marketing"],
|
|
43
|
+
"acceptable": ["dms"],
|
|
44
|
+
"mustMention": ["data-website-id"],
|
|
45
|
+
"avoid": ["dms-mailing"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
package/src/crons/jobs.ts
CHANGED
package/src/data/body.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { HTTPResult } from "@antelopejs/interface-api";
|
|
2
2
|
import { HTTP_BAD_REQUEST } from "../constants";
|
|
3
|
+
import { isText } from "../validation/guards";
|
|
3
4
|
|
|
4
5
|
const INVALID_BODY = "$dms_mailing.errors.invalid_body";
|
|
5
6
|
|
|
6
7
|
export function parseBody(body: Buffer | string): Record<string, unknown> {
|
|
7
|
-
const raw =
|
|
8
|
+
const raw = isText(body) ? body : body.toString("utf8");
|
|
8
9
|
if (!raw) return {};
|
|
9
10
|
try {
|
|
10
11
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
createTemplateSchema,
|
|
85
85
|
templateDetailsSchema,
|
|
86
86
|
} from "../validation/templates.schema";
|
|
87
|
+
import { isObject, isText } from "../validation/guards";
|
|
87
88
|
import { parseBody } from "./body";
|
|
88
89
|
import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
|
|
89
90
|
|
|
@@ -155,7 +156,7 @@ function assertKnownCategory(
|
|
|
155
156
|
category: unknown,
|
|
156
157
|
): void {
|
|
157
158
|
if (isBlank(category)) return;
|
|
158
|
-
if (
|
|
159
|
+
if (isText(category) && findCategory(categories, category)) {
|
|
159
160
|
return;
|
|
160
161
|
}
|
|
161
162
|
throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
|
|
@@ -171,7 +172,8 @@ interface CreatedRow {
|
|
|
171
172
|
* The creation's seed, and the body to store: slug trimmed, and the name
|
|
172
173
|
* typed in the reader's language as every language of the localized field,
|
|
173
174
|
* the fallback one included until someone translates it. A body a guard
|
|
174
|
-
* already parsed arrives as an object.
|
|
175
|
+
* already parsed arrives as an object. The body to store is handed on as an
|
|
176
|
+
* object: interface-data-api 0.3 refuses a JSON string.
|
|
175
177
|
*/
|
|
176
178
|
function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
177
179
|
const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
|
|
@@ -181,7 +183,6 @@ function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
|
181
183
|
() => INVALID_TEMPLATE,
|
|
182
184
|
HTTP_BAD_REQUEST,
|
|
183
185
|
);
|
|
184
|
-
// Handed as an object: interface-data-api 0.3 refuses a JSON string.
|
|
185
186
|
const normalized = {
|
|
186
187
|
...raw,
|
|
187
188
|
slug: seed.slug,
|
|
@@ -198,6 +199,12 @@ interface LocalizedController {
|
|
|
198
199
|
/** The key that writes every language of a localized field at once. */
|
|
199
200
|
const ALL_LANGUAGES = "*";
|
|
200
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The data API's `new` callback, seeding the template it creates. The
|
|
204
|
+
* module's creation dialogs send one typed name, and the stored name holds
|
|
205
|
+
* every language, written at once: the controller's language key is set to
|
|
206
|
+
* all languages before the creation.
|
|
207
|
+
*/
|
|
201
208
|
function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
202
209
|
return {
|
|
203
210
|
...base,
|
|
@@ -213,8 +220,6 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
213
220
|
const settings = await getSettings(tenantId);
|
|
214
221
|
assertKnownCategory(settings.categories, seed.category);
|
|
215
222
|
const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
|
|
216
|
-
// The module's creation dialogs send one typed name; the stored name
|
|
217
|
-
// holds every language, written at once.
|
|
218
223
|
(this as LocalizedController).language = ALL_LANGUAGES;
|
|
219
224
|
const created = (await refuseTakenSlug(
|
|
220
225
|
base.func.call(this, ctx, params, normalized, ...rest),
|
|
@@ -334,8 +339,8 @@ export async function saveDetails(
|
|
|
334
339
|
* or, from a form editing every language, all of them.
|
|
335
340
|
*/
|
|
336
341
|
function isBlankName(name: unknown): boolean {
|
|
337
|
-
const texts =
|
|
338
|
-
return !texts.some((text) =>
|
|
342
|
+
const texts = isObject(name) ? Object.values(name) : [name];
|
|
343
|
+
return !texts.some((text) => isText(text) && text.trim());
|
|
339
344
|
}
|
|
340
345
|
|
|
341
346
|
/** The names trimmed, blank ones left out, the fallback language filled. */
|
|
@@ -45,8 +45,12 @@ export class TemplateVersionModel extends BasicDataModel(
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The published versions of a template, newest first. It reads the
|
|
50
|
+
* template's publication before the version rows: any later publication
|
|
51
|
+
* must have materialized the version that read names.
|
|
52
|
+
*/
|
|
48
53
|
async listForTemplate(templateId: string): Promise<MailingTemplateVersion[]> {
|
|
49
|
-
// Read the publication first: any later publication must have materialized it.
|
|
50
54
|
const template = await new TemplateModel(this.database).get(templateId);
|
|
51
55
|
if (!template) return [];
|
|
52
56
|
const rows = await this.getBy("templateId", templateId);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@antelopejs/interface-database-decorators";
|
|
6
6
|
import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
|
|
7
7
|
import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
|
|
8
|
+
import { isText } from "../../validation/guards";
|
|
8
9
|
|
|
9
10
|
/** The attention fields of a template, as the refresh writes them. */
|
|
10
11
|
export type TemplateAttentionFields = Pick<
|
|
@@ -15,7 +16,7 @@ export type TemplateAttentionFields = Pick<
|
|
|
15
16
|
const isLocalizedText = (value: unknown): value is LocalizedText =>
|
|
16
17
|
typeof value === "object" &&
|
|
17
18
|
value !== null &&
|
|
18
|
-
Object.values(value).every(
|
|
19
|
+
Object.values(value).every(isText);
|
|
19
20
|
|
|
20
21
|
/** Where a row keeps its modifier-held fields, the localized name among them. */
|
|
21
22
|
const MODIFIER_CONTAINER = "_internal";
|
|
@@ -37,9 +38,11 @@ export class TemplateModel extends BasicDataModel(
|
|
|
37
38
|
return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The names of `template`, one per language: unlocked with `"*"`, the
|
|
43
|
+
* localized field holds the whole per-language record.
|
|
44
|
+
*/
|
|
41
45
|
static namesOf(template: MailingTemplate): LocalizedText {
|
|
42
|
-
// Unlocked with "*", the localized field holds the whole per-language record.
|
|
43
46
|
const names: unknown = template.localize("*", ["name"]).name;
|
|
44
47
|
return isLocalizedText(names) ? names : {};
|
|
45
48
|
}
|
package/src/engine/changes.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
VariableDefinition,
|
|
7
7
|
} from "../types";
|
|
8
8
|
import { walkBlocks } from "./walk";
|
|
9
|
+
import { isObject } from "../validation/guards";
|
|
9
10
|
|
|
10
11
|
export type TemplateChangeKind =
|
|
11
12
|
| "locale_added"
|
|
@@ -51,11 +52,11 @@ const NESTED_KEYS = new Set(["children", "elseChildren"]);
|
|
|
51
52
|
*/
|
|
52
53
|
function canonical(value: unknown): unknown {
|
|
53
54
|
if (Array.isArray(value)) return value.map(canonical);
|
|
54
|
-
if (!value
|
|
55
|
+
if (!isObject(value)) return value;
|
|
55
56
|
return Object.fromEntries(
|
|
56
57
|
Object.keys(value)
|
|
57
58
|
.sort()
|
|
58
|
-
.map((key) => [key, canonical(
|
|
59
|
+
.map((key) => [key, canonical(value[key])]),
|
|
59
60
|
);
|
|
60
61
|
}
|
|
61
62
|
|
package/src/engine/conditions.ts
CHANGED
|
@@ -3,15 +3,13 @@ import { getPath } from "./paths";
|
|
|
3
3
|
|
|
4
4
|
type Evaluator = (actual: unknown, expected: string) => boolean;
|
|
5
5
|
|
|
6
|
-
const asNumber = (value: unknown): number => Number(value);
|
|
7
|
-
|
|
8
6
|
const EVALUATORS: Record<ConditionOperator, Evaluator> = {
|
|
9
7
|
truthy: (actual) => Boolean(actual),
|
|
10
8
|
falsy: (actual) => !actual,
|
|
11
9
|
eq: (actual, expected) => String(actual) === expected,
|
|
12
10
|
ne: (actual, expected) => String(actual) !== expected,
|
|
13
|
-
gt: (actual, expected) =>
|
|
14
|
-
lt: (actual, expected) =>
|
|
11
|
+
gt: (actual, expected) => Number(actual) > Number(expected),
|
|
12
|
+
lt: (actual, expected) => Number(actual) < Number(expected),
|
|
15
13
|
};
|
|
16
14
|
|
|
17
15
|
/** Whether `data` passes the condition a block is shown under. */
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import type { VariableDefinition } from "../types";
|
|
2
|
+
import { isObject, isText } from "../validation/guards";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* An amount in the minor unit of its currency (cents for `EUR`), like the
|
|
@@ -55,12 +56,11 @@ export function valueFormatOf(
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export function isMoneyValue(value: unknown): value is MoneyValue {
|
|
58
|
-
if (!value
|
|
59
|
-
const candidate = value as Partial<MoneyValue>;
|
|
59
|
+
if (!isObject(value)) return false;
|
|
60
60
|
return (
|
|
61
|
-
typeof
|
|
62
|
-
Number.isFinite(
|
|
63
|
-
typeof
|
|
61
|
+
typeof value.value === "number" &&
|
|
62
|
+
Number.isFinite(value.value) &&
|
|
63
|
+
typeof value.currency === "string"
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -118,7 +118,7 @@ export function formatValue(
|
|
|
118
118
|
return Number.isNaN(value.getTime())
|
|
119
119
|
? String(value)
|
|
120
120
|
: formatDate(value, DATE_STYLE, locale);
|
|
121
|
-
if (
|
|
121
|
+
if (isText(value) && format?.datePaths.has(path))
|
|
122
122
|
return formatIsoDate(value, locale) ?? value;
|
|
123
123
|
return String(value);
|
|
124
124
|
}
|
package/src/engine/paths.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isObject } from "../validation/guards";
|
|
2
|
+
|
|
1
3
|
const PATH_SEPARATOR = ".";
|
|
2
4
|
const ARRAY_SUFFIX = "[]";
|
|
3
5
|
|
|
@@ -16,10 +18,9 @@ export function normalizePath(path: string): string {
|
|
|
16
18
|
const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
|
|
17
19
|
|
|
18
20
|
function readSegment(current: unknown, segment: string): unknown {
|
|
19
|
-
if (current
|
|
20
|
-
if (typeof current !== "object") return undefined;
|
|
21
|
+
if (!isObject(current)) return undefined;
|
|
21
22
|
if (RESERVED_SEGMENTS.includes(segment)) return undefined;
|
|
22
|
-
return
|
|
23
|
+
return current[segment];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function getPath(data: unknown, path: string): unknown {
|
|
@@ -22,7 +22,10 @@ export const MARKETING_CATEGORY = "marketing";
|
|
|
22
22
|
|
|
23
23
|
const SYSTEM_VARIABLE_TYPE = "url";
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/** The footer fields that label a link, in the order the links show. */
|
|
26
|
+
const LABEL_KEYS = ["unsubscribeLabel", "preferencesLabel"] as const;
|
|
27
|
+
|
|
28
|
+
type FooterLabelKey = (typeof LABEL_KEYS)[number];
|
|
26
29
|
|
|
27
30
|
/** Each footer link: the label that shows it and the URL the caller passes. */
|
|
28
31
|
const FOOTER_LINKS: Record<FooterLabelKey, string> = {
|
|
@@ -30,8 +33,6 @@ const FOOTER_LINKS: Record<FooterLabelKey, string> = {
|
|
|
30
33
|
preferencesLabel: PREFERENCES_URL_PATH,
|
|
31
34
|
};
|
|
32
35
|
|
|
33
|
-
const LABEL_KEYS = Object.keys(FOOTER_LINKS) as FooterLabelKey[];
|
|
34
|
-
|
|
35
36
|
export function isMarketingCategory(category?: string | null): boolean {
|
|
36
37
|
return category === MARKETING_CATEGORY;
|
|
37
38
|
}
|
package/src/engine/tokens.ts
CHANGED
|
@@ -21,8 +21,7 @@ const MISSING_TOKEN: Record<InterpolationMode, (token: string) => string> = {
|
|
|
21
21
|
|
|
22
22
|
export function extractTokens(text: string): string[] {
|
|
23
23
|
const found = new Set<string>();
|
|
24
|
-
for (const match of text.matchAll(TOKEN_PATTERN))
|
|
25
|
-
found.add(match[1] as string);
|
|
24
|
+
for (const match of text.matchAll(TOKEN_PATTERN)) found.add(match[1]);
|
|
26
25
|
return [...found];
|
|
27
26
|
}
|
|
28
27
|
|
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
|
@@ -146,7 +146,7 @@ export class TemplatesPageController extends PageController(
|
|
|
146
146
|
deepLink: true,
|
|
147
147
|
target: {
|
|
148
148
|
type: "drawer",
|
|
149
|
-
|
|
149
|
+
/** On the right, as the gallery opens it (`useTemplateDrawer`). */
|
|
150
150
|
direction: "right",
|
|
151
151
|
component: templateDrawer,
|
|
152
152
|
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,15 +17,12 @@ import {
|
|
|
17
17
|
secretRefusal,
|
|
18
18
|
WEBHOOK_SECRET_HEADER,
|
|
19
19
|
} from "../services/webhook-status";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
type SendEventType,
|
|
23
|
-
type WebhookRefusal,
|
|
24
|
-
} from "../types";
|
|
20
|
+
import type { WebhookRefusal } from "../types";
|
|
21
|
+
import { sendEventTypeSchema } from "../validation/send-event-type.schema";
|
|
25
22
|
|
|
26
23
|
const eventSchema = z.object({
|
|
27
24
|
messageId: z.string().min(1),
|
|
28
|
-
type:
|
|
25
|
+
type: sendEventTypeSchema,
|
|
29
26
|
at: z.coerce.date().optional(),
|
|
30
27
|
details: z.record(z.unknown()).optional(),
|
|
31
28
|
});
|
package/src/routes/metrics.ts
CHANGED
|
@@ -83,18 +83,20 @@ export interface TopMetric {
|
|
|
83
83
|
minDeliveries: number;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
const OPEN_RATE_METRIC: TopMetric = {
|
|
87
|
+
extract: (totals) => totals.openRate,
|
|
88
|
+
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
89
|
+
};
|
|
90
|
+
|
|
86
91
|
const TOP_METRICS: Record<string, TopMetric> = {
|
|
87
|
-
"open-rate":
|
|
88
|
-
extract: (totals) => totals.openRate,
|
|
89
|
-
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
90
|
-
},
|
|
92
|
+
"open-rate": OPEN_RATE_METRIC,
|
|
91
93
|
"click-rate": {
|
|
92
94
|
extract: (totals) => totals.clickRate,
|
|
93
95
|
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
94
96
|
},
|
|
95
97
|
sends: { extract: (totals) => totals.sends, minDeliveries: 0 },
|
|
96
98
|
};
|
|
97
|
-
const DEFAULT_TOP_METRIC =
|
|
99
|
+
const DEFAULT_TOP_METRIC = OPEN_RATE_METRIC;
|
|
98
100
|
|
|
99
101
|
interface FunnelStepDefinition {
|
|
100
102
|
id: string;
|
package/src/routes/sends.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "@antelopejs/interface-api";
|
|
11
11
|
import { assert, assertValidation } from "@antelopejs/interface-api-util";
|
|
12
12
|
import { AuthUserWithPermission } from "@antelopejs/interface-dms/guards";
|
|
13
|
-
import { ZodError } from "zod";
|
|
13
|
+
import { ZodError, type z } from "zod";
|
|
14
14
|
import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
|
|
15
15
|
import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
|
|
16
16
|
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
@@ -68,13 +68,9 @@ import {
|
|
|
68
68
|
requireTemplate,
|
|
69
69
|
snapshotOfVersion,
|
|
70
70
|
} from "../services/templates";
|
|
71
|
-
import type {
|
|
72
|
-
SendStatus,
|
|
73
|
-
SuppressionSummary,
|
|
74
|
-
TemplateContent,
|
|
75
|
-
TemplateStatus,
|
|
76
|
-
} from "../types";
|
|
71
|
+
import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
|
|
77
72
|
import {
|
|
73
|
+
type previewVersionSchema,
|
|
78
74
|
realSendSchema,
|
|
79
75
|
replayFailedSchema,
|
|
80
76
|
replaySchema,
|
|
@@ -98,12 +94,6 @@ const DRAFT_VERSION = 0;
|
|
|
98
94
|
/** Renders as the recipient got it, not as the editor previews it. */
|
|
99
95
|
const SEND_MODE: InterpolationMode = "send";
|
|
100
96
|
|
|
101
|
-
interface TestSendInput {
|
|
102
|
-
locale: string;
|
|
103
|
-
content?: TemplateContent;
|
|
104
|
-
data?: Record<string, unknown>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
97
|
export interface SendTemplateSummary {
|
|
108
98
|
id: string;
|
|
109
99
|
name: string;
|
|
@@ -274,9 +264,9 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
274
264
|
tenantId: this.tenantId,
|
|
275
265
|
to: input.to,
|
|
276
266
|
allowSuppressed: [this.user.email],
|
|
277
|
-
locale:
|
|
278
|
-
variables:
|
|
279
|
-
content:
|
|
267
|
+
locale: input.locale,
|
|
268
|
+
variables: input.data,
|
|
269
|
+
content: input.content,
|
|
280
270
|
version: requestedVersionOf(input.version),
|
|
281
271
|
isTest: true,
|
|
282
272
|
source: TEST_SEND_SOURCE,
|
|
@@ -578,8 +568,10 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
578
568
|
* The version a test of a published version records: a number names one,
|
|
579
569
|
* "draft" or "published" let the send decide.
|
|
580
570
|
*/
|
|
581
|
-
function requestedVersionOf(
|
|
582
|
-
|
|
571
|
+
function requestedVersionOf(
|
|
572
|
+
version: z.infer<typeof previewVersionSchema> | undefined,
|
|
573
|
+
): number | undefined {
|
|
574
|
+
return version === "draft" || version === "published" ? undefined : version;
|
|
583
575
|
}
|
|
584
576
|
|
|
585
577
|
function variablesOf(send: MailingSend): Record<string, unknown> {
|
package/src/routes/settings.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
settingsPatchSchema,
|
|
49
49
|
} from "../validation/settings.schema";
|
|
50
50
|
import { retentionLimit } from "../crons/retention";
|
|
51
|
+
import { isJsonObject } from "../validation/guards";
|
|
51
52
|
|
|
52
53
|
const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
|
|
53
54
|
const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
|
|
@@ -61,9 +62,9 @@ const NEXT_RUN_MINUTE = 15;
|
|
|
61
62
|
const RETENTION_DAYS_PARAM = "days";
|
|
62
63
|
|
|
63
64
|
/** A settings field the form shows under another name. */
|
|
64
|
-
const FORM_FIELD_OF
|
|
65
|
-
blockOnMissingVariables
|
|
66
|
-
|
|
65
|
+
const FORM_FIELD_OF = new Map([
|
|
66
|
+
["blockOnMissingVariables", "missingVariables"],
|
|
67
|
+
]);
|
|
67
68
|
|
|
68
69
|
const retentionDaysSchema = z.coerce
|
|
69
70
|
.number()
|
|
@@ -141,12 +142,12 @@ function webhookUrl(
|
|
|
141
142
|
/** The issue of a refused write, under the form field it belongs to. */
|
|
142
143
|
function formIssue(issue: z.ZodIssue): z.ZodIssue {
|
|
143
144
|
const [field, ...rest] = issue.path;
|
|
144
|
-
const formField = FORM_FIELD_OF
|
|
145
|
+
const formField = FORM_FIELD_OF.get(String(field));
|
|
145
146
|
return formField ? { ...issue, path: [formField, ...rest] } : issue;
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
/** A refused write: the generic message, and each issue for its field. */
|
|
149
|
-
function invalidSettings(error: unknown)
|
|
150
|
+
function invalidSettings(error: unknown) {
|
|
150
151
|
if (!(error instanceof z.ZodError)) return INVALID_SETTINGS;
|
|
151
152
|
return { message: INVALID_SETTINGS, issues: error.issues.map(formIssue) };
|
|
152
153
|
}
|
|
@@ -236,11 +237,9 @@ export class MailingSettingsController extends Controller(
|
|
|
236
237
|
/** The body as a checked patch; 400 for anything else. */
|
|
237
238
|
private readPatch(raw: Buffer): SettingsPatch {
|
|
238
239
|
const body: unknown = parseBody(raw);
|
|
239
|
-
|
|
240
|
-
typeof body === "object" && body !== null && !Array.isArray(body);
|
|
241
|
-
assert(isPatch, HTTP_BAD_REQUEST, INVALID_SETTINGS);
|
|
240
|
+
assert(isJsonObject(body), HTTP_BAD_REQUEST, INVALID_SETTINGS);
|
|
242
241
|
return assertValidation(
|
|
243
|
-
clearedToEmpty(body
|
|
242
|
+
clearedToEmpty(body),
|
|
244
243
|
(value) => settingsPatchSchema.parse(value),
|
|
245
244
|
invalidSettings,
|
|
246
245
|
);
|
|
@@ -33,7 +33,7 @@ const manualSuppressionSchema = z.object({
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
/** A refused entry: the generic message, and each issue under its field. */
|
|
36
|
-
function invalidSuppression(error: unknown)
|
|
36
|
+
function invalidSuppression(error: unknown) {
|
|
37
37
|
if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
|
|
38
38
|
return { message: INVALID_SUPPRESSION, issues: error.issues };
|
|
39
39
|
}
|
package/src/routes/templates.ts
CHANGED
|
@@ -352,7 +352,7 @@ export class TemplatesController extends Controller(
|
|
|
352
352
|
(value) => saveContentSchema.parse(value),
|
|
353
353
|
() => INVALID_CONTENT,
|
|
354
354
|
);
|
|
355
|
-
const content = input
|
|
355
|
+
const { content } = input;
|
|
356
356
|
const template = await requireTemplate(this.templates, id);
|
|
357
357
|
assertCurrentDraft(template, input.revision);
|
|
358
358
|
const variables = input.variables ?? workingVariables(template);
|
|
@@ -514,7 +514,7 @@ export class TemplatesController extends Controller(
|
|
|
514
514
|
const { content, variables } = input.content
|
|
515
515
|
? {
|
|
516
516
|
...workingSnapshot(template),
|
|
517
|
-
content: input.content
|
|
517
|
+
content: input.content,
|
|
518
518
|
}
|
|
519
519
|
: await this.previewSnapshot(template, input.version);
|
|
520
520
|
const settings = await getSettings(this.tenantId);
|
|
@@ -542,9 +542,9 @@ export class TemplatesController extends Controller(
|
|
|
542
542
|
version: "draft" | "published" | number | undefined,
|
|
543
543
|
): Promise<TemplateDraft> | TemplateDraft {
|
|
544
544
|
if (version === "published") return publishedSnapshot(template);
|
|
545
|
-
if (
|
|
546
|
-
return
|
|
547
|
-
return
|
|
545
|
+
if (version === undefined || version === "draft")
|
|
546
|
+
return workingSnapshot(template);
|
|
547
|
+
return snapshotOfVersion(this.tenantId, template, version);
|
|
548
548
|
}
|
|
549
549
|
|
|
550
550
|
/**
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type TemplateAttentionReason,
|
|
12
12
|
} from "../types";
|
|
13
13
|
import { isDeliveryUntracked, type ProviderTracking } from "./metrics";
|
|
14
|
+
import { isNumber } from "../validation/guards";
|
|
14
15
|
|
|
15
16
|
export type AttentionTone = "error" | "warning" | "neutral";
|
|
16
17
|
|
|
@@ -238,7 +239,7 @@ function namesOf(templates: MailingTemplate[]): ComposedTextParam {
|
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
function problemBreakdown(problems: SendSummary[])
|
|
242
|
+
function problemBreakdown(problems: SendSummary[]) {
|
|
242
243
|
const countOf = (status: string) =>
|
|
243
244
|
problems.filter((send) => send.status === status).length;
|
|
244
245
|
return {
|
|
@@ -334,9 +335,9 @@ const COUNT_PARAM = "count";
|
|
|
334
335
|
function feedParams(
|
|
335
336
|
item: AttentionItem,
|
|
336
337
|
): Record<string, ComposedTextParam> | undefined {
|
|
337
|
-
const params
|
|
338
|
+
const params = { ...item.params };
|
|
338
339
|
const count = item.count ?? item.params?.[COUNT_PARAM];
|
|
339
|
-
if (
|
|
340
|
+
if (isNumber(count)) {
|
|
340
341
|
params[COUNT_PARAM] = { type: "count", value: count };
|
|
341
342
|
}
|
|
342
343
|
return Object.keys(params).length ? params : undefined;
|