@antelopejs/dms-mailing 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -31
- package/dist/automation/email-event-trigger.d.ts +2 -2
- package/dist/automation/send-template-action.d.ts +11 -3
- package/dist/automation/send-template-action.js +1 -1
- package/dist/automation/send-template-action.js.map +1 -1
- package/dist/crons/jobs.d.ts +1 -1
- package/dist/data/body.d.ts +2 -1
- package/dist/data/body.js +4 -2
- package/dist/data/body.js.map +1 -1
- package/dist/data/read-only-routes.d.ts +4 -4
- package/dist/data/sends-table.d.ts +4 -4
- package/dist/data/suppressions-table.d.ts +4 -4
- package/dist/data/templates-table.d.ts +35 -12
- package/dist/data/templates-table.js +30 -24
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +1 -2
- package/dist/data-types/categories-type.js +3 -5
- package/dist/data-types/categories-type.js.map +1 -1
- package/dist/data-types/category-type.d.ts +1 -2
- package/dist/data-types/category-type.js +3 -5
- package/dist/data-types/category-type.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +7 -5
- package/dist/db/models/sends.model.d.ts +7 -5
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +7 -5
- package/dist/db/models/suppressions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.js +4 -3
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +7 -5
- package/dist/db/models/templates.model.js +2 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/models/webhook-status.model.d.ts +7 -5
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +8 -18
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.d.ts +2 -1
- package/dist/engine/conditions.js +5 -5
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/coverage.d.ts +2 -1
- package/dist/engine/coverage.js.map +1 -1
- package/dist/engine/fallback-html.js +8 -8
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/formats.d.ts +4 -6
- package/dist/engine/formats.js +22 -8
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.d.ts +4 -2
- package/dist/engine/paths.js +16 -5
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/plain-text.js +2 -4
- package/dist/engine/plain-text.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +1 -1
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/system-variables.d.ts +2 -1
- package/dist/engine/system-variables.js +1 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/tenant-export.js +10 -9
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -1
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +6 -4
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/metrics.js +31 -31
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.js +5 -5
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +4 -5
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +1 -1
- package/dist/routes/settings.js +16 -14
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/suppressions.js +9 -6
- package/dist/routes/suppressions.js.map +1 -1
- package/dist/routes/template-content.d.ts +27 -0
- package/dist/routes/template-content.js +3 -0
- package/dist/routes/template-content.js.map +1 -0
- package/dist/routes/templates.d.ts +7 -30
- package/dist/routes/templates.js +7 -6
- package/dist/routes/templates.js.map +1 -1
- package/dist/routes/zod-json.d.ts +3 -0
- package/dist/routes/zod-json.js +21 -0
- package/dist/routes/zod-json.js.map +1 -0
- package/dist/services/attention.js +16 -8
- package/dist/services/attention.js.map +1 -1
- package/dist/services/catalog.js +4 -2
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/categories.js +6 -6
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.js +16 -9
- package/dist/services/checked-send.js.map +1 -1
- package/dist/services/declaration-error.d.ts +1 -1
- package/dist/services/declaration-error.js.map +1 -1
- package/dist/services/declarations.js +4 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +13 -7
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +2 -1
- package/dist/services/localized-text.js +2 -1
- package/dist/services/localized-text.js.map +1 -1
- package/dist/services/metrics.js +2 -5
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-import.js +7 -3
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +7 -7
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +4 -2
- package/dist/services/settings.js +5 -4
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +3 -2
- package/dist/services/starters.js +5 -5
- package/dist/services/starters.js.map +1 -1
- package/dist/services/template-stats.js +5 -2
- package/dist/services/template-stats.js.map +1 -1
- package/dist/services/templates.d.ts +2 -1
- package/dist/services/templates.js +8 -4
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -2
- package/dist/services/webhook-status.js +6 -4
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/json.d.ts +1 -0
- package/dist/types/json.js +3 -0
- package/dist/types/json.js.map +1 -0
- package/dist/types/settings.d.ts +2 -2
- package/dist/validation/declarations.schema.d.ts +44 -44
- package/dist/validation/declarations.schema.js +6 -5
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +3 -0
- package/dist/validation/guards.js +15 -0
- package/dist/validation/guards.js.map +1 -0
- package/dist/validation/json.d.ts +5 -0
- package/dist/validation/json.js +54 -0
- package/dist/validation/json.js.map +1 -0
- package/dist/validation/json.schema.d.ts +2 -0
- package/dist/validation/json.schema.js +9 -0
- package/dist/validation/json.schema.js.map +1 -0
- package/dist/validation/send-event-type.schema.d.ts +2 -0
- package/dist/validation/send-event-type.schema.js +11 -0
- package/dist/validation/send-event-type.schema.js.map +1 -0
- package/dist/validation/settings.schema.js +1 -1
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +44 -42
- package/dist/validation/templates.schema.js +4 -3
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
- package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
- package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
- package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
- package/frontend-vue/app/components/DuplicateModal.vue +3 -11
- package/frontend-vue/app/components/FixAddressModal.vue +2 -1
- package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
- package/frontend-vue/app/components/RealSendModal.vue +6 -17
- package/frontend-vue/app/components/RenderingModal.vue +2 -1
- package/frontend-vue/app/components/SendDrawer.vue +5 -4
- package/frontend-vue/app/components/StarterGallery.vue +3 -11
- package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
- package/frontend-vue/app/components/TestSendModal.vue +7 -18
- package/frontend-vue/app/components/VersionHistory.vue +2 -7
- package/frontend-vue/app/components/WebhookStatus.vue +2 -1
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
- package/frontend-vue/build/backend-boundary.ts +122 -0
- package/frontend-vue/dms.frontend.build.ts +7 -0
- package/frontend-vue/eslint.config.mjs +13 -0
- package/frontend-vue/package.json +4 -2
- package/frontend-vue/pnpm-lock.yaml +250 -61
- package/frontend-vue/tests/backend-boundary.test.ts +203 -0
- package/frontend-vue/tests/dnd.test.ts +1 -2
- package/frontend-vue/tests/editor-block-list.test.ts +136 -53
- package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
- package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
- package/frontend-vue/tests/new-template-modal.test.ts +41 -6
- package/frontend-vue/tests/page-reads.test.ts +26 -6
- package/frontend-vue/tests/send-actions.test.ts +0 -1
- package/frontend-vue/tests/send-drawer.test.ts +2 -6
- package/frontend-vue/tests/send-modals.test.ts +8 -5
- package/frontend-vue/tests/sortable-list.test.ts +54 -62
- package/frontend-vue/tests/suppression.test.ts +1 -2
- package/frontend-vue/tests/template-drawer.test.ts +27 -18
- package/frontend-vue/tests/template-gallery.test.ts +7 -5
- package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
- package/package.json +16 -20
- package/skills/dms-mailing/SKILL.md +1 -1
- package/skills/dms-mailing/cases.json +49 -0
- package/src/automation/email-event-trigger.ts +2 -2
- package/src/automation/send-template-action.ts +33 -14
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +7 -3
- package/src/data/templates-table.ts +55 -56
- package/src/data-types/categories-type.ts +2 -2
- package/src/data-types/category-type.ts +2 -2
- package/src/db/models/sends.model.ts +2 -2
- package/src/db/models/template-versions.model.ts +10 -4
- package/src/db/models/templates.model.ts +6 -3
- package/src/db/tables/templates.table.ts +1 -1
- package/src/engine/changes.ts +11 -28
- package/src/engine/conditions.ts +14 -8
- package/src/engine/coverage.ts +2 -1
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +35 -21
- package/src/engine/paths.ts +41 -7
- package/src/engine/plain-text.ts +2 -4
- package/src/engine/resolve.ts +12 -4
- package/src/engine/system-variables.ts +6 -4
- package/src/engine/tokens.ts +3 -3
- package/src/hooks/tenant-export.ts +11 -13
- package/src/pages/sends.ts +5 -3
- package/src/pages/templates.ts +4 -2
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +10 -10
- package/src/routes/index.ts +1 -0
- package/src/routes/metrics.ts +35 -33
- package/src/routes/period.ts +5 -5
- package/src/routes/sends.ts +23 -26
- package/src/routes/settings.ts +21 -17
- package/src/routes/suppressions.ts +12 -8
- package/src/routes/template-content.ts +45 -0
- package/src/routes/templates.ts +16 -49
- package/src/routes/zod-json.ts +28 -0
- package/src/services/attention.ts +20 -18
- package/src/services/catalog.ts +4 -8
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declaration-error.ts +1 -1
- package/src/services/declarations.ts +7 -6
- package/src/services/events.ts +18 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +5 -7
- package/src/services/notify.ts +1 -1
- package/src/services/render.ts +5 -1
- package/src/services/send-import.ts +16 -11
- package/src/services/send.ts +10 -13
- package/src/services/settings.ts +17 -16
- package/src/services/starters.ts +12 -8
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +24 -15
- package/src/services/webhook-status.ts +13 -8
- package/src/types/json.ts +1 -0
- package/src/types/settings.ts +2 -2
- package/src/validation/declarations.schema.ts +6 -5
- package/src/validation/guards.ts +17 -0
- package/src/validation/json.schema.ts +7 -0
- package/src/validation/json.ts +66 -0
- package/src/validation/send-event-type.schema.ts +10 -0
- package/src/validation/settings.schema.ts +7 -2
- package/src/validation/templates.schema.ts +9 -4
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
package/src/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
|
}
|
|
@@ -48,10 +48,10 @@ export function reassignRemovedCategories(
|
|
|
48
48
|
moves: ReadonlyMap<string, string> = new Map(),
|
|
49
49
|
): CategoryReassignment[] {
|
|
50
50
|
const kept = new Set(categories.map((category) => category.id));
|
|
51
|
-
return templates
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
templateId: template._id,
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
return templates.flatMap((template) => {
|
|
52
|
+
const { category } = template;
|
|
53
|
+
return category && !kept.has(category)
|
|
54
|
+
? [{ templateId: template._id, category: moves.get(category) ?? "" }]
|
|
55
|
+
: [];
|
|
56
|
+
});
|
|
57
57
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
sendTemplate,
|
|
11
11
|
} from "./send";
|
|
12
12
|
import { suppressionsOf } from "./suppressions";
|
|
13
|
+
import { isText } from "../validation/guards";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* A send the module checks against the suppression list. `allowSuppressed`
|
|
@@ -24,8 +25,7 @@ export const SUPPRESSED_ERROR_PREFIX = "$dms_mailing.errors.suppressed.";
|
|
|
24
25
|
|
|
25
26
|
const NO_LATENCY = null;
|
|
26
27
|
|
|
27
|
-
const addressOf = (to: EmailAddress): string =>
|
|
28
|
-
typeof to === "string" ? to : to.email;
|
|
28
|
+
const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
|
|
29
29
|
|
|
30
30
|
const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
|
|
31
31
|
Array.isArray(to) ? to : [to];
|
|
@@ -68,16 +68,10 @@ function suppressedMessage(
|
|
|
68
68
|
async function recordSuppressed(
|
|
69
69
|
slug: string,
|
|
70
70
|
params: CheckedSendParams,
|
|
71
|
-
blocked: EmailAddress[],
|
|
72
|
-
rows: Map<string, MailingSuppression>,
|
|
71
|
+
blocked: { to: EmailAddress; row: MailingSuppression }[],
|
|
73
72
|
): Promise<SendTemplateResult[]> {
|
|
74
73
|
const prepared = await prepare(params.tenantId, slug, params);
|
|
75
|
-
const messages = blocked.map((to) =>
|
|
76
|
-
suppressedMessage(
|
|
77
|
-
to,
|
|
78
|
-
rows.get(normalizeAddress(addressOf(to))) as MailingSuppression,
|
|
79
|
-
),
|
|
80
|
-
);
|
|
74
|
+
const messages = blocked.map(({ to, row }) => suppressedMessage(to, row));
|
|
81
75
|
return recordAll(prepared, params, messages);
|
|
82
76
|
}
|
|
83
77
|
|
|
@@ -103,21 +97,25 @@ export async function sendUnlessSuppressed(
|
|
|
103
97
|
const recipients = toRecipients(params.to);
|
|
104
98
|
const rows = await blockedRecipients(params, recipients);
|
|
105
99
|
if (!rows.size) return sendTemplate(slug, params);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
const blocked = recipients.
|
|
109
|
-
|
|
100
|
+
const rowOf = (to: EmailAddress) => rows.get(normalizeAddress(addressOf(to)));
|
|
101
|
+
const isBlocked = (to: EmailAddress): boolean => rowOf(to) !== undefined;
|
|
102
|
+
const blocked = recipients.flatMap((to) => {
|
|
103
|
+
const row = rowOf(to);
|
|
104
|
+
return row ? [{ to, row }] : [];
|
|
105
|
+
});
|
|
106
|
+
const suppressedResults = await recordSuppressed(slug, params, blocked);
|
|
110
107
|
const sentResults = await sendAllowed(
|
|
111
108
|
slug,
|
|
112
109
|
params,
|
|
113
110
|
recipients.filter((to) => !isBlocked(to)),
|
|
114
111
|
);
|
|
112
|
+
let nextSuppressed = 0;
|
|
113
|
+
let nextSent = 0;
|
|
115
114
|
return aggregateResults(
|
|
116
|
-
recipients.map(
|
|
117
|
-
(to)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
: sentResults.shift()) as SendTemplateResult,
|
|
115
|
+
recipients.map((to) =>
|
|
116
|
+
isBlocked(to)
|
|
117
|
+
? suppressedResults[nextSuppressed++]
|
|
118
|
+
: sentResults[nextSent++],
|
|
121
119
|
),
|
|
122
120
|
);
|
|
123
121
|
}
|
|
@@ -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 {
|
|
@@ -93,16 +93,14 @@ async function createDeclared(
|
|
|
93
93
|
...publishedAtCreation(
|
|
94
94
|
randomUUID(),
|
|
95
95
|
{
|
|
96
|
-
content: declaration.content
|
|
96
|
+
content: declaration.content,
|
|
97
97
|
variables: declaration.variables,
|
|
98
98
|
},
|
|
99
99
|
author,
|
|
100
100
|
),
|
|
101
101
|
});
|
|
102
|
-
template = await requireTemplate(model, id
|
|
102
|
+
template = await requireTemplate(model, id);
|
|
103
103
|
} catch (error) {
|
|
104
|
-
// A concurrent declaration of the slug created it first: expected, the
|
|
105
|
-
// caller goes on with that template.
|
|
106
104
|
if (IsDuplicateKeyError(error)) return undefined;
|
|
107
105
|
throw error;
|
|
108
106
|
}
|
|
@@ -127,7 +125,7 @@ export async function declareTemplate(
|
|
|
127
125
|
`template declaration "${String(input?.slug)}"`,
|
|
128
126
|
);
|
|
129
127
|
const { tenantId, slug } = declaration;
|
|
130
|
-
assertPublishable(declaration.content
|
|
128
|
+
assertPublishable(declaration.content, slug);
|
|
131
129
|
const model = GetModel(TemplateModel, tenantId);
|
|
132
130
|
const existing = await model.getBySlug(slug);
|
|
133
131
|
if (existing) return resultOf(existing, "kept");
|
|
@@ -135,7 +133,10 @@ export async function declareTemplate(
|
|
|
135
133
|
assertKnownCategory(settings.categories, declaration.category, slug);
|
|
136
134
|
const created = await createDeclared(declaration);
|
|
137
135
|
if (created) return resultOf(created, "created");
|
|
138
|
-
|
|
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");
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
/** Category declarations of one tenant run one after the other. */
|
package/src/services/events.ts
CHANGED
|
@@ -16,6 +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 { isText } from "../validation/guards";
|
|
20
|
+
import { isJsonObject } from "../validation/json";
|
|
19
21
|
|
|
20
22
|
export interface SendCounters {
|
|
21
23
|
status: SendStatus;
|
|
@@ -59,8 +61,11 @@ const HARD_BOUNCE_TYPE = /hard|permanent/i;
|
|
|
59
61
|
/** A temporary SMTP answer: a `4xx` reply code or a `4.x.x` enhanced status. */
|
|
60
62
|
const SOFT_SMTP_ANSWER = /^\s*4\d\d\b|\b4\.\d{1,3}\.\d{1,3}\b/;
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
/** The text an event's details hold under `key`, else an empty string. */
|
|
65
|
+
function textOf(details: EmailEvent["details"], key: string): string {
|
|
66
|
+
const value = details?.[key];
|
|
67
|
+
return isText(value) ? value : "";
|
|
68
|
+
}
|
|
64
69
|
|
|
65
70
|
/**
|
|
66
71
|
* The provider's own words for a problem event (`550 5.1.1 Mailbox does not
|
|
@@ -70,7 +75,7 @@ const textOf = (value: unknown): string =>
|
|
|
70
75
|
export function problemReason(event: EmailEvent): string | undefined {
|
|
71
76
|
if (!PROBLEM_EVENTS.has(event.type)) return undefined;
|
|
72
77
|
const reason = event.details?.reason;
|
|
73
|
-
return
|
|
78
|
+
return isText(reason) && reason ? reason : undefined;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
function overridesStatus(current: SendStatus, next: SendStatus): boolean {
|
|
@@ -95,10 +100,10 @@ export function applyEvent(
|
|
|
95
100
|
* provider's `bounceType` when it gives one, else the SMTP answer's class.
|
|
96
101
|
*/
|
|
97
102
|
export function isSoftBounce(event: EmailEvent): boolean {
|
|
98
|
-
const bounceType = textOf(event.details
|
|
103
|
+
const bounceType = textOf(event.details, "bounceType");
|
|
99
104
|
if (SOFT_BOUNCE_TYPE.test(bounceType)) return true;
|
|
100
105
|
if (HARD_BOUNCE_TYPE.test(bounceType)) return false;
|
|
101
|
-
return SOFT_SMTP_ANSWER.test(textOf(event.details
|
|
106
|
+
return SOFT_SMTP_ANSWER.test(textOf(event.details, "reason"));
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/**
|
|
@@ -144,8 +149,8 @@ async function publishEmailEvent(
|
|
|
144
149
|
});
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
const eventIdOf = (details:
|
|
148
|
-
textOf(details
|
|
152
|
+
const eventIdOf = (details: EmailEvent["details"]): string =>
|
|
153
|
+
textOf(details, "eventId");
|
|
149
154
|
|
|
150
155
|
type StoredEventIdentity = Pick<
|
|
151
156
|
MailingSendEvent,
|
|
@@ -163,11 +168,11 @@ export function isSameEvent(
|
|
|
163
168
|
): boolean {
|
|
164
169
|
const eventId = eventIdOf(event.details);
|
|
165
170
|
if (eventId) {
|
|
166
|
-
const storedDetails = JSON.parse(stored.json_details || "{}")
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
const storedDetails: unknown = JSON.parse(stored.json_details || "{}");
|
|
172
|
+
return (
|
|
173
|
+
eventIdOf(isJsonObject(storedDetails) ? storedDetails : undefined) ===
|
|
174
|
+
eventId
|
|
175
|
+
);
|
|
171
176
|
}
|
|
172
177
|
if (!event.at || stored.type !== event.type) return false;
|
|
173
178
|
return (
|
|
@@ -223,7 +228,7 @@ async function suppressAfterEvent(
|
|
|
223
228
|
email: send.recipientEmail,
|
|
224
229
|
reason,
|
|
225
230
|
sendId: send._id,
|
|
226
|
-
detail: textOf(event.details
|
|
231
|
+
detail: textOf(event.details, "reason") || undefined,
|
|
227
232
|
});
|
|
228
233
|
} catch (error) {
|
|
229
234
|
Logging.Error("[dms-mailing] Could not suppress an address:", error);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
1
2
|
import type { RequestContext } from "@antelopejs/interface-api";
|
|
2
3
|
import {
|
|
3
4
|
LOCALIZED_TEXT_FALLBACK_LOCALE,
|
|
4
5
|
type LocalizedText,
|
|
5
6
|
} from "@antelopejs/interface-dms-mailing";
|
|
7
|
+
import { isText } from "../validation/guards";
|
|
6
8
|
|
|
7
9
|
/** Header the DMS frontend states the reader's language in on every request. */
|
|
8
10
|
export const CONTENT_LANGUAGE_HEADER = "x-content-language";
|
|
@@ -13,8 +15,8 @@ export const CONTENT_LANGUAGE_HEADER = "x-content-language";
|
|
|
13
15
|
*
|
|
14
16
|
* @param header Value of {@link CONTENT_LANGUAGE_HEADER}
|
|
15
17
|
*/
|
|
16
|
-
export function requestLocale(header:
|
|
17
|
-
return
|
|
18
|
+
export function requestLocale(header: IncomingHttpHeaders[string]): string {
|
|
19
|
+
return isText(header) && header.length > 0
|
|
18
20
|
? header
|
|
19
21
|
: LOCALIZED_TEXT_FALLBACK_LOCALE;
|
|
20
22
|
}
|
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;
|
|
@@ -122,7 +120,7 @@ const wasOpened = (send: SendSummary): boolean =>
|
|
|
122
120
|
function median(values: number[]): number {
|
|
123
121
|
if (!values.length) return 0;
|
|
124
122
|
const sorted = [...values].sort((left, right) => left - right);
|
|
125
|
-
return sorted[Math.floor(sorted.length / 2)]
|
|
123
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
126
124
|
}
|
|
127
125
|
|
|
128
126
|
/** A send to a suppressed address never left: no figure counts it. */
|
|
@@ -262,7 +260,7 @@ export function alignSeries(
|
|
|
262
260
|
axis: SeriesPoint[],
|
|
263
261
|
): SeriesPoint[] {
|
|
264
262
|
return points.slice(0, axis.length).map((point, index) => ({
|
|
265
|
-
x:
|
|
263
|
+
x: axis[index].x,
|
|
266
264
|
y: point.y,
|
|
267
265
|
}));
|
|
268
266
|
}
|
package/src/services/notify.ts
CHANGED
|
@@ -38,7 +38,7 @@ const PERCENT = 100;
|
|
|
38
38
|
const SENDS_PAGE = "/modules/mailing/sends";
|
|
39
39
|
const PROBLEMS_VIEW = "problems";
|
|
40
40
|
const IDEMPOTENCY_KEY_SEPARATOR = ":";
|
|
41
|
-
|
|
41
|
+
/** The DMS keys notifications across every module: ours carry the module's name. */
|
|
42
42
|
const IDEMPOTENCY_KEY_PREFIX = "mailing:problems";
|
|
43
43
|
const PROBLEMS_ICON = "i-ph-warning";
|
|
44
44
|
const PROBLEMS_TITLE = "$dms_mailing.notifications.problems_title";
|
package/src/services/render.ts
CHANGED
|
@@ -17,13 +17,17 @@ export interface MailingEmailProps {
|
|
|
17
17
|
const MailingEmailTemplate =
|
|
18
18
|
RegisterHtmlTemplate<MailingEmailProps>(EMAIL_TEMPLATE_NAME);
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The HTML of `email`: through the DMS renderer, which already drops Vue's
|
|
22
|
+
* SSR markers (dms-frontend 0.5.4), else the module's fallback HTML when the
|
|
23
|
+
* configuration turns HTML rendering off or the renderer fails.
|
|
24
|
+
*/
|
|
20
25
|
export async function renderEmailHtml(
|
|
21
26
|
email: ResolvedEmail,
|
|
22
27
|
locale: string,
|
|
23
28
|
): Promise<string> {
|
|
24
29
|
if (!getConfig().useHtmlRender) return renderFallbackHtml(email, locale);
|
|
25
30
|
try {
|
|
26
|
-
// The DMS renderer already drops Vue's SSR markers (dms-frontend 0.5.4).
|
|
27
31
|
return await GenerateHtml(MailingEmailTemplate, { locale, email });
|
|
28
32
|
} catch (error) {
|
|
29
33
|
Logging.Warn(
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "./events";
|
|
28
28
|
import { getSettings } from "./settings";
|
|
29
29
|
import { refreshTemplateAttention } from "./template-attention";
|
|
30
|
+
import { isText } from "../validation/guards";
|
|
30
31
|
|
|
31
32
|
/** Labels an imported send in the log when its caller gives no source. */
|
|
32
33
|
const IMPORT_SOURCE = "import";
|
|
@@ -47,7 +48,7 @@ function importedSendIdOf(tenantId: string, importId: string): string {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const addressOf = (to: ParsedImportedSend["to"]) =>
|
|
50
|
-
|
|
51
|
+
isText(to) ? { email: to } : to;
|
|
51
52
|
|
|
52
53
|
/** Chronological events, with the webhook's retry deduplication rules. */
|
|
53
54
|
function timelineOf(send: ParsedImportedSend): ImportedEvent[] {
|
|
@@ -59,11 +60,11 @@ function timelineOf(send: ParsedImportedSend): ImportedEvent[] {
|
|
|
59
60
|
const repeated = timeline.some((stored) =>
|
|
60
61
|
isSameEvent(
|
|
61
62
|
{
|
|
62
|
-
type: stored.type
|
|
63
|
+
type: stored.type,
|
|
63
64
|
at: stored.at,
|
|
64
65
|
json_details: JSON.stringify(stored.details ?? {}),
|
|
65
66
|
},
|
|
66
|
-
|
|
67
|
+
entry,
|
|
67
68
|
),
|
|
68
69
|
);
|
|
69
70
|
if (!repeated) timeline.push(entry);
|
|
@@ -87,7 +88,7 @@ function foldEvents(
|
|
|
87
88
|
const event: EmailEvent = {
|
|
88
89
|
provider: send.provider ?? IMPORT_SOURCE,
|
|
89
90
|
messageId: send.providerMessageId ?? "",
|
|
90
|
-
type: entry.type
|
|
91
|
+
type: entry.type,
|
|
91
92
|
at: entry.at,
|
|
92
93
|
details: entry.details,
|
|
93
94
|
};
|
|
@@ -141,6 +142,13 @@ function sendRow(
|
|
|
141
142
|
};
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** The refusal of an import that names a template the workspace lacks. */
|
|
146
|
+
const missingTemplate = (slug: string): DeclarationError =>
|
|
147
|
+
new DeclarationError(
|
|
148
|
+
"template_not_found",
|
|
149
|
+
`Imported sends name template "${slug}", which the workspace does not have`,
|
|
150
|
+
);
|
|
151
|
+
|
|
144
152
|
/** The templates the sends name, by slug; refused before anything is written. */
|
|
145
153
|
async function templatesOf(
|
|
146
154
|
tenantId: string,
|
|
@@ -151,11 +159,7 @@ async function templatesOf(
|
|
|
151
159
|
const templates = new Map<string, MailingTemplate>();
|
|
152
160
|
for (const slug of slugs) {
|
|
153
161
|
const template = await model.getBySlug(slug);
|
|
154
|
-
if (!template)
|
|
155
|
-
throw new DeclarationError(
|
|
156
|
-
"template_not_found",
|
|
157
|
-
`Imported sends name template "${slug}", which the workspace does not have`,
|
|
158
|
-
);
|
|
162
|
+
if (!template) throw missingTemplate(slug);
|
|
159
163
|
templates.set(slug, template);
|
|
160
164
|
}
|
|
161
165
|
return templates;
|
|
@@ -187,7 +191,7 @@ async function importOne(
|
|
|
187
191
|
await events.insertImported({
|
|
188
192
|
_id: `${id}-${index}`,
|
|
189
193
|
sendId: id,
|
|
190
|
-
type: entry.type
|
|
194
|
+
type: entry.type,
|
|
191
195
|
at: entry.at,
|
|
192
196
|
json_details: JSON.stringify(entry.details ?? {}),
|
|
193
197
|
});
|
|
@@ -219,7 +223,8 @@ export async function importSends(
|
|
|
219
223
|
const { fallbackLocale } = await getSettings(tenantId);
|
|
220
224
|
const result: ImportSendsResult = { imported: 0, skipped: 0, sendIds: [] };
|
|
221
225
|
for (const send of sends) {
|
|
222
|
-
const template = templates.get(send.templateSlug)
|
|
226
|
+
const template = templates.get(send.templateSlug);
|
|
227
|
+
if (!template) throw missingTemplate(send.templateSlug);
|
|
223
228
|
const { id, isImported } = await importOne(
|
|
224
229
|
tenantId,
|
|
225
230
|
send,
|
package/src/services/send.ts
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
type VersionedSnapshot,
|
|
53
53
|
workingSnapshot,
|
|
54
54
|
} from "./templates";
|
|
55
|
+
import { isText } from "../validation/guards";
|
|
55
56
|
|
|
56
57
|
export class SendError extends Error {
|
|
57
58
|
constructor(
|
|
@@ -158,14 +159,10 @@ const OUTCOME_SEVERITY: Record<SendStatus, number> = {
|
|
|
158
159
|
failed: 9,
|
|
159
160
|
};
|
|
160
161
|
|
|
161
|
-
const
|
|
162
|
-
error instanceof Error ? error.message : String(error);
|
|
163
|
-
|
|
164
|
-
const addressOf = (to: EmailAddress): string =>
|
|
165
|
-
typeof to === "string" ? to : to.email;
|
|
162
|
+
const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
|
|
166
163
|
|
|
167
164
|
const nameOf = (to: EmailAddress): string | undefined =>
|
|
168
|
-
|
|
165
|
+
isText(to) ? undefined : to.name;
|
|
169
166
|
|
|
170
167
|
const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
|
|
171
168
|
Array.isArray(to) ? to : [to];
|
|
@@ -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
|
}
|
|
@@ -468,15 +465,15 @@ async function record(
|
|
|
468
465
|
isTest: Boolean(params.isTest),
|
|
469
466
|
});
|
|
470
467
|
await GetModel(SendEventModel, tenantId).insertForActiveSend({
|
|
471
|
-
sendId
|
|
468
|
+
sendId,
|
|
472
469
|
type: status,
|
|
473
470
|
at: new Date(),
|
|
474
471
|
json_details: JSON.stringify(
|
|
475
472
|
outcome.error ? { reason: outcome.error } : {},
|
|
476
473
|
),
|
|
477
474
|
});
|
|
478
|
-
publishSendCreated(tenantId, sendId
|
|
479
|
-
return sendId
|
|
475
|
+
publishSendCreated(tenantId, sendId);
|
|
476
|
+
return sendId;
|
|
480
477
|
}
|
|
481
478
|
|
|
482
479
|
async function recordOne(
|
|
@@ -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
|
}
|
|
@@ -525,7 +522,7 @@ export async function recordAll(
|
|
|
525
522
|
export function aggregateResults(
|
|
526
523
|
results: SendTemplateResult[],
|
|
527
524
|
): SendTemplateResult {
|
|
528
|
-
const first = results[0]
|
|
525
|
+
const first = results[0];
|
|
529
526
|
const worst = results.reduce((left, right) =>
|
|
530
527
|
OUTCOME_SEVERITY[right.status] > OUTCOME_SEVERITY[left.status]
|
|
531
528
|
? right
|
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">;
|
|
@@ -32,16 +33,19 @@ const DEFAULT_TEST_DATA_LOCALE = "en";
|
|
|
32
33
|
const STARTER_CURRENCY = "EUR";
|
|
33
34
|
|
|
34
35
|
/** The ordered item of the order starter's test data, by language. */
|
|
35
|
-
const ORDER_LINE_NAMES
|
|
36
|
-
|
|
37
|
-
fr
|
|
38
|
-
|
|
36
|
+
const ORDER_LINE_NAMES = new Map([
|
|
37
|
+
[DEFAULT_TEST_DATA_LOCALE, "Ergo chair · Graphite × 4"],
|
|
38
|
+
["fr", "Fauteuil ergonomique · Graphite × 4"],
|
|
39
|
+
]);
|
|
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
|
|
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 €". */
|
|
@@ -66,18 +66,25 @@ export function sourcesOf(sends: SendSummary[]): SourceCount[] {
|
|
|
66
66
|
.slice(0, MAX_SOURCES);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/** Whether `send` went out in another locale than the one it asked for. */
|
|
70
|
+
function isRequestedFallback(
|
|
71
|
+
send: SendSummary,
|
|
72
|
+
): send is SendSummary & { requestedLocale: string } {
|
|
70
73
|
return Boolean(send.requestedLocale && send.requestedLocale !== send.locale);
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
export function isFallback(send: SendSummary): boolean {
|
|
77
|
+
return isRequestedFallback(send);
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
export function fallbacksOf(sends: SendSummary[]): FallbackCount[] {
|
|
74
|
-
const fallbacks = sends.filter(
|
|
81
|
+
const fallbacks = sends.filter(isRequestedFallback);
|
|
75
82
|
return [
|
|
76
83
|
...groupBy(fallbacks, (send) => `${send.requestedLocale}>${send.locale}`),
|
|
77
84
|
].map(([, rows]) => {
|
|
78
|
-
const first = rows[0]
|
|
85
|
+
const first = rows[0];
|
|
79
86
|
return {
|
|
80
|
-
requested: first.requestedLocale
|
|
87
|
+
requested: first.requestedLocale,
|
|
81
88
|
used: first.locale,
|
|
82
89
|
count: rows.length,
|
|
83
90
|
};
|