@antelopejs/dms-mailing 0.8.0 → 0.9.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 (205) hide show
  1. package/README.md +41 -43
  2. package/dist/automation/email-event-trigger.d.ts +2 -2
  3. package/dist/automation/send-template-action.d.ts +11 -3
  4. package/dist/automation/send-template-action.js +1 -1
  5. package/dist/automation/send-template-action.js.map +1 -1
  6. package/dist/data/body.d.ts +2 -1
  7. package/dist/data/body.js +2 -1
  8. package/dist/data/body.js.map +1 -1
  9. package/dist/data/read-only-routes.d.ts +4 -4
  10. package/dist/data/sends-table.d.ts +4 -4
  11. package/dist/data/suppressions-table.d.ts +4 -4
  12. package/dist/data/templates-table.d.ts +35 -12
  13. package/dist/data/templates-table.js +29 -24
  14. package/dist/data/templates-table.js.map +1 -1
  15. package/dist/data-types/categories-type.d.ts +1 -2
  16. package/dist/data-types/categories-type.js +3 -5
  17. package/dist/data-types/categories-type.js.map +1 -1
  18. package/dist/data-types/category-type.d.ts +1 -2
  19. package/dist/data-types/category-type.js +3 -5
  20. package/dist/data-types/category-type.js.map +1 -1
  21. package/dist/db/models/send-events.model.d.ts +7 -5
  22. package/dist/db/models/sends.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.js.map +1 -1
  24. package/dist/db/models/settings.model.d.ts +7 -5
  25. package/dist/db/models/suppressions.model.d.ts +7 -5
  26. package/dist/db/models/template-versions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.js +4 -3
  28. package/dist/db/models/template-versions.model.js.map +1 -1
  29. package/dist/db/models/templates.model.d.ts +7 -5
  30. package/dist/db/models/webhook-status.model.d.ts +7 -5
  31. package/dist/db/tables/settings.table.d.ts +1 -0
  32. package/dist/db/tables/settings.table.js +4 -0
  33. package/dist/db/tables/settings.table.js.map +1 -1
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -19
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +3 -2
  39. package/dist/engine/conditions.js.map +1 -1
  40. package/dist/engine/coverage.d.ts +2 -1
  41. package/dist/engine/coverage.js.map +1 -1
  42. package/dist/engine/formats.d.ts +4 -6
  43. package/dist/engine/formats.js +17 -3
  44. package/dist/engine/formats.js.map +1 -1
  45. package/dist/engine/paths.d.ts +4 -2
  46. package/dist/engine/paths.js +15 -3
  47. package/dist/engine/paths.js.map +1 -1
  48. package/dist/engine/plain-text.js +2 -4
  49. package/dist/engine/plain-text.js.map +1 -1
  50. package/dist/engine/resolve.d.ts +2 -1
  51. package/dist/engine/resolve.js +1 -1
  52. package/dist/engine/resolve.js.map +1 -1
  53. package/dist/engine/system-variables.d.ts +2 -1
  54. package/dist/engine/system-variables.js.map +1 -1
  55. package/dist/engine/tokens.d.ts +2 -1
  56. package/dist/engine/tokens.js.map +1 -1
  57. package/dist/hooks/tenant-export.d.ts +1 -1
  58. package/dist/hooks/tenant-export.js +10 -9
  59. package/dist/hooks/tenant-export.js.map +1 -1
  60. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  61. package/dist/implementations/dms-mailing/index.js +3 -1
  62. package/dist/implementations/dms-mailing/index.js.map +1 -1
  63. package/dist/pages/sends.js.map +1 -1
  64. package/dist/pages/templates.js +3 -1
  65. package/dist/pages/templates.js.map +1 -1
  66. package/dist/routes/events.js +4 -2
  67. package/dist/routes/events.js.map +1 -1
  68. package/dist/routes/index.d.ts +1 -0
  69. package/dist/routes/index.js +1 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/metrics.js +27 -28
  72. package/dist/routes/metrics.js.map +1 -1
  73. package/dist/routes/period.js +5 -5
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +3 -4
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +4 -3
  78. package/dist/routes/settings.js +20 -14
  79. package/dist/routes/settings.js.map +1 -1
  80. package/dist/routes/suppressions.js +9 -6
  81. package/dist/routes/suppressions.js.map +1 -1
  82. package/dist/routes/template-content.d.ts +27 -0
  83. package/dist/routes/template-content.js +3 -0
  84. package/dist/routes/template-content.js.map +1 -0
  85. package/dist/routes/templates.d.ts +7 -30
  86. package/dist/routes/templates.js +3 -2
  87. package/dist/routes/templates.js.map +1 -1
  88. package/dist/routes/zod-json.d.ts +3 -0
  89. package/dist/routes/zod-json.js +21 -0
  90. package/dist/routes/zod-json.js.map +1 -0
  91. package/dist/services/attention.js +14 -7
  92. package/dist/services/attention.js.map +1 -1
  93. package/dist/services/catalog.js +4 -2
  94. package/dist/services/catalog.js.map +1 -1
  95. package/dist/services/declaration-error.d.ts +1 -1
  96. package/dist/services/declaration-error.js.map +1 -1
  97. package/dist/services/declarations.js +4 -1
  98. package/dist/services/declarations.js.map +1 -1
  99. package/dist/services/events.js +3 -1
  100. package/dist/services/events.js.map +1 -1
  101. package/dist/services/metrics.js +2 -5
  102. package/dist/services/metrics.js.map +1 -1
  103. package/dist/services/send-import.js +4 -4
  104. package/dist/services/send-import.js.map +1 -1
  105. package/dist/services/send.js +3 -4
  106. package/dist/services/send.js.map +1 -1
  107. package/dist/services/settings.d.ts +6 -3
  108. package/dist/services/settings.js +24 -5
  109. package/dist/services/settings.js.map +1 -1
  110. package/dist/services/starters.d.ts +3 -2
  111. package/dist/services/starters.js +1 -1
  112. package/dist/services/starters.js.map +1 -1
  113. package/dist/services/templates.d.ts +2 -1
  114. package/dist/services/templates.js +5 -5
  115. package/dist/services/templates.js.map +1 -1
  116. package/dist/services/webhook-secret-declaration.d.ts +4 -0
  117. package/dist/services/webhook-secret-declaration.js +25 -0
  118. package/dist/services/webhook-secret-declaration.js.map +1 -0
  119. package/dist/services/webhook-status.d.ts +2 -2
  120. package/dist/services/webhook-status.js +13 -6
  121. package/dist/services/webhook-status.js.map +1 -1
  122. package/dist/types/json.d.ts +1 -0
  123. package/dist/types/json.js +3 -0
  124. package/dist/types/json.js.map +1 -0
  125. package/dist/types/settings.d.ts +5 -2
  126. package/dist/types/settings.js +6 -1
  127. package/dist/types/settings.js.map +1 -1
  128. package/dist/validation/declarations.schema.d.ts +41 -28
  129. package/dist/validation/declarations.schema.js +10 -4
  130. package/dist/validation/declarations.schema.js.map +1 -1
  131. package/dist/validation/guards.d.ts +0 -1
  132. package/dist/validation/guards.js +0 -4
  133. package/dist/validation/guards.js.map +1 -1
  134. package/dist/validation/json.d.ts +5 -0
  135. package/dist/validation/json.js +54 -0
  136. package/dist/validation/json.js.map +1 -0
  137. package/dist/validation/json.schema.d.ts +2 -0
  138. package/dist/validation/json.schema.js +9 -0
  139. package/dist/validation/json.schema.js.map +1 -0
  140. package/dist/validation/templates.schema.d.ts +25 -24
  141. package/dist/validation/templates.schema.js +4 -3
  142. package/dist/validation/templates.schema.js.map +1 -1
  143. package/frontend-vue/app/components/editor/EditorCanvas.vue +8 -4
  144. package/frontend-vue/app/composables/useSortableList.ts +2 -2
  145. package/frontend-vue/package.json +37 -36
  146. package/frontend-vue/pnpm-lock.yaml +65 -5
  147. package/frontend-vue/tests/editor-canvas.test.ts +142 -0
  148. package/frontend-vue/tests/sortable-list.test-d.ts +19 -0
  149. package/frontend-vue/tsconfig.typecheck.json +12 -0
  150. package/frontend-vue/vitest.config.ts +16 -0
  151. package/package.json +16 -15
  152. package/skills/dms-mailing/SKILL.md +5 -3
  153. package/src/automation/email-event-trigger.ts +2 -2
  154. package/src/automation/send-template-action.ts +33 -14
  155. package/src/data/body.ts +5 -2
  156. package/src/data/templates-table.ts +46 -52
  157. package/src/data-types/categories-type.ts +2 -2
  158. package/src/data-types/category-type.ts +2 -2
  159. package/src/db/models/sends.model.ts +2 -2
  160. package/src/db/models/template-versions.model.ts +5 -3
  161. package/src/db/tables/settings.table.ts +4 -0
  162. package/src/db/tables/templates.table.ts +1 -1
  163. package/src/engine/changes.ts +11 -29
  164. package/src/engine/conditions.ts +12 -4
  165. package/src/engine/coverage.ts +2 -1
  166. package/src/engine/formats.ts +31 -17
  167. package/src/engine/paths.ts +39 -6
  168. package/src/engine/plain-text.ts +2 -4
  169. package/src/engine/resolve.ts +12 -4
  170. package/src/engine/system-variables.ts +2 -1
  171. package/src/engine/tokens.ts +2 -1
  172. package/src/hooks/tenant-export.ts +15 -14
  173. package/src/implementations/dms-mailing/index.ts +1 -0
  174. package/src/pages/sends.ts +3 -2
  175. package/src/pages/templates.ts +3 -1
  176. package/src/routes/events.ts +7 -4
  177. package/src/routes/index.ts +1 -0
  178. package/src/routes/metrics.ts +30 -30
  179. package/src/routes/period.ts +5 -5
  180. package/src/routes/sends.ts +13 -8
  181. package/src/routes/settings.ts +34 -17
  182. package/src/routes/suppressions.ts +12 -8
  183. package/src/routes/template-content.ts +45 -0
  184. package/src/routes/templates.ts +11 -44
  185. package/src/routes/zod-json.ts +28 -0
  186. package/src/services/attention.ts +16 -15
  187. package/src/services/catalog.ts +4 -8
  188. package/src/services/declaration-error.ts +1 -1
  189. package/src/services/declarations.ts +4 -1
  190. package/src/services/events.ts +4 -2
  191. package/src/services/metrics.ts +3 -5
  192. package/src/services/send-import.ts +10 -8
  193. package/src/services/send.ts +3 -6
  194. package/src/services/settings.ts +55 -16
  195. package/src/services/starters.ts +8 -4
  196. package/src/services/templates.ts +16 -9
  197. package/src/services/webhook-secret-declaration.ts +47 -0
  198. package/src/services/webhook-status.ts +18 -9
  199. package/src/types/json.ts +1 -0
  200. package/src/types/settings.ts +16 -2
  201. package/src/validation/declarations.schema.ts +11 -3
  202. package/src/validation/guards.ts +0 -8
  203. package/src/validation/json.schema.ts +7 -0
  204. package/src/validation/json.ts +66 -0
  205. package/src/validation/templates.schema.ts +7 -3
