@antelopejs/dms-mailing 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -43
- package/dist/automation/email-event-trigger.d.ts +2 -2
- package/dist/automation/send-template-action.d.ts +11 -3
- package/dist/automation/send-template-action.js +1 -1
- package/dist/automation/send-template-action.js.map +1 -1
- package/dist/data/body.d.ts +2 -1
- package/dist/data/body.js +2 -1
- package/dist/data/body.js.map +1 -1
- package/dist/data/read-only-routes.d.ts +4 -4
- package/dist/data/sends-table.d.ts +4 -4
- package/dist/data/suppressions-table.d.ts +4 -4
- package/dist/data/templates-table.d.ts +35 -12
- package/dist/data/templates-table.js +29 -24
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +1 -2
- package/dist/data-types/categories-type.js +3 -5
- package/dist/data-types/categories-type.js.map +1 -1
- package/dist/data-types/category-type.d.ts +1 -2
- package/dist/data-types/category-type.js +3 -5
- package/dist/data-types/category-type.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +7 -5
- package/dist/db/models/sends.model.d.ts +7 -5
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +7 -5
- package/dist/db/models/suppressions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.js +4 -3
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +7 -5
- package/dist/db/models/webhook-status.model.d.ts +7 -5
- package/dist/db/tables/settings.table.d.ts +1 -0
- package/dist/db/tables/settings.table.js +4 -0
- package/dist/db/tables/settings.table.js.map +1 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +8 -19
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.d.ts +2 -1
- package/dist/engine/conditions.js +3 -2
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/coverage.d.ts +2 -1
- package/dist/engine/coverage.js.map +1 -1
- package/dist/engine/formats.d.ts +4 -6
- package/dist/engine/formats.js +17 -3
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.d.ts +4 -2
- package/dist/engine/paths.js +15 -3
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/plain-text.js +2 -4
- package/dist/engine/plain-text.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +1 -1
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/system-variables.d.ts +2 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/tenant-export.d.ts +1 -1
- package/dist/hooks/tenant-export.js +10 -9
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -0
- package/dist/implementations/dms-mailing/index.js +3 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -1
- package/dist/pages/templates.js.map +1 -1
- package/dist/routes/events.js +4 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/metrics.js +27 -28
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.js +5 -5
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +3 -4
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +4 -3
- package/dist/routes/settings.js +20 -14
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/suppressions.js +9 -6
- package/dist/routes/suppressions.js.map +1 -1
- package/dist/routes/template-content.d.ts +27 -0
- package/dist/routes/template-content.js +3 -0
- package/dist/routes/template-content.js.map +1 -0
- package/dist/routes/templates.d.ts +7 -30
- package/dist/routes/templates.js +3 -2
- package/dist/routes/templates.js.map +1 -1
- package/dist/routes/zod-json.d.ts +3 -0
- package/dist/routes/zod-json.js +21 -0
- package/dist/routes/zod-json.js.map +1 -0
- package/dist/services/attention.js +14 -7
- package/dist/services/attention.js.map +1 -1
- package/dist/services/catalog.js +4 -2
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/declaration-error.d.ts +1 -1
- package/dist/services/declaration-error.js.map +1 -1
- package/dist/services/declarations.js +4 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +3 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/metrics.js +2 -5
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/send-import.js +4 -4
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +3 -4
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +6 -3
- package/dist/services/settings.js +24 -5
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +3 -2
- package/dist/services/starters.js +1 -1
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +2 -1
- package/dist/services/templates.js +5 -5
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-secret-declaration.d.ts +4 -0
- package/dist/services/webhook-secret-declaration.js +25 -0
- package/dist/services/webhook-secret-declaration.js.map +1 -0
- package/dist/services/webhook-status.d.ts +2 -2
- package/dist/services/webhook-status.js +13 -6
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/json.d.ts +1 -0
- package/dist/types/json.js +3 -0
- package/dist/types/json.js.map +1 -0
- package/dist/types/settings.d.ts +5 -2
- package/dist/types/settings.js +6 -1
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +41 -28
- package/dist/validation/declarations.schema.js +10 -4
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +0 -1
- package/dist/validation/guards.js +0 -4
- package/dist/validation/guards.js.map +1 -1
- package/dist/validation/json.d.ts +5 -0
- package/dist/validation/json.js +54 -0
- package/dist/validation/json.js.map +1 -0
- package/dist/validation/json.schema.d.ts +2 -0
- package/dist/validation/json.schema.js +9 -0
- package/dist/validation/json.schema.js.map +1 -0
- package/dist/validation/templates.schema.d.ts +25 -24
- package/dist/validation/templates.schema.js +4 -3
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/components/editor/EditorCanvas.vue +8 -4
- package/frontend-vue/app/composables/useSortableList.ts +2 -2
- package/frontend-vue/package.json +37 -36
- package/frontend-vue/pnpm-lock.yaml +65 -5
- package/frontend-vue/tests/editor-canvas.test.ts +142 -0
- package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
- package/frontend-vue/tsconfig.typecheck.json +12 -0
- package/frontend-vue/vitest.config.ts +16 -0
- package/package.json +16 -15
- package/skills/dms-mailing/SKILL.md +5 -3
- package/src/automation/email-event-trigger.ts +2 -2
- package/src/automation/send-template-action.ts +33 -14
- package/src/data/body.ts +5 -2
- package/src/data/templates-table.ts +46 -52
- package/src/data-types/categories-type.ts +2 -2
- package/src/data-types/category-type.ts +2 -2
- package/src/db/models/sends.model.ts +2 -2
- package/src/db/models/template-versions.model.ts +5 -3
- package/src/db/tables/settings.table.ts +4 -0
- package/src/db/tables/templates.table.ts +1 -1
- package/src/engine/changes.ts +11 -29
- package/src/engine/conditions.ts +12 -4
- package/src/engine/coverage.ts +2 -1
- package/src/engine/formats.ts +31 -17
- package/src/engine/paths.ts +39 -6
- package/src/engine/plain-text.ts +2 -4
- package/src/engine/resolve.ts +12 -4
- package/src/engine/system-variables.ts +2 -1
- package/src/engine/tokens.ts +2 -1
- package/src/hooks/tenant-export.ts +15 -14
- package/src/implementations/dms-mailing/index.ts +1 -0
- package/src/pages/sends.ts +3 -2
- package/src/pages/templates.ts +3 -1
- package/src/routes/events.ts +7 -4
- package/src/routes/index.ts +1 -0
- package/src/routes/metrics.ts +30 -30
- package/src/routes/period.ts +5 -5
- package/src/routes/sends.ts +13 -8
- package/src/routes/settings.ts +34 -17
- package/src/routes/suppressions.ts +12 -8
- package/src/routes/template-content.ts +45 -0
- package/src/routes/templates.ts +11 -44
- package/src/routes/zod-json.ts +28 -0
- package/src/services/attention.ts +16 -15
- package/src/services/catalog.ts +4 -8
- package/src/services/declaration-error.ts +1 -1
- package/src/services/declarations.ts +4 -1
- package/src/services/events.ts +4 -2
- package/src/services/metrics.ts +3 -5
- package/src/services/send-import.ts +10 -8
- package/src/services/send.ts +3 -6
- package/src/services/settings.ts +55 -16
- package/src/services/starters.ts +8 -4
- package/src/services/templates.ts +16 -9
- package/src/services/webhook-secret-declaration.ts +47 -0
- package/src/services/webhook-status.ts +18 -9
- package/src/types/json.ts +1 -0
- package/src/types/settings.ts +16 -2
- package/src/validation/declarations.schema.ts +11 -3
- package/src/validation/guards.ts +0 -8
- package/src/validation/json.schema.ts +7 -0
- package/src/validation/json.ts +66 -0
- package/src/validation/templates.schema.ts +7 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MailingTemplate } from "../db";
|
|
2
|
+
import type { TemplateChange } from "../engine";
|
|
3
|
+
import type {
|
|
4
|
+
CategoryInLocale,
|
|
5
|
+
TemplateContent,
|
|
6
|
+
VariableDefinition,
|
|
7
|
+
} from "../types";
|
|
8
|
+
import type { JsonObject } from "../types/json";
|
|
9
|
+
|
|
10
|
+
export interface TemplateSender {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
replyTo: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TemplateContentResponse {
|
|
17
|
+
/** The template, its name in the reader's language. */
|
|
18
|
+
template: MailingTemplate;
|
|
19
|
+
/** The content the editor works on: the draft, else the published one. */
|
|
20
|
+
content: TemplateContent;
|
|
21
|
+
/** What customers receive; `null` when the template was never published. */
|
|
22
|
+
publishedContent: TemplateContent | null;
|
|
23
|
+
hasDraft: boolean;
|
|
24
|
+
/** Version customers receive; 0 when never published. */
|
|
25
|
+
version: number;
|
|
26
|
+
/** The draft revision a save or a publish must carry back. */
|
|
27
|
+
revision: number;
|
|
28
|
+
changes: TemplateChange[];
|
|
29
|
+
/** The declarations the editor works on: the draft's, else the published. */
|
|
30
|
+
variables: VariableDefinition[];
|
|
31
|
+
/** Paths the working content references, across every locale. */
|
|
32
|
+
detectedVariables: string[];
|
|
33
|
+
/**
|
|
34
|
+
* The contract callers send against: the published declarations and the
|
|
35
|
+
* paths the published content references; `null` when never published.
|
|
36
|
+
*/
|
|
37
|
+
publishedVariables: VariableDefinition[] | null;
|
|
38
|
+
publishedDetectedVariables: string[] | null;
|
|
39
|
+
testData: JsonObject;
|
|
40
|
+
fallbackLocale: string;
|
|
41
|
+
/** Whether a send missing a variable fails rather than goes out blank. */
|
|
42
|
+
blockOnMissingVariables: boolean;
|
|
43
|
+
categories: CategoryInLocale[];
|
|
44
|
+
sender: TemplateSender;
|
|
45
|
+
}
|
package/src/routes/templates.ts
CHANGED
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
collectContentVariablePaths,
|
|
24
24
|
resolveEmail,
|
|
25
|
-
type TemplateChange,
|
|
26
25
|
valueFormatOf,
|
|
27
26
|
} from "../engine";
|
|
28
27
|
import {
|
|
@@ -31,7 +30,7 @@ import {
|
|
|
31
30
|
type TemplateFields,
|
|
32
31
|
TemplateModel,
|
|
33
32
|
} from "../db";
|
|
34
|
-
import { saveDetails } from "../data/templates-table";
|
|
33
|
+
import { INVALID_TEMPLATE, saveDetails } from "../data/templates-table";
|
|
35
34
|
import { TemplatesPageController } from "../pages/templates";
|
|
36
35
|
import { forAudience, statsWindowStart } from "../services/metrics";
|
|
37
36
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
@@ -75,18 +74,18 @@ import {
|
|
|
75
74
|
import {
|
|
76
75
|
BUSINESS_AUDIENCE,
|
|
77
76
|
type CategoryInLocale,
|
|
78
|
-
type TemplateContent,
|
|
79
77
|
type TemplateStatus,
|
|
80
|
-
type VariableDefinition,
|
|
81
78
|
} from "../types";
|
|
82
79
|
import {
|
|
83
80
|
duplicateSchema,
|
|
84
81
|
previewSchema,
|
|
85
82
|
publishSchema,
|
|
86
83
|
saveContentSchema,
|
|
84
|
+
templateDetailsSchema,
|
|
87
85
|
testDataSchema,
|
|
88
86
|
variablesSchema,
|
|
89
87
|
} from "../validation/templates.schema";
|
|
88
|
+
import type { TemplateContentResponse } from "./template-content";
|
|
90
89
|
|
|
91
90
|
const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
|
|
92
91
|
const INVALID_CONTENT = "$dms_mailing.errors.invalid_content";
|
|
@@ -125,43 +124,6 @@ export interface CategoriesResponse {
|
|
|
125
124
|
categories: CategoryInLocale[];
|
|
126
125
|
}
|
|
127
126
|
|
|
128
|
-
export interface TemplateSender {
|
|
129
|
-
name: string;
|
|
130
|
-
email: string;
|
|
131
|
-
replyTo: string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface TemplateContentResponse {
|
|
135
|
-
/** The template, its name in the reader's language. */
|
|
136
|
-
template: MailingTemplate;
|
|
137
|
-
/** The content the editor works on: the draft, else the published one. */
|
|
138
|
-
content: TemplateContent;
|
|
139
|
-
/** What customers receive; `null` when the template was never published. */
|
|
140
|
-
publishedContent: TemplateContent | null;
|
|
141
|
-
hasDraft: boolean;
|
|
142
|
-
/** Version customers receive; 0 when never published. */
|
|
143
|
-
version: number;
|
|
144
|
-
/** The draft revision a save or a publish must carry back. */
|
|
145
|
-
revision: number;
|
|
146
|
-
changes: TemplateChange[];
|
|
147
|
-
/** The declarations the editor works on: the draft's, else the published. */
|
|
148
|
-
variables: VariableDefinition[];
|
|
149
|
-
/** Paths the working content references, across every locale. */
|
|
150
|
-
detectedVariables: string[];
|
|
151
|
-
/**
|
|
152
|
-
* The contract callers send against: the published declarations and the
|
|
153
|
-
* paths the published content references; `null` when never published.
|
|
154
|
-
*/
|
|
155
|
-
publishedVariables: VariableDefinition[] | null;
|
|
156
|
-
publishedDetectedVariables: string[] | null;
|
|
157
|
-
testData: Record<string, unknown>;
|
|
158
|
-
fallbackLocale: string;
|
|
159
|
-
/** Whether a send missing a variable fails rather than goes out blank. */
|
|
160
|
-
blockOnMissingVariables: boolean;
|
|
161
|
-
categories: CategoryInLocale[];
|
|
162
|
-
sender: TemplateSender;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
127
|
export interface StarterSummary {
|
|
166
128
|
id: string;
|
|
167
129
|
name: string;
|
|
@@ -221,9 +183,9 @@ export class TemplatesController extends Controller(
|
|
|
221
183
|
}
|
|
222
184
|
|
|
223
185
|
@Get("/starters")
|
|
224
|
-
starters()
|
|
186
|
+
starters() {
|
|
225
187
|
return {
|
|
226
|
-
starters: STARTER_TEMPLATES.map((starter) => ({
|
|
188
|
+
starters: STARTER_TEMPLATES.map((starter): StarterSummary => ({
|
|
227
189
|
id: starter.id,
|
|
228
190
|
name: starter.name,
|
|
229
191
|
slug: starter.slug,
|
|
@@ -559,7 +521,12 @@ export class TemplatesController extends Controller(
|
|
|
559
521
|
@JSONBody() body: unknown,
|
|
560
522
|
) {
|
|
561
523
|
const template = await requireTemplate(this.templates, id);
|
|
562
|
-
|
|
524
|
+
const details = assertValidation(
|
|
525
|
+
body,
|
|
526
|
+
(value) => templateDetailsSchema.parse(value),
|
|
527
|
+
() => INVALID_TEMPLATE,
|
|
528
|
+
);
|
|
529
|
+
await saveDetails(this.ctx, template, details, this.author);
|
|
563
530
|
return { id };
|
|
564
531
|
}
|
|
565
532
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { HTTPBody } from "@antelopejs/interface-api";
|
|
2
|
+
import type { ZodIssue } from "zod";
|
|
3
|
+
|
|
4
|
+
function isJson(value: unknown): value is HTTPBody {
|
|
5
|
+
if (
|
|
6
|
+
value === null ||
|
|
7
|
+
typeof value === "string" ||
|
|
8
|
+
typeof value === "number" ||
|
|
9
|
+
typeof value === "boolean"
|
|
10
|
+
) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(value)) return value.every(isJson);
|
|
14
|
+
return typeof value === "object" && Object.values(value).every(isJson);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* zod's issues as the JSON value `JSON.stringify` writes for them, so a body
|
|
19
|
+
* holding it serializes byte for byte as one holding the issues. A `ZodError`
|
|
20
|
+
* nested in an issue (`unionErrors`, `argumentsError`, `returnTypeError`)
|
|
21
|
+
* becomes its enumerable fields, `issues` and `name`.
|
|
22
|
+
*/
|
|
23
|
+
export function zodIssuesJson(issues: readonly ZodIssue[]): HTTPBody {
|
|
24
|
+
const json: unknown = JSON.parse(JSON.stringify(issues));
|
|
25
|
+
if (!isJson(json))
|
|
26
|
+
throw new TypeError("zod issues did not serialize to JSON");
|
|
27
|
+
return json;
|
|
28
|
+
}
|
|
@@ -375,20 +375,21 @@ type TemplateAttentionRule = (
|
|
|
375
375
|
context: TemplateAttentionContext,
|
|
376
376
|
) => boolean;
|
|
377
377
|
|
|
378
|
-
const TEMPLATE_ATTENTION_RULES
|
|
378
|
+
const TEMPLATE_ATTENTION_RULES = new Map<
|
|
379
379
|
TemplateAttentionReason,
|
|
380
380
|
TemplateAttentionRule
|
|
381
|
-
>
|
|
382
|
-
stale_draft
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
381
|
+
>([
|
|
382
|
+
["stale_draft", (template, context) => isStaleDraft(template, context.now)],
|
|
383
|
+
[
|
|
384
|
+
"never_sent",
|
|
385
|
+
(template, context) =>
|
|
386
|
+
template.status === "live" && !context.sentSlugs.has(template.slug),
|
|
387
|
+
],
|
|
388
|
+
[
|
|
389
|
+
"missing_locale",
|
|
390
|
+
(template, context) => isMissingLocale(template, context.knownLocales),
|
|
391
|
+
],
|
|
392
|
+
]);
|
|
392
393
|
|
|
393
394
|
/**
|
|
394
395
|
* Why a template needs a look, in a stable order: a draft never published and
|
|
@@ -399,7 +400,7 @@ export function templateAttentionReasons(
|
|
|
399
400
|
template: MailingTemplate,
|
|
400
401
|
context: TemplateAttentionContext,
|
|
401
402
|
): TemplateAttentionReason[] {
|
|
402
|
-
return
|
|
403
|
-
|
|
404
|
-
|
|
403
|
+
return [...TEMPLATE_ATTENTION_RULES]
|
|
404
|
+
.filter(([, applies]) => applies(template, context))
|
|
405
|
+
.map(([reason]) => reason);
|
|
405
406
|
}
|
package/src/services/catalog.ts
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { ModuleStatus } from "@antelopejs/interface-dms/page";
|
|
4
|
+
import { isText } from "../validation/guards";
|
|
5
|
+
import { parseJsonObject } from "../validation/json";
|
|
4
6
|
import { readCapabilities } from "./provider";
|
|
5
7
|
|
|
6
8
|
const PACKAGE_JSON_PATH = path.join(__dirname, "../../package.json");
|
|
7
9
|
|
|
8
|
-
interface PackageManifest {
|
|
9
|
-
version?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
10
|
/** The module's published version, as the catalog tile and the sidebar show it. */
|
|
13
11
|
export function moduleVersion(): string | undefined {
|
|
14
12
|
try {
|
|
15
|
-
const manifest =
|
|
16
|
-
|
|
17
|
-
) as PackageManifest;
|
|
18
|
-
return manifest.version;
|
|
13
|
+
const manifest = parseJsonObject(readFileSync(PACKAGE_JSON_PATH, "utf8"));
|
|
14
|
+
return isText(manifest.version) ? manifest.version : undefined;
|
|
19
15
|
} catch {
|
|
20
16
|
return undefined;
|
|
21
17
|
}
|
|
@@ -26,7 +26,7 @@ function describeIssues(error: ZodError): string {
|
|
|
26
26
|
/** `input` parsed by `schema`, or a `DeclarationError` under `code`. */
|
|
27
27
|
export function parseDeclared<Output, Input>(
|
|
28
28
|
schema: ZodType<Output, ZodTypeDef, Input>,
|
|
29
|
-
input:
|
|
29
|
+
input: Input,
|
|
30
30
|
code: string,
|
|
31
31
|
subject: string,
|
|
32
32
|
): Output {
|
|
@@ -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;
|
|
@@ -172,10 +172,12 @@ interface EntryOutcome {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
175
|
-
* Writes one send:
|
|
176
|
-
* the send
|
|
177
|
-
*
|
|
178
|
-
*
|
|
175
|
+
* Writes one send: the send itself first, then its events under ids derived
|
|
176
|
+
* from the send's. An event is written only after its send, so the
|
|
177
|
+
* retention's orphan-event pass never finds an import's events before their
|
|
178
|
+
* send. A run stopped half-way leaves a send with part of its history, which
|
|
179
|
+
* the next run completes: each write skips the row an earlier run wrote, and
|
|
180
|
+
* the events are written whether or not the send was already there.
|
|
179
181
|
*/
|
|
180
182
|
async function importOne(
|
|
181
183
|
tenantId: string,
|
|
@@ -184,8 +186,10 @@ async function importOne(
|
|
|
184
186
|
fallbackLocale: string,
|
|
185
187
|
): Promise<EntryOutcome> {
|
|
186
188
|
const id = importedSendIdOf(tenantId, send.importId);
|
|
187
|
-
const sends = GetModel(SendModel, tenantId);
|
|
188
189
|
const timeline = timelineOf(send);
|
|
190
|
+
const locale = send.locale ?? fallbackLocale;
|
|
191
|
+
const row = sendRow(id, send, template, locale, foldEvents(send, timeline));
|
|
192
|
+
const isImported = await GetModel(SendModel, tenantId).insertImported(row);
|
|
189
193
|
const events = GetModel(SendEventModel, tenantId);
|
|
190
194
|
for (const [index, entry] of timeline.entries()) {
|
|
191
195
|
await events.insertImported({
|
|
@@ -196,9 +200,7 @@ async function importOne(
|
|
|
196
200
|
json_details: JSON.stringify(entry.details ?? {}),
|
|
197
201
|
});
|
|
198
202
|
}
|
|
199
|
-
|
|
200
|
-
const row = sendRow(id, send, template, locale, foldEvents(send, timeline));
|
|
201
|
-
return { id, isImported: await sends.insertImported(row) };
|
|
203
|
+
return { id, isImported };
|
|
202
204
|
}
|
|
203
205
|
|
|
204
206
|
/**
|
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
|
@@ -5,10 +5,17 @@ import {
|
|
|
5
5
|
MODULE_DEFAULT_SETTINGS,
|
|
6
6
|
type MailingSettingsValues,
|
|
7
7
|
type TemplateCategory,
|
|
8
|
+
WEBHOOK_SECRET_SOURCES,
|
|
9
|
+
type WebhookSecretSource,
|
|
8
10
|
} from "../types";
|
|
11
|
+
import type { JsonObject } from "../types/json";
|
|
12
|
+
import type { SettingsPatch } from "../validation/settings.schema";
|
|
9
13
|
|
|
10
14
|
export const WEBHOOK_SECRET_BYTES = 24;
|
|
11
15
|
|
|
16
|
+
const GENERATED_SECRET: WebhookSecretSource = "generated";
|
|
17
|
+
const OPERATOR_SECRET: WebhookSecretSource = "operator";
|
|
18
|
+
|
|
12
19
|
function defaults(): MailingSettingsValues {
|
|
13
20
|
return {
|
|
14
21
|
...MODULE_DEFAULT_SETTINGS,
|
|
@@ -16,9 +23,21 @@ function defaults(): MailingSettingsValues {
|
|
|
16
23
|
senderEmail: "",
|
|
17
24
|
replyTo: "",
|
|
18
25
|
webhookSecret: randomBytes(WEBHOOK_SECRET_BYTES).toString("hex"),
|
|
26
|
+
webhookSecretSource: GENERATED_SECRET,
|
|
19
27
|
};
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The stored source, or `operator` for a row written before the source was
|
|
32
|
+
* kept: a secret of unknown origin is the workspace's until proven otherwise.
|
|
33
|
+
*/
|
|
34
|
+
function storedSecretSource(stored: string | undefined): WebhookSecretSource {
|
|
35
|
+
return (
|
|
36
|
+
WEBHOOK_SECRET_SOURCES.find((source) => source === stored) ??
|
|
37
|
+
OPERATOR_SECRET
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
function toValues(row: MailingSettings): MailingSettingsValues {
|
|
23
42
|
return {
|
|
24
43
|
fallbackLocale: row.fallbackLocale,
|
|
@@ -28,6 +47,9 @@ function toValues(row: MailingSettings): MailingSettingsValues {
|
|
|
28
47
|
senderEmail: row.senderEmail,
|
|
29
48
|
replyTo: row.replyTo,
|
|
30
49
|
webhookSecret: row.webhookSecret,
|
|
50
|
+
webhookSecretSource: storedSecretSource(row.webhookSecretSource),
|
|
51
|
+
// SAFETY: json_categories is written only by toRow as the JSON.stringify of categories that categoriesSchema
|
|
52
|
+
// validated (settingsPatchSchema, applyCategory) or that the module defaults supplied.
|
|
31
53
|
categories: JSON.parse(row.json_categories) as TemplateCategory[],
|
|
32
54
|
};
|
|
33
55
|
}
|
|
@@ -71,7 +93,12 @@ export async function getSettings(
|
|
|
71
93
|
if (existing) return existing;
|
|
72
94
|
const created = defaults();
|
|
73
95
|
if (await insertKeyedSettings(tenantId, created)) return created;
|
|
74
|
-
|
|
96
|
+
const winner = await findSettings(tenantId);
|
|
97
|
+
if (!winner)
|
|
98
|
+
throw new Error(
|
|
99
|
+
`The settings of tenant "${tenantId}" disappeared before they were read`,
|
|
100
|
+
);
|
|
101
|
+
return winner;
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
export async function saveSettings(
|
|
@@ -85,6 +112,22 @@ export async function saveSettings(
|
|
|
85
112
|
return values;
|
|
86
113
|
}
|
|
87
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Writes the webhook secret and its source alone, so a concurrent writer of
|
|
117
|
+
* the other settings keeps its fields. The tenant's row must exist: read it
|
|
118
|
+
* with `getSettings` first.
|
|
119
|
+
*/
|
|
120
|
+
export async function saveWebhookSecret(
|
|
121
|
+
tenantId: string,
|
|
122
|
+
webhookSecret: string,
|
|
123
|
+
webhookSecretSource: WebhookSecretSource,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
await GetModel(SettingsModel, tenantId).update(tenantId, {
|
|
126
|
+
webhookSecret,
|
|
127
|
+
webhookSecretSource,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
88
131
|
export function findCategory(
|
|
89
132
|
categories: TemplateCategory[],
|
|
90
133
|
id: string,
|
|
@@ -105,36 +148,32 @@ export function missingVariablesPolicy(
|
|
|
105
148
|
}
|
|
106
149
|
|
|
107
150
|
/** The form-only field folded back into `blockOnMissingVariables`. */
|
|
108
|
-
const MISSING_VARIABLES_KEY = "missingVariables";
|
|
109
151
|
const CLEARED_VALUE = "";
|
|
110
152
|
/** 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> {
|
|
153
|
+
export function clearedToEmpty(patch: JsonObject): JsonObject {
|
|
114
154
|
return Object.fromEntries(
|
|
115
155
|
Object.entries(patch).map(([key, value]) => [key, value ?? CLEARED_VALUE]),
|
|
116
156
|
);
|
|
117
157
|
}
|
|
118
158
|
|
|
119
159
|
/**
|
|
120
|
-
* The settings once `patch` (the fields a form changed,
|
|
121
|
-
*
|
|
122
|
-
* into its boolean.
|
|
160
|
+
* The settings once `patch` (the fields a form changed, cleared ones already
|
|
161
|
+
* stored as empty) is laid over the stored ones, the missing-variables choice
|
|
162
|
+
* folded back into its boolean. A secret the patch changes becomes the
|
|
163
|
+
* workspace owner's.
|
|
123
164
|
*/
|
|
124
165
|
export function mergeSettingsPatch(
|
|
125
166
|
current: MailingSettingsValues,
|
|
126
|
-
patch:
|
|
167
|
+
patch: SettingsPatch,
|
|
127
168
|
): MailingSettingsValues {
|
|
128
|
-
const written =
|
|
129
|
-
Object.entries(clearedToEmpty(patch)).filter(
|
|
130
|
-
([key]) => key !== MISSING_VARIABLES_KEY,
|
|
131
|
-
),
|
|
132
|
-
);
|
|
133
|
-
const policy = patch.missingVariables;
|
|
169
|
+
const { missingVariables: policy, ...written } = patch;
|
|
134
170
|
const isChoice = policy === REFUSE_POLICY || policy === SEND_POLICY;
|
|
135
171
|
const guard =
|
|
136
172
|
isChoice && !("blockOnMissingVariables" in patch)
|
|
137
173
|
? { blockOnMissingVariables: policy === REFUSE_POLICY }
|
|
138
174
|
: {};
|
|
139
|
-
|
|
175
|
+
const merged = { ...current, ...written, ...guard };
|
|
176
|
+
return merged.webhookSecret === current.webhookSecret
|
|
177
|
+
? merged
|
|
178
|
+
: { ...merged, webhookSecretSource: OPERATOR_SECRET };
|
|
140
179
|
}
|
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
|
|