@antelopejs/dms-mailing 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -339
- package/dist/data/templates-table.js +2 -10
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +1 -0
- package/dist/db/models/sends.model.d.ts +1 -0
- package/dist/db/models/settings.model.d.ts +1 -0
- package/dist/db/models/suppressions.model.d.ts +1 -0
- package/dist/db/models/template-slugs.model.d.ts +27 -0
- package/dist/db/models/template-slugs.model.js +43 -0
- package/dist/db/models/template-slugs.model.js.map +1 -0
- package/dist/db/models/template-versions.model.d.ts +1 -0
- package/dist/db/models/templates.model.d.ts +1 -0
- package/dist/db/models/webhook-status.model.d.ts +1 -0
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/template-slugs.table.d.ts +7 -0
- package/dist/db/tables/template-slugs.table.js +35 -0
- package/dist/db/tables/template-slugs.table.js.map +1 -0
- package/dist/routes/templates.js +1 -0
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/templates.d.ts +2 -0
- package/dist/services/templates.js +26 -0
- package/dist/services/templates.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +12 -11
- package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
- package/frontend-vue/app/composables/useSortableList.ts +159 -0
- package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +6 -13
- package/frontend-vue/tests/editor-block-list.test.ts +46 -6
- package/frontend-vue/tests/sortable-list.test.ts +253 -0
- package/package.json +31 -14
- package/skills/dms-mailing/SKILL.md +63 -0
- package/src/data/templates-table.ts +7 -27
- package/src/db/models/index.ts +1 -0
- package/src/db/models/template-slugs.model.ts +61 -0
- package/src/db/tables/index.ts +1 -0
- package/src/db/tables/template-slugs.table.ts +29 -0
- package/src/routes/templates.ts +2 -0
- package/src/services/templates.ts +49 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dms-mailing
|
|
3
|
+
description: Sends and manages transactional e-mail with the @antelopejs/dms-mailing module of the AntelopeJS DMS. Use when installing or configuring dms-mailing, sending a template from code with SendTemplate, reporting provider events with RecordEmailEvent or the webhook, designing templates (blocks, variables, locales, versions), or debugging suppressed, refused, missing-variable or fallback-locale sends.
|
|
4
|
+
icon: i-lucide-mail
|
|
5
|
+
category: messaging
|
|
6
|
+
tags: [email, templates, transactional, webhook, dms-module]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# dms-mailing
|
|
10
|
+
|
|
11
|
+
Templates edited in the dashboard, sent by slug from code, logged with provider events.
|
|
12
|
+
Pages under `/modules/mailing` (owner-only): Overview, Templates, editor (`templates/:id`), Sends,
|
|
13
|
+
Settings, Suppression list. User docs: `docs/` of this package, published at
|
|
14
|
+
https://dms.antelopejs.com/docs/modules/mailing (sending from code and the webhook format: `extending`).
|
|
15
|
+
|
|
16
|
+
## When to reach for it
|
|
17
|
+
|
|
18
|
+
- Code must send an e-mail a non-developer can reword, translate or restyle: create a template, call it by slug.
|
|
19
|
+
Hard-coding HTML in a route is the path this module replaces.
|
|
20
|
+
- Delivery must be traceable (bounces, opens, clicks) or addresses must stop receiving after a hard bounce.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
- `ajs project modules add @antelopejs/dms-mailing`, plus a module implementing `@antelopejs/interface-email`
|
|
25
|
+
(`@antelopejs/nodemailer`, `config: { ethereal: true }` in dev).
|
|
26
|
+
- Mailing 0.5 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
|
|
27
|
+
0.4 is the DMS 0.8 line. `@antelopejs/interface-dms-mailing` stays 0.7, so callers do not change.
|
|
28
|
+
- Module option: `useHtmlRender` (default `true`, DMS html-render chain; `false` = built-in standalone HTML).
|
|
29
|
+
- Set the DMS `clientBaseUrl`: the webhook URL and the e-mail logo are built on it.
|
|
30
|
+
- Per-workspace settings (sender, missing-variable guard, fallback locale, webhook secret, retention,
|
|
31
|
+
categories) live on the Settings page and `GET`/`PUT /api/mailing/settings` (changed fields only).
|
|
32
|
+
|
|
33
|
+
## Send from code
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { SendTemplate, RecordEmailEvent } from "@antelopejs/interface-dms-mailing";
|
|
37
|
+
|
|
38
|
+
const result = await SendTemplate("order-confirmed", {
|
|
39
|
+
tenantId,
|
|
40
|
+
to: [{ email: "ana@example.com", name: "Ana" }], // one address or an array
|
|
41
|
+
locale: "fr",
|
|
42
|
+
variables: { customer: { firstName: "Ana" }, order: { number: "#1042" } },
|
|
43
|
+
source: "checkout", // shows as "Called from" in the template's performance
|
|
44
|
+
});
|
|
45
|
+
// result.status: queued | sent | ... | failed | suppressed; result.results[] per recipient
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- Only the **published** version is sent; drafts go out only as tests (`[TEST]` subject, out of the figures).
|
|
49
|
+
- Variables are typed (`string`, `text`, `number`, `money`, `date`, `url`, `boolean`, `array`) and declared on
|
|
50
|
+
the template; pass every required one.
|
|
51
|
+
- In-process events: `RecordEmailEvent(tenantId, { provider, messageId, type, details })`.
|
|
52
|
+
HTTP: `POST <clientBaseUrl>/api/mailing/events/<provider>/<tenantId>` with header `x-mailing-webhook-secret`.
|
|
53
|
+
- Automation (if `interface-dms-automation` is loaded): action `mailing.send-template`, trigger `mailing.email-event`.
|
|
54
|
+
|
|
55
|
+
## Gotchas
|
|
56
|
+
|
|
57
|
+
- `status: "suppressed"` = address on the suppression list (hard bounce, spam, unsubscribe, manual). Never retry.
|
|
58
|
+
- `marketing` templates whose footer shows unsubscribe/preferences links require `unsubscribeUrl` /
|
|
59
|
+
`preferencesUrl` in `variables`; a link without URL is dropped.
|
|
60
|
+
- A missing locale falls back to the workspace's fallback locale (logged as `DE asked`).
|
|
61
|
+
- The template slug is fixed at creation: code depends on it.
|
|
62
|
+
- `mailing.send` permission gates every route that reaches recipients; writes publish on realtime topic `mailing:sends`.
|
|
63
|
+
- 0.4+ does not read a 0.3 database and moved the webhook path (tenant in the URL).
|
|
@@ -55,11 +55,13 @@ import { findCategory, getSettings } from "../services/settings";
|
|
|
55
55
|
import { findStarter, starterFields } from "../services/starters";
|
|
56
56
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
57
57
|
import {
|
|
58
|
+
assertSlugClaimed,
|
|
58
59
|
blankContent,
|
|
59
60
|
blankFields,
|
|
60
61
|
contentFieldsOf,
|
|
61
62
|
displayName,
|
|
62
63
|
initializeTemplate,
|
|
64
|
+
releaseTemplateSlugs,
|
|
63
65
|
} from "../services/templates";
|
|
64
66
|
import type { TemplateCategory } from "../types";
|
|
65
67
|
|
|
@@ -142,29 +144,6 @@ function assertKnownCategory(
|
|
|
142
144
|
throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
const creationTime = (template: MailingTemplate) =>
|
|
146
|
-
new Date(template.createdAt).getTime();
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Nothing in the database keeps slugs unique, so two creations racing past
|
|
150
|
-
* the free-slug check both land: the earliest one stays, a later one is
|
|
151
|
-
* removed again and refused like any taken slug.
|
|
152
|
-
*/
|
|
153
|
-
async function assertFirstOfSlug(
|
|
154
|
-
model: TemplateModel,
|
|
155
|
-
slug: string,
|
|
156
|
-
templateId: string,
|
|
157
|
-
): Promise<void> {
|
|
158
|
-
const [first] = (await model.getBy("slug", slug)).sort(
|
|
159
|
-
(left, right) =>
|
|
160
|
-
creationTime(left) - creationTime(right) ||
|
|
161
|
-
left._id.localeCompare(right._id),
|
|
162
|
-
);
|
|
163
|
-
if (!first || first._id === templateId) return;
|
|
164
|
-
await model.delete(templateId);
|
|
165
|
-
throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
147
|
/** The creation's seed, and the body to store: slug and name trimmed. */
|
|
169
148
|
function parseCreation(body: Buffer | string) {
|
|
170
149
|
const raw = parseBody(body);
|
|
@@ -209,7 +188,7 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
209
188
|
...rest,
|
|
210
189
|
)) as string[];
|
|
211
190
|
const templateId = ids[0] as string;
|
|
212
|
-
await
|
|
191
|
+
await assertSlugClaimed(tenantId, seed.slug, templateId);
|
|
213
192
|
const user = await authenticateTenantRequest(ctx);
|
|
214
193
|
await initializeTemplate(tenantId, templateId, displayName(user), fields);
|
|
215
194
|
return ids;
|
|
@@ -270,14 +249,15 @@ export function afterDelete(tenantId: string, ids: string[]): AfterWrite {
|
|
|
270
249
|
await Promise.all(
|
|
271
250
|
ids.map((id) => versions.getAll(id, "templateId").delete().run()),
|
|
272
251
|
);
|
|
252
|
+
await releaseTemplateSlugs(tenantId, ids);
|
|
273
253
|
await refreshTemplateAttention(tenantId);
|
|
274
254
|
};
|
|
275
255
|
}
|
|
276
256
|
|
|
277
257
|
/**
|
|
278
|
-
* Sends resolve templates by slug and
|
|
279
|
-
*
|
|
280
|
-
*
|
|
258
|
+
* Sends resolve templates by slug and only the create paths claim it, so a
|
|
259
|
+
* slug never changes once set; a category must be one the settings declare,
|
|
260
|
+
* and a name holds more than spaces.
|
|
281
261
|
*/
|
|
282
262
|
export async function assertEditable(
|
|
283
263
|
tenantId: string,
|
package/src/db/models/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
export * from "./template-versions.model";
|
|
6
7
|
export * from "./templates.model";
|
|
7
8
|
export * from "./webhook-status.model";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { BasicDataModel } from "@antelopejs/interface-database-decorators";
|
|
3
|
+
import {
|
|
4
|
+
MAILING_TEMPLATE_SLUGS_TABLE_NAME,
|
|
5
|
+
MailingTemplateSlug,
|
|
6
|
+
} from "../tables";
|
|
7
|
+
|
|
8
|
+
const ID_LENGTH = 32;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The id of a slug's claim, the same whoever creates the template. The tenant
|
|
12
|
+
* is part of it: a driver may keep every tenant's rows in one collection,
|
|
13
|
+
* where a key made of the slug alone would collide across tenants.
|
|
14
|
+
*/
|
|
15
|
+
export function templateSlugIdOf(tenantId: string, slug: string): string {
|
|
16
|
+
return createHash("sha256")
|
|
17
|
+
.update(`${tenantId}\n${slug}`)
|
|
18
|
+
.digest("hex")
|
|
19
|
+
.slice(0, ID_LENGTH);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class TemplateSlugModel extends BasicDataModel(
|
|
23
|
+
MailingTemplateSlug,
|
|
24
|
+
MAILING_TEMPLATE_SLUGS_TABLE_NAME,
|
|
25
|
+
) {
|
|
26
|
+
/**
|
|
27
|
+
* Inserts the claim; false when the key is taken. Probed by state rather
|
|
28
|
+
* than by error, since the database interface exposes no typed conflict
|
|
29
|
+
* error: a claim present after a failed insert is the lost race.
|
|
30
|
+
*/
|
|
31
|
+
async insertClaim(
|
|
32
|
+
id: string,
|
|
33
|
+
slug: string,
|
|
34
|
+
templateId: string,
|
|
35
|
+
): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
await this.insert({ _id: id, slug, templateId });
|
|
38
|
+
return true;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (await this.get(id)) return false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Drops the claim if `templateId` still holds it, never a newer one. */
|
|
46
|
+
async dropClaimOf(id: string, templateId: string): Promise<void> {
|
|
47
|
+
await this.table
|
|
48
|
+
.atomicMutation(id, {
|
|
49
|
+
type: "deleteIfEqual",
|
|
50
|
+
field: "templateId",
|
|
51
|
+
expectedValue: templateId,
|
|
52
|
+
})
|
|
53
|
+
.run();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Frees the slugs the templates held. */
|
|
57
|
+
async releaseTemplates(templateIds: string[]): Promise<void> {
|
|
58
|
+
if (!templateIds.length) return;
|
|
59
|
+
await this.table.getAll(templateIds, "templateId").delete().run();
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/db/tables/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
export * from "./template-versions.table";
|
|
6
7
|
export * from "./templates.table";
|
|
7
8
|
export * from "./webhook-status.table";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Field,
|
|
3
|
+
Index,
|
|
4
|
+
RegisterTable,
|
|
5
|
+
Table,
|
|
6
|
+
} from "@antelopejs/interface-database-decorators";
|
|
7
|
+
import { TENANT_SCHEMA_NAME } from "@antelopejs/interface-dms/constants";
|
|
8
|
+
|
|
9
|
+
export const MAILING_TEMPLATE_SLUGS_TABLE_NAME = "mailing_template_slugs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The template a slug belongs to. Its id derives from the tenant and the
|
|
13
|
+
* slug, so the primary key is what keeps a slug to one template: the database
|
|
14
|
+
* interface declares no unique secondary index, and two creations racing
|
|
15
|
+
* past the free-slug check both insert their template, but only one claim.
|
|
16
|
+
*/
|
|
17
|
+
@RegisterTable(MAILING_TEMPLATE_SLUGS_TABLE_NAME, TENANT_SCHEMA_NAME)
|
|
18
|
+
export class MailingTemplateSlug extends Table {
|
|
19
|
+
@Field("string")
|
|
20
|
+
declare _id: string;
|
|
21
|
+
|
|
22
|
+
@Field("string")
|
|
23
|
+
declare slug: string;
|
|
24
|
+
|
|
25
|
+
/** The template holding the slug; a deleted one leaves the claim free. */
|
|
26
|
+
@Index()
|
|
27
|
+
@Field("string")
|
|
28
|
+
declare templateId: string;
|
|
29
|
+
}
|
package/src/routes/templates.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type TemplateStats,
|
|
38
38
|
} from "../services/template-stats";
|
|
39
39
|
import {
|
|
40
|
+
assertSlugClaimed,
|
|
40
41
|
contentFieldsOf,
|
|
41
42
|
contentOfVersion,
|
|
42
43
|
discardDraft,
|
|
@@ -536,6 +537,7 @@ export class TemplatesController extends Controller(
|
|
|
536
537
|
...contentFieldsOf(source),
|
|
537
538
|
updatedBy: this.author,
|
|
538
539
|
});
|
|
540
|
+
await assertSlugClaimed(this.tenantId, slug, copyId as string);
|
|
539
541
|
await refreshTemplateAttention(this.tenantId);
|
|
540
542
|
return { id: copyId };
|
|
541
543
|
}
|
|
@@ -2,6 +2,7 @@ import { HTTPResult } from "@antelopejs/interface-api";
|
|
|
2
2
|
import { GetModel } from "@antelopejs/interface-database-decorators";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_FALLBACK_LOCALE,
|
|
5
|
+
HTTP_CONFLICT,
|
|
5
6
|
HTTP_NOT_FOUND,
|
|
6
7
|
LOCALE_LIST_SEPARATOR,
|
|
7
8
|
} from "../constants";
|
|
@@ -9,7 +10,9 @@ import {
|
|
|
9
10
|
type MailingTemplate,
|
|
10
11
|
type MailingTemplateVersion,
|
|
11
12
|
TemplateModel,
|
|
13
|
+
TemplateSlugModel,
|
|
12
14
|
TemplateVersionModel,
|
|
15
|
+
templateSlugIdOf,
|
|
13
16
|
} from "../db";
|
|
14
17
|
import { diffContents, diffVariables, type TemplateChange } from "../engine";
|
|
15
18
|
import {
|
|
@@ -39,6 +42,7 @@ const NEVER_PUBLISHED = 0;
|
|
|
39
42
|
const STALE_DRAFT: TemplateAttentionReason = "stale_draft";
|
|
40
43
|
const VERSION_NOT_FOUND = "$dms_mailing.errors.version_not_found";
|
|
41
44
|
const TEMPLATE_NOT_FOUND = "$dms_mailing.errors.template_not_found";
|
|
45
|
+
const DUPLICATE_SLUG = "$dms_mailing.errors.duplicate_slug";
|
|
42
46
|
|
|
43
47
|
/** The template `id` names; 404 when there is none. */
|
|
44
48
|
export async function requireTemplate(
|
|
@@ -50,6 +54,51 @@ export async function requireTemplate(
|
|
|
50
54
|
return template;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Gives `slug` to the template just inserted; false when another template
|
|
59
|
+
* holds it. A claim whose template is gone (deleted before its claim was
|
|
60
|
+
* freed) is taken over, through the same insert that settles a race.
|
|
61
|
+
*/
|
|
62
|
+
async function claimTemplateSlug(
|
|
63
|
+
tenantId: string,
|
|
64
|
+
slug: string,
|
|
65
|
+
templateId: string,
|
|
66
|
+
): Promise<boolean> {
|
|
67
|
+
const claims = GetModel(TemplateSlugModel, tenantId);
|
|
68
|
+
const id = templateSlugIdOf(tenantId, slug);
|
|
69
|
+
if (await claims.insertClaim(id, slug, templateId)) return true;
|
|
70
|
+
const holder = await claims.get(id);
|
|
71
|
+
if (holder) {
|
|
72
|
+
const templates = GetModel(TemplateModel, tenantId);
|
|
73
|
+
if (await templates.get(holder.templateId)) return false;
|
|
74
|
+
await claims.dropClaimOf(id, holder.templateId);
|
|
75
|
+
}
|
|
76
|
+
return claims.insertClaim(id, slug, templateId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keeps the template just inserted if it got its slug, or removes it again
|
|
81
|
+
* and refuses it like any taken slug: two creations racing past the free-slug
|
|
82
|
+
* check both insert, and only the claim's primary key tells them apart.
|
|
83
|
+
*/
|
|
84
|
+
export async function assertSlugClaimed(
|
|
85
|
+
tenantId: string,
|
|
86
|
+
slug: string,
|
|
87
|
+
templateId: string,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (await claimTemplateSlug(tenantId, slug, templateId)) return;
|
|
90
|
+
await GetModel(TemplateModel, tenantId).delete(templateId);
|
|
91
|
+
throw new HTTPResult(HTTP_CONFLICT, DUPLICATE_SLUG);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Frees the slugs of deleted templates for new ones. */
|
|
95
|
+
export async function releaseTemplateSlugs(
|
|
96
|
+
tenantId: string,
|
|
97
|
+
templateIds: string[],
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
await GetModel(TemplateSlugModel, tenantId).releaseTemplates(templateIds);
|
|
100
|
+
}
|
|
101
|
+
|
|
53
102
|
/** The content customers receive: the last published version. */
|
|
54
103
|
export function parseContent(template: MailingTemplate): TemplateContent {
|
|
55
104
|
return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;
|