@@ -0,0 +1,47 @@
1
+ import type {
2
+ DeclareWebhookSecretResult,
3
+ WebhookSecretDeclaration,
4
+ WebhookSecretOutcome,
5
+ } from "@antelopejs/interface-dms-mailing";
6
+ import type { MailingSettingsValues } from "../types";
7
+ import { webhookSecretDeclarationSchema } from "../validation/declarations.schema";
8
+ import { parseDeclared } from "./declaration-error";
9
+ import { webhookSecretMatches } from "./events";
10
+ import { getSettings, saveWebhookSecret } from "./settings";
11
+
12
+ const SET: WebhookSecretOutcome = "set";
13
+
14
+ /**
15
+ * What declaring `secret` does to the stored one: nothing when it is already
16
+ * stored, nothing when the workspace owner set or rotated it and the caller
17
+ * did not ask to replace it, else a write.
18
+ */
19
+ export function webhookSecretOutcome(
20
+ stored: Pick<MailingSettingsValues, "webhookSecret" | "webhookSecretSource">,
21
+ secret: string,
22
+ replaceRotated: boolean,
23
+ ): WebhookSecretOutcome {
24
+ if (webhookSecretMatches(secret, stored.webhookSecret)) return "unchanged";
25
+ if (stored.webhookSecretSource === "operator" && !replaceRotated)
26
+ return "kept";
27
+ return SET;
28
+ }
29
+
30
+ /**
31
+ * Sets the workspace's webhook secret a project ships as code. The secret is
32
+ * never returned, and a refusal names the rule it broke, never the value.
33
+ */
34
+ export async function declareWebhookSecret(
35
+ input: WebhookSecretDeclaration,
36
+ ): Promise<DeclareWebhookSecretResult> {
37
+ const { tenantId, secret, replaceRotated } = parseDeclared(
38
+ webhookSecretDeclarationSchema,
39
+ input,
40
+ "invalid_webhook_secret",
41
+ "webhook secret declaration",
42
+ );
43
+ const stored = await getSettings(tenantId);
44
+ const outcome = webhookSecretOutcome(stored, secret, replaceRotated);
45
+ if (outcome === SET) await saveWebhookSecret(tenantId, secret, "declared");
46
+ return { outcome };
47
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  WebhookRefusal,
8
8
  WebhookStatus,
9
9
  } from "../types";
