@antelopejs/dms-mailing 0.8.0 → 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 +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/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.js +10 -9
- package/dist/hooks/tenant-export.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 +1 -1
- package/dist/routes/settings.js +12 -10
- 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.js +3 -4
- 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 +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-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 +28 -28
- package/dist/validation/declarations.schema.js +4 -3
- 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/package.json +1 -1
- package/frontend-vue/pnpm-lock.yaml +44 -4
- package/package.json +16 -15
- package/skills/dms-mailing/SKILL.md +3 -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/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 +11 -13
- 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 +15 -10
- 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.ts +3 -6
- package/src/services/settings.ts +17 -16
- package/src/services/starters.ts +8 -4
- package/src/services/templates.ts +16 -9
- 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 +4 -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
|
@@ -133,7 +133,10 @@ export async function declareTemplate(
|
|
|
133
133
|
assertKnownCategory(settings.categories, declaration.category, slug);
|
|
134
134
|
const created = await createDeclared(declaration);
|
|
135
135
|
if (created) return resultOf(created, "created");
|
|
136
|
-
|
|
136
|
+
const winner = await model.getBySlug(slug);
|
|
137
|
+
if (!winner)
|
|
138
|
+
throw new Error(`Template "${slug}" disappeared before it was read`);
|
|
139
|
+
return resultOf(winner, "kept");
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
/** Category declarations of one tenant run one after the other. */
|
package/src/services/events.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { publishSendUpdated } from "../realtime";
|
|
|
16
16
|
import type { SendEventType, SendStatus } from "../types";
|
|
17
17
|
import { notifyProblems } from "./notify";
|
|
18
18
|
import { suppressAddress, suppressionReasonOf } from "./suppressions";
|
|
19
|
-
import {
|
|
19
|
+
import { isText } from "../validation/guards";
|
|
20
|
+
import { isJsonObject } from "../validation/json";
|
|
20
21
|
|
|
21
22
|
export interface SendCounters {
|
|
22
23
|
status: SendStatus;
|
|
@@ -169,7 +170,8 @@ export function isSameEvent(
|
|
|
169
170
|
if (eventId) {
|
|
170
171
|
const storedDetails: unknown = JSON.parse(stored.json_details || "{}");
|
|
171
172
|
return (
|
|
172
|
-
eventIdOf(
|
|
173
|
+
eventIdOf(isJsonObject(storedDetails) ? storedDetails : undefined) ===
|
|
174
|
+
eventId
|
|
173
175
|
);
|
|
174
176
|
}
|
|
175
177
|
if (!event.at || stored.type !== event.type) return false;
|
package/src/services/metrics.ts
CHANGED
|
@@ -11,14 +11,12 @@ const AUDIENCE_KEEPS: Record<SendAudience, (send: SendSummary) => boolean> = {
|
|
|
11
11
|
[OPERATIONAL_AUDIENCE]: () => true,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
[OPERATIONAL_AUDIENCE]: OPERATIONAL_AUDIENCE,
|
|
17
|
-
};
|
|
14
|
+
const isAudience = (raw: string): raw is SendAudience =>
|
|
15
|
+
Object.hasOwn(AUDIENCE_KEEPS, raw);
|
|
18
16
|
|
|
19
17
|
/** Named audience behind a query value; anything unknown is the business view. */
|
|
20
18
|
export function readAudience(raw: string | null | undefined): SendAudience {
|
|
21
|
-
return
|
|
19
|
+
return raw != null && isAudience(raw) ? raw : BUSINESS_AUDIENCE;
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
const STATS_WINDOW_DAYS = 30;
|
package/src/services/send.ts
CHANGED
|
@@ -159,9 +159,6 @@ const OUTCOME_SEVERITY: Record<SendStatus, number> = {
|
|
|
159
159
|
failed: 9,
|
|
160
160
|
};
|
|
161
161
|
|
|
162
|
-
const describeError = (error: unknown): string =>
|
|
163
|
-
error instanceof Error ? error.message : String(error);
|
|
164
|
-
|
|
165
162
|
const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
|
|
166
163
|
|
|
167
164
|
const nameOf = (to: EmailAddress): string | undefined =>
|
|
@@ -376,7 +373,7 @@ async function deliverOne(
|
|
|
376
373
|
status: "failed",
|
|
377
374
|
outcome: {
|
|
378
375
|
latencyMs: Date.now() - startedAt,
|
|
379
|
-
error:
|
|
376
|
+
error: error instanceof Error ? error.message : String(error),
|
|
380
377
|
},
|
|
381
378
|
};
|
|
382
379
|
}
|
|
@@ -423,7 +420,7 @@ export async function deliverBatch(
|
|
|
423
420
|
return failAll(
|
|
424
421
|
input.recipients,
|
|
425
422
|
Date.now() - startedAt,
|
|
426
|
-
|
|
423
|
+
error instanceof Error ? error.message : String(error),
|
|
427
424
|
);
|
|
428
425
|
}
|
|
429
426
|
}
|
|
@@ -504,7 +501,7 @@ async function recordOne(
|
|
|
504
501
|
sendId: UNRECORDED_SEND_ID,
|
|
505
502
|
status: "failed",
|
|
506
503
|
messageId: message.outcome.providerMessageId,
|
|
507
|
-
error:
|
|
504
|
+
error: error instanceof Error ? error.message : String(error),
|
|
508
505
|
};
|
|
509
506
|
}
|
|
510
507
|
}
|
package/src/services/settings.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
type MailingSettingsValues,
|
|
7
7
|
type TemplateCategory,
|
|
8
8
|
} from "../types";
|
|
9
|
+
import type { JsonObject } from "../types/json";
|
|
10
|
+
import type { SettingsPatch } from "../validation/settings.schema";
|
|
9
11
|
|
|
10
12
|
export const WEBHOOK_SECRET_BYTES = 24;
|
|
11
13
|
|
|
@@ -28,6 +30,8 @@ function toValues(row: MailingSettings): MailingSettingsValues {
|
|
|
28
30
|
senderEmail: row.senderEmail,
|
|
29
31
|
replyTo: row.replyTo,
|
|
30
32
|
webhookSecret: row.webhookSecret,
|
|
33
|
+
// SAFETY: json_categories is written only by toRow as the JSON.stringify of categories that categoriesSchema
|
|
34
|
+
// validated (settingsPatchSchema, applyCategory) or that the module defaults supplied.
|
|
31
35
|
categories: JSON.parse(row.json_categories) as TemplateCategory[],
|
|
32
36
|
};
|
|
33
37
|
}
|
|
@@ -71,7 +75,12 @@ export async function getSettings(
|
|
|
71
75
|
if (existing) return existing;
|
|
72
76
|
const created = defaults();
|
|
73
77
|
if (await insertKeyedSettings(tenantId, created)) return created;
|
|
74
|
-
|
|
78
|
+
const winner = await findSettings(tenantId);
|
|
79
|
+
if (!winner)
|
|
80
|
+
throw new Error(
|
|
81
|
+
`The settings of tenant "${tenantId}" disappeared before they were read`,
|
|
82
|
+
);
|
|
83
|
+
return winner;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
export async function saveSettings(
|
|
@@ -105,36 +114,28 @@ export function missingVariablesPolicy(
|
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
/** The form-only field folded back into `blockOnMissingVariables`. */
|
|
108
|
-
const MISSING_VARIABLES_KEY = "missingVariables";
|
|
109
117
|
const CLEARED_VALUE = "";
|
|
110
118
|
/** The patch with each cleared field (`null`) as the empty value it stores. */
|
|
111
|
-
export function clearedToEmpty(
|
|
112
|
-
patch: Record<string, unknown>,
|
|
113
|
-
): Record<string, unknown> {
|
|
119
|
+
export function clearedToEmpty(patch: JsonObject): JsonObject {
|
|
114
120
|
return Object.fromEntries(
|
|
115
121
|
Object.entries(patch).map(([key, value]) => [key, value ?? CLEARED_VALUE]),
|
|
116
122
|
);
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/**
|
|
120
|
-
* The settings once `patch` (the fields a form changed,
|
|
121
|
-
*
|
|
122
|
-
* into its boolean.
|
|
126
|
+
* The settings once `patch` (the fields a form changed, cleared ones already
|
|
127
|
+
* stored as empty) is laid over the stored ones, the missing-variables choice
|
|
128
|
+
* folded back into its boolean.
|
|
123
129
|
*/
|
|
124
130
|
export function mergeSettingsPatch(
|
|
125
131
|
current: MailingSettingsValues,
|
|
126
|
-
patch:
|
|
132
|
+
patch: SettingsPatch,
|
|
127
133
|
): MailingSettingsValues {
|
|
128
|
-
const written =
|
|
129
|
-
Object.entries(clearedToEmpty(patch)).filter(
|
|
130
|
-
([key]) => key !== MISSING_VARIABLES_KEY,
|
|
131
|
-
),
|
|
132
|
-
);
|
|
133
|
-
const policy = patch.missingVariables;
|
|
134
|
+
const { missingVariables: policy, ...written } = patch;
|
|
134
135
|
const isChoice = policy === REFUSE_POLICY || policy === SEND_POLICY;
|
|
135
136
|
const guard =
|
|
136
137
|
isChoice && !("blockOnMissingVariables" in patch)
|
|
137
138
|
? { blockOnMissingVariables: policy === REFUSE_POLICY }
|
|
138
139
|
: {};
|
|
139
|
-
return { ...current, ...written, ...guard }
|
|
140
|
+
return { ...current, ...written, ...guard };
|
|
140
141
|
}
|
package/src/services/starters.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
TemplateContent,
|
|
6
6
|
VariableDefinition,
|
|
7
7
|
} from "../types";
|
|
8
|
+
import type { JsonObject } from "../types/json";
|
|
8
9
|
import { footerForCategory, formattingLocale } from "../engine";
|
|
9
10
|
import { blankFields, footerBlock } from "./templates";
|
|
10
11
|
|
|
@@ -21,9 +22,9 @@ export interface StarterTemplate {
|
|
|
21
22
|
content: TemplateContent;
|
|
22
23
|
variables: VariableDefinition[];
|
|
23
24
|
/** The test data as an English-speaking workspace gets it. */
|
|
24
|
-
testData:
|
|
25
|
+
testData: JsonObject;
|
|
25
26
|
/** The test data with its amounts written the way `locale` writes them. */
|
|
26
|
-
testDataFor: (locale: string) =>
|
|
27
|
+
testDataFor: (locale: string) => JsonObject;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
type StarterSource = Omit<StarterTemplate, "testData">;
|
|
@@ -39,9 +40,12 @@ const ORDER_LINE_NAMES = new Map([
|
|
|
39
40
|
const LOCALE_PARTS = /[-_]/;
|
|
40
41
|
|
|
41
42
|
/** A test text in the language of `locale`, English when it has none. */
|
|
42
|
-
function inLanguageOf(
|
|
43
|
+
function inLanguageOf(
|
|
44
|
+
texts: ReadonlyMap<string, string>,
|
|
45
|
+
locale: string,
|
|
46
|
+
): string {
|
|
43
47
|
const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
|
|
44
|
-
return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE);
|
|
48
|
+
return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE) ?? "";
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/** An amount the way `locale` writes it: "€1,556.00", "1 556,00 €". */
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
VariableDefinition,
|
|
28
28
|
} from "../types";
|
|
29
29
|
import { DEFAULT_FALLBACK_LOCALE } from "../types";
|
|
30
|
+
import type { JsonObject } from "../types/json";
|
|
30
31
|
|
|
31
32
|
export interface TemplateAuthor {
|
|
32
33
|
name?: string;
|
|
@@ -73,6 +74,7 @@ export async function refuseTakenSlug<T>(write: Promise<T>): Promise<T> {
|
|
|
73
74
|
|
|
74
75
|
/** The content customers receive: the last published version. */
|
|
75
76
|
export function parseContent(template: MailingTemplate): TemplateContent {
|
|
77
|
+
// SAFETY: json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
|
|
76
78
|
return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -83,6 +85,7 @@ export function hasDraft(template: MailingTemplate): boolean {
|
|
|
83
85
|
/** The content the editor works on: the draft, or the published content. */
|
|
84
86
|
export function workingContent(template: MailingTemplate): TemplateContent {
|
|
85
87
|
if (!template.json_draft) return parseContent(template);
|
|
88
|
+
// SAFETY: json_draft is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
|
|
86
89
|
return JSON.parse(template.json_draft) as TemplateContent;
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -102,6 +105,7 @@ export function isPublished(template: MailingTemplate): boolean {
|
|
|
102
105
|
export function parseVariables(
|
|
103
106
|
template: MailingTemplate,
|
|
104
107
|
): VariableDefinition[] {
|
|
108
|
+
// SAFETY: json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
|
|
105
109
|
return JSON.parse(template.json_variables) as VariableDefinition[];
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -114,6 +118,7 @@ export function workingVariables(
|
|
|
114
118
|
): VariableDefinition[] {
|
|
115
119
|
if (!hasDraft(template) || !template.json_variables_draft)
|
|
116
120
|
return parseVariables(template);
|
|
121
|
+
// SAFETY: json_variables_draft is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
|
|
117
122
|
return JSON.parse(template.json_variables_draft) as VariableDefinition[];
|
|
118
123
|
}
|
|
119
124
|
|
|
@@ -169,10 +174,9 @@ export function pendingChanges(template: MailingTemplate): TemplateChange[] {
|
|
|
169
174
|
});
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
export function parseTestData(
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
return JSON.parse(template.json_test_data) as Record<string, unknown>;
|
|
177
|
+
export function parseTestData(template: MailingTemplate): JsonObject {
|
|
178
|
+
// SAFETY: json_test_data is written only as JSON.stringify of an object that its route or declaration schema validated, or the module built.
|
|
179
|
+
return JSON.parse(template.json_test_data) as JsonObject;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
interface FooterLabels {
|
|
@@ -190,16 +194,16 @@ const ENGLISH_FOOTER_LABELS: FooterLabels = {
|
|
|
190
194
|
* e-mail without an unsubscribe link is a legal problem, so a blank template
|
|
191
195
|
* ships a footer — and nothing else: no invented subject, prose or address.
|
|
192
196
|
*/
|
|
193
|
-
const FOOTER_LABELS
|
|
194
|
-
en
|
|
195
|
-
fr
|
|
196
|
-
|
|
197
|
+
const FOOTER_LABELS = new Map<string, FooterLabels>([
|
|
198
|
+
["en", ENGLISH_FOOTER_LABELS],
|
|
199
|
+
["fr", { unsubscribe: "Se désinscrire", preferences: "Préférences" }],
|
|
200
|
+
]);
|
|
197
201
|
|
|
198
202
|
const FOOTER_BLOCK_ID = "footer";
|
|
199
203
|
|
|
200
204
|
/** A footer in `locale`: its legal line and the labels of its two links. */
|
|
201
205
|
export function footerBlock(locale: string, text = ""): Block {
|
|
202
|
-
const labels = FOOTER_LABELS
|
|
206
|
+
const labels = FOOTER_LABELS.get(locale) ?? ENGLISH_FOOTER_LABELS;
|
|
203
207
|
return {
|
|
204
208
|
id: FOOTER_BLOCK_ID,
|
|
205
209
|
type: "footer",
|
|
@@ -293,6 +297,7 @@ export async function discardDraft(
|
|
|
293
297
|
export function parseVersionChanges(
|
|
294
298
|
row: MailingTemplateVersion,
|
|
295
299
|
): TemplateChange[] {
|
|
300
|
+
// SAFETY: json_changes is written only as JSON.stringify of the TemplateChange[] from diffContents and diffVariables.
|
|
296
301
|
return JSON.parse(row.json_changes) as TemplateChange[];
|
|
297
302
|
}
|
|
298
303
|
|
|
@@ -300,6 +305,7 @@ export function parseVersionChanges(
|
|
|
300
305
|
export function parseVersionVariables(
|
|
301
306
|
row: MailingTemplateVersion,
|
|
302
307
|
): VariableDefinition[] {
|
|
308
|
+
// SAFETY: a version row's json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
|
|
303
309
|
return JSON.parse(row.json_variables) as VariableDefinition[];
|
|
304
310
|
}
|
|
305
311
|
|
|
@@ -455,6 +461,7 @@ export async function contentOfVersion(
|
|
|
455
461
|
export function parseVersionContent(
|
|
456
462
|
row: MailingTemplateVersion,
|
|
457
463
|
): TemplateContent {
|
|
464
|
+
// SAFETY: a version row's json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated.
|
|
458
465
|
return JSON.parse(row.json_content) as TemplateContent;
|
|
459
466
|
}
|
|
460
467
|
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
WebhookRefusal,
|
|
8
8
|
WebhookStatus,
|
|
9
9
|
} from "../types";
|
|
10
|
+
import { isObject, isText } from "../validation/guards";
|
|
10
11
|
import { webhookSecretMatches } from "./events";
|
|
11
12
|
import { WEBHOOK_SECRET_HEADER } from "../constants";
|
|
12
13
|
|
|
@@ -28,11 +29,11 @@ export const WEBHOOK_REFUSAL_MESSAGES: Record<WebhookRefusal, string> = {
|
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
/** The body of a refused webhook call. */
|
|
31
|
-
export
|
|
32
|
+
export type WebhookRefusalBody = {
|
|
32
33
|
error: "webhook_refused";
|
|
33
34
|
reason: WebhookRefusal;
|
|
34
35
|
message: string;
|
|
35
|
-
}
|
|
36
|
+
};
|
|
36
37
|
|
|
37
38
|
/** What "Send a test event" found when it called the webhook URL. */
|
|
38
39
|
export interface WebhookTestOutcome {
|
|
@@ -143,15 +144,13 @@ export async function readWebhookStatus(
|
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
const describeError = (error: unknown): string =>
|
|
147
|
-
error instanceof Error
|
|
148
|
-
? String((error.cause as Error | undefined)?.message ?? error.message)
|
|
149
|
-
: String(error);
|
|
150
|
-
|
|
151
147
|
async function refusalOf(
|
|
152
148
|
response: Response,
|
|
153
149
|
): Promise<WebhookRefusal | undefined> {
|
|
154
150
|
try {
|
|
151
|
+
// SAFETY: Response.json() answers any, and the URL under test is this module's own webhook route, which refuses
|
|
152
|
+
// with refusalBody(reason); another endpoint can answer any JSON, which is read the same way, and a non-object
|
|
153
|
+
// reads as no refusal (only null throws, into the catch below).
|
|
155
154
|
const body = (await response.json()) as Partial<WebhookRefusalBody>;
|
|
156
155
|
return body.reason;
|
|
157
156
|
} catch {
|
|
@@ -192,11 +191,17 @@ export async function sendWebhookTest(
|
|
|
192
191
|
refusal: response.ok ? undefined : await refusalOf(response),
|
|
193
192
|
};
|
|
194
193
|
} catch (error) {
|
|
194
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
195
|
+
const causeMessage =
|
|
196
|
+
isObject(cause) && isText(cause.message) ? cause.message : undefined;
|
|
195
197
|
return {
|
|
196
198
|
isReceived: false,
|
|
197
199
|
url,
|
|
198
200
|
durationMs: elapsed(),
|
|
199
|
-
networkError:
|
|
201
|
+
networkError:
|
|
202
|
+
error instanceof Error
|
|
203
|
+
? (causeMessage ?? error.message)
|
|
204
|
+
: String(error),
|
|
200
205
|
};
|
|
201
206
|
}
|
|
202
207
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { JsonObject, JsonValue } from "@antelopejs/interface-dms-mailing";
|
package/src/types/settings.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
|
|
2
2
|
|
|
3
3
|
/** A template category as stored: its label written once per language. */
|
|
4
|
-
export
|
|
4
|
+
export type TemplateCategory = {
|
|
5
5
|
id: string;
|
|
6
6
|
label: LocalizedText;
|
|
7
7
|
icon: string;
|
|
8
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
/** A template category as one reader sees it: the label in their language. */
|
|
11
11
|
export interface CategoryInLocale {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
templateContentSchema,
|
|
9
9
|
variableListSchema,
|
|
10
10
|
} from "./templates.schema";
|
|
11
|
+
import { jsonObjectSchema } from "./json.schema";
|
|
11
12
|
|
|
12
13
|
const MAX_AUTHOR_LENGTH = MAX_NAME_LENGTH;
|
|
13
14
|
const MAX_IMPORT_ID_LENGTH = 200;
|
|
@@ -36,7 +37,7 @@ export const templateDeclarationSchema = z.object({
|
|
|
36
37
|
category: z.string().trim().default(""),
|
|
37
38
|
content: templateContentSchema,
|
|
38
39
|
variables: variableListSchema.default([]),
|
|
39
|
-
testData:
|
|
40
|
+
testData: jsonObjectSchema.default({}),
|
|
40
41
|
author: z.string().trim().min(1).max(MAX_AUTHOR_LENGTH).optional(),
|
|
41
42
|
});
|
|
42
43
|
|
|
@@ -78,7 +79,7 @@ const recipientSchema = z.union([
|
|
|
78
79
|
const importedEventSchema = z.object({
|
|
79
80
|
type: sendEventTypeSchema,
|
|
80
81
|
at: z.coerce.date(),
|
|
81
|
-
details:
|
|
82
|
+
details: jsonObjectSchema.optional(),
|
|
82
83
|
});
|
|
83
84
|
|
|
84
85
|
const importedSendSchema = z.object({
|
|
@@ -87,7 +88,7 @@ const importedSendSchema = z.object({
|
|
|
87
88
|
to: recipientSchema,
|
|
88
89
|
sentAt: z.coerce.date(),
|
|
89
90
|
locale: z.string().trim().min(1).optional(),
|
|
90
|
-
variables:
|
|
91
|
+
variables: jsonObjectSchema.default({}),
|
|
91
92
|
provider: z.string().optional(),
|
|
92
93
|
providerMessageId: z.string().optional(),
|
|
93
94
|
source: z.string().optional(),
|
package/src/validation/guards.ts
CHANGED
|
@@ -3,14 +3,6 @@ export function isText(value: unknown): value is string {
|
|
|
3
3
|
return typeof value === "string";
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Whether `value` is a JSON object as a parsed body holds one: an object,
|
|
8
|
-
* neither `null` nor an array. Its values are not checked.
|
|
9
|
-
*/
|
|
10
|
-
export function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
11
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
6
|
/**
|
|
15
7
|
* Whether `value` is an object, arrays included, and not `null`: reading any
|
|
16
8
|
* key of it yields a value of unknown type.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { JsonObject, JsonValue } from "../types/json";
|
|
2
|
+
import { isNumber, isObject, isText } from "./guards";
|
|
3
|
+
|
|
4
|
+
/** A step of the walk in {@link isJsonValue}: visit a node, or leave one. */
|
|
5
|
+
type JsonWalkStep = { visit: unknown } | { leave: object };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether `value` is wholly JSON data: strings, numbers, booleans, null, and
|
|
9
|
+
* arrays and plain objects of the same, none of them reached through itself.
|
|
10
|
+
* Walked with a stack, so a deeply nested value cannot overflow it.
|
|
11
|
+
*/
|
|
12
|
+
export function isJsonValue(value: unknown): value is JsonValue {
|
|
13
|
+
const stack: JsonWalkStep[] = [{ visit: value }];
|
|
14
|
+
const ancestors = new Set<object>();
|
|
15
|
+
for (let step = stack.pop(); step; step = stack.pop()) {
|
|
16
|
+
if ("leave" in step) {
|
|
17
|
+
ancestors.delete(step.leave);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const item = step.visit;
|
|
21
|
+
if (
|
|
22
|
+
item === null ||
|
|
23
|
+
isText(item) ||
|
|
24
|
+
isNumber(item) ||
|
|
25
|
+
typeof item === "boolean"
|
|
26
|
+
)
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof item !== "object" || ancestors.has(item)) return false;
|
|
29
|
+
const prototype: unknown = Object.getPrototypeOf(item);
|
|
30
|
+
if (
|
|
31
|
+
!Array.isArray(item) &&
|
|
32
|
+
prototype !== Object.prototype &&
|
|
33
|
+
prototype !== null
|
|
34
|
+
)
|
|
35
|
+
return false;
|
|
36
|
+
ancestors.add(item);
|
|
37
|
+
stack.push({ leave: item });
|
|
38
|
+
for (const child of Object.values(item)) stack.push({ visit: child });
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether `value` is wholly JSON data and an object: neither `null` nor an array. */
|
|
44
|
+
export function isJsonObject(value: unknown): value is JsonObject {
|
|
45
|
+
return (
|
|
46
|
+
typeof value === "object" &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
!Array.isArray(value) &&
|
|
49
|
+
isJsonValue(value)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `text` parsed as JSON; a `SyntaxError` when it is not. */
|
|
54
|
+
export function parseJson(text: string): JsonValue {
|
|
55
|
+
const parsed: unknown = JSON.parse(text);
|
|
56
|
+
if (!isJsonValue(parsed)) throw new TypeError("expected JSON data");
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `text` parsed as a JSON object. */
|
|
61
|
+
export function parseJsonObject(text: string): JsonObject {
|
|
62
|
+
const parsed = parseJson(text);
|
|
63
|
+
if (!isObject(parsed) || Array.isArray(parsed))
|
|
64
|
+
throw new TypeError("expected a JSON object");
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MAX_NAME_LENGTH, MAX_SLUG_LENGTH, SLUG_PATTERN } from "../constants";
|
|
3
3
|
import type { Block } from "../types";
|
|
4
|
+
import { jsonObjectSchema } from "./json.schema";
|
|
4
5
|
|
|
5
6
|
const MIN_HEADING_SIZE = 12;
|
|
6
7
|
const MAX_HEADING_SIZE = 48;
|
|
@@ -149,7 +150,7 @@ export const saveContentSchema = z.object({
|
|
|
149
150
|
revision: draftRevisionSchema,
|
|
150
151
|
});
|
|
151
152
|
|
|
152
|
-
export const testDataSchema = z.object({ data:
|
|
153
|
+
export const testDataSchema = z.object({ data: jsonObjectSchema });
|
|
153
154
|
|
|
154
155
|
/**
|
|
155
156
|
* Which content a preview renders when it carries none: the working draft
|
|
@@ -166,7 +167,7 @@ export const previewSchema = z.object({
|
|
|
166
167
|
locale: z.string().min(1).optional(),
|
|
167
168
|
version: previewVersionSchema.optional(),
|
|
168
169
|
content: templateContentSchema.optional(),
|
|
169
|
-
data:
|
|
170
|
+
data: jsonObjectSchema.optional(),
|
|
170
171
|
});
|
|
171
172
|
|
|
172
173
|
/**
|
|
@@ -176,7 +177,7 @@ export const previewSchema = z.object({
|
|
|
176
177
|
export const realSendSchema = z.object({
|
|
177
178
|
to: z.array(z.string().email()).min(1).max(MAX_TEST_RECIPIENT_COUNT),
|
|
178
179
|
locale: z.string().min(1).optional(),
|
|
179
|
-
data:
|
|
180
|
+
data: jsonObjectSchema.optional(),
|
|
180
181
|
});
|
|
181
182
|
|
|
182
183
|
export const testSendSchema = previewSchema.extend({
|
|
@@ -193,6 +194,9 @@ export const templateDetailsSchema = z.object({
|
|
|
193
194
|
category: z.string().nullable().optional(),
|
|
194
195
|
});
|
|
195
196
|
|
|
197
|
+
/** The details a template's drawer or the editor saves, validated. */
|
|
198
|
+
export type TemplateDetails = z.infer<typeof templateDetailsSchema>;
|
|
199
|
+
|
|
196
200
|
export const duplicateSchema = z.object({
|
|
197
201
|
slug: slugSchema,
|
|
198
202
|
name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
|