@antelopejs/dms-mailing 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/dist/crons/jobs.d.ts +1 -1
- package/dist/data/body.js +2 -1
- package/dist/data/body.js.map +1 -1
- package/dist/data/templates-table.js +4 -3
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.js +2 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/engine/changes.js +2 -1
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.js +2 -3
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/fallback-html.js +8 -8
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/formats.js +6 -6
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.js +2 -3
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/system-variables.js +1 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +2 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/metrics.js +6 -5
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/sends.js +1 -1
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -6
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.js +4 -4
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/attention.js +2 -1
- package/dist/services/attention.js.map +1 -1
- package/dist/services/categories.js +6 -6
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.js +16 -9
- package/dist/services/checked-send.js.map +1 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +11 -7
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +2 -1
- package/dist/services/localized-text.js +2 -1
- package/dist/services/localized-text.js.map +1 -1
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-import.js +7 -3
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +4 -3
- package/dist/services/send.js.map +1 -1
- package/dist/services/starters.js +5 -5
- package/dist/services/starters.js.map +1 -1
- package/dist/services/template-stats.js +5 -2
- package/dist/services/template-stats.js.map +1 -1
- package/dist/services/templates.js +6 -2
- package/dist/services/templates.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +16 -16
- package/dist/validation/declarations.schema.js +2 -2
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +4 -0
- package/dist/validation/guards.js +19 -0
- package/dist/validation/guards.js.map +1 -0
- package/dist/validation/send-event-type.schema.d.ts +2 -0
- package/dist/validation/send-event-type.schema.js +11 -0
- package/dist/validation/send-event-type.schema.js.map +1 -0
- package/dist/validation/settings.schema.js +1 -1
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +19 -18
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
- package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
- package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
- package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
- package/frontend-vue/app/components/DuplicateModal.vue +3 -11
- package/frontend-vue/app/components/FixAddressModal.vue +2 -1
- package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
- package/frontend-vue/app/components/RealSendModal.vue +6 -17
- package/frontend-vue/app/components/RenderingModal.vue +2 -1
- package/frontend-vue/app/components/SendDrawer.vue +5 -4
- package/frontend-vue/app/components/StarterGallery.vue +3 -11
- package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
- package/frontend-vue/app/components/TestSendModal.vue +7 -18
- package/frontend-vue/app/components/VersionHistory.vue +2 -7
- package/frontend-vue/app/components/WebhookStatus.vue +2 -1
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
- package/frontend-vue/build/backend-boundary.ts +122 -0
- package/frontend-vue/dms.frontend.build.ts +7 -0
- package/frontend-vue/eslint.config.mjs +13 -0
- package/frontend-vue/package.json +4 -2
- package/frontend-vue/pnpm-lock.yaml +210 -61
- package/frontend-vue/tests/backend-boundary.test.ts +203 -0
- package/frontend-vue/tests/dnd.test.ts +1 -2
- package/frontend-vue/tests/editor-block-list.test.ts +136 -53
- package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
- package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
- package/frontend-vue/tests/new-template-modal.test.ts +41 -6
- package/frontend-vue/tests/page-reads.test.ts +26 -6
- package/frontend-vue/tests/send-actions.test.ts +0 -1
- package/frontend-vue/tests/send-drawer.test.ts +2 -6
- package/frontend-vue/tests/send-modals.test.ts +8 -5
- package/frontend-vue/tests/sortable-list.test.ts +54 -62
- package/frontend-vue/tests/suppression.test.ts +1 -2
- package/frontend-vue/tests/template-drawer.test.ts +27 -18
- package/frontend-vue/tests/template-gallery.test.ts +7 -5
- package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
- package/package.json +3 -8
- package/skills/dms-mailing/SKILL.md +3 -3
- package/skills/dms-mailing/cases.json +49 -0
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +2 -1
- package/src/data/templates-table.ts +12 -7
- package/src/db/models/template-versions.model.ts +5 -1
- package/src/db/models/templates.model.ts +6 -3
- package/src/engine/changes.ts +3 -2
- package/src/engine/conditions.ts +2 -4
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +6 -6
- package/src/engine/paths.ts +4 -3
- package/src/engine/system-variables.ts +4 -3
- package/src/engine/tokens.ts +1 -2
- package/src/pages/sends.ts +5 -3
- package/src/pages/templates.ts +1 -1
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +3 -6
- package/src/routes/metrics.ts +7 -5
- package/src/routes/sends.ts +10 -18
- package/src/routes/settings.ts +8 -9
- package/src/routes/suppressions.ts +1 -1
- package/src/routes/templates.ts +5 -5
- package/src/services/attention.ts +4 -3
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declarations.ts +3 -5
- package/src/services/events.ts +16 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +2 -2
- package/src/services/notify.ts +1 -1
- package/src/services/render.ts +5 -1
- package/src/services/send-import.ts +16 -11
- package/src/services/send.ts +7 -7
- package/src/services/starters.ts +6 -6
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +10 -8
- package/src/validation/declarations.schema.ts +2 -2
- package/src/validation/guards.ts +25 -0
- package/src/validation/send-event-type.schema.ts +10 -0
- package/src/validation/settings.schema.ts +7 -2
- package/src/validation/templates.schema.ts +2 -1
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
|
@@ -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
|
}
|
|
@@ -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");
|
package/src/services/events.ts
CHANGED
|
@@ -16,6 +16,7 @@ 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 { isObject, isText } from "../validation/guards";
|
|
19
20
|
|
|
20
21
|
export interface SendCounters {
|
|
21
22
|
status: SendStatus;
|
|
@@ -59,8 +60,11 @@ const HARD_BOUNCE_TYPE = /hard|permanent/i;
|
|
|
59
60
|
/** A temporary SMTP answer: a `4xx` reply code or a `4.x.x` enhanced status. */
|
|
60
61
|
const SOFT_SMTP_ANSWER = /^\s*4\d\d\b|\b4\.\d{1,3}\.\d{1,3}\b/;
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
/** The text an event's details hold under `key`, else an empty string. */
|
|
64
|
+
function textOf(details: EmailEvent["details"], key: string): string {
|
|
65
|
+
const value = details?.[key];
|
|
66
|
+
return isText(value) ? value : "";
|
|
67
|
+
}
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
70
|
* The provider's own words for a problem event (`550 5.1.1 Mailbox does not
|
|
@@ -70,7 +74,7 @@ const textOf = (value: unknown): string =>
|
|
|
70
74
|
export function problemReason(event: EmailEvent): string | undefined {
|
|
71
75
|
if (!PROBLEM_EVENTS.has(event.type)) return undefined;
|
|
72
76
|
const reason = event.details?.reason;
|
|
73
|
-
return
|
|
77
|
+
return isText(reason) && reason ? reason : undefined;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
function overridesStatus(current: SendStatus, next: SendStatus): boolean {
|
|
@@ -95,10 +99,10 @@ export function applyEvent(
|
|
|
95
99
|
* provider's `bounceType` when it gives one, else the SMTP answer's class.
|
|
96
100
|
*/
|
|
97
101
|
export function isSoftBounce(event: EmailEvent): boolean {
|
|
98
|
-
const bounceType = textOf(event.details
|
|
102
|
+
const bounceType = textOf(event.details, "bounceType");
|
|
99
103
|
if (SOFT_BOUNCE_TYPE.test(bounceType)) return true;
|
|
100
104
|
if (HARD_BOUNCE_TYPE.test(bounceType)) return false;
|
|
101
|
-
return SOFT_SMTP_ANSWER.test(textOf(event.details
|
|
105
|
+
return SOFT_SMTP_ANSWER.test(textOf(event.details, "reason"));
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
/**
|
|
@@ -144,8 +148,8 @@ async function publishEmailEvent(
|
|
|
144
148
|
});
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
const eventIdOf = (details:
|
|
148
|
-
textOf(details
|
|
151
|
+
const eventIdOf = (details: EmailEvent["details"]): string =>
|
|
152
|
+
textOf(details, "eventId");
|
|
149
153
|
|
|
150
154
|
type StoredEventIdentity = Pick<
|
|
151
155
|
MailingSendEvent,
|
|
@@ -163,11 +167,10 @@ export function isSameEvent(
|
|
|
163
167
|
): boolean {
|
|
164
168
|
const eventId = eventIdOf(event.details);
|
|
165
169
|
if (eventId) {
|
|
166
|
-
const storedDetails = JSON.parse(stored.json_details || "{}")
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return eventIdOf(storedDetails) === eventId;
|
|
170
|
+
const storedDetails: unknown = JSON.parse(stored.json_details || "{}");
|
|
171
|
+
return (
|
|
172
|
+
eventIdOf(isObject(storedDetails) ? storedDetails : undefined) === eventId
|
|
173
|
+
);
|
|
171
174
|
}
|
|
172
175
|
if (!event.at || stored.type !== event.type) return false;
|
|
173
176
|
return (
|
|
@@ -223,7 +226,7 @@ async function suppressAfterEvent(
|
|
|
223
226
|
email: send.recipientEmail,
|
|
224
227
|
reason,
|
|
225
228
|
sendId: send._id,
|
|
226
|
-
detail: textOf(event.details
|
|
229
|
+
detail: textOf(event.details, "reason") || undefined,
|
|
227
230
|
});
|
|
228
231
|
} catch (error) {
|
|
229
232
|
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
|
@@ -122,7 +122,7 @@ const wasOpened = (send: SendSummary): boolean =>
|
|
|
122
122
|
function median(values: number[]): number {
|
|
123
123
|
if (!values.length) return 0;
|
|
124
124
|
const sorted = [...values].sort((left, right) => left - right);
|
|
125
|
-
return sorted[Math.floor(sorted.length / 2)]
|
|
125
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/** A send to a suppressed address never left: no figure counts it. */
|
|
@@ -262,7 +262,7 @@ export function alignSeries(
|
|
|
262
262
|
axis: SeriesPoint[],
|
|
263
263
|
): SeriesPoint[] {
|
|
264
264
|
return points.slice(0, axis.length).map((point, index) => ({
|
|
265
|
-
x:
|
|
265
|
+
x: axis[index].x,
|
|
266
266
|
y: point.y,
|
|
267
267
|
}));
|
|
268
268
|
}
|
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(
|
|
@@ -161,11 +162,10 @@ const OUTCOME_SEVERITY: Record<SendStatus, number> = {
|
|
|
161
162
|
const describeError = (error: unknown): string =>
|
|
162
163
|
error instanceof Error ? error.message : String(error);
|
|
163
164
|
|
|
164
|
-
const addressOf = (to: EmailAddress): string =>
|
|
165
|
-
typeof to === "string" ? to : to.email;
|
|
165
|
+
const addressOf = (to: EmailAddress): string => (isText(to) ? to : to.email);
|
|
166
166
|
|
|
167
167
|
const nameOf = (to: EmailAddress): string | undefined =>
|
|
168
|
-
|
|
168
|
+
isText(to) ? undefined : to.name;
|
|
169
169
|
|
|
170
170
|
const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
|
|
171
171
|
Array.isArray(to) ? to : [to];
|
|
@@ -468,15 +468,15 @@ async function record(
|
|
|
468
468
|
isTest: Boolean(params.isTest),
|
|
469
469
|
});
|
|
470
470
|
await GetModel(SendEventModel, tenantId).insertForActiveSend({
|
|
471
|
-
sendId
|
|
471
|
+
sendId,
|
|
472
472
|
type: status,
|
|
473
473
|
at: new Date(),
|
|
474
474
|
json_details: JSON.stringify(
|
|
475
475
|
outcome.error ? { reason: outcome.error } : {},
|
|
476
476
|
),
|
|
477
477
|
});
|
|
478
|
-
publishSendCreated(tenantId, sendId
|
|
479
|
-
return sendId
|
|
478
|
+
publishSendCreated(tenantId, sendId);
|
|
479
|
+
return sendId;
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
async function recordOne(
|
|
@@ -525,7 +525,7 @@ export async function recordAll(
|
|
|
525
525
|
export function aggregateResults(
|
|
526
526
|
results: SendTemplateResult[],
|
|
527
527
|
): SendTemplateResult {
|
|
528
|
-
const first = results[0]
|
|
528
|
+
const first = results[0];
|
|
529
529
|
const worst = results.reduce((left, right) =>
|
|
530
530
|
OUTCOME_SEVERITY[right.status] > OUTCOME_SEVERITY[left.status]
|
|
531
531
|
? right
|
package/src/services/starters.ts
CHANGED
|
@@ -32,16 +32,16 @@ const DEFAULT_TEST_DATA_LOCALE = "en";
|
|
|
32
32
|
const STARTER_CURRENCY = "EUR";
|
|
33
33
|
|
|
34
34
|
/** The ordered item of the order starter's test data, by language. */
|
|
35
|
-
const ORDER_LINE_NAMES
|
|
36
|
-
|
|
37
|
-
fr
|
|
38
|
-
|
|
35
|
+
const ORDER_LINE_NAMES = new Map([
|
|
36
|
+
[DEFAULT_TEST_DATA_LOCALE, "Ergo chair · Graphite × 4"],
|
|
37
|
+
["fr", "Fauteuil ergonomique · Graphite × 4"],
|
|
38
|
+
]);
|
|
39
39
|
const LOCALE_PARTS = /[-_]/;
|
|
40
40
|
|
|
41
41
|
/** A test text in the language of `locale`, English when it has none. */
|
|
42
|
-
function inLanguageOf(texts:
|
|
42
|
+
function inLanguageOf(texts: ReadonlyMap<string, string>, locale: string) {
|
|
43
43
|
const language = locale.toLowerCase().split(LOCALE_PARTS)[0] ?? "";
|
|
44
|
-
return texts
|
|
44
|
+
return texts.get(language) ?? texts.get(DEFAULT_TEST_DATA_LOCALE);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** 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
|
};
|
|
@@ -180,13 +180,18 @@ interface FooterLabels {
|
|
|
180
180
|
preferences: string;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
const ENGLISH_FOOTER_LABELS: FooterLabels = {
|
|
184
|
+
unsubscribe: "Unsubscribe",
|
|
185
|
+
preferences: "Preferences",
|
|
186
|
+
};
|
|
187
|
+
|
|
183
188
|
/**
|
|
184
189
|
* The one piece of content the module still writes for a new template. An
|
|
185
190
|
* e-mail without an unsubscribe link is a legal problem, so a blank template
|
|
186
191
|
* ships a footer — and nothing else: no invented subject, prose or address.
|
|
187
192
|
*/
|
|
188
193
|
const FOOTER_LABELS: Record<string, FooterLabels> = {
|
|
189
|
-
en:
|
|
194
|
+
en: ENGLISH_FOOTER_LABELS,
|
|
190
195
|
fr: { unsubscribe: "Se désinscrire", preferences: "Préférences" },
|
|
191
196
|
};
|
|
192
197
|
|
|
@@ -194,7 +199,7 @@ const FOOTER_BLOCK_ID = "footer";
|
|
|
194
199
|
|
|
195
200
|
/** A footer in `locale`: its legal line and the labels of its two links. */
|
|
196
201
|
export function footerBlock(locale: string, text = ""): Block {
|
|
197
|
-
const labels = FOOTER_LABELS[locale] ??
|
|
202
|
+
const labels = FOOTER_LABELS[locale] ?? ENGLISH_FOOTER_LABELS;
|
|
198
203
|
return {
|
|
199
204
|
id: FOOTER_BLOCK_ID,
|
|
200
205
|
type: "footer",
|
|
@@ -226,16 +231,13 @@ export function isStaleRevision(
|
|
|
226
231
|
return revision !== undefined && revision !== template.draftRevision;
|
|
227
232
|
}
|
|
228
233
|
|
|
229
|
-
function draftWriteFields(
|
|
230
|
-
template: MailingTemplate,
|
|
231
|
-
author: string,
|
|
232
|
-
): TemplateFields {
|
|
234
|
+
function draftWriteFields(template: MailingTemplate, author: string) {
|
|
233
235
|
return {
|
|
234
236
|
draftRevision: template.draftRevision + 1,
|
|
235
237
|
draftUpdatedAt: new Date(),
|
|
236
238
|
draftUpdatedBy: author,
|
|
237
239
|
updatedBy: author,
|
|
238
|
-
};
|
|
240
|
+
} satisfies TemplateFields;
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
/** Whether `draft` is exactly what customers and callers already have. */
|
|
@@ -271,7 +273,7 @@ export async function saveDraft(
|
|
|
271
273
|
...locales,
|
|
272
274
|
});
|
|
273
275
|
await refreshAttentionIfFlagged(tenantId, template, STALE_DRAFT);
|
|
274
|
-
return fields.draftRevision
|
|
276
|
+
return fields.draftRevision;
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
export async function discardDraft(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { MAX_NAME_LENGTH } from "../constants";
|
|
4
|
-
import {
|
|
4
|
+
import { sendEventTypeSchema } from "./send-event-type.schema";
|
|
5
5
|
import { categorySchema, settingsSchema } from "./settings.schema";
|
|
6
6
|
import {
|
|
7
7
|
slugSchema,
|
|
@@ -76,7 +76,7 @@ const recipientSchema = z.union([
|
|
|
76
76
|
]);
|
|
77
77
|
|
|
78
78
|
const importedEventSchema = z.object({
|
|
79
|
-
type:
|
|
79
|
+
type: sendEventTypeSchema,
|
|
80
80
|
at: z.coerce.date(),
|
|
81
81
|
details: z.record(z.unknown()).optional(),
|
|
82
82
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Whether `value` is a string. */
|
|
2
|
+
export function isText(value: unknown): value is string {
|
|
3
|
+
return typeof value === "string";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether `value` is a JSON object as a parsed body holds one: an object,
|
|
8
|
+
* neither `null` nor an array. Its values are not checked.
|
|
9
|
+
*/
|
|
10
|
+
export function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether `value` is an object, arrays included, and not `null`: reading any
|
|
16
|
+
* key of it yields a value of unknown type.
|
|
17
|
+
*/
|
|
18
|
+
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === "object" && value !== null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether `value` is a number. */
|
|
23
|
+
export function isNumber(value: unknown): value is number {
|
|
24
|
+
return typeof value === "number";
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SEND_EVENT_TYPES } from "../types";
|
|
3
|
+
|
|
4
|
+
const [FIRST_EVENT_TYPE, ...OTHER_EVENT_TYPES] = SEND_EVENT_TYPES;
|
|
5
|
+
|
|
6
|
+
/** A send event's type: one of {@link SEND_EVENT_TYPES}. */
|
|
7
|
+
export const sendEventTypeSchema = z.enum([
|
|
8
|
+
FIRST_EVENT_TYPE,
|
|
9
|
+
...OTHER_EVENT_TYPES,
|
|
10
|
+
]);
|
|
@@ -123,12 +123,18 @@ function moveIssues(
|
|
|
123
123
|
kept: Set<string>,
|
|
124
124
|
): CategoryIssue[] {
|
|
125
125
|
return entries.flatMap((entry, index) =>
|
|
126
|
-
|
|
126
|
+
entry.moveTo === undefined || kept.has(entry.moveTo)
|
|
127
127
|
? []
|
|
128
128
|
: [{ message: UNKNOWN_MOVE_TARGET, path: [index, "moveTo"] }],
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Adds the issues of the category rows: blank labels and duplicates in each
|
|
134
|
+
* language, a removed marketing category, a move to a category that is not
|
|
135
|
+
* kept. Each issue is reported once per row and part, whichever language
|
|
136
|
+
* shows it first.
|
|
137
|
+
*/
|
|
132
138
|
function refineCategoryEntries(
|
|
133
139
|
entries: CategoryEntry[],
|
|
134
140
|
context: z.RefinementCtx,
|
|
@@ -147,7 +153,6 @@ function refineCategoryEntries(
|
|
|
147
153
|
...moveIssues(entriesIn(entries, LOCALIZED_TEXT_FALLBACK_LOCALE), kept),
|
|
148
154
|
]
|
|
149
155
|
.filter((issue) => {
|
|
150
|
-
// One issue per row and part, whichever language shows it first.
|
|
151
156
|
const id = JSON.stringify(issue);
|
|
152
157
|
if (seen.has(id)) return false;
|
|
153
158
|
seen.add(id);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MAX_NAME_LENGTH, MAX_SLUG_LENGTH, SLUG_PATTERN } from "../constants";
|
|
3
|
+
import type { Block } from "../types";
|
|
3
4
|
|
|
4
5
|
const MIN_HEADING_SIZE = 12;
|
|
5
6
|
const MAX_HEADING_SIZE = 48;
|
|
@@ -31,7 +32,7 @@ export const conditionSchema = z.object({
|
|
|
31
32
|
const alignSchema = z.enum(["left", "center", "right"]);
|
|
32
33
|
const base = { id: z.string().min(1), visibleIf: conditionSchema.nullable() };
|
|
33
34
|
|
|
34
|
-
export const blockSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
35
|
+
export const blockSchema: z.ZodType<Block, z.ZodTypeDef, unknown> = z.lazy(() =>
|
|
35
36
|
z.discriminatedUnion("type", [
|
|
36
37
|
z.object({
|
|
37
38
|
...base,
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import en from '../i18n/locales/mailing-en-GB.json'
|
|
5
|
-
|
|
6
|
-
const APP = join(__dirname, '..', 'app')
|
|
7
|
-
const EDITOR_SOURCES = [
|
|
8
|
-
join(APP, 'components', 'Editor.vue'),
|
|
9
|
-
join(APP, 'composables', 'useEditorActions.ts'),
|
|
10
|
-
join(APP, 'composables', 'useEditorShortcuts.ts'),
|
|
11
|
-
...readdirSync(join(APP, 'components', 'editor')).map((file) =>
|
|
12
|
-
join(APP, 'components', 'editor', file),
|
|
13
|
-
),
|
|
14
|
-
]
|
|
15
|
-
const LITERAL_KEY =
|
|
16
|
-
/'(dms_mailing\.(?:editor|blocks|templates\.status)\.[a-z_.]+)'/g
|
|
17
|
-
const PREFIX_CONSTANT = /const (\w*KEY) = '(dms_mailing[a-z_.]*)'/g
|
|
18
|
-
const PREFIXED_KEY = /\$\{(\w*KEY)\}\.([a-z_.]+)`/g
|
|
19
|
-
|
|
20
|
-
function keysUsedIn(source: string): string[] {
|
|
21
|
-
const prefixes = new Map(
|
|
22
|
-
[...source.matchAll(PREFIX_CONSTANT)].map((match) => [match[1], match[2]]),
|
|
23
|
-
)
|
|
24
|
-
const prefixValues = new Set(prefixes.values())
|
|
25
|
-
const literal = [...source.matchAll(LITERAL_KEY)]
|
|
26
|
-
.map((match) => match[1] as string)
|
|
27
|
-
.filter((key) => !prefixValues.has(key))
|
|
28
|
-
const prefixed = [...source.matchAll(PREFIXED_KEY)]
|
|
29
|
-
.filter((match) => prefixes.has(match[1]))
|
|
30
|
-
.map((match) => `${prefixes.get(match[1])}.${match[2]}`)
|
|
31
|
-
return [...literal, ...prefixed]
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function keysOf(value: unknown, prefix = ''): string[] {
|
|
35
|
-
if (typeof value !== 'object' || value === null) return [prefix]
|
|
36
|
-
return Object.entries(value).flatMap(([key, child]) =>
|
|
37
|
-
keysOf(child, prefix ? `${prefix}.${key}` : key),
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('editor locales', () => {
|
|
42
|
-
it('define every literal key the editor components use', () => {
|
|
43
|
-
const known = new Set(keysOf(en))
|
|
44
|
-
const used = EDITOR_SOURCES.flatMap((file) =>
|
|
45
|
-
keysUsedIn(readFileSync(file, 'utf8')),
|
|
46
|
-
)
|
|
47
|
-
expect(used.length).toBeGreaterThan(0)
|
|
48
|
-
expect(used.filter((key) => !known.has(key))).toEqual([])
|
|
49
|
-
})
|
|
50
|
-
})
|