@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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.
Files changed (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
@@ -0,0 +1,106 @@
1
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ import * as z from "zod";
3
+ import { CONTENT_FORMATS, type ContentFormat } from "./constants.js";
4
+ import type { TemplateResourceRow } from "./table.js";
5
+
6
+ const templateDraftSchema = z.object({
7
+ content: z.string().nullable(),
8
+ contentFormat: z.enum(CONTENT_FORMATS),
9
+ variableSchema: z.string(),
10
+ meta: z.string().nullable(),
11
+ title: z.string().nullable(),
12
+ folder: z.string().nullable(),
13
+ parentTemplateId: z.string().nullable(),
14
+ });
15
+
16
+ // An unpublished edit beside the active version; variableSchema and meta stay
17
+ // JSON strings, exactly like the columns they replace on publish.
18
+ export type TemplateDraft = {
19
+ readonly content: string | null;
20
+ readonly contentFormat: ContentFormat;
21
+ readonly variableSchema: string;
22
+ readonly meta: string | null;
23
+ readonly title: string | null;
24
+ readonly folder: string | null;
25
+ readonly parentTemplateId: string | null;
26
+ };
27
+
28
+ export function parseTemplateDraft(raw: string | null): TemplateDraft | null {
29
+ if (raw === null) return null;
30
+ try {
31
+ const parsed = templateDraftSchema.safeParse(JSON.parse(raw));
32
+ return parsed.success ? parsed.data : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function serializeTemplateDraft(draft: TemplateDraft): string {
39
+ return JSON.stringify(draft);
40
+ }
41
+
42
+ export function activeVersionOf(row: TemplateResourceRow): number {
43
+ return row.activeVersion ?? row.version;
44
+ }
45
+
46
+ // `version` also counts draft saves, so only a row without a served version shows it as its number.
47
+ export function servedVersionOf(row: TemplateResourceRow): number {
48
+ return row.status === "active" ? activeVersionOf(row) : row.version;
49
+ }
50
+
51
+ export function activeSinceOf(row: TemplateResourceRow): Temporal.Instant {
52
+ return row.activeSince ?? row.modifiedAt ?? row.insertedAt;
53
+ }
54
+
55
+ export type ActiveInfo = { readonly version: number; readonly since: Temporal.Instant };
56
+
57
+ export type WorkingRow = TemplateResourceRow & { readonly active: ActiveInfo | null };
58
+
59
+ // A SystemAdmin edits system rows, so for them those rows count as their own.
60
+ function isSystemAdmin(user: { readonly roles: readonly string[] }): boolean {
61
+ return user.roles.includes("SystemAdmin");
62
+ }
63
+
64
+ // A system row that was never served keeps its draft in the main fields, so only a SystemAdmin may read it.
65
+ export function isHiddenSystemDraft(
66
+ user: { readonly roles: readonly string[] },
67
+ row: Pick<TemplateResourceRow, "scope" | "status">,
68
+ ): boolean {
69
+ return row.scope === "system" && row.status === "draft" && !isSystemAdmin(user);
70
+ }
71
+
72
+ export function draftViewerTenantId(
73
+ user: { readonly tenantId: string; readonly roles: readonly string[] },
74
+ row: TemplateResourceRow,
75
+ ): string {
76
+ return row.scope === "system" && isSystemAdmin(user) ? row.tenantId : user.tenantId;
77
+ }
78
+
79
+ // The version an editor works on: the open draft if there is one, else the stored row.
80
+ // A draft is only shown to the tenant that owns the row, never through a system row.
81
+ export function toWorkingRow(row: TemplateResourceRow, viewerTenantId: string): WorkingRow {
82
+ const active =
83
+ row.status === "active" ? { version: activeVersionOf(row), since: activeSinceOf(row) } : null;
84
+ const draft = row.tenantId === viewerTenantId ? parseTemplateDraft(row.draft) : null;
85
+ // Saves and discards of drafts raise row.version; an editor without a draft shows the served number.
86
+ if (draft === null) return { ...row, version: active?.version ?? row.version, active };
87
+ return { ...row, ...draft, status: "draft", active };
88
+ }
89
+
90
+ // Event changes that turn a row into a served one. Every value is explicit so a
91
+ // rebuild reproduces the row without deriving anything.
92
+ export function servedStampChanges(row: { readonly version: number } | null) {
93
+ return {
94
+ activeVersion: row === null ? 1 : row.version + 1,
95
+ activeSince: Temporal.Now.instant(),
96
+ };
97
+ }
98
+
99
+ export function activeStampChanges(row: { readonly version: number } | null) {
100
+ return { status: "active", ...servedStampChanges(row) };
101
+ }
102
+
103
+ // Publishing also consumes the open draft.
104
+ export function activationChanges(row: TemplateResourceRow | null) {
105
+ return { ...activeStampChanges(row), draft: null };
106
+ }
@@ -1,5 +1,10 @@
1
1
  import * as z from "zod";
2
- import { PDF_PAGE_FORMATS, type TemplateKind } from "./constants.js";
2
+ import {
3
+ HEX_COLOR_PATTERN,
4
+ PDF_ORIENTATIONS,
5
+ PDF_PAGE_FORMATS,
6
+ type TemplateKind,
7
+ } from "./constants.js";
3
8
 
4
9
  // One or more kebab segments joined by ":" — notification templates are seeded
5
10
  // under their qualified name `feature:notify:name`.
@@ -22,21 +27,26 @@ const singleLine = (max: number) =>
22
27
  .max(max)
23
28
  .refine((value) => !/[\r\n]/.test(value), "must not contain line breaks");
24
29
 
25
- const mailMetaSchema = z
26
- .object({
27
- subject: singleLine(200).min(1),
28
- previewText: singleLine(200).optional(),
29
- layoutSlug: templateSlugSchema.optional(),
30
- category: z.enum(MAIL_CATEGORIES).optional(),
31
- sender: z
32
- .string()
33
- .max(60)
34
- .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "sender must be a kebab-case key")
35
- .optional(),
36
- })
37
- .strict();
30
+ // Rows saved before the sender key was dropped still carry it; discard it on read so they stay editable.
31
+ function withoutLegacySender(raw: unknown): unknown {
32
+ if (typeof raw !== "object" || raw === null || !("sender" in raw)) return raw;
33
+ const { sender: _legacySender, ...rest } = raw;
34
+ return rest;
35
+ }
38
36
 
39
- const PDF_ORIENTATIONS = ["portrait", "landscape"] as const;
37
+ const mailMetaSchema = z.preprocess(
38
+ withoutLegacySender,
39
+ z
40
+ .object({
41
+ subject: singleLine(200).min(1),
42
+ previewText: singleLine(200).optional(),
43
+ layoutSlug: templateSlugSchema.optional(),
44
+ category: z.enum(MAIL_CATEGORIES).optional(),
45
+ senderName: singleLine(80).min(1).optional(),
46
+ replyTo: singleLine(254).pipe(z.email()).optional(),
47
+ })
48
+ .strict(),
49
+ );
40
50
 
41
51
  const marginMm = z.number().finite().min(0).max(100).optional();
42
52
 
@@ -71,11 +81,26 @@ const pdfMetaSchema = z
71
81
  })
