@antelopejs/dms-mailing 0.2.3 → 0.3.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 (117) hide show
  1. package/README.md +7 -8
  2. package/dist/antelope-modules.d.ts +5 -5
  3. package/dist/antelope-modules.js +5 -5
  4. package/dist/antelope-modules.js.map +1 -1
  5. package/dist/constants.d.ts +0 -3
  6. package/dist/constants.js +1 -4
  7. package/dist/constants.js.map +1 -1
  8. package/dist/crons/retention.js +1 -1
  9. package/dist/crons/retention.js.map +1 -1
  10. package/dist/data/templates-table.d.ts +1 -1
  11. package/dist/data/templates-table.js +21 -0
  12. package/dist/data/templates-table.js.map +1 -1
  13. package/dist/data-types/categories-type.d.ts +5 -5
  14. package/dist/db/models/sends.model.d.ts +2 -1
  15. package/dist/db/models/sends.model.js +1 -0
  16. package/dist/db/models/sends.model.js.map +1 -1
  17. package/dist/db/models/settings.model.d.ts +1 -1
  18. package/dist/db/models/settings.model.js +4 -1
  19. package/dist/db/models/settings.model.js.map +1 -1
  20. package/dist/db/tables/send-events.table.js +0 -1
  21. package/dist/db/tables/send-events.table.js.map +1 -1
  22. package/dist/db/tables/sends.table.js +0 -1
  23. package/dist/db/tables/sends.table.js.map +1 -1
  24. package/dist/db/tables/templates.table.js +0 -1
  25. package/dist/db/tables/templates.table.js.map +1 -1
  26. package/dist/hooks/tenant-export.js +1 -1
  27. package/dist/hooks/tenant-export.js.map +1 -1
  28. package/dist/pages/editor.js +0 -1
  29. package/dist/pages/editor.js.map +1 -1
  30. package/dist/pages/module.d.ts +1 -1
  31. package/dist/pages/overview.d.ts +5 -5
  32. package/dist/pages/overview.js +0 -1
  33. package/dist/pages/overview.js.map +1 -1
  34. package/dist/pages/sends.d.ts +3 -3
  35. package/dist/pages/sends.js +0 -1
  36. package/dist/pages/sends.js.map +1 -1
  37. package/dist/pages/settings/form.d.ts +1 -1
  38. package/dist/pages/settings/page.d.ts +2 -2
  39. package/dist/pages/settings/page.js +0 -2
  40. package/dist/pages/settings/page.js.map +1 -1
  41. package/dist/pages/templates.d.ts +1 -1
  42. package/dist/pages/templates.js +0 -1
  43. package/dist/pages/templates.js.map +1 -1
  44. package/dist/permissions.js +10 -32
  45. package/dist/permissions.js.map +1 -1
  46. package/dist/quick-actions.d.ts +2 -2
  47. package/dist/routes/events.js +2 -2
  48. package/dist/routes/events.js.map +1 -1
  49. package/dist/routes/period.js +7 -4
  50. package/dist/routes/period.js.map +1 -1
  51. package/dist/routes/templates.d.ts +2 -2
  52. package/dist/services/attention.d.ts +3 -3
  53. package/dist/services/categories.d.ts +5 -5
  54. package/dist/services/events.d.ts +1 -1
  55. package/dist/services/events.js.map +1 -1
  56. package/dist/services/metrics.d.ts +4 -4
  57. package/dist/services/settings.d.ts +1 -0
  58. package/dist/services/settings.js +25 -8
  59. package/dist/services/settings.js.map +1 -1
  60. package/dist/test/antelope.test.js +1 -0
  61. package/dist/test/antelope.test.js.map +1 -1
  62. package/dist/test/integration/events.test.js +11 -0
  63. package/dist/test/integration/events.test.js.map +1 -1
  64. package/dist/test/integration/sends.test.js +53 -0
  65. package/dist/test/integration/sends.test.js.map +1 -1
  66. package/dist/test/integration/settings.test.js +92 -0
  67. package/dist/test/integration/settings.test.js.map +1 -1
  68. package/dist/test/integration/templates.test.js +68 -2
  69. package/dist/test/integration/templates.test.js.map +1 -1
  70. package/dist/test/unit/period.test.js +17 -0
  71. package/dist/test/unit/period.test.js.map +1 -1
  72. package/dist/test/unit/permissions.test.d.ts +1 -0
  73. package/dist/test/unit/permissions.test.js +27 -0
  74. package/dist/test/unit/permissions.test.js.map +1 -0
  75. package/dist/test/unit/webhook-secret.test.js +1 -0
  76. package/dist/test/unit/webhook-secret.test.js.map +1 -1
  77. package/dist/validation/settings.schema.d.ts +6 -6
  78. package/dist/validation/templates.schema.d.ts +14 -14
  79. package/frontend-vue/app/components/editor/EditorDataPanel.vue +3 -1
  80. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -0
  81. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -0
  82. package/frontend-vue/package.json +1 -1
  83. package/frontend-vue/pnpm-lock.yaml +2 -28
  84. package/package.json +10 -12
  85. package/src/antelope-modules.ts +5 -5
  86. package/src/constants.ts +0 -3
  87. package/src/crons/retention.ts +3 -1
  88. package/src/data/templates-table.ts +46 -0
  89. package/src/db/models/sends.model.ts +6 -2
  90. package/src/db/models/settings.model.ts +7 -1
  91. package/src/db/tables/send-events.table.ts +0 -1
  92. package/src/db/tables/sends.table.ts +0 -1
  93. package/src/db/tables/templates.table.ts +0 -1
  94. package/src/hooks/tenant-export.ts +1 -1
  95. package/src/pages/editor.ts +1 -2
  96. package/src/pages/overview.ts +0 -2
  97. package/src/pages/sends.ts +1 -7
  98. package/src/pages/settings/page.ts +1 -3
  99. package/src/pages/templates.ts +1 -7
  100. package/src/permissions.ts +14 -42
  101. package/src/routes/events.ts +3 -3
  102. package/src/routes/metrics.ts +3 -3
  103. package/src/routes/period.ts +9 -4
  104. package/src/routes/templates.ts +1 -1
  105. package/src/services/attention.ts +5 -5
  106. package/src/services/events.ts +4 -4
  107. package/src/services/metrics.ts +9 -9
  108. package/src/services/settings.ts +36 -7
  109. package/src/test/antelope.test.ts +2 -0
  110. package/src/test/integration/events.test.ts +24 -1
  111. package/src/test/integration/sends.test.ts +33 -0
  112. package/src/test/integration/settings.test.ts +78 -0
  113. package/src/test/integration/templates.test.ts +49 -6
  114. package/src/test/unit/period.test.ts +27 -0
  115. package/src/test/unit/permissions.test.ts +30 -0
  116. package/src/test/unit/webhook-secret.test.ts +1 -0
  117. package/frontend-vue/tests/email-render-cases.json +0 -77
