@cosmicdrift/kumiko-bundled-features 0.1.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 +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// mail-transport-smtp — concrete SMTP-implementation for the
|
|
4
|
+
// mail-foundation plugin-API.
|
|
5
|
+
//
|
|
6
|
+
// **Was diese Feature liefert:**
|
|
7
|
+
// 1. Provider-spezifische Tenant-Config (host/port/secure/from/authUser)
|
|
8
|
+
// und Secret (smtp.password). Self-contained — mail-foundation kennt
|
|
9
|
+
// diese Schlüssel nicht; wenn ein App-Owner zwischen SMTP und Brevo-
|
|
10
|
+
// API wechseln will, hat er pro-Plugin eigenständige Config-Sets.
|
|
11
|
+
// 2. **Plugin-Registration** via `r.useExtension("mailTransport",
|
|
12
|
+
// "smtp", { build })`. Beim Boot kennt mail-foundation's
|
|
13
|
+
// Factory-Lookup damit den name "smtp" ↔ build-Funktion.
|
|
14
|
+
// 3. `build(ctx, tenantId)` liest die config-keys + secret und ruft
|
|
15
|
+
// `createSmtpTransport()` aus channel-email auf. Das ist der
|
|
16
|
+
// EINZIGE Cross-Feature-Import dieses Plugins — bewusst lokal
|
|
17
|
+
// gehalten, mail-foundation bleibt provider-frei.
|
|
18
|
+
//
|
|
19
|
+
// **Pattern-Vorbild:** mirrors `channel-email` registering itself for
|
|
20
|
+
// `delivery`. Diese Feature ist analog: registriert sich für
|
|
21
|
+
// mail-foundation's "mailTransport"-Extension-Point.
|
|
22
|
+
//
|
|
23
|
+
// **Boot-Dependencies:**
|
|
24
|
+
// - `mail-foundation` — extension-point owner
|
|
25
|
+
// - `config` — für die Tenant-Config-Keys
|
|
26
|
+
// - `secrets` — für das verschlüsselte SMTP-Password
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
createSmtpTransport,
|
|
30
|
+
type EmailTransport,
|
|
31
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
32
|
+
import {
|
|
33
|
+
requireDefined,
|
|
34
|
+
requireNonEmpty,
|
|
35
|
+
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
36
|
+
import type { MailTransportPlugin } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
37
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
38
|
+
import {
|
|
39
|
+
access,
|
|
40
|
+
createTenantConfig,
|
|
41
|
+
defineFeature,
|
|
42
|
+
type HandlerContext,
|
|
43
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
|
|
45
|
+
const FEATURE_NAME = "mail-transport-smtp";
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Feature-definition
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
52
|
+
r.requires("config");
|
|
53
|
+
r.requires("secrets");
|
|
54
|
+
r.requires("mail-foundation");
|
|
55
|
+
|
|
56
|
+
// Provider-secret. Sensitive: redact-helper for admin-UI display.
|
|
57
|
+
const password = r.secret("smtp.password", {
|
|
58
|
+
label: { de: "SMTP-Passwort", en: "SMTP password" },
|
|
59
|
+
hint: {
|
|
60
|
+
de: "Login-Passwort am SMTP-Server. Bei Brevo/Postmark/SES heißt es 'API key' bzw. 'SMTP credentials'.",
|
|
61
|
+
en: "Login password at the SMTP server. Brevo/Postmark/SES call it 'API key' or 'SMTP credentials'.",
|
|
62
|
+
},
|
|
63
|
+
redact: (plaintext) => {
|
|
64
|
+
if (plaintext.length < 8) return "•".repeat(plaintext.length);
|
|
65
|
+
return `${plaintext.slice(0, 3)}...${plaintext.slice(-2)}`;
|
|
66
|
+
},
|
|
67
|
+
scope: "tenant",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const configKeys = r.config({
|
|
71
|
+
keys: {
|
|
72
|
+
host: createTenantConfig("text", {
|
|
73
|
+
default: "",
|
|
74
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
75
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
76
|
+
}),
|
|
77
|
+
port: createTenantConfig("number", {
|
|
78
|
+
default: 587,
|
|
79
|
+
bounds: { min: 1, max: 65535 },
|
|
80
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
81
|
+
}),
|
|
82
|
+
secure: createTenantConfig("boolean", {
|
|
83
|
+
default: false,
|
|
84
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
85
|
+
}),
|
|
86
|
+
from: createTenantConfig("text", {
|
|
87
|
+
default: "",
|
|
88
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
89
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
90
|
+
}),
|
|
91
|
+
authUser: createTenantConfig("text", {
|
|
92
|
+
default: "",
|
|
93
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
94
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Plugin: register against mail-foundation's "mailTransport" extension.
|
|
100
|
+
// `entityName` "smtp" is what tenants set in mail-foundation's
|
|
101
|
+
// `provider` config-key to pick this transport.
|
|
102
|
+
const plugin: MailTransportPlugin = {
|
|
103
|
+
build: async (ctx: HandlerContext, tenantId: string) => buildSmtpTransport(ctx, tenantId),
|
|
104
|
+
};
|
|
105
|
+
r.useExtension("mailTransport", "smtp", plugin);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
/** Config-key-handles — typed reads via `ctx.config(...)` in
|
|
109
|
+
* consumer handlers. */
|
|
110
|
+
configKeys,
|
|
111
|
+
/** Secret-handle for the SMTP password. */
|
|
112
|
+
password,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Typed handle for the SMTP password — exported so seeds + tests can
|
|
117
|
+
* set it via `secrets:write:set` with the full qualified-name. */
|
|
118
|
+
export const SMTP_PASSWORD = mailTransportSmtpFeature.exports.password;
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// Internal: build the EmailTransport from tenant config + secret
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
async function buildSmtpTransport(ctx: HandlerContext, tenantId: string): Promise<EmailTransport> {
|
|
125
|
+
const ctxConfig = ctx.config;
|
|
126
|
+
if (!ctxConfig) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${FEATURE_NAME}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SMTP_HINT = "Set via tenant-admin UI or seed-handler before sending mail.";
|
|
133
|
+
const host = requireNonEmpty(
|
|
134
|
+
await ctxConfig(mailTransportSmtpFeature.exports.configKeys.host),
|
|
135
|
+
FEATURE_NAME,
|
|
136
|
+
"host",
|
|
137
|
+
SMTP_HINT,
|
|
138
|
+
);
|
|
139
|
+
const port = requireDefined(
|
|
140
|
+
await ctxConfig(mailTransportSmtpFeature.exports.configKeys.port),
|
|
141
|
+
FEATURE_NAME,
|
|
142
|
+
"port",
|
|
143
|
+
) as number;
|
|
144
|
+
const secure = requireDefined(
|
|
145
|
+
await ctxConfig(mailTransportSmtpFeature.exports.configKeys.secure),
|
|
146
|
+
FEATURE_NAME,
|
|
147
|
+
"secure",
|
|
148
|
+
) as boolean;
|
|
149
|
+
const from = requireNonEmpty(
|
|
150
|
+
await ctxConfig(mailTransportSmtpFeature.exports.configKeys.from),
|
|
151
|
+
FEATURE_NAME,
|
|
152
|
+
"from",
|
|
153
|
+
SMTP_HINT,
|
|
154
|
+
);
|
|
155
|
+
const authUser = requireNonEmpty(
|
|
156
|
+
await ctxConfig(mailTransportSmtpFeature.exports.configKeys.authUser),
|
|
157
|
+
FEATURE_NAME,
|
|
158
|
+
"authUser",
|
|
159
|
+
SMTP_HINT,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const password = await readPassword(ctx, tenantId);
|
|
163
|
+
|
|
164
|
+
return createSmtpTransport({
|
|
165
|
+
host,
|
|
166
|
+
port,
|
|
167
|
+
secure,
|
|
168
|
+
from,
|
|
169
|
+
auth: { user: authUser, pass: password },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readPassword(ctx: HandlerContext, tenantId: string): Promise<string> {
|
|
174
|
+
const secrets = requireSecretsContext(ctx, FEATURE_NAME);
|
|
175
|
+
const branded = await secrets.get(tenantId, SMTP_PASSWORD);
|
|
176
|
+
if (!branded) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`${FEATURE_NAME}: ${SMTP_PASSWORD.name} not set for tenant ${tenantId} — Tenant-Admin must set it via /api/write/secrets:write:set`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return branded.reveal();
|
|
182
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Integration test for the rate-limiting feature: proves the status
|
|
2
|
+
// query handler is registered, accessible to admins, and reports the
|
|
3
|
+
// real bucket state from the framework's RateLimitResolver.
|
|
4
|
+
//
|
|
5
|
+
// L3 dispatcher hook + resolver wiring are tested in framework-side
|
|
6
|
+
// suites; here we only verify the feature's own surface area.
|
|
7
|
+
|
|
8
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { createRateLimitingFeature } from "../feature";
|
|
13
|
+
|
|
14
|
+
let stack: TestStack;
|
|
15
|
+
const admin = TestUsers.admin;
|
|
16
|
+
|
|
17
|
+
// Helper handler with a tight rate limit so we can drain the bucket
|
|
18
|
+
// fast enough for the status query to observe a non-trivial state.
|
|
19
|
+
const probeFeature = defineFeature("rl-probe", (r) => {
|
|
20
|
+
r.queryHandler("ping", z.object({}), async () => ({ ok: true }), {
|
|
21
|
+
access: { roles: ["Admin"] },
|
|
22
|
+
rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
stack = await setupTestStack({
|
|
28
|
+
features: [createRateLimitingFeature(), probeFeature],
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await stack.cleanup();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await stack.redis.flushNamespace();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("rate-limiting feature — status query", () => {
|
|
41
|
+
test("reports a fresh bucket as fully available before any traffic", async () => {
|
|
42
|
+
const status = await stack.http.queryOk<{
|
|
43
|
+
bucket: string;
|
|
44
|
+
limit: number;
|
|
45
|
+
remaining: number;
|
|
46
|
+
windowSeconds: number;
|
|
47
|
+
}>(
|
|
48
|
+
"rate-limiting:query:status",
|
|
49
|
+
{ bucket: `user:${admin.id}`, limit: 5, windowSeconds: 60 },
|
|
50
|
+
admin,
|
|
51
|
+
);
|
|
52
|
+
expect(status.bucket).toBe(`user:${admin.id}`);
|
|
53
|
+
expect(status.limit).toBe(5);
|
|
54
|
+
expect(status.remaining).toBe(5);
|
|
55
|
+
expect(status.windowSeconds).toBe(60);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reports the deducted remaining tokens after real handler traffic", async () => {
|
|
59
|
+
// Drain the bucket via the probe handler — same per/limit/window
|
|
60
|
+
// as the status query peeks below, so the buckets line up.
|
|
61
|
+
for (let i = 0; i < 3; i++) {
|
|
62
|
+
await stack.http.queryOk("rl-probe:query:ping", {}, admin);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const status = await stack.http.queryOk<{ remaining: number }>(
|
|
66
|
+
"rate-limiting:query:status",
|
|
67
|
+
{ bucket: `user:${admin.id}`, limit: 5, windowSeconds: 60 },
|
|
68
|
+
admin,
|
|
69
|
+
);
|
|
70
|
+
// 3 deductions of cost-1 → 2 tokens left.
|
|
71
|
+
expect(status.remaining).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("status access requires Admin/SystemAdmin", async () => {
|
|
75
|
+
const guest = TestUsers.user;
|
|
76
|
+
const res = await stack.http.query(
|
|
77
|
+
"rate-limiting:query:status",
|
|
78
|
+
{ bucket: "user:0", limit: 1, windowSeconds: 60 },
|
|
79
|
+
guest,
|
|
80
|
+
);
|
|
81
|
+
// Access-denied surfaces as 403 in the dispatcher's outer wrapper.
|
|
82
|
+
expect(res.status).toBe(403);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { rateLimitStatus } from "./handlers/status.query";
|
|
3
|
+
|
|
4
|
+
// Opt-in feature. Loading it does NOT install rate-limit middleware —
|
|
5
|
+
// the framework auto-wires the L3 dispatcher hook and the resolver
|
|
6
|
+
// when (a) at least one handler declared a rateLimit option, OR (b) the
|
|
7
|
+
// caller passed `context.rateLimit` explicitly (e.g. for L1/L2 setup).
|
|
8
|
+
//
|
|
9
|
+
// Loading this feature only adds the ops-side status query. Apps that
|
|
10
|
+
// only use L3 (handler-opt-in) and don't need ops introspection can
|
|
11
|
+
// skip this feature entirely — the resolver still runs.
|
|
12
|
+
export function createRateLimitingFeature() {
|
|
13
|
+
return defineFeature("rateLimiting", (r) => {
|
|
14
|
+
r.queryHandler(rateLimitStatus);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { RateLimitErrors } from "../constants";
|
|
5
|
+
|
|
6
|
+
// Ops-side bucket inspection. Pass the bucket key (e.g. "user:42",
|
|
7
|
+
// "user+handler:42:orders:write:order:create") plus the limit/window the bucket
|
|
8
|
+
// was configured with, and get back the current state. Backed by
|
|
9
|
+
// resolver.peek() — purely read-only, no token deduction and no
|
|
10
|
+
// refill-timestamp update, so dashboards can poll without nudging the
|
|
11
|
+
// bucket state ahead of the next real request.
|
|
12
|
+
//
|
|
13
|
+
// Use cases: ops-CLI ("kumiko rl status user:42"), support agent debugging
|
|
14
|
+
// "why is this user blocked", dashboard tile.
|
|
15
|
+
//
|
|
16
|
+
// Bucket key format is owned by the framework (see rate-limit/bucket.ts);
|
|
17
|
+
// callers pass the constructed key directly. We don't synthesize from
|
|
18
|
+
// (per, user, handler) here — peeking is a low-level op, the lookup
|
|
19
|
+
// surface stays small.
|
|
20
|
+
export const rateLimitStatus = defineQueryHandler({
|
|
21
|
+
// Short name — the registry qualifies this to `rate-limiting:query:status`
|
|
22
|
+
// when the feature is registered. Passing the qualified form here would
|
|
23
|
+
// double-prefix it and the handler wouldn't be reachable.
|
|
24
|
+
name: "status",
|
|
25
|
+
schema: z.object({
|
|
26
|
+
bucket: z.string().min(1),
|
|
27
|
+
limit: z.number().int().positive(),
|
|
28
|
+
windowSeconds: z.number().int().positive(),
|
|
29
|
+
}),
|
|
30
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
31
|
+
handler: async (query, ctx) => {
|
|
32
|
+
if (!ctx.rateLimit) {
|
|
33
|
+
throw new UnprocessableError(RateLimitErrors.resolverUnavailable, {
|
|
34
|
+
i18nKey: "rateLimiting.errors.resolverUnavailable",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const decision = await ctx.rateLimit.peek(query.payload.bucket, {
|
|
38
|
+
limit: query.payload.limit,
|
|
39
|
+
windowSeconds: query.payload.windowSeconds,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
bucket: query.payload.bucket,
|
|
43
|
+
limit: decision.limit,
|
|
44
|
+
remaining: decision.remaining,
|
|
45
|
+
windowSeconds: decision.windowSeconds,
|
|
46
|
+
// resetAt is meaningful only if the bucket is currently exhausted;
|
|
47
|
+
// we still return it so dashboards can show "next refill" uniformly.
|
|
48
|
+
resetAt: decision.resetAt.toString(),
|
|
49
|
+
retryAfterSeconds: decision.retryAfterSeconds,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { simpleRenderer } from "../simple-renderer";
|
|
3
|
+
|
|
4
|
+
describe("simple renderer", () => {
|
|
5
|
+
test("renders header", async () => {
|
|
6
|
+
const html = await simpleRenderer.render({
|
|
7
|
+
template: "test",
|
|
8
|
+
variables: { header: "Willkommen" },
|
|
9
|
+
});
|
|
10
|
+
expect(html).toContain("<h1");
|
|
11
|
+
expect(html).toContain("Willkommen");
|
|
12
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("renders text section", async () => {
|
|
16
|
+
const html = await simpleRenderer.render({
|
|
17
|
+
template: "test",
|
|
18
|
+
variables: {
|
|
19
|
+
sections: [{ text: "Dies ist ein Absatz." }],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
expect(html).toContain("<p");
|
|
23
|
+
expect(html).toContain("Dies ist ein Absatz.");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("renders button section with link", async () => {
|
|
27
|
+
const html = await simpleRenderer.render({
|
|
28
|
+
template: "test",
|
|
29
|
+
variables: {
|
|
30
|
+
sections: [{ button: { label: "Klick mich", url: "https://example.com/action" } }],
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
expect(html).toContain('href="https://example.com/action"');
|
|
34
|
+
expect(html).toContain("Klick mich");
|
|
35
|
+
expect(html).toContain("<a ");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("renders footer", async () => {
|
|
39
|
+
const html = await simpleRenderer.render({
|
|
40
|
+
template: "test",
|
|
41
|
+
variables: { footer: "Kumiko Framework" },
|
|
42
|
+
});
|
|
43
|
+
expect(html).toContain("Kumiko Framework");
|
|
44
|
+
expect(html).toContain("border-top");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("renders full email with all parts", async () => {
|
|
48
|
+
const html = await simpleRenderer.render({
|
|
49
|
+
template: "order-assigned",
|
|
50
|
+
variables: {
|
|
51
|
+
header: "Neuer Auftrag",
|
|
52
|
+
sections: [
|
|
53
|
+
{ text: "Auftrag #42 wurde dir zugewiesen." },
|
|
54
|
+
{ button: { label: "Auftrag oeffnen", url: "/orders/42" } },
|
|
55
|
+
],
|
|
56
|
+
footer: "Automatische Benachrichtigung",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
61
|
+
expect(html).toContain("Neuer Auftrag");
|
|
62
|
+
expect(html).toContain("Auftrag #42 wurde dir zugewiesen.");
|
|
63
|
+
expect(html).toContain('href="/orders/42"');
|
|
64
|
+
expect(html).toContain("Auftrag oeffnen");
|
|
65
|
+
expect(html).toContain("Automatische Benachrichtigung");
|
|
66
|
+
expect(html).toContain("</html>");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("escapes HTML in all fields", async () => {
|
|
70
|
+
const html = await simpleRenderer.render({
|
|
71
|
+
template: "test",
|
|
72
|
+
variables: {
|
|
73
|
+
header: '<script>alert("xss")</script>',
|
|
74
|
+
sections: [
|
|
75
|
+
{ text: "Text with <b>tags</b>" },
|
|
76
|
+
{ button: { label: "Click <here>", url: 'https://evil.com/"><script>' } },
|
|
77
|
+
],
|
|
78
|
+
footer: "Footer & more",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(html).not.toContain("<script>");
|
|
83
|
+
expect(html).not.toContain("<b>");
|
|
84
|
+
expect(html).toContain("<script>");
|
|
85
|
+
expect(html).toContain("<b>");
|
|
86
|
+
expect(html).toContain("Footer & more");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("renders empty template without errors", async () => {
|
|
90
|
+
const html = await simpleRenderer.render({
|
|
91
|
+
template: "empty",
|
|
92
|
+
variables: {},
|
|
93
|
+
});
|
|
94
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
95
|
+
expect(html).toContain("</html>");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { simpleRenderer } from "./simple-renderer";
|
|
3
|
+
|
|
4
|
+
export function createRendererSimpleFeature(): FeatureDefinition {
|
|
5
|
+
return defineFeature("rendererSimple", (r) => {
|
|
6
|
+
r.requires("delivery");
|
|
7
|
+
|
|
8
|
+
r.useExtension("notificationRenderer", "simple", {
|
|
9
|
+
render: simpleRenderer.render,
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { NotificationRenderer } from "../delivery";
|
|
2
|
+
|
|
3
|
+
type Section =
|
|
4
|
+
| { readonly text: string }
|
|
5
|
+
| { readonly button: { readonly label: string; readonly url: string } };
|
|
6
|
+
|
|
7
|
+
type EmailTemplateData = {
|
|
8
|
+
// Preferred: structured email data
|
|
9
|
+
readonly header?: string;
|
|
10
|
+
readonly sections?: readonly Section[];
|
|
11
|
+
readonly footer?: string;
|
|
12
|
+
// Fallback: plain title + body (used when no structured template is defined)
|
|
13
|
+
readonly title?: string;
|
|
14
|
+
readonly body?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function escapeHtml(str: string): string {
|
|
18
|
+
return str
|
|
19
|
+
.replace(/&/g, "&")
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">")
|
|
22
|
+
.replace(/"/g, """);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderSection(section: Section): string {
|
|
26
|
+
if ("text" in section) {
|
|
27
|
+
return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
|
|
28
|
+
}
|
|
29
|
+
if ("button" in section) {
|
|
30
|
+
return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
|
|
31
|
+
}
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Simple Renderer: turns structured email template data into HTML with inline CSS.
|
|
36
|
+
// No external dependencies, no template engine — just string concatenation.
|
|
37
|
+
export const simpleRenderer: NotificationRenderer = {
|
|
38
|
+
name: "simple",
|
|
39
|
+
|
|
40
|
+
async render(input) {
|
|
41
|
+
const data = input.variables as EmailTemplateData;
|
|
42
|
+
|
|
43
|
+
// Fallback: if no structured fields, use title + body as header + single text section
|
|
44
|
+
const header = data.header ?? data.title;
|
|
45
|
+
const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
|
|
46
|
+
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
|
|
49
|
+
parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
|
|
50
|
+
|
|
51
|
+
if (header) {
|
|
52
|
+
parts.push(
|
|
53
|
+
`<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (sections) {
|
|
58
|
+
for (const section of sections) {
|
|
59
|
+
parts.push(renderSection(section));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (data.footer) {
|
|
64
|
+
parts.push(
|
|
65
|
+
`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(data.footer)}</p>`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
parts.push("</div></body></html>");
|
|
70
|
+
return parts.join("");
|
|
71
|
+
},
|
|
72
|
+
};
|