72
82
  .strict();
73
83
 
74
- const layoutMetaSchema = z.object({}).strict();
84
+ const MAIL_LAYOUT_FONTS = ["sans", "serif"] as const;
85
+ const MAIL_LAYOUT_ACCENT_LINES = ["branding", "none"] as const;
86
+ const MAIL_LAYOUT_BUTTON_STYLES = ["filled-rounded", "filled-square", "outline"] as const;
87
+
88
+ const mailLayoutMetaSchema = z
89
+ .object({
90
+ width: z.union([z.literal(560), z.literal(600), z.literal(640)]).optional(),
91
+ font: z.enum(MAIL_LAYOUT_FONTS).optional(),
92
+ outerBackground: z.string().regex(HEX_COLOR_PATTERN, "must be a #rrggbb color").optional(),
93
+ accentLine: z.enum(MAIL_LAYOUT_ACCENT_LINES).optional(),
94
+ buttonStyle: z.enum(MAIL_LAYOUT_BUTTON_STYLES).optional(),
95
+ })
96
+ .strict();
97
+
98
+ const pdfLayoutMetaSchema = z.object({}).strict();
75
99
 
76
100
  export type MailTemplateMeta = z.infer<typeof mailMetaSchema>;
77
101
  export type PdfTemplateMeta = z.infer<typeof pdfMetaSchema>;