10
+ import { isObject, isText } from "../validation/guards";
10
11
  import { webhookSecretMatches } from "./events";
11
12
  import { WEBHOOK_SECRET_HEADER } from "../constants";
12
13
 
@@ -28,11 +29,11 @@ export const WEBHOOK_REFUSAL_MESSAGES: Record<WebhookRefusal, string> = {
28
29
  };
29
30
 
30
31
  /** The body of a refused webhook call. */
31
- export interface WebhookRefusalBody {
32
+ export type WebhookRefusalBody = {
32
33
  error: "webhook_refused";
33
34
  reason: WebhookRefusal;
34
35
  message: string;
35
- }
36
+ };
36
37
 
37
38
  /** What "Send a test event" found when it called the webhook URL. */
38
39
  export interface WebhookTestOutcome {
@@ -143,17 +144,19 @@ export async function readWebhookStatus(
143
144
  );
144
145
  }
145
146
 
146
- const describeError = (error: unknown): string =>
147
- error instanceof Error
148
- ? String((error.cause as Error | undefined)?.message ?? error.message)
149
- : String(error);
147
+ function isWebhookRefusal(value: unknown): value is WebhookRefusal {
148
+ return isText(value) && Object.hasOwn(WEBHOOK_REFUSAL_MESSAGES, value);
149
+ }
150
150
 
