@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
|
@@ -11,11 +11,11 @@ import {
|
|
|
11
11
|
import { MODULE_ID } from "../constants";
|
|
12
12
|
import {
|
|
13
13
|
type MailingTemplate,
|
|
14
|
-
SettingsModel,
|
|
15
14
|
SuppressionModel,
|
|
16
15
|
TemplateModel,
|
|
17
16
|
TemplateVersionModel,
|
|
18
17
|
} from "../db";
|
|
18
|
+
import { findSettings } from "../services/settings";
|
|
19
19
|
import type { MailingSettingsValues } from "../types";
|
|
20
20
|
|
|
21
21
|
export type ExportedSettings = Omit<MailingSettingsValues, "webhookSecret">;
|
|
@@ -26,7 +26,7 @@ export type ExportPageLoader<T> = (
|
|
|
26
26
|
) => Promise<T[]>;
|
|
27
27
|
|
|
28
28
|
interface RowCodec<T> {
|
|
29
|
-
fromDatabase(row:
|
|
29
|
+
fromDatabase(row: T): T | undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface ExportedCollection {
|
|
@@ -72,18 +72,16 @@ export async function* jsonArrayChunks<T>(
|
|
|
72
72
|
async function exportedSettings(
|
|
73
73
|
tenantId: string,
|
|
74
74
|
): Promise<ExportedSettings | null> {
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
75
|
+
const settings = await findSettings(tenantId);
|
|
76
|
+
if (!settings) return null;
|
|
77
77
|
return {
|
|
78
|
-
fallbackLocale:
|
|
79
|
-
logRetentionDays:
|
|
80
|
-
blockOnMissingVariables:
|
|
81
|
-
senderName:
|
|
82
|
-
senderEmail:
|
|
83
|
-
replyTo:
|
|
84
|
-
categories:
|
|
85
|
-
row.json_categories,
|
|
86
|
-
) as ExportedSettings["categories"],
|
|
78
|
+
fallbackLocale: settings.fallbackLocale,
|
|
79
|
+
logRetentionDays: settings.logRetentionDays,
|
|
80
|
+
blockOnMissingVariables: settings.blockOnMissingVariables,
|
|
81
|
+
senderName: settings.senderName,
|
|
82
|
+
senderEmail: settings.senderEmail,
|
|
83
|
+
replyTo: settings.replyTo,
|
|
84
|
+
categories: settings.categories,
|
|
87
85
|
};
|
|
88
86
|
}
|
|
89
87
|
|
package/src/pages/templates.ts
CHANGED
|
@@ -109,7 +109,9 @@ export class TemplatesPageController extends PageController(
|
|
|
109
109
|
},
|
|
110
110
|
async edit(ctx, { id, body, current }) {
|
|
111
111
|
await assertEditable(getRequestTenantId(ctx), body, current);
|
|
112
|
-
return
|
|
112
|
+
return body.name === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: afterEdit(this.model, id, localeOfRequest(ctx));
|
|
113
115
|
},
|
|
114
116
|
},
|
|
115
117
|
rowActions: {
|
package/src/routes/events.ts
CHANGED
|
@@ -18,13 +18,16 @@ import {
|
|
|
18
18
|
WEBHOOK_SECRET_HEADER,
|
|
19
19
|
} from "../services/webhook-status";
|
|
20
20
|
import type { WebhookRefusal } from "../types";
|
|
21
|
+
import type { JsonValue } from "../types/json";
|
|
22
|
+
import { parseJson } from "../validation/json";
|
|
23
|
+
import { jsonObjectSchema } from "../validation/json.schema";
|
|
21
24
|
import { sendEventTypeSchema } from "../validation/send-event-type.schema";
|
|
22
25
|
|
|
23
26
|
const eventSchema = z.object({
|
|
24
27
|
messageId: z.string().min(1),
|
|
25
28
|
type: sendEventTypeSchema,
|
|
26
29
|
at: z.coerce.date().optional(),
|
|
27
|
-
details:
|
|
30
|
+
details: jsonObjectSchema.optional(),
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
type ParsedEvent = z.infer<typeof eventSchema>;
|
|
@@ -38,7 +41,7 @@ const STATUS_OF_REFUSAL: Record<WebhookRefusal, number> = {
|
|
|
38
41
|
interface ReceivedEvent {
|
|
39
42
|
provider: string;
|
|
40
43
|
secret: string | undefined;
|
|
41
|
-
body:
|
|
44
|
+
body: JsonValue | undefined;
|
|
42
45
|
/** The tenant the URL names. */
|
|
43
46
|
tenantId: string;
|
|
44
47
|
}
|
|
@@ -54,9 +57,9 @@ export interface WebhookResponse {
|
|
|
54
57
|
* The body as JSON, `undefined` when it is not: a malformed body is refused
|
|
55
58
|
* as an invalid event, after the secret, never answered with a parser error.
|
|
56
59
|
*/
|
|
57
|
-
function parseBody(raw: Buffer):
|
|
60
|
+
function parseBody(raw: Buffer): JsonValue | undefined {
|
|
58
61
|
try {
|
|
59
|
-
return
|
|
62
|
+
return parseJson(raw.toString("utf8"));
|
|
60
63
|
} catch {
|
|
61
64
|
return undefined;
|
|
62
65
|
}
|
package/src/routes/index.ts
CHANGED
package/src/routes/metrics.ts
CHANGED
|
@@ -63,16 +63,16 @@ const TEMPLATE_VOLUME_KEY = "$dms_mailing.metrics.template_volume";
|
|
|
63
63
|
const FIRST_ROW_OFFSET = 0;
|
|
64
64
|
const SINGLE_ROW = 1;
|
|
65
65
|
|
|
66
|
-
const KPI_EXTRACTORS
|
|
67
|
-
sends
|
|
68
|
-
deliverability
|
|
69
|
-
"open-rate"
|
|
70
|
-
"click-rate"
|
|
71
|
-
bounces
|
|
72
|
-
unsubscribes
|
|
73
|
-
queued
|
|
74
|
-
latency
|
|
75
|
-
|
|
66
|
+
const KPI_EXTRACTORS = new Map<string, TotalsExtractor>([
|
|
67
|
+
["sends", (totals) => totals.sends],
|
|
68
|
+
["deliverability", (totals) => totals.deliverability],
|
|
69
|
+
["open-rate", (totals) => totals.openRate],
|
|
70
|
+
["click-rate", (totals) => totals.clickRate],
|
|
71
|
+
["bounces", (totals) => totals.bounced],
|
|
72
|
+
["unsubscribes", (totals) => totals.unsubscribed],
|
|
73
|
+
["queued", (totals) => totals.queued],
|
|
74
|
+
["latency", (totals) => totals.latencyMedian],
|
|
75
|
+
]);
|
|
76
76
|
|
|
77
77
|
/** The KPIs read from delivery events: unmeasured when nothing reports them. */
|
|
78
78
|
const DELIVERY_METRICS = new Set(["deliverability", "open-rate", "click-rate"]);
|
|
@@ -88,14 +88,17 @@ const OPEN_RATE_METRIC: TopMetric = {
|
|
|
88
88
|
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
-
const TOP_METRICS
|
|
92
|
-
"open-rate"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
91
|
+
const TOP_METRICS = new Map<string, TopMetric>([
|
|
92
|
+
["open-rate", OPEN_RATE_METRIC],
|
|
93
|
+
[
|
|
94
|
+
"click-rate",
|
|
95
|
+
{
|
|
96
|
+
extract: (totals) => totals.clickRate,
|
|
97
|
+
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
["sends", { extract: (totals) => totals.sends, minDeliveries: 0 }],
|
|
101
|
+
]);
|
|
99
102
|
const DEFAULT_TOP_METRIC = OPEN_RATE_METRIC;
|
|
100
103
|
|
|
101
104
|
interface FunnelStepDefinition {
|
|
@@ -225,7 +228,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
225
228
|
async kpi(
|
|
226
229
|
@Parameter("metric", "param") metric: string,
|
|
227
230
|
): Promise<KpiCardData> {
|
|
228
|
-
const extract = KPI_EXTRACTORS
|
|
231
|
+
const extract = KPI_EXTRACTORS.get(metric);
|
|
229
232
|
assert(extract, HTTP_NOT_FOUND, UNKNOWN_METRIC);
|
|
230
233
|
const range = currentRange(this.ctx);
|
|
231
234
|
const [current, previous, tracking] = await Promise.all([
|
|
@@ -254,7 +257,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
254
257
|
@Get("/top-templates")
|
|
255
258
|
async topTemplates() {
|
|
256
259
|
const metric =
|
|
257
|
-
TOP_METRICS
|
|
260
|
+
TOP_METRICS.get(this.ctx.url.searchParams.get("metric") ?? "") ??
|
|
258
261
|
DEFAULT_TOP_METRIC;
|
|
259
262
|
const range = currentRange(this.ctx);
|
|
260
263
|
const [sends, previous, templates] = await Promise.all([
|
|
@@ -469,22 +472,19 @@ export function comparedSteps(
|
|
|
469
472
|
/** Which sends the funnel counts: all, or one side of the marketing split. */
|
|
470
473
|
export type FunnelKind = "all" | "marketing" | "transactional";
|
|
471
474
|
|
|
472
|
-
const FUNNEL_KINDS: Record<string, FunnelKind> = {
|
|
473
|
-
all: "all",
|
|
474
|
-
marketing: "marketing",
|
|
475
|
-
transactional: "transactional",
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
export function readFunnelKind(raw: string | null): FunnelKind {
|
|
479
|
-
return FUNNEL_KINDS[raw ?? ""] ?? "all";
|
|
480
|
-
}
|
|
481
|
-
|
|
482
475
|
const KIND_KEEPS: Record<FunnelKind, (category: string) => boolean> = {
|
|
483
476
|
all: () => true,
|
|
484
477
|
marketing: (category) => isMarketingCategory(category),
|
|
485
478
|
transactional: (category) => !isMarketingCategory(category),
|
|
486
479
|
};
|
|
487
480
|
|
|
481
|
+
const isFunnelKind = (raw: string): raw is FunnelKind =>
|
|
482
|
+
Object.hasOwn(KIND_KEEPS, raw);
|
|
483
|
+
|
|
484
|
+
export function readFunnelKind(raw: string | null): FunnelKind {
|
|
485
|
+
return raw !== null && isFunnelKind(raw) ? raw : "all";
|
|
486
|
+
}
|
|
487
|
+
|
|
488
488
|
/**
|
|
489
489
|
* Sends of the templates on one side of the split. A template's category
|
|
490
490
|
* decides: "marketing" is the campaign side, everything else transactional.
|
package/src/routes/period.ts
CHANGED
|
@@ -18,10 +18,10 @@ const PREVIOUS_YEAR = "previous-year";
|
|
|
18
18
|
* What the comparison series is called, by the selector's `comparison`. A
|
|
19
19
|
* custom window, or a caller naming none, is neither of the two.
|
|
20
20
|
*/
|
|
21
|
-
const COMPARISON_SERIES_LABELS
|
|
22
|
-
"previous-period"
|
|
23
|
-
[PREVIOUS_YEAR
|
|
24
|
-
|
|
21
|
+
const COMPARISON_SERIES_LABELS = new Map([
|
|
22
|
+
["previous-period", "$dms_mailing.metrics.previous"],
|
|
23
|
+
[PREVIOUS_YEAR, "$dms_mailing.metrics.previous_year"],
|
|
24
|
+
]);
|
|
25
25
|
const COMPARED_SERIES_LABEL = "$dms_mailing.metrics.compared";
|
|
26
26
|
|
|
27
27
|
function readQuery(ctx: RangeContext, key: string): string | undefined {
|
|
@@ -67,7 +67,7 @@ export function isComparisonWanted(ctx: RangeContext): boolean {
|
|
|
67
67
|
/** The label of the comparison series, for the comparison the caller asks. */
|
|
68
68
|
export function comparisonLabel(ctx: RangeContext): string {
|
|
69
69
|
const comparison = readQuery(ctx, "comparison") ?? "previous-period";
|
|
70
|
-
return COMPARISON_SERIES_LABELS
|
|
70
|
+
return COMPARISON_SERIES_LABELS.get(comparison) ?? COMPARED_SERIES_LABEL;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function yearBefore(date: Date): Date {
|
package/src/routes/sends.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
snapshotOfVersion,
|
|
70
70
|
} from "../services/templates";
|
|
71
71
|
import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
|
|
72
|
+
import type { JsonObject } from "../types/json";
|
|
72
73
|
import {
|
|
73
74
|
type previewVersionSchema,
|
|
74
75
|
realSendSchema,
|
|
@@ -184,10 +185,10 @@ const sameAddress = (left: string, right: string): boolean =>
|
|
|
184
185
|
normalizeAddress(left) === normalizeAddress(right);
|
|
185
186
|
|
|
186
187
|
/** A bad address gets its own message; any other invalid field is the content's. */
|
|
187
|
-
function sendInputError(error:
|
|
188
|
-
const isRecipient =
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
function sendInputError(error: ZodError): string {
|
|
189
|
+
const isRecipient = error.issues.some(
|
|
190
|
+
(issue) => issue.path[0] === RECIPIENT_FIELD,
|
|
191
|
+
);
|
|
191
192
|
return isRecipient ? INVALID_RECIPIENT : INVALID_CONTENT;
|
|
192
193
|
}
|
|
193
194
|
|
|
@@ -255,7 +256,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
255
256
|
const input = assertValidation(
|
|
256
257
|
body,
|
|
257
258
|
(value) => testSendSchema.parse(value),
|
|
258
|
-
|
|
259
|
+
(error) =>
|
|
260
|
+
error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
|
|
259
261
|
);
|
|
260
262
|
const template = await requireTemplate(this.templates, id);
|
|
261
263
|
const ownAddressSuppressed = await this.ownSuppression(input.to);
|
|
@@ -301,7 +303,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
301
303
|
const input = assertValidation(
|
|
302
304
|
body,
|
|
303
305
|
(value) => realSendSchema.parse(value),
|
|
304
|
-
|
|
306
|
+
(error) =>
|
|
307
|
+
error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
|
|
305
308
|
);
|
|
306
309
|
const template = await requireTemplate(this.templates, id);
|
|
307
310
|
const result = await this.send(
|
|
@@ -574,8 +577,10 @@ function requestedVersionOf(
|
|
|
574
577
|
return version === "draft" || version === "published" ? undefined : version;
|
|
575
578
|
}
|
|
576
579
|
|
|
577
|
-
function variablesOf(send: MailingSend):
|
|
578
|
-
|
|
580
|
+
function variablesOf(send: MailingSend): JsonObject {
|
|
581
|
+
// SAFETY: json_variables is written only as JSON.stringify(params.variables ?? {}) (services/send.ts) or
|
|
582
|
+
// JSON.stringify(send.variables) (services/send-import.ts), both a JsonObject.
|
|
583
|
+
return JSON.parse(send.json_variables || "{}") as JsonObject;
|
|
579
584
|
}
|
|
580
585
|
|
|
581
586
|
function summarize(template: MailingTemplate): SendTemplateSummary {
|
package/src/routes/settings.ts
CHANGED
|
@@ -48,7 +48,8 @@ import {
|
|
|
48
48
|
settingsPatchSchema,
|
|
49
49
|
} from "../validation/settings.schema";
|
|
50
50
|
import { retentionLimit } from "../crons/retention";
|
|
51
|
-
import { isJsonObject } from "../validation/
|
|
51
|
+
import { isJsonObject } from "../validation/json";
|
|
52
|
+
import { zodIssuesJson } from "./zod-json";
|
|
52
53
|
|
|
53
54
|
const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
|
|
54
55
|
const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
|
|
@@ -90,7 +91,7 @@ export interface MailingSettingsResponse extends MailingSettingsValues {
|
|
|
90
91
|
export interface SettingsNotice {
|
|
91
92
|
title: string;
|
|
92
93
|
color?: string;
|
|
93
|
-
params?: Record<string,
|
|
94
|
+
params?: Record<string, number | string>;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
export interface SettingsWriteResponse extends MailingSettingsValues {
|
|
@@ -147,9 +148,11 @@ function formIssue(issue: z.ZodIssue): z.ZodIssue {
|
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
/** A refused write: the generic message, and each issue for its field. */
|
|
150
|
-
function invalidSettings(error:
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
function invalidSettings(error: z.ZodError) {
|
|
152
|
+
return {
|
|
153
|
+
message: INVALID_SETTINGS,
|
|
154
|
+
issues: zodIssuesJson(error.issues.map(formIssue)),
|
|
155
|
+
};
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
function senderNotice(values: MailingSettingsValues): SettingsNotice[] {
|
|
@@ -241,7 +244,8 @@ export class MailingSettingsController extends Controller(
|
|
|
241
244
|
return assertValidation(
|
|
242
245
|
clearedToEmpty(body),
|
|
243
246
|
(value) => settingsPatchSchema.parse(value),
|
|
244
|
-
|
|
247
|
+
(error) =>
|
|
248
|
+
error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
|
|
245
249
|
);
|
|
246
250
|
}
|
|
247
251
|
|
|
@@ -295,7 +299,8 @@ export class MailingSettingsController extends Controller(
|
|
|
295
299
|
return assertValidation(
|
|
296
300
|
raw,
|
|
297
301
|
(value) => retentionDaysSchema.parse(value),
|
|
298
|
-
|
|
302
|
+
(error) =>
|
|
303
|
+
error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
|
|
299
304
|
);
|
|
300
305
|
}
|
|
301
306
|
|
|
@@ -306,12 +311,12 @@ export class MailingSettingsController extends Controller(
|
|
|
306
311
|
@Get("/category-usage")
|
|
307
312
|
async categoryUsage(): Promise<{ counts: Record<string, number> }> {
|
|
308
313
|
const templates = await this.templates.getAll();
|
|
309
|
-
const counts
|
|
314
|
+
const counts = new Map<string, number>();
|
|
310
315
|
for (const template of templates) {
|
|
311
316
|
if (!template.category || template.status === ARCHIVED_STATUS) continue;
|
|
312
|
-
counts
|
|
317
|
+
counts.set(template.category, (counts.get(template.category) ?? 0) + 1);
|
|
313
318
|
}
|
|
314
|
-
return { counts };
|
|
319
|
+
return { counts: Object.fromEntries(counts) };
|
|
315
320
|
}
|
|
316
321
|
|
|
317
322
|
/**
|
|
@@ -18,6 +18,7 @@ import { API_BASE_PATH, HTTP_CONFLICT, HTTP_NOT_FOUND } from "../constants";
|
|
|
18
18
|
import { SuppressionModel } from "../db";
|
|
19
19
|
import { SuppressionsPageController } from "../pages/suppressions";
|
|
20
20
|
import { suppressAddress } from "../services/suppressions";
|
|
21
|
+
import { zodIssuesJson } from "./zod-json";
|
|
21
22
|
|
|
22
23
|
const INVALID_SUPPRESSION = "$dms_mailing.suppressions.errors.invalid";
|
|
23
24
|
const ALREADY_SUPPRESSED = "$dms_mailing.suppressions.errors.already";
|
|
@@ -32,12 +33,6 @@ const manualSuppressionSchema = z.object({
|
|
|
32
33
|
note: z.string().trim().max(MAX_NOTE_LENGTH).optional(),
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
/** A refused entry: the generic message, and each issue under its field. */
|
|
36
|
-
function invalidSuppression(error: unknown) {
|
|
37
|
-
if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
|
|
38
|
-
return { message: INVALID_SUPPRESSION, issues: error.issues };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
36
|
/** An address already on the list, told under the address field. */
|
|
42
37
|
const alreadySuppressed = () => ({
|
|
43
38
|
message: ALREADY_SUPPRESSED,
|
|
@@ -56,13 +51,22 @@ export class SuppressionsController extends Controller(
|
|
|
56
51
|
@TenantScopedModel(SuppressionModel)
|
|
57
52
|
declare suppressions: SuppressionModel;
|
|
58
53
|
|
|
59
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Puts an address on the list by hand: nothing is sent to it any more. A
|
|
56
|
+
* refused entry answers the generic message, and each issue under its field.
|
|
57
|
+
*/
|
|
60
58
|
@Post("")
|
|
61
59
|
async add(@JSONBody() body: unknown): Promise<{ added: true }> {
|
|
62
60
|
const input = assertValidation(
|
|
63
61
|
body ?? {},
|
|
64
62
|
(value) => manualSuppressionSchema.parse(value),
|
|
65
|
-
|
|
63
|
+
(error) => {
|
|
64
|
+
if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
|
|
65
|
+
return {
|
|
66
|
+
message: INVALID_SUPPRESSION,
|
|
67
|
+
issues: zodIssuesJson(error.issues),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
66
70
|
);
|
|
67
71
|
const isAdded = await suppressAddress(getRequestTenantId(this.ctx), {
|
|
68
72
|
email: input.email,
|
|
@@ -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 {
|