@antelopejs/dms-mailing 0.7.0 → 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 +28 -10
- package/dist/constants.d.ts +0 -2
- package/dist/constants.js +1 -3
- package/dist/constants.js.map +1 -1
- 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 +5 -11
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +0 -1
- package/dist/db/models/index.js +0 -1
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.js +2 -3
- package/dist/db/models/send-events.model.js.map +1 -1
- package/dist/db/models/sends.model.js +2 -3
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.js +4 -10
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/index.d.ts +0 -1
- package/dist/db/tables/index.js +0 -1
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/settings.table.d.ts +0 -1
- package/dist/db/tables/settings.table.js +0 -4
- package/dist/db/tables/settings.table.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +0 -3
- package/dist/db/tables/templates.table.js +1 -13
- package/dist/db/tables/templates.table.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/hooks/index.d.ts +0 -1
- package/dist/hooks/index.js +0 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +3 -4
- package/dist/implementations/dms-mailing/index.js +8 -9
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/settings/form.js +8 -0
- package/dist/pages/settings/form.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 +6 -10
- 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 +42 -184
- 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 -5
- 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/settings.d.ts +1 -1
- package/dist/services/settings.js +1 -5
- package/dist/services/settings.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.d.ts +2 -2
- package/dist/services/templates.js +54 -29
- package/dist/services/templates.js.map +1 -1
- package/dist/types/settings.d.ts +2 -0
- package/dist/types/settings.js +8 -1
- package/dist/types/settings.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.d.ts +16 -16
- 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 +8 -13
- package/skills/dms-mailing/SKILL.md +11 -17
- package/skills/dms-mailing/cases.json +49 -0
- package/src/constants.ts +0 -2
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +2 -1
- package/src/data/templates-table.ts +15 -22
- package/src/db/models/index.ts +0 -1
- package/src/db/models/send-events.model.ts +2 -2
- package/src/db/models/sends.model.ts +7 -8
- package/src/db/models/template-versions.model.ts +5 -1
- package/src/db/models/templates.model.ts +9 -16
- package/src/db/tables/index.ts +0 -1
- package/src/db/tables/settings.table.ts +0 -8
- package/src/db/tables/templates.table.ts +2 -16
- 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/hooks/index.ts +0 -1
- package/src/implementations/dms-mailing/index.ts +5 -18
- package/src/index.ts +1 -6
- package/src/pages/sends.ts +5 -3
- package/src/pages/settings/form.ts +16 -0
- 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 +16 -18
- 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 +62 -318
- 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 +18 -13
- package/src/services/send.ts +7 -7
- package/src/services/settings.ts +3 -10
- package/src/services/starters.ts +6 -6
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +69 -52
- package/src/types/settings.ts +20 -0
- 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/dist/db/models/template-slugs.model.d.ts +0 -27
- package/dist/db/models/template-slugs.model.js +0 -43
- package/dist/db/models/template-slugs.model.js.map +0 -1
- package/dist/db/tables/template-slugs.table.d.ts +0 -7
- package/dist/db/tables/template-slugs.table.js +0 -35
- package/dist/db/tables/template-slugs.table.js.map +0 -1
- package/dist/hooks/database-ready.d.ts +0 -7
- package/dist/hooks/database-ready.js +0 -26
- package/dist/hooks/database-ready.js.map +0 -1
- package/dist/services/fingerprint.d.ts +0 -1
- package/dist/services/fingerprint.js +0 -20
- package/dist/services/fingerprint.js.map +0 -1
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
- package/src/db/models/template-slugs.model.ts +0 -61
- package/src/db/tables/template-slugs.table.ts +0 -29
- package/src/hooks/database-ready.ts +0 -47
- package/src/services/fingerprint.ts +0 -22
|
@@ -23,9 +23,9 @@ https://dms.antelopejs.com/docs/modules/mailing (sending from code and the webho
|
|
|
23
23
|
|
|
24
24
|
- `ajs project modules add @antelopejs/dms-mailing`, plus a module implementing `@antelopejs/interface-email`
|
|
25
25
|
(`@antelopejs/nodemailer`, `config: { ethereal: true }` in dev).
|
|
26
|
-
- Mailing 0.
|
|
27
|
-
0.
|
|
28
|
-
interface 0.9
|
|
26
|
+
- Mailing 0.7.1 requires `@antelopejs/dms` `>=0.11.0 <1.0.0`, so DMS 0.11 or later (interface-dms 0.9, renderer `@antelopejs/dms-frontend` `>=0.9.1 <0.10.0`);
|
|
27
|
+
0.7.0 is the DMS 0.10 line. It implements `@antelopejs/interface-dms-mailing` `>=0.9.0 <0.10.0`: declaration and import calls,
|
|
28
|
+
and `DeclareSender` since interface 0.9; `SendTemplate` / `RecordEmailEvent` callers do not change.
|
|
29
29
|
- Module option: `useHtmlRender` (default `true`, DMS html-render chain; `false` = built-in standalone HTML).
|
|
30
30
|
- Set the DMS `clientBaseUrl`: the webhook URL and the e-mail logo are built on it.
|
|
31
31
|
- Per-workspace settings (sender, missing-variable guard, fallback locale, webhook secret, retention,
|
|
@@ -68,23 +68,17 @@ export async function declareMailingContent(tenantId: string) {
|
|
|
68
68
|
content: { locales: { en: { subject: "Your {{plan.name}} plan is renewed", preheader: "", blocks: [] } } },
|
|
69
69
|
variables: [{ path: "plan.name", type: "string", required: true }],
|
|
70
70
|
testData: { plan: { name: "Pro" } },
|
|
71
|
-
}); // created |
|
|
71
|
+
}); // created | kept
|
|
72
72
|
}
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
- Require explicit non-blank `tenantId` for declarations and imports
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Deployment updates/backfills require an authorized inventory and `IsWorkspaceProvisioningCommitted`, failing closed.
|
|
83
|
-
- Idempotent on the tenant's slug, published at once (v1, version rows, slug claim, attention refresh: a normal template).
|
|
84
|
-
- Rule: same declaration → `unchanged`; changed declaration → `updated` only if the template is live, has no
|
|
85
|
-
draft and still holds exactly the last declaration (fingerprint on `declaredHash`); otherwise `kept`.
|
|
86
|
-
A dashboard-made template under that slug is never taken over. Deleted → recreated next boot; archive instead.
|
|
87
|
-
- Categories: added once; a category a user relabelled/removed, or a default one, is `kept`.
|
|
75
|
+
- Require explicit non-blank `tenantId` for declarations and imports. Call them once per tenant, from a
|
|
76
|
+
`Hook.TENANT_BEING_PROVISIONED` handler (the DMS fires it for `default` on the start that creates it, after
|
|
77
|
+
`DATABASE_INITIALIZED`; dms-saas for each workspace). Never from `start()` or `DATABASE_INITIALIZED`.
|
|
78
|
+
- Written once: a missing slug is created and published (v1, version rows, unique slug, attention refresh: a normal
|
|
79
|
+
template) → `created`; a slug the tenant already has (declared or made in the dashboard) is left as it is → `kept`.
|
|
80
|
+
No fingerprint, no refresh: from its creation on, the template belongs to the tenant.
|
|
81
|
+
- Categories: a missing id is added → `created`; an existing one, a default included, is `kept`.
|
|
88
82
|
- `name` / `label` are localized texts `{ en, fr, … }` (stored `@Localized`, `en` required as fallback), never `$`
|
|
89
83
|
i18n keys; each user reads, searches and sorts them in their language (`x-content-language`).
|
|
90
84
|
- `DeclareSender({ tenantId, email, name?, replyTo? })` writes the settings' sender (resolves `void`); call it once
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"id": "mailing-send-template",
|
|
6
|
+
"area": "modules",
|
|
7
|
+
"style": "clean",
|
|
8
|
+
"prompt": "Send the welcome e-mail template when a user signs up.",
|
|
9
|
+
"expect": {
|
|
10
|
+
"skills": ["dms-mailing"],
|
|
11
|
+
"acceptable": ["dms", "dms-auth"],
|
|
12
|
+
"mustMention": ["SendTemplate"]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "mailing-bounces",
|
|
17
|
+
"area": "modules",
|
|
18
|
+
"style": "clean",
|
|
19
|
+
"prompt": "Our e-mail provider reports bounces through a webhook. How do we record them?",
|
|
20
|
+
"expect": {
|
|
21
|
+
"skills": ["dms-mailing"],
|
|
22
|
+
"acceptable": ["dms"],
|
|
23
|
+
"mustMention": ["RecordEmailEvent"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "mailing-missing-variable-casual",
|
|
28
|
+
"area": "modules",
|
|
29
|
+
"style": "casual",
|
|
30
|
+
"prompt": "mail send says missing variable firstName??",
|
|
31
|
+
"expect": {
|
|
32
|
+
"skills": ["dms-mailing"],
|
|
33
|
+
"acceptable": ["dms"]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "dms-mailing-avoid-tracker",
|
|
38
|
+
"area": "modules",
|
|
39
|
+
"style": "clean",
|
|
40
|
+
"prompt": "Put the tracking script on our public website and count how many visitors click the signup button.",
|
|
41
|
+
"expect": {
|
|
42
|
+
"skills": ["dms-marketing"],
|
|
43
|
+
"acceptable": ["dms"],
|
|
44
|
+
"mustMention": ["data-website-id"],
|
|
45
|
+
"avoid": ["dms-mailing"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -23,8 +23,6 @@ export const GALLERY_DISPLAY_ID = `${MODULE_ID}:gallery`;
|
|
|
23
23
|
export const TEMPLATE_CATEGORIES_TYPE_ID = "mailing_template_categories";
|
|
24
24
|
export const TEMPLATE_CATEGORY_TYPE_ID = "mailing_template_category";
|
|
25
25
|
|
|
26
|
-
export const DEFAULT_FALLBACK_LOCALE = "en";
|
|
27
|
-
export const DEFAULT_LOG_RETENTION_DAYS = 90;
|
|
28
26
|
export const MAX_SLUG_LENGTH = 64;
|
|
29
27
|
export const MAX_NAME_LENGTH = 120;
|
|
30
28
|
export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
package/src/crons/jobs.ts
CHANGED
package/src/data/body.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { HTTPResult } from "@antelopejs/interface-api";
|
|
2
2
|
import { HTTP_BAD_REQUEST } from "../constants";
|
|
3
|
+
import { isText } from "../validation/guards";
|
|
3
4
|
|
|
4
5
|
const INVALID_BODY = "$dms_mailing.errors.invalid_body";
|
|
5
6
|
|
|
6
7
|
export function parseBody(body: Buffer | string): Record<string, unknown> {
|
|
7
|
-
const raw =
|
|
8
|
+
const raw = isText(body) ? body : body.toString("utf8");
|
|
8
9
|
if (!raw) return {};
|
|
9
10
|
try {
|
|
10
11
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
@@ -71,13 +71,12 @@ import { findCategory, getSettings } from "../services/settings";
|
|
|
71
71
|
import { findStarter, starterFields } from "../services/starters";
|
|
72
72
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
73
73
|
import {
|
|
74
|
-
assertSlugClaimed,
|
|
75
74
|
blankContent,
|
|
76
75
|
blankFields,
|
|
77
76
|
contentFieldsOf,
|
|
78
77
|
displayName,
|
|
79
78
|
initializeTemplate,
|
|
80
|
-
|
|
79
|
+
refuseTakenSlug,
|
|
81
80
|
} from "../services/templates";
|
|
82
81
|
import type { TemplateCategory } from "../types";
|
|
83
82
|
|
|
@@ -85,10 +84,10 @@ import {
|
|
|
85
84
|
createTemplateSchema,
|
|
86
85
|
templateDetailsSchema,
|
|
87
86
|
} from "../validation/templates.schema";
|
|
87
|
+
import { isObject, isText } from "../validation/guards";
|
|
88
88
|
import { parseBody } from "./body";
|
|
89
89
|
import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
|
|
90
90
|
|
|
91
|
-
const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
|
|
92
91
|
const SLUG_IMMUTABLE = "$dms_mailing.errors.slug_immutable";
|
|
93
92
|
const SOURCE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
|
|
94
93
|
const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
|
|
@@ -157,7 +156,7 @@ function assertKnownCategory(
|
|
|
157
156
|
category: unknown,
|
|
158
157
|
): void {
|
|
159
158
|
if (isBlank(category)) return;
|
|
160
|
-
if (
|
|
159
|
+
if (isText(category) && findCategory(categories, category)) {
|
|
161
160
|
return;
|
|
162
161
|
}
|
|
163
162
|
throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
|
|
@@ -173,7 +172,8 @@ interface CreatedRow {
|
|
|
173
172
|
* The creation's seed, and the body to store: slug trimmed, and the name
|
|
174
173
|
* typed in the reader's language as every language of the localized field,
|
|
175
174
|
* the fallback one included until someone translates it. A body a guard
|
|
176
|
-
* already parsed arrives as an object.
|
|
175
|
+
* already parsed arrives as an object. The body to store is handed on as an
|
|
176
|
+
* object: interface-data-api 0.3 refuses a JSON string.
|
|
177
177
|
*/
|
|
178
178
|
function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
179
179
|
const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
|
|
@@ -183,7 +183,6 @@ function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
|
183
183
|
() => INVALID_TEMPLATE,
|
|
184
184
|
HTTP_BAD_REQUEST,
|
|
185
185
|
);
|
|
186
|
-
// Handed as an object: interface-data-api 0.3 refuses a JSON string.
|
|
187
186
|
const normalized = {
|
|
188
187
|
...raw,
|
|
189
188
|
slug: seed.slug,
|
|
@@ -200,6 +199,12 @@ interface LocalizedController {
|
|
|
200
199
|
/** The key that writes every language of a localized field at once. */
|
|
201
200
|
const ALL_LANGUAGES = "*";
|
|
202
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The data API's `new` callback, seeding the template it creates. The
|
|
204
|
+
* module's creation dialogs send one typed name, and the stored name holds
|
|
205
|
+
* every language, written at once: the controller's language key is set to
|
|
206
|
+
* all languages before the creation.
|
|
207
|
+
*/
|
|
203
208
|
function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
204
209
|
return {
|
|
205
210
|
...base,
|
|
@@ -212,25 +217,14 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
212
217
|
) {
|
|
213
218
|
const { seed, normalized } = parseCreation(body, localeOfRequest(ctx));
|
|
214
219
|
const tenantId = getRequestTenantId(ctx);
|
|
215
|
-
const model = GetModel(TemplateModel, tenantId);
|
|
216
|
-
if (await model.getBySlug(seed.slug)) {
|
|
217
|
-
throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
|
|
218
|
-
}
|
|
219
220
|
const settings = await getSettings(tenantId);
|
|
220
221
|
assertKnownCategory(settings.categories, seed.category);
|
|
221
222
|
const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
|
|
222
|
-
// The module's creation dialogs send one typed name; the stored name
|
|
223
|
-
// holds every language, written at once.
|
|
224
223
|
(this as LocalizedController).language = ALL_LANGUAGES;
|
|
225
|
-
const created = (await
|
|
226
|
-
this,
|
|
227
|
-
ctx,
|
|
228
|
-
params,
|
|
229
|
-
normalized,
|
|
230
|
-
...rest,
|
|
224
|
+
const created = (await refuseTakenSlug(
|
|
225
|
+
base.func.call(this, ctx, params, normalized, ...rest),
|
|
231
226
|
)) as CreatedRow;
|
|
232
227
|
const templateId = created._id;
|
|
233
|
-
await assertSlugClaimed(tenantId, seed.slug, templateId);
|
|
234
228
|
const user = await authenticateTenantRequest(ctx);
|
|
235
229
|
await initializeTemplate(tenantId, templateId, displayName(user), fields);
|
|
236
230
|
return created;
|
|
@@ -291,7 +285,6 @@ export function afterDelete(tenantId: string, ids: string[]): AfterWrite {
|
|
|
291
285
|
await Promise.all(
|
|
292
286
|
ids.map((id) => versions.getAll(id, "templateId").delete().run()),
|
|
293
287
|
);
|
|
294
|
-
await releaseTemplateSlugs(tenantId, ids);
|
|
295
288
|
await refreshTemplateAttention(tenantId);
|
|
296
289
|
};
|
|
297
290
|
}
|
|
@@ -346,8 +339,8 @@ export async function saveDetails(
|
|
|
346
339
|
* or, from a form editing every language, all of them.
|
|
347
340
|
*/
|
|
348
341
|
function isBlankName(name: unknown): boolean {
|
|
349
|
-
const texts =
|
|
350
|
-
return !texts.some((text) =>
|
|
342
|
+
const texts = isObject(name) ? Object.values(name) : [name];
|
|
343
|
+
return !texts.some((text) => isText(text) && text.trim());
|
|
351
344
|
}
|
|
352
345
|
|
|
353
346
|
/** The names trimmed, blank ones left out, the fallback language filled. */
|
package/src/db/models/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ export * from "./send-events.model";
|
|
|
2
2
|
export * from "./sends.model";
|
|
3
3
|
export * from "./settings.model";
|
|
4
4
|
export * from "./suppressions.model";
|
|
5
|
-
export * from "./template-slugs.model";
|
|
6
5
|
export * from "./template-versions.model";
|
|
7
6
|
export * from "./templates.model";
|
|
8
7
|
export * from "./webhook-status.model";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IsDuplicateKeyError } from "@antelopejs/interface-database";
|
|
1
2
|
import { BasicDataModel } from "@antelopejs/interface-database-decorators";
|
|
2
3
|
import { MAILING_SEND_EVENTS_TABLE_NAME, MailingSendEvent } from "../tables";
|
|
3
4
|
import { SendModel } from "./sends.model";
|
|
@@ -28,11 +29,10 @@ export class SendEventModel extends BasicDataModel(
|
|
|
28
29
|
* the same import already did.
|
|
29
30
|
*/
|
|
30
31
|
async insertImported(event: MailingSendEvent): Promise<void> {
|
|
31
|
-
if (await this.get(event._id)) return;
|
|
32
32
|
try {
|
|
33
33
|
await this.insert(event);
|
|
34
34
|
} catch (error) {
|
|
35
|
-
if (!(
|
|
35
|
+
if (!IsDuplicateKeyError(error)) throw error;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { BasicDataModel } from "@antelopejs/interface-database-decorators";
|
|
3
|
-
import
|
|
4
|
-
AtomicMutationOutcome,
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
type AtomicMutationOutcome,
|
|
5
|
+
IsDuplicateKeyError,
|
|
6
|
+
type ValueProxy,
|
|
6
7
|
} from "@antelopejs/interface-database";
|
|
7
8
|
import { isResolvedStage, stageOf } from "../../types";
|
|
8
9
|
import { MAILING_SENDS_TABLE_NAME, MailingSend } from "../tables";
|
|
@@ -89,17 +90,15 @@ export class SendModel extends BasicDataModel(
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
|
-
* Writes an imported send under its own id; false when
|
|
93
|
-
*
|
|
94
|
-
* slug claims, since the database exposes no typed conflict error.
|
|
93
|
+
* Writes an imported send under its own id; false when an earlier run of
|
|
94
|
+
* the same import already wrote it (its id is derived from the import's).
|
|
95
95
|
*/
|
|
96
96
|
async insertImported(send: ImportedSendRow): Promise<boolean> {
|
|
97
|
-
if (await this.get(send._id)) return false;
|
|
98
97
|
try {
|
|
99
98
|
await this.table.insert({ ...send, revision: randomUUID() }).run();
|
|
100
99
|
return true;
|
|
101
100
|
} catch (error) {
|
|
102
|
-
if (
|
|
101
|
+
if (IsDuplicateKeyError(error)) return false;
|
|
103
102
|
throw error;
|
|
104
103
|
}
|
|
105
104
|
}
|
|
@@ -45,8 +45,12 @@ export class TemplateVersionModel extends BasicDataModel(
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The published versions of a template, newest first. It reads the
|
|
50
|
+
* template's publication before the version rows: any later publication
|
|
51
|
+
* must have materialized the version that read names.
|
|
52
|
+
*/
|
|
48
53
|
async listForTemplate(templateId: string): Promise<MailingTemplateVersion[]> {
|
|
49
|
-
// Read the publication first: any later publication must have materialized it.
|
|
50
54
|
const template = await new TemplateModel(this.database).get(templateId);
|
|
51
55
|
if (!template) return [];
|
|
52
56
|
const rows = await this.getBy("templateId", templateId);
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from "@antelopejs/interface-database-decorators";
|
|
6
6
|
import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
|
|
7
7
|
import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
|
|
8
|
-
import {
|
|
8
|
+
import { isText } from "../../validation/guards";
|
|
9
9
|
|
|
10
10
|
/** The attention fields of a template, as the refresh writes them. */
|
|
11
11
|
export type TemplateAttentionFields = Pick<
|
|
@@ -16,7 +16,7 @@ export type TemplateAttentionFields = Pick<
|
|
|
16
16
|
const isLocalizedText = (value: unknown): value is LocalizedText =>
|
|
17
17
|
typeof value === "object" &&
|
|
18
18
|
value !== null &&
|
|
19
|
-
Object.values(value).every(
|
|
19
|
+
Object.values(value).every(isText);
|
|
20
20
|
|
|
21
21
|
/** Where a row keeps its modifier-held fields, the localized name among them. */
|
|
22
22
|
const MODIFIER_CONTAINER = "_internal";
|
|
@@ -38,9 +38,11 @@ export class TemplateModel extends BasicDataModel(
|
|
|
38
38
|
return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The names of `template`, one per language: unlocked with `"*"`, the
|
|
43
|
+
* localized field holds the whole per-language record.
|
|
44
|
+
*/
|
|
42
45
|
static namesOf(template: MailingTemplate): LocalizedText {
|
|
43
|
-
// Unlocked with "*", the localized field holds the whole per-language record.
|
|
44
46
|
const names: unknown = template.localize("*", ["name"]).name;
|
|
45
47
|
return isLocalizedText(names) ? names : {};
|
|
46
48
|
}
|
|
@@ -90,19 +92,10 @@ export class TemplateModel extends BasicDataModel(
|
|
|
90
92
|
return outcome === "applied";
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/** The template holding `slug`: the unique index keeps it to one. */
|
|
93
96
|
async getBySlug(slug: string): Promise<MailingTemplate | undefined> {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
// Senders must see the claimed template, not an earlier draft candidate.
|
|
97
|
-
if (rows.length > 1) {
|
|
98
|
-
const claims = await new TemplateSlugModel(this.database).getBy(
|
|
99
|
-
"templateId",
|
|
100
|
-
...rows.map((row) => row._id),
|
|
101
|
-
);
|
|
102
|
-
const claim = claims.find((candidate) => candidate.slug === slug);
|
|
103
|
-
if (claim) return rows.find((row) => row._id === claim.templateId);
|
|
104
|
-
}
|
|
105
|
-
return rows[0];
|
|
97
|
+
const [template] = await this.getBy("slug", slug);
|
|
98
|
+
return template;
|
|
106
99
|
}
|
|
107
100
|
|
|
108
101
|
/**
|
package/src/db/tables/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ export * from "./send-events.table";
|
|
|
2
2
|
export * from "./sends.table";
|
|
3
3
|
export * from "./settings.table";
|
|
4
4
|
export * from "./suppressions.table";
|
|
5
|
-
export * from "./template-slugs.table";
|
|
6
5
|
export * from "./template-versions.table";
|
|
7
6
|
export * from "./templates.table";
|
|
8
7
|
export * from "./webhook-status.table";
|
|
@@ -46,12 +46,4 @@ export class MailingSettings extends Table {
|
|
|
46
46
|
|
|
47
47
|
@Field("string")
|
|
48
48
|
declare webhookSecret: string;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* JSON-serialized fingerprint of each category `DeclareTemplateCategory`
|
|
52
|
-
* applied, by its id: what tells a category untouched since from one a
|
|
53
|
-
* user relabelled or removed.
|
|
54
|
-
*/
|
|
55
|
-
@Field("string")
|
|
56
|
-
declare json_declared_categories?: string;
|
|
57
49
|
}
|
|
@@ -31,7 +31,8 @@ export class MailingTemplate extends Table.with(LocalizationModifier) {
|
|
|
31
31
|
@Field("date")
|
|
32
32
|
declare updatedAt: Date;
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/** What sends name the template by: one template per slug in a tenant. */
|
|
35
|
+
@Index({ unique: true })
|
|
35
36
|
@Field("string")
|
|
36
37
|
declare slug: string;
|
|
37
38
|
|
|
@@ -141,25 +142,10 @@ export class MailingTemplate extends Table.with(LocalizationModifier) {
|
|
|
141
142
|
@Field("date")
|
|
142
143
|
declare unpublishedAt?: Date;
|
|
143
144
|
|
|
144
|
-
/**
|
|
145
|
-
* Fingerprint of the last declaration `DeclareTemplate` applied; blank for
|
|
146
|
-
* a template made in the dashboard. A template still matching it is
|
|
147
|
-
* untouched since, and the next declaration may refresh it.
|
|
148
|
-
*/
|
|
149
|
-
@Field("string")
|
|
150
|
-
declare declaredHash?: string;
|
|
151
|
-
|
|
152
145
|
/** Every semantic edit invalidates a publication's optimistic snapshot. */
|
|
153
146
|
@Field("string")
|
|
154
147
|
declare revision?: string;
|
|
155
148
|
|
|
156
|
-
/** An unmodified declaration seed can be completed after a crashed creator. */
|
|
157
|
-
@Field("string")
|
|
158
|
-
declare declarationSeedRevision?: string;
|
|
159
|
-
|
|
160
|
-
@Field("string")
|
|
161
|
-
declare pendingDeclaredHash?: string;
|
|
162
|
-
|
|
163
149
|
/**
|
|
164
150
|
* Complete latest version row, committed atomically with its content. Its
|
|
165
151
|
* history row can be recovered after an interrupted publication.
|
package/src/engine/changes.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
VariableDefinition,
|
|
7
7
|
} from "../types";
|
|
8
8
|
import { walkBlocks } from "./walk";
|
|
9
|
+
import { isObject } from "../validation/guards";
|
|
9
10
|
|
|
10
11
|
export type TemplateChangeKind =
|
|
11
12
|
| "locale_added"
|
|
@@ -51,11 +52,11 @@ const NESTED_KEYS = new Set(["children", "elseChildren"]);
|
|
|
51
52
|
*/
|
|
52
53
|
function canonical(value: unknown): unknown {
|
|
53
54
|
if (Array.isArray(value)) return value.map(canonical);
|
|
54
|
-
if (!value
|
|
55
|
+
if (!isObject(value)) return value;
|
|
55
56
|
return Object.fromEntries(
|
|
56
57
|
Object.keys(value)
|
|
57
58
|
.sort()
|
|
58
|
-
.map((key) => [key, canonical(
|
|
59
|
+
.map((key) => [key, canonical(value[key])]),
|
|
59
60
|
);
|
|
60
61
|
}
|
|
61
62
|
|
package/src/engine/conditions.ts
CHANGED
|
@@ -3,15 +3,13 @@ import { getPath } from "./paths";
|
|
|
3
3
|
|
|
4
4
|
type Evaluator = (actual: unknown, expected: string) => boolean;
|
|
5
5
|
|
|
6
|
-
const asNumber = (value: unknown): number => Number(value);
|
|
7
|
-
|
|
8
6
|
const EVALUATORS: Record<ConditionOperator, Evaluator> = {
|
|
9
7
|
truthy: (actual) => Boolean(actual),
|
|
10
8
|
falsy: (actual) => !actual,
|
|
11
9
|
eq: (actual, expected) => String(actual) === expected,
|
|
12
10
|
ne: (actual, expected) => String(actual) !== expected,
|
|
13
|
-
gt: (actual, expected) =>
|
|
14
|
-
lt: (actual, expected) =>
|
|
11
|
+
gt: (actual, expected) => Number(actual) > Number(expected),
|
|
12
|
+
lt: (actual, expected) => Number(actual) < Number(expected),
|
|
15
13
|
};
|
|
16
14
|
|
|
17
15
|
/** Whether `data` passes the condition a block is shown under. */
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type { ResolvedBlock, ResolvedEmail } from "../types";
|
|
2
2
|
|
|
3
|
-
const ESCAPES
|
|
4
|
-
"&"
|
|
5
|
-
"<"
|
|
6
|
-
">"
|
|
7
|
-
'"'
|
|
8
|
-
"'"
|
|
9
|
-
|
|
3
|
+
const ESCAPES = new Map([
|
|
4
|
+
["&", "&"],
|
|
5
|
+
["<", "<"],
|
|
6
|
+
[">", ">"],
|
|
7
|
+
['"', """],
|
|
8
|
+
["'", "'"],
|
|
9
|
+
]);
|
|
10
10
|
|
|
11
11
|
export const escapeHtml = (value: string): string =>
|
|
12
|
-
value.replace(/[&<>"']/g, (char) => ESCAPES
|
|
12
|
+
value.replace(/[&<>"']/g, (char) => ESCAPES.get(char) ?? char);
|
|
13
13
|
|
|
14
14
|
const LINE_BREAK = /\r?\n/g;
|
|
15
15
|
|
|
@@ -77,7 +77,7 @@ const row = (label: string, value: string, weight: string): string =>
|
|
|
77
77
|
const table = (rows: string): string =>
|
|
78
78
|
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BLOCK_SPACING}">${rows}</table>`;
|
|
79
79
|
|
|
80
|
-
const TEXT_RENDERERS
|
|
80
|
+
const TEXT_RENDERERS = {
|
|
81
81
|
heading: (block) =>
|
|
82
82
|
block.type === "heading"
|
|
83
83
|
? `<h1 style="${FONT};font-size:${block.size}px;font-weight:600;color:${HEADING_COLOR};text-align:${block.align};margin:0 0 24px;${WRAP}">${escapeLines(block.text)}</h1>`
|
|
@@ -94,7 +94,7 @@ const TEXT_RENDERERS: Record<string, BlockHtml> = {
|
|
|
94
94
|
block.type === "button"
|
|
95
95
|
? `<div style="text-align:${block.align};margin:24px 0"><a href="${escapeHtml(safeUrl(block.href))}" style="${FONT};${BUTTON_STYLE}">${escapeHtml(block.text)}</a></div>`
|
|
96
96
|
: "",
|
|
97
|
-
}
|
|
97
|
+
} satisfies Record<string, BlockHtml>;
|
|
98
98
|
|
|
99
99
|
const footerHtml = (block: ResolvedBlock): string => {
|
|
100
100
|
if (block.type !== "footer") return "";
|
|
@@ -109,7 +109,7 @@ const footerHtml = (block: ResolvedBlock): string => {
|
|
|
109
109
|
return `${DIVIDER}<p style="${FOOTER_STYLE}">${escapeLines(block.text)}${linkLine}</p>`;
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
-
const LAYOUT_RENDERERS
|
|
112
|
+
const LAYOUT_RENDERERS = {
|
|
113
113
|
hero: (block) =>
|
|
114
114
|
block.type === "hero" && block.imageUrl
|
|
115
115
|
? `<img src="${escapeHtml(safeUrl(block.imageUrl))}" alt="${escapeHtml(block.alt)}" style="display:block;width:100%;border-radius:8px;margin:0 0 20px">`
|
|
@@ -124,12 +124,12 @@ const LAYOUT_RENDERERS: Record<string, BlockHtml> = {
|
|
|
124
124
|
block.type === "total" ? table(row(block.label, block.value, "600")) : "",
|
|
125
125
|
divider: () => DIVIDER,
|
|
126
126
|
footer: footerHtml,
|
|
127
|
-
}
|
|
127
|
+
} satisfies Record<string, BlockHtml>;
|
|
128
128
|
|
|
129
129
|
const RENDERERS: Record<ResolvedBlock["type"], BlockHtml> = {
|
|
130
130
|
...TEXT_RENDERERS,
|
|
131
131
|
...LAYOUT_RENDERERS,
|
|
132
|
-
}
|
|
132
|
+
};
|
|
133
133
|
|
|
134
134
|
const FOOTER_TYPE = "footer";
|
|
135
135
|
|
package/src/engine/formats.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { VariableDefinition } from "../types";
|
|
2
|
+
import { isObject, isText } from "../validation/guards";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* An amount in the minor unit of its currency (cents for `EUR`), like the
|
|
@@ -55,12 +56,11 @@ export function valueFormatOf(
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export function isMoneyValue(value: unknown): value is MoneyValue {
|
|
58
|
-
if (!value
|
|
59
|
-
const candidate = value as Partial<MoneyValue>;
|
|
59
|
+
if (!isObject(value)) return false;
|
|
60
60
|
return (
|
|
61
|
-
typeof
|
|
62
|
-
Number.isFinite(
|
|
63
|
-
typeof
|
|
61
|
+
typeof value.value === "number" &&
|
|
62
|
+
Number.isFinite(value.value) &&
|
|
63
|
+
typeof value.currency === "string"
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -118,7 +118,7 @@ export function formatValue(
|
|
|
118
118
|
return Number.isNaN(value.getTime())
|
|
119
119
|
? String(value)
|
|
120
120
|
: formatDate(value, DATE_STYLE, locale);
|
|
121
|
-
if (
|
|
121
|
+
if (isText(value) && format?.datePaths.has(path))
|
|
122
122
|
return formatIsoDate(value, locale) ?? value;
|
|
123
123
|
return String(value);
|
|
124
124
|
}
|
package/src/engine/paths.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isObject } from "../validation/guards";
|
|
2
|
+
|
|
1
3
|
const PATH_SEPARATOR = ".";
|
|
2
4
|
const ARRAY_SUFFIX = "[]";
|
|
3
5
|
|
|
@@ -16,10 +18,9 @@ export function normalizePath(path: string): string {
|
|
|
16
18
|
const RESERVED_SEGMENTS = ["__proto__", "constructor", "prototype"];
|
|
17
19
|
|
|
18
20
|
function readSegment(current: unknown, segment: string): unknown {
|
|
19
|
-
if (current
|
|
20
|
-
if (typeof current !== "object") return undefined;
|
|
21
|
+
if (!isObject(current)) return undefined;
|
|
21
22
|
if (RESERVED_SEGMENTS.includes(segment)) return undefined;
|
|
22
|
-
return
|
|
23
|
+
return current[segment];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function getPath(data: unknown, path: string): unknown {
|
|
@@ -22,7 +22,10 @@ export const MARKETING_CATEGORY = "marketing";
|
|
|
22
22
|
|
|
23
23
|
const SYSTEM_VARIABLE_TYPE = "url";
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/** The footer fields that label a link, in the order the links show. */
|
|
26
|
+
const LABEL_KEYS = ["unsubscribeLabel", "preferencesLabel"] as const;
|
|
27
|
+
|
|
28
|
+
type FooterLabelKey = (typeof LABEL_KEYS)[number];
|
|
26
29
|
|
|
27
30
|
/** Each footer link: the label that shows it and the URL the caller passes. */
|
|
28
31
|
const FOOTER_LINKS: Record<FooterLabelKey, string> = {
|
|
@@ -30,8 +33,6 @@ const FOOTER_LINKS: Record<FooterLabelKey, string> = {
|
|
|
30
33
|
preferencesLabel: PREFERENCES_URL_PATH,
|
|
31
34
|
};
|
|
32
35
|
|
|
33
|
-
const LABEL_KEYS = Object.keys(FOOTER_LINKS) as FooterLabelKey[];
|
|
34
|
-
|
|
35
36
|
export function isMarketingCategory(category?: string | null): boolean {
|
|
36
37
|
return category === MARKETING_CATEGORY;
|
|
37
38
|
}
|
package/src/engine/tokens.ts
CHANGED
|
@@ -21,8 +21,7 @@ const MISSING_TOKEN: Record<InterpolationMode, (token: string) => string> = {
|
|
|
21
21
|
|
|
22
22
|
export function extractTokens(text: string): string[] {
|
|
23
23
|
const found = new Set<string>();
|
|
24
|
-
for (const match of text.matchAll(TOKEN_PATTERN))
|
|
25
|
-
found.add(match[1] as string);
|
|
24
|
+
for (const match of text.matchAll(TOKEN_PATTERN)) found.add(match[1]);
|
|
26
25
|
return [...found];
|
|
27
26
|
}
|
|
28
27
|
|
package/src/hooks/index.ts
CHANGED