151
+ /** The webhook's own refusal reason in a response, not one another endpoint gives. */
151
152
  async function refusalOf(
152
153
  response: Response,
153
154
  ): Promise<WebhookRefusal | undefined> {
154
155
  try {
155
- const body = (await response.json()) as Partial<WebhookRefusalBody>;
156
- return body.reason;
156
+ const body: unknown = await response.json();
157
+ return isObject(body) && isWebhookRefusal(body.reason)
158
+ ? body.reason
159
+ : undefined;
157
160
  } catch {
158
161
  return undefined;
159
162
  }
@@ -192,11 +195,17 @@ export async function sendWebhookTest(
192
195
  refusal: response.ok ? undefined : await refusalOf(response),
193
196
  };
194
197
  } catch (error) {
198
+ const cause = error instanceof Error ? error.cause : undefined;
199
+ const causeMessage =
200
+ isObject(cause) && isText(cause.message) ? cause.message : undefined;
195
201
  return {
196
202
  isReceived: false,
197
203
  url,
198
204
  durationMs: elapsed(),
199
- networkError: describeError(error),
205
+ networkError:
206
+ error instanceof Error
207
+ ? (causeMessage ?? error.message)
208
+ : String(error),
200
209
  };
201
210
  }
202
211
  }
@@ -0,0 +1 @@
1
+ export type { JsonObject, JsonValue } from "@antelopejs/interface-dms-mailing";
@@ -1,11 +1,11 @@
1
1
  import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
2
2
 
3
3
  /** A template category as stored: its label written once per language. */
4
- export interface TemplateCategory {
4
+ export type TemplateCategory = {
5
5
  id: string;
6
6
  label: LocalizedText;
7
7
  icon: string;
8
- }
8
+ };
9
9
 
10
10
  /** A template category as one reader sees it: the label in their language. */