@@ -44,15 +44,43 @@ function toRow(values: MailingSettingsValues): Partial<MailingSettings> {
44
44
  return { ...rest, json_categories: JSON.stringify(categories) };
45
45
  }
46
46
 
47
+ /** The tenant's stored settings, without creating them when there are none. */
48
+ export async function findSettings(
49
+ tenantId: string,
50
+ ): Promise<MailingSettingsValues | undefined> {
51
+ const existing = await GetModel(SettingsModel, tenantId).getSingleton(
52
+ tenantId,
53
+ );
54
+ return existing ? toValues(existing) : undefined;
55
+ }
56
+
57
+ /**
58
+ * Creates the tenant's row under its tenant id, so two concurrent first
59
+ * readers cannot both insert one: the loser's insert is refused on the key.
60
+ * Resolves false when another writer created the row first.
61
+ */
62
+ async function insertKeyedSettings(
63
+ tenantId: string,
64
+ values: MailingSettingsValues,
65
+ ): Promise<boolean> {
66
+ const model = GetModel(SettingsModel, tenantId);
67
+ try {
68
+ await model.insert({ ...toRow(values), _id: tenantId });
69
+ return true;
70
+ } catch (error) {
71
+ if (await model.get(tenantId)) return false;
72
+ throw error;
73
+ }
74
+ }
75
+
47
76
  export async function getSettings(
48
77
  tenantId: string,
49
78
  ): Promise<MailingSettingsValues> {
50
- const model = GetModel(SettingsModel, tenantId);
51
- const existing = await model.getSingleton();
52
- if (existing) return toValues(existing);
79
+ const existing = await findSettings(tenantId);
80
+ if (existing) return existing;
53
81
  const created = defaults();
54
- await model.insert(toRow(created));
55
- return created;
82
+ if (await insertKeyedSettings(tenantId, created)) return created;
83
+ return (await findSettings(tenantId)) as MailingSettingsValues;
56
84
  }
