@cosmicdrift/kumiko-bundled-features 0.365.1 → 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/dist/admin-shell/feature.js +9 -9
- package/dist/agent-tools/tool-dispatch.d.ts +9 -1
- package/dist/agent-tools/tool-dispatch.js +55 -5
- package/dist/audit/feature.js +1 -1
- package/dist/auth-email-password/constants.d.ts +0 -1
- package/dist/auth-email-password/constants.js +0 -2
- package/dist/auth-email-password/feature.js +3 -3
- package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
- package/dist/auth-mfa/feature.js +4 -4
- package/dist/auth-mfa-user-data/index.js +1 -1
- package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
- package/dist/billing-foundation/feature.js +1 -1
- package/dist/cap-counter/cap-reservation.js +73 -25
- package/dist/cap-counter/constants.d.ts +1 -0
- package/dist/cap-counter/constants.js +2 -0
- package/dist/cap-counter/feature.js +2 -2
- package/dist/cap-counter/tables.js +5 -2
- package/dist/cap-overview/feature.js +3 -3
- package/dist/channel-discord/feature.js +2 -2
- package/dist/channel-email/email-channel.d.ts +5 -1
- package/dist/channel-email/email-channel.js +8 -2
- package/dist/channel-email/feature.js +2 -2
- package/dist/channel-in-app/feature.js +1 -1
- package/dist/channel-push/feature.js +1 -1
- package/dist/channel-slack/feature.js +2 -2
- package/dist/channel-teams/feature.js +2 -2
- package/dist/channel-telegram/feature.js +2 -2
- package/dist/compliance-profiles/feature.js +1 -1
- package/dist/compliance-profiles-ops/index.js +3 -3
- package/dist/config/feature.js +1 -1
- package/dist/custom-fields/feature.js +2 -2
- package/dist/custom-fields/wire-for-entity.js +17 -13
- package/dist/data-retention/feature.js +1 -1
- package/dist/data-retention/presets.d.ts +4 -4
- package/dist/delivery/channel-context.js +1 -0
- package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
- package/dist/delivery/db/queries/address-opt-outs.js +6 -2
- package/dist/delivery/db/queries/preferences.d.ts +1 -1
- package/dist/delivery/db/queries/preferences.js +4 -4
- package/dist/delivery/delivery-service.d.ts +2 -0
- package/dist/delivery/delivery-service.js +39 -6
- package/dist/delivery/feature.js +10 -6
- package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
- package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
- package/dist/delivery/handlers/set-preference.write.js +3 -1
- package/dist/delivery/handlers/unsubscribe-address.write.d.ts +6 -0
- package/dist/delivery/handlers/unsubscribe-address.write.js +11 -6
- package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
- package/dist/delivery/index.d.ts +3 -0
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/notification-type-aliases.d.ts +3 -0
- package/dist/delivery/notification-type-aliases.js +11 -0
- package/dist/delivery/testing.d.ts +2 -0
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/unsubscribe-link.d.ts +25 -0
- package/dist/delivery/unsubscribe-link.js +40 -0
- package/dist/delivery/unsubscribe-page-strings.d.ts +11 -0
- package/dist/delivery/unsubscribe-page-strings.js +15 -0
- package/dist/delivery/unsubscribe.d.ts +3 -0
- package/dist/delivery/unsubscribe.js +65 -29
- package/dist/derivatives-sharp/feature.js +1 -1
- package/dist/document-ingest-foundation/feature.js +2 -2
- package/dist/file-derivatives/feature.js +3 -3
- package/dist/file-foundation/feature.js +1 -1
- package/dist/file-provider-inmemory/feature.js +2 -2
- package/dist/file-provider-s3/feature.js +3 -3
- package/dist/file-provider-s3-env/feature.js +2 -2
- package/dist/files-tenant-data/index.js +1 -1
- package/dist/folders/feature.js +2 -2
- package/dist/folders-user-data/index.js +1 -1
- package/dist/form-draft/feature.js +2 -2
- package/dist/form-draft-user-data/index.js +1 -1
- package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
- package/dist/inbound-mail-foundation/connect-routes.js +6 -1
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/types.d.ts +17 -7
- package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
- package/dist/inbound-provider-imap/feature.js +2 -2
- package/dist/inbound-provider-inmemory/feature.js +1 -1
- package/dist/ledger/feature.js +5 -5
- package/dist/legal-pages/feature.js +1 -1
- package/dist/mail-foundation/feature.d.ts +16 -1
- package/dist/mail-foundation/feature.js +30 -9
- package/dist/mail-foundation/index.d.ts +1 -1
- package/dist/mail-foundation/index.js +1 -1
- package/dist/mail-transport-inmemory/feature.js +2 -2
- package/dist/mail-transport-smtp/feature.js +3 -3
- package/dist/managed-pages/css-gate.js +1 -1
- package/dist/managed-pages/feature.js +2 -2
- package/dist/metrics/feature.js +2 -2
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history-user-data/index.js +1 -1
- package/dist/personal-access-tokens/feature.js +17 -13
- package/dist/readiness/feature.js +2 -2
- package/dist/renderer-foundation/feature.js +1 -1
- package/dist/renderer-pdf/feature.js +1 -1
- package/dist/secrets/feature.js +1 -1
- package/dist/seo/feature.js +1 -1
- package/dist/sessions/feature.js +20 -16
- package/dist/step-dispatcher/feature.js +1 -1
- package/dist/subscription-mollie/feature.js +1 -1
- package/dist/subscription-stripe/feature.js +3 -3
- package/dist/tags/feature.js +2 -2
- package/dist/template-resolver/compile-document-body.js +47 -11
- package/dist/template-resolver/document-sanitizer.d.ts +2 -0
- package/dist/template-resolver/document-sanitizer.js +16 -1
- package/dist/template-resolver/document-schema.d.ts +2 -0
- package/dist/template-resolver/document-schema.js +31 -0
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver/handlers/bundle-import.js +3 -2
- package/dist/template-resolver/handlers/copy-locale.write.js +3 -2
- package/dist/template-resolver/handlers/find-by-id.query.js +5 -1
- package/dist/template-resolver/handlers/list.query.js +3 -1
- package/dist/template-resolver/handlers/overview.query.js +4 -2
- package/dist/template-resolver/handlers/template-write.d.ts +2 -1
- package/dist/template-resolver/handlers/template-write.js +12 -1
- package/dist/template-resolver/index.d.ts +3 -2
- package/dist/template-resolver/index.js +3 -2
- package/dist/template-resolver/qualified-names.d.ts +1 -0
- package/dist/template-resolver/qualified-names.js +1 -0
- package/dist/template-resolver/seeding.d.ts +4 -0
- package/dist/template-resolver/seeding.js +40 -5
- package/dist/template-resolver/template-preview.js +2 -0
- package/dist/template-resolver/template-system-only.d.ts +8 -0
- package/dist/template-resolver/template-system-only.js +11 -0
- package/dist/template-resolver/template-validation.js +42 -28
- package/dist/template-resolver/web/i18n.js +1 -0
- package/dist/template-resolver-user-data/index.js +1 -1
- package/dist/tenant/feature.js +4 -4
- package/dist/tenant/seeding.js +1 -1
- package/dist/tenant-lifecycle/feature.js +2 -3
- package/dist/tenant-settings/feature.js +1 -1
- package/dist/tier-engine/feature.js +86 -72
- package/dist/user/feature.js +1 -1
- package/dist/user-data-rights/feature.js +4 -4
- package/dist/user-data-rights-defaults/feature.js +2 -2
- package/dist/user-profile/feature.js +4 -4
- package/dist/waitlist/feature.js +2 -2
- package/package.json +10 -10
- package/src/agent-tools/changes.json +6 -0
- package/src/auth-email-password/changes.json +13 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/channel-email/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/delivery/changes.json +18 -0
- package/src/inbound-mail-foundation/changes.json +13 -0
- package/src/template-resolver/changes.json +25 -0
- package/src/user-data-rights/changes.json +6 -0
|
@@ -9,8 +9,8 @@ export function createChannelTeamsFeature(options = {}) {
|
|
|
9
9
|
category: "notifications",
|
|
10
10
|
recommended: false,
|
|
11
11
|
});
|
|
12
|
-
r.requires("delivery");
|
|
13
|
-
r.requires("secrets");
|
|
12
|
+
r.requires({ features: ["delivery"] });
|
|
13
|
+
r.requires({ features: ["secrets"] });
|
|
14
14
|
const webhookTarget = resolveChatWebhookTarget({ defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS }, options);
|
|
15
15
|
const webhooks = r.secretNamespace("webhooks", {
|
|
16
16
|
label: {
|
|
@@ -10,8 +10,8 @@ export function createChannelTelegramFeature(options = {}) {
|
|
|
10
10
|
category: "notifications",
|
|
11
11
|
recommended: false,
|
|
12
12
|
});
|
|
13
|
-
r.requires("delivery");
|
|
14
|
-
r.requires("secrets");
|
|
13
|
+
r.requires({ features: ["delivery"] });
|
|
14
|
+
r.requires({ features: ["secrets"] });
|
|
15
15
|
const botToken = r.secret("botToken", {
|
|
16
16
|
label: { en: "Telegram bot token" },
|
|
17
17
|
hint: {
|
|
@@ -22,7 +22,7 @@ export function createComplianceProfilesFeature(options) {
|
|
|
22
22
|
// Context, Profile-Selection ist eigene Entity, sub-processor-Liste
|
|
23
23
|
// sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
|
|
24
24
|
// r.requires hier rein.
|
|
25
|
-
r.entity("tenant-compliance-profile", tenantComplianceProfileEntity);
|
|
25
|
+
r.entity({ name: "tenant-compliance-profile", ...tenantComplianceProfileEntity });
|
|
26
26
|
r.exposesApi("compliance.forTenant");
|
|
27
27
|
const handlers = {
|
|
28
28
|
setProfile: r.writeHandler({ ...setProfileWrite, access: { roles: resolvedAccess } }),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// a separate feature (mirrors folders-user-data/notes-history-user-data)
|
|
8
8
|
// rather than folded into compliance-profiles: this is the one genuinely
|
|
9
9
|
// cross-tenant capability in the picker's orbit, and it alone needs
|
|
10
|
-
// r.systemScope() + a hard r.requires("tenant") — apps that only want the
|
|
10
|
+
// r.systemScope() + a hard r.requires({ features: ["tenant"] }) — apps that only want the
|
|
11
11
|
// per-tenant picker (the vast majority) stay unaffected.
|
|
12
12
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
13
|
import { tenantsMissingProfileQuery } from "./handlers/tenants-missing-profile.query.js";
|
|
@@ -19,8 +19,8 @@ export const complianceProfilesOpsFeature = defineFeature("compliance-profiles-o
|
|
|
19
19
|
recommended: false,
|
|
20
20
|
});
|
|
21
21
|
r.systemScope();
|
|
22
|
-
r.requires("compliance-profiles");
|
|
23
|
-
r.requires("tenant");
|
|
22
|
+
r.requires({ features: ["compliance-profiles"] });
|
|
23
|
+
r.requires({ features: ["tenant"] });
|
|
24
24
|
const queries = {
|
|
25
25
|
tenantsMissingProfile: r.queryHandler(tenantsMissingProfileQuery),
|
|
26
26
|
};
|
package/dist/config/feature.js
CHANGED
|
@@ -24,7 +24,7 @@ export function createConfigFeature() {
|
|
|
24
24
|
// projection write in one TX. Subscribers that need config-change
|
|
25
25
|
// semantics listen to those auto-events via r.multiStreamProjection
|
|
26
26
|
// (see docs/plans/architecture/event-sourcing-pivot.md §4.7).
|
|
27
|
-
r.entity("config-value", configValueEntity);
|
|
27
|
+
r.entity({ name: "config-value", ...configValueEntity });
|
|
28
28
|
const handlers = {
|
|
29
29
|
set: r.writeHandler(setWrite),
|
|
30
30
|
reset: r.writeHandler(resetWrite),
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// (crypto-shredding, event ciphertext, forget pipeline).
|
|
11
11
|
//
|
|
12
12
|
// **Was diese Feature liefert (B1 + B2, 2026-05-23):**
|
|
13
|
-
// 1. r.entity("field-definition") — Definition-Storage (event-sourced).
|
|
13
|
+
// 1. r.entity({ name: "field-definition" }) — Definition-Storage (event-sourced).
|
|
14
14
|
// 2. define-tenant-field / define-system-field — RBAC write-handlers für
|
|
15
15
|
// Definition-CRUD.
|
|
16
16
|
// 3. delete-tenant-field / delete-system-field — RBAC write-handlers.
|
|
@@ -75,7 +75,7 @@ function registerCustomFields(r, variant) {
|
|
|
75
75
|
category: "data",
|
|
76
76
|
recommended: false,
|
|
77
77
|
});
|
|
78
|
-
r.entity("field-definition", fieldDefinitionEntity);
|
|
78
|
+
r.entity({ name: "field-definition", ...fieldDefinitionEntity });
|
|
79
79
|
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
80
80
|
// Returned EventDefs liefern .name als compile-time literal-typed string,
|
|
81
81
|
// den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
|
|
@@ -28,8 +28,8 @@ export function customFieldsField() {
|
|
|
28
28
|
// Consumer-side:
|
|
29
29
|
//
|
|
30
30
|
// defineFeature("property-mgmt", (r) => {
|
|
31
|
-
// r.entity("property", propertyEntity); // muss customFieldsField() haben
|
|
32
|
-
// r.requires(CUSTOM_FIELDS_FEATURE_NAME);
|
|
31
|
+
// r.entity({ name: "property", ...propertyEntity }); // muss customFieldsField() haben
|
|
32
|
+
// r.requires({ features: [CUSTOM_FIELDS_FEATURE_NAME] });
|
|
33
33
|
// wireCustomFieldsFor(r, "property", propertyTable);
|
|
34
34
|
// });
|
|
35
35
|
//
|
|
@@ -158,18 +158,22 @@ export function wireCustomFieldsFor(r, entityName, entityTable) {
|
|
|
158
158
|
});
|
|
159
159
|
// postQuery-hook: flatten row.customFields jsonb auf root-level der
|
|
160
160
|
// API-response. Spec-Promise Z.4 "indistinguishable von Stammfeldern".
|
|
161
|
-
r.hook(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
161
|
+
r.hook({
|
|
162
|
+
type: "postQuery",
|
|
163
|
+
target: { allOf: entityName },
|
|
164
|
+
handler: async ({ rows }) => ({
|
|
165
|
+
rows: rows.map((row) => {
|
|
166
|
+
const customFields = row["customFields"];
|
|
167
|
+
if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
|
|
168
|
+
return {
|
|
169
|
+
...customFields, // @cast-boundary db-row jsonb runtime-untyped
|
|
170
|
+
...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return row;
|
|
174
|
+
}),
|
|
171
175
|
}),
|
|
172
|
-
})
|
|
176
|
+
});
|
|
173
177
|
// Search-Payload-Extension: customFields-keys flach ins Index-Doc.
|
|
174
178
|
// Anders als postQuery-hook (der ALLE keys merged) trägt der Search-
|
|
175
179
|
// Contributor nur die als searchable=true definierten fields bei. v1
|
|
@@ -37,7 +37,7 @@ export function createDataRetentionFeature() {
|
|
|
37
37
|
category: "compliance",
|
|
38
38
|
recommended: false,
|
|
39
39
|
});
|
|
40
|
-
r.entity("tenant-retention-override", tenantRetentionOverrideEntity);
|
|
40
|
+
r.entity({ name: "tenant-retention-override", ...tenantRetentionOverrideEntity });
|
|
41
41
|
// S2.D3: Cross-Feature-API fuer Forget-Flow + Cleanup-Job
|
|
42
42
|
r.exposesApi("retention.policyFor");
|
|
43
43
|
r.queryHandler(policyForQuery);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Retention policy keyed by entity name, i.e. the `name` from
|
|
4
|
+
* r.entity({ name, ... }) (kebab-case or lowercase). The cleanup job walks
|
|
5
|
+
* all known entities; anything missing from the preset falls back to the
|
|
6
|
+
* entity default (layer 1).
|
|
7
7
|
*/
|
|
8
8
|
export type RetentionPreset = Readonly<Record<string, RetentionDef>>;
|
|
9
9
|
export type RetentionPresetKey = "default" | "dsgvo-basic" | "dsgvo-hgb" | "swiss-dsg";
|
|
@@ -92,6 +92,7 @@ export function buildChannelContext(db, registry, sseBroker, tenantId, secrets,
|
|
|
92
92
|
sseBroker,
|
|
93
93
|
tenantId,
|
|
94
94
|
secrets,
|
|
95
|
+
config: tenantConfig,
|
|
95
96
|
renderMail: createTemplateMailRenderer(db, registry, tenantId, tenantConfig),
|
|
96
97
|
...(filesForTenant !== undefined && {
|
|
97
98
|
readAttachment: createEmailAttachmentReader(db, registry, tenantId, tenantConfig, filesForTenant),
|
|
@@ -2,4 +2,4 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
export type AddressOptOutKey = string;
|
|
4
4
|
export declare function addressOptOutKey(addressHash: string, channel: string): AddressOptOutKey;
|
|
5
|
-
export declare function selectOptedOutAddresses(db: DbConnection, tenantId: TenantId,
|
|
5
|
+
export declare function selectOptedOutAddresses(db: DbConnection, tenantId: TenantId, notificationTypeNames: readonly string[], addressHashes: readonly string[]): Promise<ReadonlySet<AddressOptOutKey>>;
|
|
@@ -7,9 +7,13 @@ export function addressOptOutKey(addressHash, channel) {
|
|
|
7
7
|
// ("*") semantics for address opt-outs, the token that creates a row always
|
|
8
8
|
// carries one concrete notificationType/channel pair. One query covers every
|
|
9
9
|
// recipient of a notify() call.
|
|
10
|
-
export async function selectOptedOutAddresses(db, tenantId,
|
|
10
|
+
export async function selectOptedOutAddresses(db, tenantId, notificationTypeNames, addressHashes) {
|
|
11
11
|
if (addressHashes.length === 0)
|
|
12
12
|
return new Set();
|
|
13
|
-
const rows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
13
|
+
const rows = await selectMany(db, notificationAddressOptOutsTable, {
|
|
14
|
+
tenantId,
|
|
15
|
+
notificationType: { in: notificationTypeNames },
|
|
16
|
+
addressHash: { in: addressHashes },
|
|
17
|
+
});
|
|
14
18
|
return new Set(rows.map((row) => addressOptOutKey(row.addressHash, row.channel)));
|
|
15
19
|
}
|
|
@@ -5,4 +5,4 @@ export type NotificationPreferenceRow = {
|
|
|
5
5
|
readonly channel: string;
|
|
6
6
|
readonly enabled: boolean;
|
|
7
7
|
};
|
|
8
|
-
export declare function selectNotificationPreferences(db: DbConnection, tenantId: TenantId, userId: string,
|
|
8
|
+
export declare function selectNotificationPreferences(db: DbConnection, tenantId: TenantId, userId: string, notificationTypeNames: readonly string[], channelName: string): Promise<readonly NotificationPreferenceRow[]>;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
// @wrapper-known semantic-alias
|
|
3
|
-
export async function selectNotificationPreferences(db, tenantId, userId,
|
|
3
|
+
export async function selectNotificationPreferences(db, tenantId, userId, notificationTypeNames, channelName) {
|
|
4
4
|
return unsafeReadRetrying(db, `SELECT notification_type AS "notificationType", channel, enabled
|
|
5
5
|
FROM read_notification_preferences
|
|
6
6
|
WHERE tenant_id = $1
|
|
7
7
|
AND user_id = $2
|
|
8
8
|
AND (
|
|
9
|
-
(notification_type = $3 AND channel = $4)
|
|
9
|
+
(notification_type = ANY($3::text[]) AND channel = $4)
|
|
10
10
|
OR (notification_type = '*' AND channel = $4)
|
|
11
|
-
OR (notification_type = $3 AND channel = '*')
|
|
12
|
-
)`, [tenantId, userId,
|
|
11
|
+
OR (notification_type = ANY($3::text[]) AND channel = '*')
|
|
12
|
+
)`, [tenantId, userId, notificationTypeNames, channelName]);
|
|
13
13
|
}
|
|
@@ -6,6 +6,7 @@ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
|
6
6
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
7
7
|
import type { Redis } from "ioredis";
|
|
8
8
|
import { type DeliveryChannel, type DeliveryService } from "./types.js";
|
|
9
|
+
import { type UnsubscribeLinkConfig } from "./unsubscribe-link.js";
|
|
9
10
|
export declare function redactedMessageOf(err: unknown): string;
|
|
10
11
|
export type RateLimitConfig = {
|
|
11
12
|
readonly redis: Redis;
|
|
@@ -28,6 +29,7 @@ export type DeliveryServiceOptions = {
|
|
|
28
29
|
readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
|
|
29
30
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
30
31
|
readonly log?: Logger;
|
|
32
|
+
readonly unsubscribeLinks?: UnsubscribeLinkConfig;
|
|
31
33
|
};
|
|
32
34
|
export declare function collectChannels(registry: Registry): DeliveryChannel[];
|
|
33
35
|
export declare function createDeliveryService(options: DeliveryServiceOptions): DeliveryService;
|
|
@@ -9,10 +9,12 @@ import { redactErrorText } from "../shared/index.js";
|
|
|
9
9
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
10
10
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
11
11
|
import { buildChannelContext } from "./channel-context.js";
|
|
12
|
-
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, } from "./constants.js";
|
|
12
|
+
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, EMAIL_CHANNEL_NAME, } from "./constants.js";
|
|
13
13
|
import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
|
|
14
14
|
import { selectNotificationPreferences } from "./db/queries/preferences.js";
|
|
15
|
+
import { notificationTypeNames } from "./notification-type-aliases.js";
|
|
15
16
|
import { isDeliveryChannelPlugin, } from "./types.js";
|
|
17
|
+
import { buildUnsubscribeUrl, unsubscribeLinkOrigin, } from "./unsubscribe-link.js";
|
|
16
18
|
export function redactedMessageOf(err) {
|
|
17
19
|
return redactErrorText(err instanceof Error ? err.message : String(err));
|
|
18
20
|
}
|
|
@@ -27,7 +29,7 @@ export function collectChannels(registry) {
|
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
export function createDeliveryService(options) {
|
|
30
|
-
const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, } = options;
|
|
32
|
+
const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, unsubscribeLinks, } = options;
|
|
31
33
|
const idemRedis = idempotencyRedis ?? rateLimit?.redis;
|
|
32
34
|
const logError = createFallbackLogger("delivery", log);
|
|
33
35
|
// Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
|
|
@@ -254,6 +256,31 @@ export function createDeliveryService(options) {
|
|
|
254
256
|
});
|
|
255
257
|
}
|
|
256
258
|
}
|
|
259
|
+
// A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
|
|
260
|
+
// context, never from the notification data.
|
|
261
|
+
async function withUnsubscribeUrl(message, channelName, recipient, channelCtx) {
|
|
262
|
+
if (!unsubscribeLinks || channelName !== EMAIL_CHANNEL_NAME)
|
|
263
|
+
return message;
|
|
264
|
+
if (registry.getAllNotifications().get(message.notificationType)?.unsubscribe !== true) {
|
|
265
|
+
return message;
|
|
266
|
+
}
|
|
267
|
+
const supplied = message.data?.["unsubscribeUrl"];
|
|
268
|
+
if (typeof supplied === "string" && supplied !== "")
|
|
269
|
+
return message;
|
|
270
|
+
const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
|
|
271
|
+
tenantId: channelCtx.tenantId,
|
|
272
|
+
notificationType: message.notificationType,
|
|
273
|
+
}));
|
|
274
|
+
const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
|
|
275
|
+
tenantId: channelCtx.tenantId,
|
|
276
|
+
notificationType: message.notificationType,
|
|
277
|
+
channel: channelName,
|
|
278
|
+
locale: message.locale,
|
|
279
|
+
});
|
|
280
|
+
return unsubscribeUrl === undefined
|
|
281
|
+
? message
|
|
282
|
+
: { ...message, data: { ...message.data, unsubscribeUrl } };
|
|
283
|
+
}
|
|
257
284
|
function buildMessage(notificationType, data, channelName, locale) {
|
|
258
285
|
// Look up per-channel template from notification definition
|
|
259
286
|
const notifDef = registry.getAllNotifications().get(notificationType);
|
|
@@ -292,7 +319,7 @@ export function createDeliveryService(options) {
|
|
|
292
319
|
if (addressHash !== undefined)
|
|
293
320
|
addressHashes.add(addressHash);
|
|
294
321
|
}
|
|
295
|
-
return selectOptedOutAddresses(run.db, tenantId, notificationType, [...addressHashes]);
|
|
322
|
+
return selectOptedOutAddresses(run.db, tenantId, notificationTypeNames(registry, notificationType), [...addressHashes]);
|
|
296
323
|
}
|
|
297
324
|
function isAddressSuppressed(suppressed, address, channelName) {
|
|
298
325
|
const addressHash = hashUnsubscribeAddress(address);
|
|
@@ -354,13 +381,19 @@ export function createDeliveryService(options) {
|
|
|
354
381
|
// { type: "orderAssigned", channel: "*", enabled: true } enables orderAssigned everywhere
|
|
355
382
|
// → orderAssigned on inApp: disabled (conservative) unless an exact entry overrides.
|
|
356
383
|
async function isChannelEnabled(run, userId, tenantId, notificationType, channelName) {
|
|
357
|
-
const prefs = await selectNotificationPreferences(run.db, tenantId, userId, notificationType, channelName);
|
|
384
|
+
const prefs = await selectNotificationPreferences(run.db, tenantId, userId, notificationTypeNames(registry, notificationType), channelName);
|
|
358
385
|
if (prefs.length === 0)
|
|
359
386
|
return true;
|
|
360
387
|
// Exact match (both specific) wins over any wildcard
|
|
361
388
|
const exact = prefs.find((p) => p.notificationType === notificationType && p.channel === channelName);
|
|
362
389
|
if (exact)
|
|
363
390
|
return exact.enabled;
|
|
391
|
+
// Rows stored under a former name: the user's choice from before the rename
|
|
392
|
+
const aliasExact = prefs.filter((p) => p.notificationType !== "*" &&
|
|
393
|
+
p.notificationType !== notificationType &&
|
|
394
|
+
p.channel === channelName);
|
|
395
|
+
if (aliasExact.length > 0)
|
|
396
|
+
return !aliasExact.some((p) => p.enabled === false);
|
|
364
397
|
// Only wildcards matched: any disabled entry disables delivery (deterministic
|
|
365
398
|
// and conservative — DB ordering no longer decides the outcome).
|
|
366
399
|
return !prefs.some((p) => p.enabled === false);
|
|
@@ -428,7 +461,7 @@ export function createDeliveryService(options) {
|
|
|
428
461
|
run,
|
|
429
462
|
channel,
|
|
430
463
|
address,
|
|
431
|
-
message,
|
|
464
|
+
message: await withUnsubscribeUrl(message, channel.name, { kind: "user", userId }, channelCtx),
|
|
432
465
|
channelCtx,
|
|
433
466
|
tenantId,
|
|
434
467
|
recipientId: userId,
|
|
@@ -501,7 +534,7 @@ export function createDeliveryService(options) {
|
|
|
501
534
|
run,
|
|
502
535
|
channel,
|
|
503
536
|
address,
|
|
504
|
-
message,
|
|
537
|
+
message: await withUnsubscribeUrl(message, channel.name, { kind: "address", address }, channelCtx),
|
|
505
538
|
channelCtx,
|
|
506
539
|
tenantId,
|
|
507
540
|
recipientId,
|
package/dist/delivery/feature.js
CHANGED
|
@@ -19,7 +19,7 @@ export function createDeliveryFeature(options) {
|
|
|
19
19
|
const resolvedAccess = options?.access ?? access.admin;
|
|
20
20
|
const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
|
|
21
21
|
return defineFeature("delivery", (r) => {
|
|
22
|
-
r.describe(
|
|
22
|
+
r.describe("The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.");
|
|
23
23
|
r.uiHints({
|
|
24
24
|
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
25
25
|
category: "notifications",
|
|
@@ -27,18 +27,22 @@ export function createDeliveryFeature(options) {
|
|
|
27
27
|
});
|
|
28
28
|
r.systemScope();
|
|
29
29
|
// delivery-log's tenantId column has refEntity: "tenant:tenant" (boot-checked).
|
|
30
|
-
r.requires("tenant");
|
|
30
|
+
r.requires({ features: ["tenant"] });
|
|
31
31
|
// Backing table: the (tenant,user,type,channel) uniqueIndex lives only on
|
|
32
32
|
// the physical table, not on the entity fields, so the generator would
|
|
33
33
|
// otherwise omit it → duplicate preference rows on concurrent upserts.
|
|
34
|
-
r.entity(
|
|
35
|
-
|
|
34
|
+
r.entity({
|
|
35
|
+
name: "notification-preference",
|
|
36
|
+
...notificationPreferenceEntity,
|
|
37
|
+
backingTable: notificationPreferencesTable,
|
|
36
38
|
});
|
|
37
39
|
// Same ride-along reason as notification-preference above: the
|
|
38
40
|
// (tenant,addressHash,type,channel) uniqueIndex lives only on the
|
|
39
41
|
// physical table.
|
|
40
|
-
r.entity(
|
|
41
|
-
|
|
42
|
+
r.entity({
|
|
43
|
+
name: "notification-address-opt-out",
|
|
44
|
+
...notificationAddressOptOutEntity,
|
|
45
|
+
backingTable: notificationAddressOptOutsTable,
|
|
42
46
|
});
|
|
43
47
|
r.storeTable(deliveryAttemptsTableMeta, {
|
|
44
48
|
reason: "read_side.delivery_attempt_log",
|
|
@@ -2,6 +2,7 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { removeAddressOptOut } from "../address-opt-out.js";
|
|
5
|
+
import { currentNotificationType, notificationTypeNames } from "../notification-type-aliases.js";
|
|
5
6
|
export const resubscribeAddressWrite = defineWriteHandler({
|
|
6
7
|
name: "resubscribeAddress",
|
|
7
8
|
schema: z.object({
|
|
@@ -18,7 +19,8 @@ export const resubscribeAddressWrite = defineWriteHandler({
|
|
|
18
19
|
agent: { expose: false, risk: "high" },
|
|
19
20
|
description: "Removes a no-account address opt-out for one notificationType/channel combination; dispatched by the signed resubscribe route, not meant for UI callers.",
|
|
20
21
|
handler: async (event, ctx) => {
|
|
21
|
-
const { addressHash,
|
|
22
|
+
const { addressHash, channel } = event.payload;
|
|
23
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
22
24
|
const { tenantId } = event.user;
|
|
23
25
|
if (!ctx.systemDb) {
|
|
24
26
|
throw new InternalError({
|
|
@@ -26,14 +28,16 @@ export const resubscribeAddressWrite = defineWriteHandler({
|
|
|
26
28
|
});
|
|
27
29
|
}
|
|
28
30
|
const db = ctx.systemDb.assertTenantMatch(tenantId);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
for (const name of notificationTypeNames(ctx.registry, notificationType)) {
|
|
32
|
+
const result = await removeAddressOptOut(db, event.user, {
|
|
33
|
+
tenantId,
|
|
34
|
+
addressHash,
|
|
35
|
+
notificationType: name,
|
|
36
|
+
channel,
|
|
37
|
+
});
|
|
38
|
+
if (!result.isSuccess)
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
37
41
|
return { isSuccess: true, data: { notificationType, channel } };
|
|
38
42
|
},
|
|
39
43
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
4
5
|
import { upsertPreference } from "../upsert-preference.js";
|
|
5
6
|
export const resubscribeUserWrite = defineWriteHandler({
|
|
6
7
|
name: "resubscribeUser",
|
|
@@ -16,7 +17,8 @@ export const resubscribeUserWrite = defineWriteHandler({
|
|
|
16
17
|
agent: { expose: false },
|
|
17
18
|
description: "Re-enables one notification type and channel combination for the userId carried in a verified unsubscribe token; dispatched by the signed resubscribe route, not meant for UI callers.",
|
|
18
19
|
handler: async (event, ctx) => {
|
|
19
|
-
const { userId,
|
|
20
|
+
const { userId, channel } = event.payload;
|
|
21
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
20
22
|
const { tenantId } = event.user;
|
|
21
23
|
if (!ctx.systemDb) {
|
|
22
24
|
throw new InternalError({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
4
5
|
import { upsertPreference } from "../upsert-preference.js";
|
|
5
6
|
export const setPreferenceWrite = defineWriteHandler({
|
|
6
7
|
name: "setPreference",
|
|
@@ -18,7 +19,8 @@ export const setPreferenceWrite = defineWriteHandler({
|
|
|
18
19
|
},
|
|
19
20
|
description: "Enables or disables one notification type and channel combination for the calling user, where either side may be the wildcard * as a catch-all; use it when a user changes their own notification settings.",
|
|
20
21
|
handler: async (event, ctx) => {
|
|
21
|
-
const {
|
|
22
|
+
const { channel, enabled } = event.payload;
|
|
23
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
22
24
|
const { id: userId, tenantId } = event.user;
|
|
23
25
|
// delivery runs in system-scope (feature.ts r.systemScope()) — ctx.systemDb
|
|
24
26
|
// is guaranteed there. No client-supplied userId exists on this payload;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
export declare const unsubscribeAddressPayloadSchema: z.ZodObject<{
|
|
3
|
+
addressHash: z.ZodString;
|
|
4
|
+
notificationType: z.ZodString;
|
|
5
|
+
channel: z.ZodString;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
export type UnsubscribeAddressPayload = z.infer<typeof unsubscribeAddressPayloadSchema>;
|
|
2
8
|
export declare const unsubscribeAddressWrite: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"unsubscribeAddress", z.ZodObject<{
|
|
3
9
|
addressHash: z.ZodString;
|
|
4
10
|
notificationType: z.ZodString;
|
|
@@ -2,13 +2,17 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { upsertAddressOptOut } from "../address-opt-out.js";
|
|
5
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
6
|
+
// Also the payload an app job receives with `trigger: { on: DeliveryHandlers.unsubscribeAddress }`
|
|
7
|
+
// after a committed address opt-out (the tenant is the job's `ctx.triggeredBy.tenantId`).
|
|
8
|
+
export const unsubscribeAddressPayloadSchema = z.object({
|
|
9
|
+
addressHash: z.string().min(1),
|
|
10
|
+
notificationType: z.string().min(1),
|
|
11
|
+
channel: z.string().min(1),
|
|
12
|
+
});
|
|
5
13
|
export const unsubscribeAddressWrite = defineWriteHandler({
|
|
6
14
|
name: "unsubscribeAddress",
|
|
7
|
-
schema:
|
|
8
|
-
addressHash: z.string().min(1),
|
|
9
|
-
notificationType: z.string().min(1),
|
|
10
|
-
channel: z.string().min(1),
|
|
11
|
-
}),
|
|
15
|
+
schema: unsubscribeAddressPayloadSchema,
|
|
12
16
|
access: { roles: access.systemAdmin },
|
|
13
17
|
// Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
|
|
14
18
|
// verify() has already proven the address-token signature — not a surface
|
|
@@ -16,7 +20,8 @@ export const unsubscribeAddressWrite = defineWriteHandler({
|
|
|
16
20
|
agent: { expose: false },
|
|
17
21
|
description: "Records a no-account address opt-out for one notificationType/channel combination; dispatched by the signed unsubscribe route, not meant for UI callers.",
|
|
18
22
|
handler: async (event, ctx) => {
|
|
19
|
-
const { addressHash,
|
|
23
|
+
const { addressHash, channel } = event.payload;
|
|
24
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
20
25
|
const { tenantId } = event.user;
|
|
21
26
|
if (!ctx.systemDb) {
|
|
22
27
|
throw new InternalError({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
4
5
|
import { upsertPreference } from "../upsert-preference.js";
|
|
5
6
|
export const unsubscribeUserWrite = defineWriteHandler({
|
|
6
7
|
name: "unsubscribeUser",
|
|
@@ -18,7 +19,8 @@ export const unsubscribeUserWrite = defineWriteHandler({
|
|
|
18
19
|
handler: async (event, ctx) => {
|
|
19
20
|
// userId comes from the payload, not event.user.id — the caller here is
|
|
20
21
|
// the system user dispatchSystemWrite runs as, not the user unsubscribing.
|
|
21
|
-
const { userId,
|
|
22
|
+
const { userId, channel } = event.payload;
|
|
23
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
22
24
|
const { tenantId } = event.user;
|
|
23
25
|
if (!ctx.systemDb) {
|
|
24
26
|
throw new InternalError({
|
package/dist/delivery/index.d.ts
CHANGED
|
@@ -9,8 +9,11 @@ export type { DeliveryStatusValue } from "./constants.js";
|
|
|
9
9
|
export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, EMAIL_CHANNEL_NAME, } from "./constants.js";
|
|
10
10
|
export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
|
|
11
11
|
export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
|
|
12
|
+
export { type UnsubscribeAddressPayload, unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
|
|
12
13
|
export { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
13
14
|
export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
|
|
14
15
|
export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.js";
|
|
15
16
|
export { type AttachmentOrigin, type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type RenderedMessage, } from "./types.js";
|
|
16
17
|
export { type AddressUnsubscribeTokenPayload, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.js";
|
|
18
|
+
export type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
|
|
19
|
+
export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
|
package/dist/delivery/index.js
CHANGED
|
@@ -8,8 +8,10 @@ export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget,
|
|
|
8
8
|
export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, EMAIL_CHANNEL_NAME, } from "./constants.js";
|
|
9
9
|
export { collectChannels, createDeliveryService, } from "./delivery-service.js";
|
|
10
10
|
export { createDeliveryFeature } from "./feature.js";
|
|
11
|
+
export { unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
|
|
11
12
|
export { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
12
13
|
export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
|
|
13
14
|
export { createDeliveryTestContext } from "./testing.js";
|
|
14
15
|
export { isDeliveryChannelPlugin, } from "./types.js";
|
|
15
16
|
export { createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
|
|
17
|
+
export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function notificationTypeNames(registry, type) {
|
|
2
|
+
const renamedFrom = registry.getAllNotifications().get(type)?.renamedFrom ?? [];
|
|
3
|
+
return [type, ...renamedFrom];
|
|
4
|
+
}
|
|
5
|
+
export function currentNotificationType(registry, type) {
|
|
6
|
+
for (const [name, definition] of registry.getAllNotifications()) {
|
|
7
|
+
if (definition.renamedFrom?.includes(type))
|
|
8
|
+
return name;
|
|
9
|
+
}
|
|
10
|
+
return type;
|
|
11
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
8
8
|
import type { Redis } from "ioredis";
|
|
9
9
|
import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
|
|
10
10
|
import type { DeliveryService } from "./types.js";
|
|
11
|
+
import type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
|
|
11
12
|
export type CreateDeliveryTestContextOptions = {
|
|
12
13
|
readonly tenantUserIdsQuery?: string;
|
|
13
14
|
readonly rateLimit?: RateLimitConfig;
|
|
@@ -19,6 +20,7 @@ export type CreateDeliveryTestContextOptions = {
|
|
|
19
20
|
readonly idempotencyRedis?: Redis;
|
|
20
21
|
readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
|
|
21
22
|
readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
|
|
23
|
+
readonly unsubscribeLinks?: UnsubscribeLinkConfig;
|
|
22
24
|
};
|
|
23
25
|
/**
|
|
24
26
|
* Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import type { DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { ConfigAccessor, DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
5
5
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
6
6
|
import type { MailPayload, RenderedTemplateRef } from "../renderer-foundation/index.js";
|
|
@@ -23,6 +23,7 @@ export type ChannelContext = {
|
|
|
23
23
|
readonly sseBroker: SseBroker | undefined;
|
|
24
24
|
readonly tenantId: TenantId;
|
|
25
25
|
readonly secrets?: SecretsContext | undefined;
|
|
26
|
+
readonly config?: ConfigAccessor | undefined;
|
|
26
27
|
readonly renderMail?: (payload: MailPayload) => Promise<RenderedMail>;
|
|
27
28
|
readonly readAttachment?: (ref: EmailAttachmentRef, origin?: AttachmentOrigin) => Promise<{
|
|
28
29
|
readonly content: Uint8Array;
|