11
11
  export interface CategoryInLocale {
@@ -14,6 +14,19 @@ export interface CategoryInLocale {
14
14
  icon: string;
15
15
  }
16
16
 
17
+ /**
18
+ * Who set the webhook secret: the module drew it (`generated`), a project
19
+ * declared it (`declared`), or the workspace owner set or rotated it in the
20
+ * settings (`operator`). A declaration replaces only the first two.
21
+ */
22
+ export const WEBHOOK_SECRET_SOURCES = [
23
+ "generated",
24
+ "declared",
25
+ "operator",
26
+ ] as const;
27
+
28
+ export type WebhookSecretSource = (typeof WEBHOOK_SECRET_SOURCES)[number];
29
+
17
30
  export interface MailingSettingsValues {
18
31
  fallbackLocale: string;
19
32
  logRetentionDays: number;
@@ -22,6 +35,7 @@ export interface MailingSettingsValues {
22
35
  senderEmail: string;
23
36
  replyTo: string;
24
37
  webhookSecret: string;
38
+ webhookSecretSource: WebhookSecretSource;
25
39
  categories: TemplateCategory[];
26
40
  }
27
41
 
@@ -8,6 +8,7 @@ import {
8
8
  templateContentSchema,
9
9
  variableListSchema,
10
10
  } from "./templates.schema";
11
+ import { jsonObjectSchema } from "./json.schema";
11
12
 
12
13
  const MAX_AUTHOR_LENGTH = MAX_NAME_LENGTH;
13
14
  const MAX_IMPORT_ID_LENGTH = 200;
@@ -36,7 +37,7 @@ export const templateDeclarationSchema = z.object({
36
37
  category: z.string().trim().default(""),
37
38
  content: templateContentSchema,
38
39
  variables: variableListSchema.default([]),
39
- testData: z.record(z.unknown()).default({}),
40
+ testData: jsonObjectSchema.default({}),
40
41
  author: z.string().trim().min(1).max(MAX_AUTHOR_LENGTH).optional(),
41
42
  });
42
43
 
@@ -67,6 +68,13 @@ export const senderDeclarationSchema = z.object({
67
68
 
68
69
  export type ParsedSenderDeclaration = z.infer<typeof senderDeclarationSchema>;
69
70
 
71
+ /** A webhook secret declared from code: the settings form's rule. */
72
+ export const webhookSecretDeclarationSchema = z.object({
73
+ tenantId: tenantIdSchema,
74
+ secret: settingsSchema.shape.webhookSecret,
75
+ replaceRotated: z.boolean().default(false),
76
+ });
77
+
70
78
  const recipientSchema = z.union([
71
79
  z.string().trim().email(),
72
80
  z.object({
@@ -78,7 +86,7 @@ const recipientSchema = z.union([
78
86
  const importedEventSchema = z.object({
79
87
  type: sendEventTypeSchema,
80
88
  at: z.coerce.date(),
81
- details: z.record(z.unknown()).optional(),
89
+ details: jsonObjectSchema.optional(),
82
90
  });
83
91
 
84
92
  const importedSendSchema = z.object({
@@ -87,7 +95,7 @@ const importedSendSchema = z.object({
87
95
  to: recipientSchema,
88
96
  sentAt: z.coerce.date(),
89
97
  locale: z.string().trim().min(1).optional(),
90
- variables: z.record(z.unknown()).default({}),
98
+ variables: jsonObjectSchema.default({}),
91
99
  provider: z.string().optional(),
92
100
  providerMessageId: z.string().optional(),
93
101
  source: z.string().optional(),
@@ -3,14 +3,6 @@ export function isText(value: unknown): value is string {
3
3
  return typeof value === "string";
4
4
  }
5
5
 
6
- /**
7
- * Whether `value` is a JSON object as a parsed body holds one: an object,
8
- * neither `null` nor an array. Its values are not checked.
9
- */
10
- export function isJsonObject(value: unknown): value is Record<string, unknown> {
11
- return typeof value === "object" && value !== null && !Array.isArray(value);
12
- }
13
-
14
6
  /**
15
7
  * Whether `value` is an object, arrays included, and not `null`: reading any
16
8
  * key of it yields a value of unknown type.
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ import { isJsonObject } from "./json";
3
+
4
+ /** A JSON object whose every value is JSON data, such as a request body carries. */
5
+ export const jsonObjectSchema = z
6
+ .unknown()
7
+ .refine(isJsonObject, "expected a JSON object");
@@ -0,0 +1,66 @@
1
+ import type { JsonObject, JsonValue } from "../types/json";
2
+ import { isNumber, isObject, isText } from "./guards";
3
+
4
+ /** A step of the walk in {@link isJsonValue}: visit a node, or leave one. */
5
+ type JsonWalkStep = { visit: unknown } | { leave: object };
6
+
7
+ /**
8
+ * Whether `value` is wholly JSON data: strings, numbers, booleans, null, and
9
+ * arrays and plain objects of the same, none of them reached through itself.
10
+ * Walked with a stack, so a deeply nested value cannot overflow it.
11
+ */
12
+ export function isJsonValue(value: unknown): value is JsonValue {
13
+ const stack: JsonWalkStep[] = [{ visit: value }];
14
+ const ancestors = new Set<object>();
15
+ for (let step = stack.pop(); step; step = stack.pop()) {
16
+ if ("leave" in step) {
17
+ ancestors.delete(step.leave);
18
+ continue;
19
+ }
20
+ const item = step.visit;
21
+ if (
22
+ item === null ||
23
+ isText(item) ||
24
+ isNumber(item) ||
25
+ typeof item === "boolean"
26
+ )
27
+ continue;
28
+ if (typeof item !== "object" || ancestors.has(item)) return false;
29
+ const prototype: unknown = Object.getPrototypeOf(item);
30
+ if (
31
+ !Array.isArray(item) &&
32
+ prototype !== Object.prototype &&
33
+ prototype !== null
34
+ )
35
+ return false;
36
+ ancestors.add(item);
37
+ stack.push({ leave: item });
38
+ for (const child of Object.values(item)) stack.push({ visit: child });
39
+ }
40
+ return true;
41
+ }
42
+
43
+ /** Whether `value` is wholly JSON data and an object: neither `null` nor an array. */
44
+ export function isJsonObject(value: unknown): value is JsonObject {
45
+ return (
46
+ typeof value === "object" &&
47
+ value !== null &&
48
+ !Array.isArray(value) &&
49
+ isJsonValue(value)
50
+ );
51
+ }
52
+
53
+ /** `text` parsed as JSON; a `SyntaxError` when it is not. */
54
+ export function parseJson(text: string): JsonValue {
55
+ const parsed: unknown = JSON.parse(text);
56
+ if (!isJsonValue(parsed)) throw new TypeError("expected JSON data");
57
+ return parsed;
58
+ }
59
+
60
+ /** `text` parsed as a JSON object. */
61
+ export function parseJsonObject(text: string): JsonObject {
62
+ const parsed = parseJson(text);
63
+ if (!isObject(parsed) || Array.isArray(parsed))
64
+ throw new TypeError("expected a JSON object");
65
+ return parsed;
66
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_NAME_LENGTH, MAX_SLUG_LENGTH, SLUG_PATTERN } from "../constants";
3
3
  import type { Block } from "../types";
4
+ import { jsonObjectSchema } from "./json.schema";
4
5
 
5
6
  const MIN_HEADING_SIZE = 12;
6
7
  const MAX_HEADING_SIZE = 48;
@@ -149,7 +150,7 @@ export const saveContentSchema = z.object({
149
150
  revision: draftRevisionSchema,
150
151
  });
151
152
 
152
- export const testDataSchema = z.object({ data: z.record(z.unknown()) });
153
+ export const testDataSchema = z.object({ data: jsonObjectSchema });
153
154
 
154
155
  /**
155
156
  * Which content a preview renders when it carries none: the working draft
@@ -166,7 +167,7 @@ export const previewSchema = z.object({
166
167
  locale: z.string().min(1).optional(),
167
168
  version: previewVersionSchema.optional(),
168
169
  content: templateContentSchema.optional(),
169
- data: z.record(z.unknown()).optional(),
170
+ data: jsonObjectSchema.optional(),
170
171
  });
171
172
 
172
173
  /**
@@ -176,7 +177,7 @@ export const previewSchema = z.object({
176
177
  export const realSendSchema = z.object({
177
178
  to: z.array(z.string().email()).min(1).max(MAX_TEST_RECIPIENT_COUNT),
178
179
  locale: z.string().min(1).optional(),
179
- data: z.record(z.unknown()).optional(),
180
+ data: jsonObjectSchema.optional(),
180
181
  });
181
182
 
182
183
  export const testSendSchema = previewSchema.extend({
@@ -193,6 +194,9 @@ export const templateDetailsSchema = z.object({
193
194
  category: z.string().nullable().optional(),
194
195
  });
195
196
 
197
+ /** The details a template's drawer or the editor saves, validated. */
198
+ export type TemplateDetails = z.infer<typeof templateDetailsSchema>;
199
+
196
200
  export const duplicateSchema = z.object({
197
201
  slug: slugSchema,
198
202
  name: z.string().trim().min(1).max(MAX_NAME_LENGTH),