57
85
 
58
86
  export async function saveSettings(
@@ -60,9 +88,10 @@ export async function saveSettings(
60
88
  values: MailingSettingsValues,
61
89
  ): Promise<MailingSettingsValues> {
62
90
  const model = GetModel(SettingsModel, tenantId);
63
- const existing = await model.getSingleton();
91
+ const existing = await model.getSingleton(tenantId);
64
92
  if (existing) await model.update(existing._id, toRow(values));
65
- else await model.insert(toRow(values));
93
+ else if (!(await insertKeyedSettings(tenantId, values)))
94
+ await model.update(tenantId, toRow(values));
66
95
  return values;
67
96
  }
68
97
 
@@ -126,6 +126,8 @@ export default defineConfig({
126
126
  source: SHARED_MODULE_SOURCES.api,
127
127
  config: {
128
128
  servers: [{ protocol: "http", host: "127.0.0.1", port: API_PORT }],
129
+ // Required outside development, which the test run is not.
130
+ publicBaseUrl: `http://127.0.0.1:${API_PORT}`,
129
131
  },
130
132
  },
131
133
  "file-storage-local": {
@@ -3,11 +3,12 @@ import {
3
3
  clearEmailEventSubscriptions,
4
4
  emailEventTrigger,
5
5
  } from "../../automation/email-event-trigger";
6
- import { authorizedClient } from "../helpers/http";
6
+ import { authorizedClient, createClient } from "../helpers/http";
7
7
  import { ensureOwnerSession } from "../helpers/owner";
8
8
 
9
9
  const HTTP_OK = 200;
10
10
  const HTTP_FORBIDDEN = 403;
11
+ const SECRET_LENGTH = 48;
11
12
  const TABLE = "/api/mailing/tables/templates";
12
13
 
13
14
  interface EventRow {
@@ -100,6 +101,28 @@ describe("[integration] events", () => {
100
101
  });
101
102
  });
102
103
 
104
+ describe("[integration] webhook for a tenant without settings", () => {
105
+ // The endpoint is unauthenticated and takes the tenant from the body: reading
106
+ // the secret must not create a settings row for whatever tenant id it names.
107
+ it("refuses the event and stores nothing", async () => {
108
+ const { GetModel } =
109
+ await import("@antelopejs/interface-database-decorators");
110
+ const { SettingsModel } = await import("../../db");
111
+ const tenantId = `unknown-tenant-${Date.now()}`;
112
+
113
+ const hook = await createClient().post(
114
+ "/api/mailing/events/manual",
115
+ { messageId: "unknown-message", type: "opened", tenantId },
116
+ { headers: { "x-mailing-webhook-secret": "a".repeat(SECRET_LENGTH) } },
117
+ );
118
+
119
+ expect(hook.status).to.equal(HTTP_FORBIDDEN);
120
+ expect(await GetModel(SettingsModel, tenantId).getAll()).to.have.lengthOf(
121
+ 0,
122
+ );
123
+ });
124
+ });
125
+
103
126
  describe("[integration] send event purge", () => {
104
127
  // The retention cron deletes a send's events in one filtered statement. A
105
128
  // wrong predicate there would take another send's history with it, so pin
@@ -45,6 +45,39 @@ describe("[integration] sends", () => {
45
45
  expect(detail.data.events[0].type).to.equal(detail.data.send.status);
46
46
  });
47
47
 
48
+ // Metrics load every send of their window; the rendered variables are the
49
+ // one unbounded field and none of them reads it.
50
+ it("reads a metrics window without the rendered variables", async () => {
51
+ const session = await ensureOwnerSession();
52
+ const client = authorizedClient(session.accessToken);
53
+ const created = await client.post(`${TABLE}/new`, {
54
+ slug: "metrics-window",
55
+ name: "Metrics window",
56
+ category: "orders",
57
+ });
58
+ const sent = await client.post(
59
+ `/api/mailing/templates/${created.data[0]}/test-send`,
60
+ { to: ["metrics@example.test"], locale: "en", data: { any: "value" } },
61
+ );
62
+ expect(sent.status, JSON.stringify(sent.data)).to.equal(HTTP_OK);
63
+ const sendId = sent.data.results[0].sendId as string;
64
+
65
+ const { GetModel } =
66
+ await import("@antelopejs/interface-database-decorators");
67
+ const { DEFAULT_TENANT_ID } =
68
+ await import("@antelopejs/interface-dms/constants");
69
+ const { SendModel } = await import("../../db");
70
+ const now = Date.now();
71
+ const rows = await GetModel(SendModel, DEFAULT_TENANT_ID).listBetween(
72
+ new Date(now - DAY_MS),
73
+ new Date(now + DAY_MS),
74
+ );
75
+
76
+ const row = rows.find((candidate) => candidate._id === sendId);
77
+ expect(row?.templateSlug).to.equal("metrics-window");
78
+ expect(row).to.not.have.property("json_variables");
79
+ });
80
+
48
81
  it("refuses a real send on a draft template", async () => {
49
82
  const session = await ensureOwnerSession();
50
83
  const client = authorizedClient(session.accessToken);
@@ -48,3 +48,81 @@ describe("[integration] settings", () => {
48
48
  expect(restored.status).to.equal(HTTP_OK);
49
49
  });
50
50
  });
51
+
52
+ const CONCURRENT_READERS = 5;
53
+ const LEGACY_RETENTION_DAYS = 45;
54
+ const LEGACY_SECRET = "l".repeat(48);
55
+
56
+ async function loadSettingsModules() {
57
+ const { GetModel } =
58
+ await import("@antelopejs/interface-database-decorators");
59
+ const { SettingsModel } = await import("../../db");
60
+ const settings = await import("../../services/settings");
61
+ return {
62
+ ...settings,
63
+ rowsOf: (tenantId: string) => GetModel(SettingsModel, tenantId).getAll(),
64
+ insertLegacyRow: (tenantId: string) =>
65
+ GetModel(SettingsModel, tenantId).insert({
66
+ fallbackLocale: "en",
67
+ logRetentionDays: LEGACY_RETENTION_DAYS,
68
+ blockOnMissingVariables: false,
69
+ senderName: "",
70
+ senderEmail: "",
71
+ replyTo: "",
72
+ json_categories: "[]",
73
+ webhookSecret: LEGACY_SECRET,
74
+ }),
75
+ };
76
+ }
77
+
78
+ describe("[integration] settings row per tenant", () => {
79
+ // Every request path reads the settings, so a tenant's first reads arrive
80
+ // together. Each used to insert its own default row with its own webhook
81
+ // secret; the row is now keyed by the tenant id so only one can land.
82
+ it("creates a single row when first readers race", async () => {
83
+ const { getSettings, rowsOf } = await loadSettingsModules();
84
+ const tenantId = `settings-race-${Date.now()}`;
85
+
86
+ const reads = await Promise.all(
87
+ Array.from({ length: CONCURRENT_READERS }, () => getSettings(tenantId)),
88
+ );
89
+
90
+ const rows = await rowsOf(tenantId);
91
+ expect(rows).to.have.lengthOf(1);
92
+ expect(rows[0]?._id).to.equal(tenantId);
93
+ for (const read of reads) {
94
+ expect(read.webhookSecret).to.equal(rows[0]?.webhookSecret);
95
+ }
96
+ });
97
+
98
+ it("keeps reading and updating a row written before the key existed", async () => {
99
+ const { getSettings, saveSettings, rowsOf, insertLegacyRow } =
100
+ await loadSettingsModules();
101
+ const tenantId = `settings-legacy-${Date.now()}`;
102
+ await insertLegacyRow(tenantId);
103
+
104
+ const read = await getSettings(tenantId);
105
+ expect(read.webhookSecret).to.equal(LEGACY_SECRET);
106
+ await saveSettings(tenantId, {
107
+ ...read,
108
+ logRetentionDays: UPDATED_RETENTION_DAYS,
109
+ });
110
+
111
+ const rows = await rowsOf(tenantId);
112
+ expect(rows).to.have.lengthOf(1);
113
+ expect(rows[0]?.logRetentionDays).to.equal(UPDATED_RETENTION_DAYS);
114
+ });
115
+
116
+ it("saves a tenant's first settings under the tenant id", async () => {
117
+ const { getSettings, saveSettings, rowsOf } = await loadSettingsModules();
118
+ const tenantId = `settings-first-save-${Date.now()}`;
119
+ const values = await getSettings(`settings-template-${Date.now()}`);
120
+
121
+ await saveSettings(tenantId, { ...values, webhookSecret: LEGACY_SECRET });
122
+
123
+ const rows = await rowsOf(tenantId);
124
+ expect(rows).to.have.lengthOf(1);
125
+ expect(rows[0]?._id).to.equal(tenantId);
126
+ expect(rows[0]?.webhookSecret).to.equal(LEGACY_SECRET);
127
+ });
128
+ });
@@ -5,6 +5,7 @@ import { ensureOwnerSession } from "../helpers/owner";
5
5
  const HTTP_OK = 200;
6
6
  const HTTP_BAD_REQUEST = 400;
7
7
  const HTTP_NOT_FOUND = 404;
8
+ const HTTP_CONFLICT = 409;
8
9
  const TABLE = "/api/mailing/tables/templates";
9
10
 
10
11
  interface TemplateRow {
@@ -116,6 +117,43 @@ describe("[integration] templates table", () => {
116
117
  expect(row.data.category).to.equal("billing");
117
118
  });
118
119
 
120
+ // Sends resolve a template by slug and only the create paths check it is
121
+ // free, so an edit must neither move it nor, by omitting it, clear it.
122
+ it("refuses a slug change on edit and keeps the slug when it is omitted", async () => {
123
+ const session = await ensureOwnerSession();
124
+ const client = authorizedClient(session.accessToken);
125
+ const created = await client.post(`${TABLE}/new`, {
126
+ slug: "fixed-slug",
127
+ name: "Fixed slug",
128
+ category: "orders",
129
+ });
130
+ const id = created.data[0] as string;
131
+
132
+ const moved = await client.put(`${TABLE}/edit?id=${id}`, {
133
+ slug: "order-confirmed",
134
+ name: "Fixed slug",
135
+ category: "orders",
136
+ });
137
+ expect(moved.status).to.equal(HTTP_CONFLICT);
138
+ expect(JSON.stringify(moved.data)).to.contain("slug_immutable");
139
+
140
+ const omitted = await client.put(`${TABLE}/edit?id=${id}`, {
141
+ name: "Renamed without slug",
142
+ category: "orders",
143
+ });
144
+ expect(omitted.status, JSON.stringify(omitted.data)).to.equal(HTTP_OK);
145
+ const row = await client.get(`${TABLE}/get?id=${id}`);
146
+ expect(row.data.slug).to.equal("fixed-slug");
147
+ expect(row.data.name).to.equal("Renamed without slug");
148
+
149
+ const { GetModel } =
150
+ await import("@antelopejs/interface-database-decorators");
151
+ const { DEFAULT_TENANT_ID } =
152
+ await import("@antelopejs/interface-dms/constants");
153
+ const { TemplateModel } = await import("../../db");
154
+ await GetModel(TemplateModel, DEFAULT_TENANT_ID).delete(id);
155
+ });
156
+
119
157
  it("refuses an unknown source template", async () => {
120
158
  const session = await ensureOwnerSession();
121
159
  const client = authorizedClient(session.accessToken);
@@ -211,12 +249,17 @@ describe("[integration] template content and status", () => {
211
249
  it("previews the current content with test data and reports missing variables", async () => {
212
250
  const session = await ensureOwnerSession();
213
251
  const client = authorizedClient(session.accessToken);
214
- const list = await client.get(`${TABLE}/list`);
215
- const id = (
216
- list.data.results.find(
217
- (item: TemplateRow) => item.slug === "welcome",
218
- ) as TemplateRow
219
- )._id;
252
+ // Looked up by slug rather than on the table's first page, which other
253
+ // tests fill with their own templates.
254
+ const { GetModel } =
255
+ await import("@antelopejs/interface-database-decorators");
256
+ const { DEFAULT_TENANT_ID } =
257
+ await import("@antelopejs/interface-dms/constants");
258
+ const { TemplateModel } = await import("../../db");
259
+ const welcome = await GetModel(TemplateModel, DEFAULT_TENANT_ID).getBySlug(
260
+ "welcome",
261
+ );
262
+ const id = welcome?._id as string;
220
263
  const preview = await client.post(`/api/mailing/templates/${id}/preview`, {
221
264
  locale: "en",
222
265
  data: {},
@@ -61,4 +61,31 @@ describe("[unit] period range", () => {
61
61
  expect(spanDays(previous)).to.equal(MAX_WINDOW_DAYS);
62
62
  expect(previous.to.getTime()).to.equal(current.from.getTime());
63
63
  });
64
+
65
+ // `compareFrom`/`compareTo` are plain query parameters too, and every metric
66
+ // reads its comparison window from the send log in full.
67
+ it("clamps an explicit comparison window like the current one", () => {
68
+ const current = currentRange(ctxWith({}));
69
+ const previous = comparisonRange(
70
+ ctxWith({
71
+ compareFrom: "1970-01-01T00:00:00.000Z",
72
+ compareTo: "2026-09-01T00:00:00.000Z",
73
+ }),
74
+ current,
75
+ );
76
+ expect(spanDays(previous)).to.equal(MAX_WINDOW_DAYS);
77
+ expect(previous.to.toISOString()).to.equal("2026-09-01T00:00:00.000Z");
78
+ });
79
+
80
+ it("keeps an explicit comparison window within the cap", () => {
81
+ const current = currentRange(ctxWith({}));
82
+ const previous = comparisonRange(
83
+ ctxWith({
84
+ compareFrom: "2026-08-01T00:00:00.000Z",
85
+ compareTo: "2026-09-01T00:00:00.000Z",
86
+ }),
87
+ current,
88
+ );
89
+ expect(previous.from.toISOString()).to.equal("2026-08-01T00:00:00.000Z");
90
+ });
64
91
  });
@@ -0,0 +1,30 @@
1
+ import { expect } from "chai";
2
+ import { GetPermission } from "@antelopejs/interface-dms/permissions";
3
+ import { MAILING_SEND_PERMISSION } from "../../constants";
4
+
5
+ const SENDS_PAGE_PERMISSION = "modules.mailing.sends";
6
+ const SETTINGS_PAGE_PERMISSION = "settings.mailing.settings";
7
+ const REMOVED_PERMISSIONS = [
8
+ "mailing.access",
9
+ "mailing.templates.manage",
10
+ "mailing.settings.manage",
11
+ ];
12
+
13
+ describe("[unit] permissions", () => {
14
+ it("makes the send permission depend on the sends page permission", async () => {
15
+ const permission = await GetPermission(MAILING_SEND_PERMISSION);
16
+ expect(permission?.dependencies).to.deep.equal([SENDS_PAGE_PERMISSION]);
17
+ });
18
+
19
+ it("registers the settings page default permission", async () => {
20
+ expect(await GetPermission(SETTINGS_PAGE_PERMISSION)).to.not.equal(
21
+ undefined,
22
+ );
23
+ });
24
+
25
+ it("no longer registers the page-access permissions", async () => {
26
+ for (const id of REMOVED_PERMISSIONS) {
27
+ expect(await GetPermission(id), id).to.equal(undefined);
28
+ }
29
+ });
30
+ });
@@ -22,6 +22,7 @@ describe("[unit] webhook secret", () => {
22
22
  expect(webhookSecretMatches("", "")).to.equal(false);
23
23
  expect(webhookSecretMatches(undefined, "")).to.equal(false);
24
24
  expect(webhookSecretMatches(VALID_SECRET, "")).to.equal(false);
25
+ expect(webhookSecretMatches(VALID_SECRET, undefined)).to.equal(false);
25
26
  });
26
27
 
27
28
  it("refuses a missing or wrong header against a real secret", () => {
@@ -1,77 +0,0 @@
1
- [
2
- {
3
- "locale": "en",
4
- "email": {
5
- "subject": "Your receipt",
6
- "preheader": "Thanks for your order",
7
- "missing": [],
8
- "hiddenBlockIds": [],
9
- "blocks": [
10
- {
11
- "id": "intro",
12
- "type": "paragraph",
13
- "text": "Hello Alice & Bob",
14
- "align": "left"
15
- },
16
- {
17
- "id": "items",
18
- "type": "list",
19
- "rows": [
20
- { "label": "Plan", "value": "Pro" },
21
- { "label": "Seats", "value": "3" }
22
- ]
23
- },
24
- { "id": "total", "type": "total", "label": "Total", "value": "€29.00" },
25
- {
26
- "id": "footer",
27
- "type": "footer",
28
- "text": "Sent by Antelope",
29
- "unsubscribeLabel": "Unsubscribe",
30
- "preferencesLabel": "Preferences",
31
- "unsubscribeUrl": "https://example.test/unsubscribe/en",
32
- "preferencesUrl": "https://example.test/preferences/en"
33
- }
34
- ]
35
- }
36
- },
37
- {
38
- "locale": "fr",
39
- "email": {
40
- "subject": "Votre reçu",
41
- "preheader": "Merci pour votre commande",
42
- "missing": [],
43
- "hiddenBlockIds": [],
44
- "blocks": [
45
- {
46
- "id": "intro",
47
- "type": "paragraph",
48
- "text": "Bonjour Alice & Bob",
49
- "align": "left"
50
- },
51
- {
52
- "id": "items",
53
- "type": "list",
54
- "rows": [
55
- { "label": "Offre", "value": "Pro" },
56
- { "label": "Places", "value": "3" }
57
- ]
58
- },
59
- {
60
- "id": "total",
61
- "type": "total",
62
- "label": "Total",
63
- "value": "29,00 €"
64
- },
65
- {
66
- "id": "footer",
67
- "type": "footer",
68
- "text": "Envoyé par Antelope",
69
- "unsubscribeLabel": "Se désabonner",
70
- "preferencesLabel": "Préférences",
71
- "unsubscribeUrl": "https://example.test/unsubscribe/fr",
72
- "preferencesUrl": "https://example.test/preferences/fr"
73
- }
74
- ]
75
- }
76
- }
77
- ]