@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.
Files changed (47) hide show
  1. package/README.md +61 -339
  2. package/dist/data/templates-table.js +2 -10
  3. package/dist/data/templates-table.js.map +1 -1
  4. package/dist/db/models/index.d.ts +1 -0
  5. package/dist/db/models/index.js +1 -0
  6. package/dist/db/models/index.js.map +1 -1
  7. package/dist/db/models/send-events.model.d.ts +1 -0
  8. package/dist/db/models/sends.model.d.ts +1 -0
  9. package/dist/db/models/settings.model.d.ts +1 -0
  10. package/dist/db/models/suppressions.model.d.ts +1 -0
  11. package/dist/db/models/template-slugs.model.d.ts +27 -0
  12. package/dist/db/models/template-slugs.model.js +43 -0
  13. package/dist/db/models/template-slugs.model.js.map +1 -0
  14. package/dist/db/models/template-versions.model.d.ts +1 -0
  15. package/dist/db/models/templates.model.d.ts +1 -0
  16. package/dist/db/models/webhook-status.model.d.ts +1 -0
  17. package/dist/db/tables/index.d.ts +1 -0
  18. package/dist/db/tables/index.js +1 -0
  19. package/dist/db/tables/index.js.map +1 -1
  20. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  21. package/dist/db/tables/template-slugs.table.js +35 -0
  22. package/dist/db/tables/template-slugs.table.js.map +1 -0
  23. package/dist/routes/templates.js +1 -0
  24. package/dist/routes/templates.js.map +1 -1
  25. package/dist/services/templates.d.ts +2 -0
  26. package/dist/services/templates.js +26 -0
  27. package/dist/services/templates.js.map +1 -1
  28. package/frontend-vue/app/components/CategoriesInput.vue +12 -11
  29. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  30. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  31. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  32. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  33. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  34. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  35. package/frontend-vue/package.json +4 -3
  36. package/frontend-vue/pnpm-lock.yaml +6 -13
  37. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  38. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  39. package/package.json +31 -14
  40. package/skills/dms-mailing/SKILL.md +63 -0
  41. package/src/data/templates-table.ts +7 -27
  42. package/src/db/models/index.ts +1 -0
  43. package/src/db/models/template-slugs.model.ts +61 -0
  44. package/src/db/tables/index.ts +1 -0
  45. package/src/db/tables/template-slugs.table.ts +29 -0
  46. package/src/routes/templates.ts +2 -0
  47. 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 assertFirstOfSlug(model, seed.slug, templateId);
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 nothing enforces its uniqueness but the
279
- * create checks, so a slug never changes once set; a category must be one the
280
- * settings declare, and a name holds more than spaces.
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,
@@ -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
+ }
@@ -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
+ }
@@ -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;