@cosmicdrift/kumiko-samples 0.366.0 → 0.367.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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +54 -2
- package/packages/bundled-features/src/delivery/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +10 -5
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +4 -0
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
- package/packages/bundled-features/src/mail-foundation/feature.ts +50 -9
- package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
- package/packages/bundled-features/src/template-resolver/changes.json +25 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
- package/packages/bundled-features/src/template-resolver/index.ts +4 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
- package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
- package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
- package/samples/apps/use-all-bundled/package.json +1 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
signatureRoute,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
8
8
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
|
|
9
10
|
import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
10
11
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
11
12
|
import * as jose from "jose";
|
|
@@ -17,6 +18,14 @@ import {
|
|
|
17
18
|
DeliveryErrors,
|
|
18
19
|
DeliveryHandlers,
|
|
19
20
|
} from "./constants.js";
|
|
21
|
+
import "./unsubscribe-page-strings.js";
|
|
22
|
+
|
|
23
|
+
// The mail's locale, so the pages behind the link speak the mail's language. Optional: links
|
|
24
|
+
// signed before the claim existed keep verifying and fall back to English.
|
|
25
|
+
const localeClaimSchema = z
|
|
26
|
+
.string()
|
|
27
|
+
.regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/)
|
|
28
|
+
.optional();
|
|
20
29
|
|
|
21
30
|
// Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
|
|
22
31
|
// wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
|
|
@@ -32,6 +41,7 @@ const unsubscribeJwtPayloadSchema = z.object({
|
|
|
32
41
|
tenantId: z.string().min(1),
|
|
33
42
|
notificationType: z.string().min(1),
|
|
34
43
|
channel: z.string().min(1),
|
|
44
|
+
locale: localeClaimSchema,
|
|
35
45
|
});
|
|
36
46
|
|
|
37
47
|
// Address-token payload. `sub` and `addressHash` are the same value (the
|
|
@@ -45,6 +55,7 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
|
|
|
45
55
|
notificationType: z.string().min(1),
|
|
46
56
|
channel: z.string().min(1),
|
|
47
57
|
addressHash: z.string().min(1),
|
|
58
|
+
locale: localeClaimSchema,
|
|
48
59
|
});
|
|
49
60
|
|
|
50
61
|
export type UnsubscribeTokenPayload = {
|
|
@@ -52,6 +63,7 @@ export type UnsubscribeTokenPayload = {
|
|
|
52
63
|
readonly tenantId: TenantId;
|
|
53
64
|
readonly notificationType: string;
|
|
54
65
|
readonly channel: string;
|
|
66
|
+
readonly locale?: string | undefined;
|
|
55
67
|
};
|
|
56
68
|
|
|
57
69
|
export type AddressUnsubscribeTokenPayload = {
|
|
@@ -59,6 +71,7 @@ export type AddressUnsubscribeTokenPayload = {
|
|
|
59
71
|
readonly address: string;
|
|
60
72
|
readonly notificationType: string;
|
|
61
73
|
readonly channel: string;
|
|
74
|
+
readonly locale?: string | undefined;
|
|
62
75
|
};
|
|
63
76
|
|
|
64
77
|
/**
|
|
@@ -87,6 +100,11 @@ function assertUnsubscribeSecret(secret: string, caller: string): void {
|
|
|
87
100
|
}
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
// An unusable locale is left out instead of failing the send; the pages then fall back to English.
|
|
104
|
+
function localeClaim(locale: string | undefined): { locale?: string } {
|
|
105
|
+
return locale !== undefined && localeClaimSchema.safeParse(locale).success ? { locale } : {};
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
|
|
91
109
|
error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
|
|
92
110
|
} as const;
|
|
@@ -101,6 +119,7 @@ export async function signUnsubscribeToken(
|
|
|
101
119
|
tenantId: payload.tenantId,
|
|
102
120
|
notificationType: payload.notificationType,
|
|
103
121
|
channel: payload.channel,
|
|
122
|
+
...localeClaim(payload.locale),
|
|
104
123
|
})
|
|
105
124
|
.setProtectedHeader({ alg: "HS256" })
|
|
106
125
|
.setSubject(String(payload.userId))
|
|
@@ -136,6 +155,7 @@ export async function signAddressUnsubscribeToken(
|
|
|
136
155
|
channel: payload.channel,
|
|
137
156
|
addressHash,
|
|
138
157
|
kind: "address",
|
|
158
|
+
...localeClaim(payload.locale),
|
|
139
159
|
})
|
|
140
160
|
.setProtectedHeader({ alg: "HS256" })
|
|
141
161
|
.setSubject(addressHash)
|
|
@@ -154,6 +174,7 @@ type VerifiedUnsubscribe =
|
|
|
154
174
|
readonly addressHash: string;
|
|
155
175
|
readonly notificationType: string;
|
|
156
176
|
readonly channel: string;
|
|
177
|
+
readonly locale: string | undefined;
|
|
157
178
|
}
|
|
158
179
|
| {
|
|
159
180
|
readonly kind: "user";
|
|
@@ -161,6 +182,7 @@ type VerifiedUnsubscribe =
|
|
|
161
182
|
readonly userId: string;
|
|
162
183
|
readonly notificationType: string;
|
|
163
184
|
readonly channel: string;
|
|
185
|
+
readonly locale: string | undefined;
|
|
164
186
|
};
|
|
165
187
|
|
|
166
188
|
// RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
|
|
@@ -183,6 +205,7 @@ async function signResubscribeToken(
|
|
|
183
205
|
tenantId: verified.tenantId,
|
|
184
206
|
notificationType: verified.notificationType,
|
|
185
207
|
channel: verified.channel,
|
|
208
|
+
...localeClaim(verified.locale),
|
|
186
209
|
};
|
|
187
210
|
const jwt =
|
|
188
211
|
verified.kind === "address"
|
|
@@ -254,6 +277,7 @@ async function verifyUnsubscribeToken(
|
|
|
254
277
|
addressHash: parsed.addressHash,
|
|
255
278
|
notificationType: parsed.notificationType,
|
|
256
279
|
channel: parsed.channel,
|
|
280
|
+
locale: parsed.locale,
|
|
257
281
|
};
|
|
258
282
|
}
|
|
259
283
|
|
|
@@ -274,6 +298,7 @@ async function verifyUnsubscribeToken(
|
|
|
274
298
|
userId: parsed.sub,
|
|
275
299
|
notificationType: parsed.notificationType,
|
|
276
300
|
channel: parsed.channel,
|
|
301
|
+
locale: parsed.locale,
|
|
277
302
|
};
|
|
278
303
|
} catch (err) {
|
|
279
304
|
if (err instanceof ExtraRouteRejection) throw err;
|
|
@@ -358,40 +383,68 @@ async function dispatchResubscribeWrite(
|
|
|
358
383
|
return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
|
|
359
384
|
}
|
|
360
385
|
|
|
361
|
-
function
|
|
386
|
+
function pageLocale(locale: string | undefined): string {
|
|
387
|
+
return resolveMailLocale(locale ?? "en");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function htmlPage(locale: string, title: string, bodyHtml: string): string {
|
|
362
391
|
return `<!doctype html>
|
|
363
|
-
<html>
|
|
392
|
+
<html lang="${escapeHtmlAttr(locale)}">
|
|
364
393
|
<head>
|
|
365
394
|
<meta charset="utf-8">
|
|
366
395
|
<meta name="robots" content="noindex">
|
|
367
|
-
<
|
|
396
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
397
|
+
<title>${escapeHtmlAttr(title)}</title>
|
|
368
398
|
</head>
|
|
369
399
|
<body>
|
|
370
|
-
|
|
371
|
-
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
372
|
-
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
373
|
-
<button type="submit">Unsubscribe</button>
|
|
374
|
-
</form>
|
|
400
|
+
${bodyHtml}
|
|
375
401
|
</body>
|
|
376
402
|
</html>`;
|
|
377
403
|
}
|
|
378
404
|
|
|
379
|
-
function
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
<
|
|
386
|
-
|
|
387
|
-
<
|
|
388
|
-
|
|
405
|
+
function confirmationPage(token: string, locale: string | undefined): string {
|
|
406
|
+
const lang = pageLocale(locale);
|
|
407
|
+
return htmlPage(
|
|
408
|
+
lang,
|
|
409
|
+
mailT(lang, "mail.unsubscribe.confirmTitle"),
|
|
410
|
+
`<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmQuestion"))}</p>
|
|
411
|
+
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
412
|
+
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
413
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
|
|
414
|
+
</form>`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function unsubscribedPage(resubscribeToken: string, locale: string | undefined): string {
|
|
419
|
+
const lang = pageLocale(locale);
|
|
420
|
+
return htmlPage(
|
|
421
|
+
lang,
|
|
422
|
+
mailT(lang, "mail.unsubscribe.doneTitle"),
|
|
423
|
+
`<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
|
|
389
424
|
<form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
|
|
390
425
|
<input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
|
|
391
|
-
<button type="submit"
|
|
392
|
-
</form
|
|
393
|
-
|
|
394
|
-
|
|
426
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
|
|
427
|
+
</form>`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resubscribedPage(locale: string | undefined): string {
|
|
432
|
+
const lang = pageLocale(locale);
|
|
433
|
+
return htmlPage(
|
|
434
|
+
lang,
|
|
435
|
+
mailT(lang, "mail.unsubscribe.resubscribedTitle"),
|
|
436
|
+
`<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function failedPage(locale: string | undefined): string {
|
|
441
|
+
const lang = pageLocale(locale);
|
|
442
|
+
const message = mailT(lang, "mail.unsubscribe.failed");
|
|
443
|
+
return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isFormRequest(contentType: string | undefined): boolean {
|
|
447
|
+
return contentType?.includes("application/x-www-form-urlencoded") === true;
|
|
395
448
|
}
|
|
396
449
|
|
|
397
450
|
// One-click clients put the token in the query string; only the
|
|
@@ -431,7 +484,7 @@ export function createUnsubscribeRoutes(
|
|
|
431
484
|
assertUnsubscribeSecret(options.secret, "createUnsubscribeRoutes");
|
|
432
485
|
const encodedSecret = new TextEncoder().encode(options.secret);
|
|
433
486
|
|
|
434
|
-
const confirmRoute = signatureRoute<{ token: string }>({
|
|
487
|
+
const confirmRoute = signatureRoute<{ token: string; locale: string | undefined }>({
|
|
435
488
|
method: "GET",
|
|
436
489
|
path: DELIVERY_UNSUBSCRIBE_PATH,
|
|
437
490
|
entry: "signature",
|
|
@@ -444,11 +497,11 @@ export function createUnsubscribeRoutes(
|
|
|
444
497
|
"missing unsubscribe token",
|
|
445
498
|
);
|
|
446
499
|
}
|
|
447
|
-
await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
448
|
-
return { token };
|
|
500
|
+
const verified = await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
501
|
+
return { token, locale: verified.locale };
|
|
449
502
|
},
|
|
450
|
-
handler: async (c, { token }) =>
|
|
451
|
-
c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
|
|
503
|
+
handler: async (c, { token, locale }) =>
|
|
504
|
+
c.html(confirmationPage(token, locale), 200, CONFIRMATION_PAGE_HEADERS),
|
|
452
505
|
});
|
|
453
506
|
|
|
454
507
|
const writeRoute = signatureRoute<VerifiedUnsubscribe>({
|
|
@@ -463,11 +516,13 @@ export function createUnsubscribeRoutes(
|
|
|
463
516
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
464
517
|
const succeeded = await dispatchUnsubscribeWrite(verified, deps);
|
|
465
518
|
if (!succeeded) {
|
|
466
|
-
return c.html(
|
|
519
|
+
return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
|
|
467
520
|
}
|
|
468
|
-
return c.html(
|
|
469
|
-
|
|
470
|
-
|
|
521
|
+
return c.html(
|
|
522
|
+
unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale),
|
|
523
|
+
200,
|
|
524
|
+
{ "Cache-Control": "no-store" },
|
|
525
|
+
);
|
|
471
526
|
},
|
|
472
527
|
});
|
|
473
528
|
|
|
@@ -482,6 +537,14 @@ export function createUnsubscribeRoutes(
|
|
|
482
537
|
verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
|
|
483
538
|
handler: async (c, verified, deps) => {
|
|
484
539
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
540
|
+
// The undo button on the unsubscribed page posts a form; a browser should land on a page.
|
|
541
|
+
if (isFormRequest(c.req.header("content-type"))) {
|
|
542
|
+
return outcome === "success"
|
|
543
|
+
? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
|
|
544
|
+
: c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
|
|
545
|
+
"Cache-Control": "no-store",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
485
548
|
if (outcome === "limit_reached") {
|
|
486
549
|
return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
|
|
487
550
|
"Cache-Control": "no-store",
|
|
@@ -39,9 +39,11 @@ import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-
|
|
|
39
39
|
import {
|
|
40
40
|
access,
|
|
41
41
|
type ConfigAccessor,
|
|
42
|
+
createSystemUser,
|
|
42
43
|
createTenantConfig,
|
|
43
44
|
defineFeature,
|
|
44
45
|
type Registry,
|
|
46
|
+
type TenantId,
|
|
45
47
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
46
48
|
|
|
47
49
|
const FEATURE_NAME = "mail-foundation";
|
|
@@ -121,7 +123,7 @@ declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
|
121
123
|
|
|
122
124
|
export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
123
125
|
r.describe(
|
|
124
|
-
"Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending \u2014 use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences.",
|
|
126
|
+
"Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending \u2014 use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.",
|
|
125
127
|
);
|
|
126
128
|
r.uiHints({
|
|
127
129
|
displayLabel: "Mail Transport Foundation",
|
|
@@ -187,6 +189,28 @@ export async function createTransportForTenant(
|
|
|
187
189
|
tenantId: string,
|
|
188
190
|
handlerName = "mail-foundation:transport-factory",
|
|
189
191
|
): Promise<EmailTransport> {
|
|
192
|
+
const transport = await createTransportForTenantIfConfigured(ctx, tenantId, handlerName);
|
|
193
|
+
if (transport === null) {
|
|
194
|
+
const usages = ctx.registry?.getExtensionUsages(MAIL_TRANSPORT_EXTENSION) ?? [];
|
|
195
|
+
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
196
|
+
throw new Error(
|
|
197
|
+
`${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
|
|
198
|
+
`Mount a mail-transport-* feature first if no plugins are registered.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return transport;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Like createTransportForTenant, but an empty `provider` key means "this tenant has no
|
|
206
|
+
* mail provider" and yields null. Every other problem (unknown provider, plugin build
|
|
207
|
+
* failure) still throws, so a configured tenant never silently falls back.
|
|
208
|
+
*/
|
|
209
|
+
export async function createTransportForTenantIfConfigured(
|
|
210
|
+
ctx: MailTransportContext,
|
|
211
|
+
tenantId: string,
|
|
212
|
+
handlerName = "mail-foundation:transport-factory",
|
|
213
|
+
): Promise<EmailTransport | null> {
|
|
190
214
|
const ctxConfig = ctx.config;
|
|
191
215
|
if (!ctxConfig) {
|
|
192
216
|
throw new Error(
|
|
@@ -204,14 +228,7 @@ export async function createTransportForTenant(
|
|
|
204
228
|
FEATURE_NAME,
|
|
205
229
|
"provider",
|
|
206
230
|
) as string; // @cast-boundary engine-payload
|
|
207
|
-
if (provider.length === 0)
|
|
208
|
-
const usages = ctx.registry.getExtensionUsages(MAIL_TRANSPORT_EXTENSION);
|
|
209
|
-
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
210
|
-
throw new Error(
|
|
211
|
-
`${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
|
|
212
|
-
`Mount a mail-transport-* feature first if no plugins are registered.`,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
231
|
+
if (provider.length === 0) return null;
|
|
215
232
|
|
|
216
233
|
const usages = ctx.registry.getExtensionUsages(MAIL_TRANSPORT_EXTENSION);
|
|
217
234
|
const usage = usages.find((u) => u.entityName === provider);
|
|
@@ -231,3 +248,27 @@ export async function createTransportForTenant(
|
|
|
231
248
|
}
|
|
232
249
|
return withPiiCiphertextGuard(await usage.options.build(ctx, tenantId));
|
|
233
250
|
}
|
|
251
|
+
|
|
252
|
+
export type TenantMailTransportScope = {
|
|
253
|
+
readonly tenantId: TenantId;
|
|
254
|
+
readonly config?: ConfigAccessor | undefined;
|
|
255
|
+
readonly registry: Registry;
|
|
256
|
+
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext | undefined;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** The `tenantTransport` resolver for `createChannelEmailFeature`: the tenant's configured
|
|
260
|
+
* provider, or null so the channel falls back to its app transport. */
|
|
261
|
+
export function tenantMailTransport(
|
|
262
|
+
scope: TenantMailTransportScope,
|
|
263
|
+
): Promise<EmailTransport | null> {
|
|
264
|
+
return createTransportForTenantIfConfigured(
|
|
265
|
+
{
|
|
266
|
+
config: scope.config,
|
|
267
|
+
registry: scope.registry,
|
|
268
|
+
secrets: scope.secrets,
|
|
269
|
+
_userId: createSystemUser(scope.tenantId).id,
|
|
270
|
+
},
|
|
271
|
+
scope.tenantId,
|
|
272
|
+
"channel-email",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
export {
|
|
11
11
|
createTransportForTenant,
|
|
12
|
+
createTransportForTenantIfConfigured,
|
|
12
13
|
isMailTransportPlugin,
|
|
13
14
|
MAIL_TRANSPORT_EXTENSION,
|
|
14
15
|
type MailTransportContext,
|
|
15
16
|
type MailTransportPlugin,
|
|
16
17
|
mailFoundationFeature,
|
|
18
|
+
type TenantMailTransportScope,
|
|
19
|
+
tenantMailTransport,
|
|
17
20
|
} from "./feature.js";
|
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.367.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Notifications can keep their mail template system-only",
|
|
6
|
+
"detail": "r.notification(name, { systemOnly: true }) marks the stored mail template as a platform text. Tenant admins no longer see it in the template overview, list or detail, and creating a tenant version (upsert-tenant, copy-locale, import-bundle, also a SystemAdmin writing for a tenant) is refused with access_denied and templateResolver.errors.systemOnlyTemplate. SystemAdmins edit the system template as before, and the mail renderer always uses it, even if a tenant row exists from before."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.367.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Rich text placeholders no longer nest paragraphs",
|
|
12
|
+
"detail": "A richText placeholder alone in a paragraph compiles into a div that keeps the paragraph attributes, and one inside other text renders inline with line breaks instead of its own paragraphs. Mails and PDFs no longer contain <p><p>. richText and stepIs are now reserved template variable names, like the other renderer helpers.",
|
|
13
|
+
"migration": "A notification that declares a template variable called richText, stepIs or percent fails at boot; rename the variable."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.367.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "Template bundles with a logo can be seeded",
|
|
19
|
+
"detail": "seedSystemTemplateBundle(db, bundle, { dispatcher }) now seeds bundles that carry resources such as a logo by running the import-bundle-system write on the given seed dispatcher. With ifExists \"skip\" (default) nothing is written when every locale row already exists; if only some exist, the whole bundle is imported again. Without a dispatcher such a bundle still fails with a message that names the missing option."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.367.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "Template conditions with data-if and a percent variable type",
|
|
25
|
+
"detail": "A paragraph, heading or table row with data-if=\"name\" renders only when the variable has a value (0 counts as a value); inside a repeat row the name refers to the row field, on the repeat row itself to the root variable. The sanitizer keeps the attribute, and template validation reports a condition on an undeclared variable. New variable type percent (templateVariable.percent()) takes percentage points and renders locale-aware, 12 as \"12 %\" in German and \"12%\" in English; the preview uses its example value. The document designer has no toolbar control for conditions yet; the attribute survives editing."
|
|
26
|
+
},
|
|
2
27
|
{
|
|
3
28
|
"version": "0.365.0",
|
|
4
29
|
"type": "breaking",
|
|
@@ -3,8 +3,9 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
3
3
|
import type { Element as ScanElement } from "happy-dom";
|
|
4
4
|
import { isResourceImageContentType } from "../shared/index.js";
|
|
5
5
|
import { rewriteStepsBlock } from "./compile-steps-block.js";
|
|
6
|
-
import { withParsedDocument } from "./document-sanitizer.js";
|
|
6
|
+
import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
|
|
7
7
|
import {
|
|
8
|
+
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
8
9
|
DOCUMENT_TEXT_ALIGNMENTS,
|
|
9
10
|
isAllowedDocumentLinkHref,
|
|
10
11
|
isDocumentPlaceholderHref,
|
|
@@ -25,6 +26,8 @@ type RowScope = { readonly listName: string; readonly itemFields: TemplateVariab
|
|
|
25
26
|
type CompileState = {
|
|
26
27
|
readonly variables: TemplateVariables;
|
|
27
28
|
readonly expressions: string[];
|
|
29
|
+
// richText spans that fill a whole block; their value brings its own paragraphs.
|
|
30
|
+
readonly blockRichTextSpans: Set<ScanElement>;
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
function marker(state: CompileState, expression: string): string {
|
|
@@ -59,10 +62,19 @@ function resolveReference(
|
|
|
59
62
|
return { path: scope === null ? name : `@root.${name}`, def: ownVariable(variables, name) };
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
|
|
65
|
+
// The sanitized rich text value always consists of blocks; inside a line of
|
|
66
|
+
// other text it renders inline so no paragraph ends up inside a paragraph.
|
|
67
|
+
function valueExpression(
|
|
68
|
+
path: string,
|
|
69
|
+
def: TemplateVariableDef | undefined,
|
|
70
|
+
isWholeBlock: boolean,
|
|
71
|
+
): string {
|
|
63
72
|
if (def?.type === "money") return `{{money ${path}}}`;
|
|
64
73
|
if (def?.type === "date") return `{{date ${path}}}`;
|
|
65
|
-
if (def?.type === "
|
|
74
|
+
if (def?.type === "percent") return `{{percent ${path}}}`;
|
|
75
|
+
if (def?.type === "richText") {
|
|
76
|
+
return isWholeBlock ? `{{richText ${path}}}` : `{{richText ${path} inline=true}}`;
|
|
77
|
+
}
|
|
66
78
|
return `{{${path}}}`;
|
|
67
79
|
}
|
|
68
80
|
|
|
@@ -70,7 +82,13 @@ function rewriteVariable(element: ScanElement, state: CompileState): void {
|
|
|
70
82
|
const name = element.getAttribute("data-variable") ?? "";
|
|
71
83
|
const replacement = element.ownerDocument.createTextNode(
|
|
72
84
|
isDocumentReferenceName(name)
|
|
73
|
-
? marker(
|
|
85
|
+
? marker(
|
|
86
|
+
state,
|
|
87
|
+
valueExpression(
|
|
88
|
+
...pathAndDef(name, element, state),
|
|
89
|
+
state.blockRichTextSpans.has(element),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
74
92
|
: "",
|
|
75
93
|
);
|
|
76
94
|
element.replaceWith(replacement);
|
|
@@ -112,10 +130,27 @@ function wrapVariableOnlyBlock(block: ScanElement, state: CompileState): void {
|
|
|
112
130
|
const name = soleVariableName(block);
|
|
113
131
|
// skip: a line with static content or several placeholders stays as it is
|
|
114
132
|
if (name === null) return;
|
|
115
|
-
const [path] = pathAndDef(name, block, state);
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
133
|
+
const [path, def] = pathAndDef(name, block, state);
|
|
134
|
+
const host =
|
|
135
|
+
def?.type === "richText" && block.tagName === "P" ? richTextBlockHost(block, state) : block;
|
|
136
|
+
const document = host.ownerDocument;
|
|
137
|
+
host.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
|
|
138
|
+
host.after(document.createTextNode(marker(state, "{{/if}}")));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A paragraph cannot hold the paragraphs of a rich text value, a div can; it
|
|
142
|
+
// keeps the paragraph's attributes so alignment and size still apply.
|
|
143
|
+
function richTextBlockHost(paragraph: ScanElement, state: CompileState): ScanElement {
|
|
144
|
+
const host = paragraph.ownerDocument.createElement("div");
|
|
145
|
+
for (const attribute of Array.from(paragraph.attributes)) {
|
|
146
|
+
host.setAttribute(attribute.name, attribute.value ?? "");
|
|
147
|
+
}
|
|
148
|
+
for (const span of Array.from(paragraph.querySelectorAll("span[data-variable]"))) {
|
|
149
|
+
state.blockRichTextSpans.add(span);
|
|
150
|
+
}
|
|
151
|
+
host.append(...Array.from(paragraph.childNodes));
|
|
152
|
+
paragraph.replaceWith(host);
|
|
153
|
+
return host;
|
|
119
154
|
}
|
|
120
155
|
|
|
121
156
|
function rewriteLink(element: ScanElement, state: CompileState): void {
|
|
@@ -146,6 +181,20 @@ function rewriteRepeatRow(row: ScanElement, state: CompileState): void {
|
|
|
146
181
|
}
|
|
147
182
|
}
|
|
148
183
|
|
|
184
|
+
function rewriteConditionalBlock(block: ScanElement, state: CompileState): void {
|
|
185
|
+
const name = block.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "";
|
|
186
|
+
block.removeAttribute(DOCUMENT_CONDITION_ATTRIBUTE);
|
|
187
|
+
if (!isDocumentReferenceName(name)) {
|
|
188
|
+
block.remove();
|
|
189
|
+
// skip: a condition on a name that cannot be a variable never shows
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const [path] = pathAndDef(name, conditionScopeElement(block), state);
|
|
193
|
+
const document = block.ownerDocument;
|
|
194
|
+
block.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
|
|
195
|
+
block.after(document.createTextNode(marker(state, "{{/if}}")));
|
|
196
|
+
}
|
|
197
|
+
|
|
149
198
|
function stripActiveContent(element: ScanElement): void {
|
|
150
199
|
for (const attribute of Array.from(element.attributes)) {
|
|
151
200
|
if (/^on/i.test(attribute.name)) element.removeAttribute(attribute.name);
|
|
@@ -311,7 +360,7 @@ export function compileDocumentBodyToHandlebars(
|
|
|
311
360
|
variables: TemplateVariables,
|
|
312
361
|
options: CompileDocumentBodyOptions = {},
|
|
313
362
|
): string {
|
|
314
|
-
const state: CompileState = { variables, expressions: [] };
|
|
363
|
+
const state: CompileState = { variables, expressions: [], blockRichTextSpans: new Set() };
|
|
315
364
|
const scrubbed = html.replaceAll(EXPRESSION_OPEN, "").replaceAll(EXPRESSION_CLOSE, "");
|
|
316
365
|
const compiled = withParsedDocument(scrubbed, (root) => {
|
|
317
366
|
stripScriptsAndActiveContent(root, options);
|
|
@@ -325,6 +374,9 @@ export function compileDocumentBodyToHandlebars(
|
|
|
325
374
|
color: options.stepsColor,
|
|
326
375
|
});
|
|
327
376
|
}
|
|
377
|
+
for (const block of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
|
|
378
|
+
rewriteConditionalBlock(block, state);
|
|
379
|
+
}
|
|
328
380
|
for (const block of Array.from(root.querySelectorAll(VARIABLE_ONLY_BLOCK_SELECTOR))) {
|
|
329
381
|
wrapVariableOnlyBlock(block, state);
|
|
330
382
|
}
|
|
@@ -7,6 +7,7 @@ import { type Element as ScanElement, Window } from "happy-dom";
|
|
|
7
7
|
import type { ContentFormat } from "./constants.js";
|
|
8
8
|
import { DOCUMENT_HTML_KINDS } from "./constants.js";
|
|
9
9
|
import {
|
|
10
|
+
DOCUMENT_CONDITION_ATTRIBUTE,
|
|
10
11
|
documentExtensions,
|
|
11
12
|
isAllowedDocumentLinkHref,
|
|
12
13
|
isDocumentPlaceholderHref,
|
|
@@ -138,6 +139,7 @@ function scanImage(element: ScanElement, out: DocumentViolation[]): void {
|
|
|
138
139
|
const REFERENCE_ATTRIBUTES: ReadonlyArray<readonly [string, (value: string) => boolean]> = [
|
|
139
140
|
["data-variable", isDocumentReferenceName],
|
|
140
141
|
["data-each", isDocumentReferenceName],
|
|
142
|
+
[DOCUMENT_CONDITION_ATTRIBUTE, isDocumentReferenceName],
|
|
141
143
|
["data-resource", isDocumentResourceKey],
|
|
142
144
|
["data-steps", isDocumentReferenceName],
|
|
143
145
|
["data-step", isDocumentStepKey],
|
|
@@ -270,6 +272,17 @@ export function sanitizeDocumentHtml(html: string): SanitizeHtmlResult {
|
|
|
270
272
|
};
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
// For a sanitized value placed inside a line of text: paragraphs become line
|
|
276
|
+
// breaks. Other blocks (lists, headings) cannot be made inline and stay as they are.
|
|
277
|
+
export function flattenParagraphsToLineBreaks(sanitizedHtml: string): string {
|
|
278
|
+
return withParsedDocument(sanitizedHtml, (body) => {
|
|
279
|
+
const blocks = Array.from(body.children);
|
|
280
|
+
const isParagraphsOnly =
|
|
281
|
+
blocks.length === body.childNodes.length && blocks.every((block) => block.tagName === "P");
|
|
282
|
+
return isParagraphsOnly ? blocks.map((block) => block.innerHTML).join("<br>") : sanitizedHtml;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
273
286
|
// Snippets accept any format, but their html is as untrusted as a mail body.
|
|
274
287
|
function isSanitizedHtmlKind(kind: string): boolean {
|
|
275
288
|
return kind === "snippet" || isOneOf(DOCUMENT_HTML_KINDS, kind);
|
|
@@ -336,3 +349,9 @@ export function documentViolationsToFieldIssues(
|
|
|
336
349
|
params: { detail: item.detail },
|
|
337
350
|
}));
|
|
338
351
|
}
|
|
352
|
+
|
|
353
|
+
// A repeat row's own data-if is decided per list, outside the row's item scope,
|
|
354
|
+
// because the compiled {{#if}} lands outside its {{#each}}.
|
|
355
|
+
export function conditionScopeElement(block: ScanElement): ScanElement {
|
|
356
|
+
return block.matches("tr[data-each]") ? (block.parentElement ?? block) : block;
|
|
357
|
+
}
|
|
@@ -314,6 +314,38 @@ export const DocumentTextSize = Extension.create({
|
|
|
314
314
|
},
|
|
315
315
|
});
|
|
316
316
|
|
|
317
|
+
// A block shown only when the named variable has a value (data-if, the
|
|
318
|
+
// conditional counterpart of data-each). Table rows qualify so an invoice can
|
|
319
|
+
// drop a discount line; tiptap merges the attribute into paragraph, heading
|
|
320
|
+
// and row markup only.
|
|
321
|
+
export const DOCUMENT_CONDITION_ATTRIBUTE = "data-if";
|
|
322
|
+
|
|
323
|
+
export const DocumentCondition = Extension.create({
|
|
324
|
+
name: "documentCondition",
|
|
325
|
+
addGlobalAttributes() {
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
types: ["paragraph", "heading", "tableRow"],
|
|
329
|
+
attributes: {
|
|
330
|
+
showIf: {
|
|
331
|
+
default: null,
|
|
332
|
+
parseHTML: (element) => {
|
|
333
|
+
const name = element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE);
|
|
334
|
+
return name !== null && isDocumentReferenceName(name) ? name : null;
|
|
335
|
+
},
|
|
336
|
+
renderHTML: (attributes) => {
|
|
337
|
+
const name: unknown = attributes["showIf"];
|
|
338
|
+
return typeof name === "string" && isDocumentReferenceName(name)
|
|
339
|
+
? { [DOCUMENT_CONDITION_ATTRIBUTE]: name }
|
|
340
|
+
: {};
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
317
349
|
export const documentExtensions: Extensions = [
|
|
318
350
|
StarterKit.configure({
|
|
319
351
|
blockquote: false,
|
|
@@ -348,4 +380,5 @@ export const documentExtensions: Extensions = [
|
|
|
348
380
|
DocumentSteps,
|
|
349
381
|
DocumentTextAlign,
|
|
350
382
|
DocumentTextSize,
|
|
383
|
+
DocumentCondition,
|
|
351
384
|
];
|
|
@@ -32,6 +32,7 @@ import { resolveTemplateVariables } from "../template-validation.js";
|
|
|
32
32
|
import { MAX_FILE_ATTACHMENTS_PER_TEMPLATE } from "./resource-shared.js";
|
|
33
33
|
import { evaluateTemplateWrite } from "./template-gates.js";
|
|
34
34
|
import {
|
|
35
|
+
refuseSystemOnlyTenantOverride,
|
|
35
36
|
refuseTenantScopeUnderSystemTenant,
|
|
36
37
|
type TemplateRowPayload,
|
|
37
38
|
writeTemplateRow,
|
|
@@ -113,7 +114,14 @@ function checkBundleAttachments(bundle: TemplateBundle): WriteResult<null> {
|
|
|
113
114
|
export async function prepareTemplateBundleBeforeTransaction(
|
|
114
115
|
args: BundleImport & { readonly status: BundleImportStatus },
|
|
115
116
|
): Promise<BeforeTransactionResult<PreparedBundleResources>> {
|
|
116
|
-
const refused =
|
|
117
|
+
const refused =
|
|
118
|
+
refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId) ??
|
|
119
|
+
refuseSystemOnlyTenantOverride(
|
|
120
|
+
args.ctx.registry,
|
|
121
|
+
args.scope,
|
|
122
|
+
args.bundle.slug,
|
|
123
|
+
args.bundle.kind,
|
|
124
|
+
);
|
|
117
125
|
if (refused !== null) return refused;
|
|
118
126
|
const issues = await collectBundleIssues(args, args.status);
|
|
119
127
|
if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
|
|
@@ -30,7 +30,11 @@ import { resolveTemplateVariables } from "../template-validation.js";
|
|
|
30
30
|
import { targetExistsIssue } from "./bundle-import.js";
|
|
31
31
|
import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
|
|
32
32
|
import { parseStoredJson, storedRecord } from "./template-gates.js";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
refuseSystemOnlyTenantOverride,
|
|
35
|
+
refuseTenantScopeUnderSystemTenant,
|
|
36
|
+
writeTemplateRow,
|
|
37
|
+
} from "./template-write.js";
|
|
34
38
|
|
|
35
39
|
type CopyScope = "tenant" | "system";
|
|
36
40
|
type WriteCtx = HandlerContext;
|
|
@@ -108,7 +112,9 @@ async function checkCopyRequest(
|
|
|
108
112
|
);
|
|
109
113
|
}
|
|
110
114
|
const tenantId = scope === "system" ? SYSTEM_TENANT_ID : event.user.tenantId;
|
|
111
|
-
const refused =
|
|
115
|
+
const refused =
|
|
116
|
+
refuseTenantScopeUnderSystemTenant(scope, tenantId) ??
|
|
117
|
+
refuseSystemOnlyTenantOverride(ctx.registry, scope, slug, kind);
|
|
112
118
|
if (refused !== null) return refused;
|
|
113
119
|
|
|
114
120
|
const readDb = readDbFor(ctx, scope);
|