78
- export type LayoutTemplateMeta = z.infer<typeof layoutMetaSchema>;
102
+ export type MailLayoutMeta = z.infer<typeof mailLayoutMetaSchema>;
103
+ export type LayoutTemplateMeta = MailLayoutMeta | z.infer<typeof pdfLayoutMetaSchema>;
79
104
  export type TemplateMeta = MailTemplateMeta | PdfTemplateMeta | LayoutTemplateMeta;
80
105
 
81
106
  export function isMailTemplateMeta(meta: TemplateMeta | null): meta is MailTemplateMeta {
@@ -87,7 +112,29 @@ export function readPdfTemplateMeta(meta: TemplateMeta | null): PdfTemplateMeta
87
112
  return parsed.success ? parsed.data : null;
88
113
  }
89
114
 
90
- export type TemplateMetaIssue = { readonly path: string; readonly message: string };
115
+ export function readMailLayoutMeta(meta: unknown): MailLayoutMeta {
116
+ const parsed = mailLayoutMetaSchema.safeParse(meta ?? {});
117
+ return parsed.success ? parsed.data : {};
118
+ }
119
+
120
+ export type TemplateMetaIssue = {
121
+ readonly path: string;
122
+ /** The setting the issue is about (`subject`), `meta` when it concerns the object as a whole. */
123
+ readonly field: string;
124
+ /** The value is absent or empty, as opposed to present and wrong. */
125
+ readonly missing: boolean;
126
+ /** Parser wording; for logs and tests, never for the UI. */
127
+ readonly message: string;
128
+ };
129
+
130
+ function valueAt(raw: unknown, path: readonly PropertyKey[]): unknown {
131
+ let current: unknown = raw;
132
+ for (const key of path) {
133
+ if (typeof current !== "object" || current === null) return undefined;
134
+ current = Object.entries(current).find(([name]) => name === String(key))?.[1];
135
+ }
136
+ return current;
137
+ }
91
138
 
92
139
  export type TemplateMetaResult =
93
140
  | { readonly ok: true; readonly meta: TemplateMeta | null }
@@ -100,8 +147,9 @@ function schemaForKind(kind: TemplateKind) {
100
147
  case "document-pdf":
101
148
  return pdfMetaSchema;
102
149
  case "mail-layout":
150
+ return mailLayoutMetaSchema;
103
151
  case "pdf-layout":
104
- return layoutMetaSchema;
152
+ return pdfLayoutMetaSchema;
105
153
  default:
106
154
  return null;
107
155
  }
@@ -114,16 +162,31 @@ export function parseTemplateMeta(kind: TemplateKind, raw: unknown): TemplateMet
114
162
  if (schema === null) {
115
163
  return raw === undefined || raw === null
116
164
  ? { ok: true, meta: null }
117
- : { ok: false, issues: [{ path: "meta", message: `kind ${kind} does not take meta` }] };
165
+ : {
166
+ ok: false,
167
+ issues: [
168
+ {
169
+ path: "meta",
170
+ field: "meta",
171
+ missing: false,
172
+ message: `kind ${kind} does not take meta`,
173
+ },
174
+ ],
175
+ };
118
176
  }
119
177
  if ((raw === undefined || raw === null) && kind !== "mail") return { ok: true, meta: null };
120
178
  const parsed = schema.safeParse(raw ?? undefined);
121
179
  if (parsed.success) return { ok: true, meta: parsed.data };
122
180
  return {
123
181
  ok: false,
124
- issues: parsed.error.issues.map((issue) => ({
125
- path: ["meta", ...issue.path.map(String)].join("."),
126
- message: issue.message,
127
- })),
182
+ issues: parsed.error.issues.map((issue) => {
183
+ const value = valueAt(raw, issue.path);
184
+ return {
185
+ path: ["meta", ...issue.path.map(String)].join("."),
186
+ field: issue.path.length === 0 ? "meta" : String(issue.path[0]),
187
+ missing: value === undefined || value === "",
188
+ message: issue.message,
189
+ };
190
+ }),
128
191
  };
129
192
  }
@@ -13,6 +13,7 @@ import {
13
13
  ValidationError,
14
14
  } from "@cosmicdrift/kumiko-framework/errors";
15
15
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
16
+ import { escapeHtml } from "@cosmicdrift/kumiko-headless";
16
17
  import { createEmailAttachmentReader, createTemplateMailRenderer } from "../delivery/index.js";
17
18
  import { fileContextForTenant } from "../file-foundation/index.js";
18
19
  import {
@@ -24,10 +25,20 @@ import {
24
25
  } from "../renderer-foundation/index.js";
25
26
  import { createTemplateResolverApi, TemplateNotFoundError, type TemplateResource } from "./api.js";
26
27
  import { collectDocumentResourceKeys } from "./compile-document-body.js";
27
- import { MAIL_PREVIEW_MAX_BYTES, SYSTEM_TENANT_ID, type TemplateScope } from "./constants.js";
28
+ import {
29
+ DEFAULT_MAIL_LAYOUT_SLUG,
30
+ MAIL_PREVIEW_MAX_BYTES,
31
+ SYSTEM_TENANT_ID,
32
+ type TemplateScope,
33
+ } from "./constants.js";
28
34
  import { parseStoredJson } from "./handlers/template-gates.js";
29
35
  import { validateTemplateDraft } from "./handlers/validate.query.js";
30
- import { type LayoutRegions, readLayoutRegions, sanitizeLayoutHtml } from "./layout-regions.js";
36
+ import {
37
+ type LayoutRegions,
38
+ readLayoutRegions,
39
+ sanitizeLayoutHtml,
40
+ type TemplateWriteScope,
41
+ } from "./layout-regions.js";
31
42
  import { type LinkedResourceEntry, parseLinkedResourceEntries } from "./linked-resources.js";
32
43
  import { buildDisplayHtml } from "./preview-display-html.js";
33
44
  import { TemplateResolverErrors } from "./qualified-names.js";
@@ -42,7 +53,7 @@ import {
42
53
 
43
54
  const SAMPLE_LINK = "https://example.com";
44
55
  const SAMPLE_DATE = "2026-01-15";
45
- const SAMPLE_LIST_ROWS = 2;
56
+ const SAMPLE_LIST_ROWS = 3;
46
57
  const DEFAULT_PDF_LAYOUT_SLUG = "default";
47
58
 
48
59
  function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
@@ -50,6 +61,8 @@ function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
50
61
  switch (def.type) {
51
62
  case "text":
52
63
  return `[${def.label ?? name}]`;
64
+ case "richText":
65
+ return `<p>[${escapeHtml(def.label ?? name)}]</p>`;
53
66
  case "link":
54
67
  return SAMPLE_LINK;
55
68
  case "money":
@@ -141,6 +154,12 @@ export type TemplatePreview = {
141
154
  readonly text: string | null;
142
155
  readonly attachments: readonly PreviewAttachment[];
143
156
  readonly layout: PreviewLayout | null;
157
+ /** Mail only: which layout version the html was rendered through. */
158
+ readonly mailLayout?: {
159
+ readonly slug: string;
160
+ readonly scope: TemplateScope;
161
+ readonly version: number;
162
+ } | null;
144
163
  readonly sampleValues: Readonly<Record<string, unknown>>;
145
164
  readonly checks: readonly PreviewCheck[];
146
165
  readonly validation: ValidationResponse;
@@ -152,6 +171,7 @@ export type TemplatePreviewOutcome =
152
171
  readonly preview: TemplatePreview;
153
172
  /** What the send path needs to attach; kept out of the response shown to the editor. */
154
173
  readonly mailAttachments: readonly MailAttachmentRef[];
174
+ readonly sender: { readonly fromName?: string; readonly replyTo?: string };
155
175
  }
156
176
  | { readonly ok: false; readonly error: KumikoError };
157
177
 
@@ -180,7 +200,7 @@ async function loadStoredDraft(
180
200
  async function describeAttachments(
181
201
  ctx: HandlerContext,
182
202
  refs: readonly MailAttachmentRef[],
183
- ): Promise<PreviewAttachment[]> {
203
+ ): Promise<{ readonly attachments: PreviewAttachment[]; readonly missing: string[] }> {
184
204
  const fileRefIds = refs.flatMap((ref) => (ref.kind === "rendered-pdf" ? [] : [ref.fileRefId]));
185
205
  const rows =
186
206
  fileRefIds.length === 0
@@ -191,7 +211,14 @@ async function describeAttachments(
191
211
  const sizeById = new Map(rows.map((row) => [row.id, Number(row.size)]));
192
212
  // An inline image is named by its content id because the stored file name is personal data and
193
213
  // may be ciphertext; a file attachment carries the name the admin gave it.
194
- return refs.map((ref) => previewAttachmentOf(ref, sizeById));
214
+ const attachments = refs.map((ref) => previewAttachmentOf(ref, sizeById));
215
+ // A fileRef without a row has no known size: it is reported, not counted as 0 bytes.
216
+ const missing = refs.flatMap((ref, index) =>
217
+ ref.kind !== "rendered-pdf" && !sizeById.has(ref.fileRefId)
218
+ ? [attachments[index]?.name ?? ""]
219
+ : [],
220
+ );
221
+ return { attachments, missing };
195
222
  }
196
223
 
197
224
  function previewAttachmentOf(
@@ -213,8 +240,28 @@ function previewAttachmentOf(
213
240
  }
214
241
  }
215
242
 
243
+ // Part headers and multipart boundary lines around one attachment in the MIME message.
244
+ export const MIME_PART_OVERHEAD_BYTES = 512;
245
+
246
+ // The provider cap applies to the encoded message: attachments travel base64-encoded.
247
+ export function encodedAttachmentBytes(rawBytes: number): number {
248
+ return Math.ceil(rawBytes / 3) * 4 + MIME_PART_OVERHEAD_BYTES;
249
+ }
250
+
216
251
  const attachmentSizeOf = (attachment: PreviewAttachment): number =>
217
- attachment.kind === "rendered-pdf" ? 0 : attachment.sizeBytes;
252
+ attachment.kind === "rendered-pdf" ? 0 : encodedAttachmentBytes(attachment.sizeBytes);
253
+
254
+ export function estimateMailBytes(args: {
255
+ readonly html: string;
256
+ readonly text: string;
257
+ readonly attachments: readonly PreviewAttachment[];
258
+ }): number {
259
+ return (
260
+ utf8Bytes(args.html) +
261
+ utf8Bytes(args.text) +
262
+ args.attachments.reduce((sum, attachment) => sum + attachmentSizeOf(attachment), 0)
263
+ );
264
+ }
218
265
 
219
266
  // The row an editor is working on, whatever its status: the stored template only resolves when
220
267
  // active, so a draft would otherwise lose the images and attachments uploaded to it.
@@ -328,6 +375,7 @@ async function renderDocumentPreview(
328
375
  return {
329
376
  ok: true,
330
377
  mailAttachments: [],
378
+ sender: {},
331
379
  preview: {
332
380
  kind: "document-pdf",
333
381
  subject: null,
@@ -362,7 +410,8 @@ async function renderDocumentPreview(
362
410
  registry: ctx.registry,
363
411
  tenantId: user.tenantId,
364
412
  ...(ctx.config !== undefined && { config: ctx.config }),
365
- filesForTenant: (fileTenantId) => fileContextForTenant(ctx, fileTenantId),
413
+ filesForTenant: (fileTenantId) =>
414
+ fileContextForTenant(ctx, ctx.user.tenantId, fileTenantId),
366
415
  },
367
416
  );
368
417
  } catch (e) {
@@ -372,6 +421,7 @@ async function renderDocumentPreview(
372
421
  return {
373
422
  ok: true,
374
423
  mailAttachments: [],
424
+ sender: {},
375
425
  preview: {
376
426
  kind: "document-pdf",
377
427
  subject: null,
@@ -409,11 +459,12 @@ export type DocumentDraftOutcome =
409
459
  }
410
460
  | { readonly ok: false; readonly error: KumikoError };
411
461
 
412
- // A document draft is always tenant-scoped here, like in the preview: it passes the write gates
413
- // and a failing draft is rejected with the same problems as a save.
462
+ // The caller derives `scope` once from the role (SystemAdmin edits the system row), so the write
463
+ // gates and the print see the same row: a failing draft is rejected with the same problems as a save.
414
464
  export async function evaluateDocumentDraft(
415
465
  ctx: HandlerContext,
416
466
  user: SessionUser,
467
+ scope: TemplateWriteScope,
417
468
  draft: {
418
469
  readonly slug: string;
419
470
  readonly locale: string;
@@ -430,7 +481,7 @@ export async function evaluateDocumentDraft(
430
481
  meta: draft.meta,
431
482
  variableSchema: draft.variableSchema,
432
483
  locale: draft.locale,
433
- scope: "tenant",
484
+ scope,
434
485
  });
435
486
  if (!evaluation.ok) {
436
487
  return { ok: false, error: new ValidationError({ fields: [...evaluation.issues] }) };
@@ -445,51 +496,39 @@ export async function evaluateDocumentDraft(
445
496
  };
446
497
  }
447
498
 
448
- // Renders a draft (or, without content, the stored template) with sample values, going through
449
- // the same write gates and the same mail renderer as a real send. Nothing is stored or sent.
450
- export async function renderTemplatePreview(
499
+ type DraftSource = {
500
+ readonly content: string;
501
+ readonly meta: unknown;
502
+ readonly variableSchema: Readonly<Record<string, unknown>>;
503
+ };
504
+
505
+ // The posted draft when it carries content, else the stored template; null when none is stored.
506
+ async function resolveDraftSource(
451
507
  ctx: HandlerContext,
452
508
  user: SessionUser,
453
509
  input: TemplatePreviewInput,
454
- ): Promise<TemplatePreviewOutcome> {
455
- let draftContent = input.content;
456
- let draftMeta: unknown = input.meta;
457
- let draftVariableSchema = input.variableSchema;
458
- if (draftContent === undefined) {
459
- const stored = await loadStoredDraft(ctx, user, input);
460
- if (stored === null) return failed(new NotFoundError("template-resource", input.slug));
461
- draftContent = stored.content;
462
- draftMeta = stored.meta;
463
- draftVariableSchema = stored.variableSchema;
510
+ ): Promise<DraftSource | null> {
511
+ if (input.content !== undefined) {
512
+ return { content: input.content, meta: input.meta, variableSchema: input.variableSchema };
464
513
  }
514
+ const stored = await loadStoredDraft(ctx, user, input);
515
+ if (stored === null) return null;
516
+ return { content: stored.content, meta: stored.meta, variableSchema: stored.variableSchema };
517
+ }
465
518
 
466
- const { response: validation, evaluation } = await validateTemplateDraft(ctx, user, {
467
- slug: input.slug,
468
- kind: input.kind,
469
- contentFormat: "html",
470
- content: draftContent,
471
- meta: draftMeta,
519
+ async function renderMailPreview(
520
+ ctx: HandlerContext,
521
+ user: SessionUser,
522
+ args: DocumentPreviewArgs,
523
+ ): Promise<TemplatePreviewOutcome> {
524
+ const {
525
+ input,
526
+ content,
527
+ meta,
528
+ validation,
529
+ sampleValues,
472
530
  variableSchema: draftVariableSchema,
473
- locale: input.locale,
474
- scope: "tenant",
475
- });
476
- if (!evaluation.ok) return failed(new ValidationError({ fields: [...evaluation.issues] }));
477
-
478
- const content = evaluation.content ?? "";
479
- const meta = parseTemplateMeta(input.kind, parseStoredJson(evaluation.metaJson));
480
- const sampleValues = sampleValuesFor(validation.variables);
481
-
482
- if (input.kind === "document-pdf") {
483
- return renderDocumentPreview(ctx, user, {
484
- input,
485
- content,
486
- meta,
487
- variableSchema: draftVariableSchema,
488
- sampleValues,
489
- validation,
490
- });
491
- }
492
-
531
+ } = args;
493
532
  if (!meta.ok || !isMailTemplateMeta(meta.meta)) {
494
533
  return failed(
495
534
  new UnprocessableError(TemplateResolverErrors.previewRenderFailed, {
@@ -525,23 +564,27 @@ export async function renderTemplatePreview(
525
564
 
526
565
  const mailAttachments = rendered.attachments ?? [];
527
566
  const inline = mailAttachments.filter((ref) => ref.kind === "inline");
528
- const attachments = await describeAttachments(ctx, mailAttachments);
567
+ const { attachments, missing: missingAttachments } = await describeAttachments(
568
+ ctx,
569
+ mailAttachments,
570
+ );
529
571
  const displayHtml = await buildDisplayHtml(
530
572
  rendered.html,
531
573
  inline,
532
574
  createEmailAttachmentReader(ctx.db, ctx.registry, user.tenantId, ctx.config, (tenantId) =>
533
- fileContextForTenant(ctx, tenantId),
575
+ fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
534
576
  ),
535
577
  );
536
578
  const resolvedCids = new Set(inline.map((ref) => ref.cid));
537
579
  const unresolved = collectDocumentResourceKeys(content).filter((key) => !resolvedCids.has(key));
538
- const totalBytes =
539
- utf8Bytes(rendered.html) +
540
- utf8Bytes(rendered.text) +
541
- attachments.reduce((sum, attachment) => sum + attachmentSizeOf(attachment), 0);
580
+ const totalBytes = estimateMailBytes({ html: rendered.html, text: rendered.text, attachments });
542
581
  return {
543
582
  ok: true,
544
583
  mailAttachments,
584
+ sender: {
585
+ ...(rendered.fromName !== undefined && { fromName: rendered.fromName }),
586
+ ...(rendered.replyTo !== undefined && { replyTo: rendered.replyTo }),
587
+ },
545
588
  preview: {
546
589
  kind: "mail",
547
590
  subject: rendered.subject,
@@ -551,9 +594,17 @@ export async function renderTemplatePreview(
551
594
  text: rendered.text,
552
595
  attachments,
553
596
  layout: null,
597
+ mailLayout:
598
+ rendered.layout === undefined
599
+ ? null
600
+ : {
601
+ slug: mailMeta.layoutSlug ?? DEFAULT_MAIL_LAYOUT_SLUG,
602
+ scope: rendered.layout.scope,
603
+ version: rendered.layout.templateVersion,
604
+ },
554
605
  sampleValues,
555
606
  checks: [
556
- missingResourceCheck(unresolved),
607
+ missingResourceCheck([...unresolved, ...missingAttachments]),
557
608
  {
558
609
  code: "text_present",
559
610
  ok: rendered.text.trim() !== "",
@@ -571,3 +622,55 @@ export async function renderTemplatePreview(
571
622
  },
572
623
  };
573
624
  }
625
+
626
+ // Renders a draft (or, without content, the stored template) with sample values, going through
627
+ // the same write gates and the same mail renderer as a real send. Nothing is stored or sent.
628
+ export async function renderTemplatePreview(
629
+ ctx: HandlerContext,
630
+ user: SessionUser,
631
+ input: TemplatePreviewInput,
632
+ ): Promise<TemplatePreviewOutcome> {
633
+ const draftSource = await resolveDraftSource(ctx, user, input);
634
+ if (draftSource === null) return failed(new NotFoundError("template-resource", input.slug));
635
+ const {
636
+ content: draftContent,
637
+ meta: draftMeta,
638
+ variableSchema: draftVariableSchema,
639
+ } = draftSource;
640
+
641
+ const { response: validation, evaluation } = await validateTemplateDraft(ctx, user, {
642
+ slug: input.slug,
643
+ kind: input.kind,
644
+ contentFormat: "html",
645
+ content: draftContent,
646
+ meta: draftMeta,
647
+ variableSchema: draftVariableSchema,
648
+ locale: input.locale,
649
+ scope: "tenant",
650
+ });
651
+ if (!evaluation.ok) return failed(new ValidationError({ fields: [...evaluation.issues] }));
652
+
653
+ const content = evaluation.content ?? "";
654
+ const meta = parseTemplateMeta(input.kind, parseStoredJson(evaluation.metaJson));
655
+ const sampleValues = sampleValuesFor(validation.variables);
656
+
657
+ if (input.kind === "document-pdf") {
658
+ return renderDocumentPreview(ctx, user, {
659
+ input,
660
+ content,
661
+ meta,
662
+ variableSchema: draftVariableSchema,
663
+ sampleValues,
664
+ validation,
665
+ });
666
+ }
667
+
668
+ return renderMailPreview(ctx, user, {
669
+ input,
670
+ content,
671
+ meta,
672
+ variableSchema: draftVariableSchema,
673
+ sampleValues,
674
+ validation,
675
+ });
676
+ }
@@ -6,7 +6,8 @@ import type {
6
6
  WriteResult,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
- import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
9
+ import { createFileRefFromBytes, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
10
+ import { fileContextForTenant } from "../file-foundation/index.js";
10
11
  import { requireForTenant } from "../shared/index.js";
11
12
  import { executor } from "./handlers/shared.js";
12
13
  import type { LinkedResourceEntry } from "./linked-resources.js";
@@ -35,13 +36,52 @@ export async function readTemplateResource(
35
36
  id: fileRefId,
36
37
  isDeleted: false,
37
38
  });
38
- // ctx.files only reaches the caller's own key space.
39
- if (!row?.storageKey.startsWith(`${ctx.user.tenantId}/`)) return null;
40
- const handle = ctx.files.ref(row.storageKey);
39
+ if (!row) return null;
40
+ const handle = ctx.files.refFromFileRef(row);
41
41
  if (!(await handle.exists())) return null;
42
42
  return { fileName: row.fileName, contentType: row.mimeType, bytes: await handle.read() };
43
43
  }
44
44
 
45
+ // Gives the target tenant its own copy of every file behind the entries, so a template that was
46
+ // copied from the system default keeps working once the tenant exports it or measures its
47
+ // attachments. Rendered-PDF links carry no file and pass through. An entry whose source file is
48
+ // gone is left out: it could not be resolved at render time either.
49
+ export async function duplicateLinkedResourceFiles(
50
+ ctx: HandlerContext,
51
+ args: {
52
+ readonly user: SessionUser;
53
+ readonly tenantId: TenantId;
54
+ readonly resources: Readonly<Record<string, LinkedResourceEntry>>;
55
+ },
56
+ ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
57
+ const { user, tenantId } = args;
58
+ const { db } = requireForTenant(ctx, tenantId);
59
+ const copies: Record<string, LinkedResourceEntry> = {};
60
+ for (const [key, entry] of Object.entries(args.resources)) {
61
+ if (typeof entry !== "string" && entry.kind === "rendered-pdf") {
62
+ copies[key] = entry;
63
+ continue;
64
+ }
65
+ const sourceFileRefId = typeof entry === "string" ? entry : entry.fileRefId;
66
+ const file = await readTemplateResource(ctx, sourceFileRefId);
67
+ // skip: the source file is gone, there is nothing to copy
68
+ if (file === null) continue;
69
+ const created = await createFileRefFromBytes(
70
+ {
71
+ db,
72
+ files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
73
+ registry: ctx.registry,
74
+ user: { ...user, tenantId },
75
+ },
76
+ { fileName: file.fileName, mimeType: file.contentType, data: file.bytes },
77
+ );
78
+ if (!created.isSuccess) return created;
79
+ copies[key] =
80
+ typeof entry === "string" ? created.data.id : { ...entry, fileRefId: created.data.id };
81
+ }
82
+ return { isSuccess: true, data: copies };
83
+ }
84
+
45
85
  // Replaces the whole key map of one row with files that are already stored (bundle import,
46
86
  // copy-locale). Single keys go through storeTemplateResource and resource-remove.
47
87
  export async function attachTemplateResources(