@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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { ChannelContext } from "./types.js";
|
|
3
|
+
/** Secret shared with `createUnsubscribeRoutes` plus the public origin the links point at. */
|
|
4
|
+
export type UnsubscribeLinkConfig = {
|
|
5
|
+
readonly secret: string;
|
|
6
|
+
readonly baseUrl: string;
|
|
7
|
+
/** The sending tenant's own public origin (subdomain or custom domain), null when it has
|
|
8
|
+
* none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
|
|
9
|
+
* fall back to `baseUrl`, so a missing tenant origin never fails a send. */
|
|
10
|
+
readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
|
|
11
|
+
};
|
|
12
|
+
export type UnsubscribeLinkRecipient = {
|
|
13
|
+
readonly kind: "user";
|
|
14
|
+
readonly userId: string;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: "address";
|
|
17
|
+
readonly address: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function unsubscribeLinkOrigin(config: UnsubscribeLinkConfig, ctx: ChannelContext, onFallback: (reason: string) => void): Promise<string>;
|
|
20
|
+
export declare function buildUnsubscribeUrl(config: UnsubscribeLinkConfig, origin: string, recipient: UnsubscribeLinkRecipient, scope: {
|
|
21
|
+
tenantId: TenantId;
|
|
22
|
+
notificationType: string;
|
|
23
|
+
channel: string;
|
|
24
|
+
locale: string | undefined;
|
|
25
|
+
}): Promise<string | undefined>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { publicOriginOf } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
3
|
+
import { DELIVERY_UNSUBSCRIBE_PATH } from "./constants.js";
|
|
4
|
+
import { signAddressUnsubscribeToken, signUnsubscribeToken } from "./unsubscribe.js";
|
|
5
|
+
// https only, unless the app itself runs on plain http (local dev): a tenant origin must not
|
|
6
|
+
// downgrade the link that carries the token.
|
|
7
|
+
function isUsableTenantOrigin(origin, baseUrl) {
|
|
8
|
+
return (origin.startsWith("https://") || (baseUrl.startsWith("http://") && origin.startsWith("http://")));
|
|
9
|
+
}
|
|
10
|
+
export async function unsubscribeLinkOrigin(config, ctx, onFallback) {
|
|
11
|
+
if (config.tenantOrigin === undefined)
|
|
12
|
+
return config.baseUrl;
|
|
13
|
+
let resolved;
|
|
14
|
+
try {
|
|
15
|
+
resolved = await config.tenantOrigin(ctx);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
onFallback(`tenantOrigin threw (${err instanceof Error ? err.name : typeof err})`);
|
|
19
|
+
return config.baseUrl;
|
|
20
|
+
}
|
|
21
|
+
if (resolved === null || resolved === undefined)
|
|
22
|
+
return config.baseUrl;
|
|
23
|
+
const origin = publicOriginOf(resolved);
|
|
24
|
+
if (origin === undefined || !isUsableTenantOrigin(origin, config.baseUrl)) {
|
|
25
|
+
onFallback("tenantOrigin returned no usable https origin");
|
|
26
|
+
return config.baseUrl;
|
|
27
|
+
}
|
|
28
|
+
return origin;
|
|
29
|
+
}
|
|
30
|
+
// Undefined means "no link": without a blind-index key an address opt-out could never be
|
|
31
|
+
// matched again, and a mail without a link is better than a failed send.
|
|
32
|
+
export async function buildUnsubscribeUrl(config, origin, recipient, scope) {
|
|
33
|
+
if (recipient.kind === "address" && hashUnsubscribeAddress(recipient.address) === undefined) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const token = recipient.kind === "user"
|
|
37
|
+
? await signUnsubscribeToken({ ...scope, userId: recipient.userId }, config.secret)
|
|
38
|
+
: await signAddressUnsubscribeToken({ ...scope, address: recipient.address }, config.secret);
|
|
39
|
+
return `${origin}${DELIVERY_UNSUBSCRIBE_PATH}?token=${encodeURIComponent(token)}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const UNSUBSCRIBE_PAGE_EN: {
|
|
2
|
+
readonly "mail.unsubscribe.confirmTitle": "Unsubscribe";
|
|
3
|
+
readonly "mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?";
|
|
4
|
+
readonly "mail.unsubscribe.confirmButton": "Unsubscribe";
|
|
5
|
+
readonly "mail.unsubscribe.doneTitle": "Unsubscribed";
|
|
6
|
+
readonly "mail.unsubscribe.doneMessage": "You have been unsubscribed.";
|
|
7
|
+
readonly "mail.unsubscribe.undoButton": "Undo";
|
|
8
|
+
readonly "mail.unsubscribe.resubscribedTitle": "Subscribed again";
|
|
9
|
+
readonly "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.";
|
|
10
|
+
readonly "mail.unsubscribe.failed": "That did not work. Please try again later.";
|
|
11
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
|
+
// The pages behind a mailed unsubscribe link speak the mail's language, so their texts live in
|
|
3
|
+
// the mail translations: English here, other languages in the locale packages.
|
|
4
|
+
export const UNSUBSCRIBE_PAGE_EN = {
|
|
5
|
+
"mail.unsubscribe.confirmTitle": "Unsubscribe",
|
|
6
|
+
"mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?",
|
|
7
|
+
"mail.unsubscribe.confirmButton": "Unsubscribe",
|
|
8
|
+
"mail.unsubscribe.doneTitle": "Unsubscribed",
|
|
9
|
+
"mail.unsubscribe.doneMessage": "You have been unsubscribed.",
|
|
10
|
+
"mail.unsubscribe.undoButton": "Undo",
|
|
11
|
+
"mail.unsubscribe.resubscribedTitle": "Subscribed again",
|
|
12
|
+
"mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
|
|
13
|
+
"mail.unsubscribe.failed": "That did not work. Please try again later.",
|
|
14
|
+
};
|
|
15
|
+
registerMailTranslations("en", UNSUBSCRIBE_PAGE_EN);
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import "./unsubscribe-page-strings.js";
|
|
3
4
|
export type UnsubscribeTokenPayload = {
|
|
4
5
|
readonly userId: string;
|
|
5
6
|
readonly tenantId: TenantId;
|
|
6
7
|
readonly notificationType: string;
|
|
7
8
|
readonly channel: string;
|
|
9
|
+
readonly locale?: string | undefined;
|
|
8
10
|
};
|
|
9
11
|
export type AddressUnsubscribeTokenPayload = {
|
|
10
12
|
readonly tenantId: TenantId;
|
|
11
13
|
readonly address: string;
|
|
12
14
|
readonly notificationType: string;
|
|
13
15
|
readonly channel: string;
|
|
16
|
+
readonly locale?: string | undefined;
|
|
14
17
|
};
|
|
15
18
|
/**
|
|
16
19
|
* `secret` must be a value dedicated to unsubscribe-token signing — do NOT
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { ExtraRouteRejection, signatureRoute, } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
3
|
import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
3
4
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
5
|
import * as jose from "jose";
|
|
5
6
|
import * as z from "zod";
|
|
6
7
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
7
8
|
import { DELIVERY_RESUBSCRIBE_PATH, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, } from "./constants.js";
|
|
9
|
+
import "./unsubscribe-page-strings.js";
|
|
10
|
+
// The mail's locale, so the pages behind the link speak the mail's language. Optional: links
|
|
11
|
+
// signed before the claim existed keep verifying and fall back to English.
|
|
12
|
+
const localeClaimSchema = z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/)
|
|
15
|
+
.optional();
|
|
8
16
|
// Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
|
|
9
17
|
// wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
|
|
10
18
|
//
|
|
@@ -19,6 +27,7 @@ const unsubscribeJwtPayloadSchema = z.object({
|
|
|
19
27
|
tenantId: z.string().min(1),
|
|
20
28
|
notificationType: z.string().min(1),
|
|
21
29
|
channel: z.string().min(1),
|
|
30
|
+
locale: localeClaimSchema,
|
|
22
31
|
});
|
|
23
32
|
// Address-token payload. `sub` and `addressHash` are the same value (the
|
|
24
33
|
// subject claim IS the blind-index hash) — kept as two fields because the
|
|
@@ -31,6 +40,7 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
|
|
|
31
40
|
notificationType: z.string().min(1),
|
|
32
41
|
channel: z.string().min(1),
|
|
33
42
|
addressHash: z.string().min(1),
|
|
43
|
+
locale: localeClaimSchema,
|
|
34
44
|
});
|
|
35
45
|
const UNSUBSCRIBE_EXPIRY = "7d";
|
|
36
46
|
const RESUBSCRIBE_EXPIRY = "1h";
|
|
@@ -42,6 +52,10 @@ function assertUnsubscribeSecret(secret, caller) {
|
|
|
42
52
|
throw new Error(`${caller}: secret must be ≥${MIN_UNSUBSCRIBE_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`);
|
|
43
53
|
}
|
|
44
54
|
}
|
|
55
|
+
// An unusable locale is left out instead of failing the send; the pages then fall back to English.
|
|
56
|
+
function localeClaim(locale) {
|
|
57
|
+
return locale !== undefined && localeClaimSchema.safeParse(locale).success ? { locale } : {};
|
|
58
|
+
}
|
|
45
59
|
const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
|
|
46
60
|
error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
|
|
47
61
|
};
|
|
@@ -52,6 +66,7 @@ export async function signUnsubscribeToken(payload, secret) {
|
|
|
52
66
|
tenantId: payload.tenantId,
|
|
53
67
|
notificationType: payload.notificationType,
|
|
54
68
|
channel: payload.channel,
|
|
69
|
+
...localeClaim(payload.locale),
|
|
55
70
|
})
|
|
56
71
|
.setProtectedHeader({ alg: "HS256" })
|
|
57
72
|
.setSubject(String(payload.userId))
|
|
@@ -80,6 +95,7 @@ export async function signAddressUnsubscribeToken(payload, secret) {
|
|
|
80
95
|
channel: payload.channel,
|
|
81
96
|
addressHash,
|
|
82
97
|
kind: "address",
|
|
98
|
+
...localeClaim(payload.locale),
|
|
83
99
|
})
|
|
84
100
|
.setProtectedHeader({ alg: "HS256" })
|
|
85
101
|
.setSubject(addressHash)
|
|
@@ -104,6 +120,7 @@ async function signResubscribeToken(verified, encodedSecret) {
|
|
|
104
120
|
tenantId: verified.tenantId,
|
|
105
121
|
notificationType: verified.notificationType,
|
|
106
122
|
channel: verified.channel,
|
|
123
|
+
...localeClaim(verified.locale),
|
|
107
124
|
};
|
|
108
125
|
const jwt = verified.kind === "address"
|
|
109
126
|
? new jose.SignJWT({
|
|
@@ -154,6 +171,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
|
|
|
154
171
|
addressHash: parsed.addressHash,
|
|
155
172
|
notificationType: parsed.notificationType,
|
|
156
173
|
channel: parsed.channel,
|
|
174
|
+
locale: parsed.locale,
|
|
157
175
|
};
|
|
158
176
|
}
|
|
159
177
|
const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
@@ -169,6 +187,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
|
|
|
169
187
|
userId: parsed.sub,
|
|
170
188
|
notificationType: parsed.notificationType,
|
|
171
189
|
channel: parsed.channel,
|
|
190
|
+
locale: parsed.locale,
|
|
172
191
|
};
|
|
173
192
|
}
|
|
174
193
|
catch (err) {
|
|
@@ -228,39 +247,50 @@ async function dispatchResubscribeWrite(verified, deps) {
|
|
|
228
247
|
return "success";
|
|
229
248
|
return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
|
|
230
249
|
}
|
|
231
|
-
function
|
|
250
|
+
function pageLocale(locale) {
|
|
251
|
+
return resolveMailLocale(locale ?? "en");
|
|
252
|
+
}
|
|
253
|
+
function htmlPage(locale, title, bodyHtml) {
|
|
232
254
|
return `<!doctype html>
|
|
233
|
-
<html>
|
|
255
|
+
<html lang="${escapeHtmlAttr(locale)}">
|
|
234
256
|
<head>
|
|
235
257
|
<meta charset="utf-8">
|
|
236
258
|
<meta name="robots" content="noindex">
|
|
237
|
-
<
|
|
259
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
260
|
+
<title>${escapeHtmlAttr(title)}</title>
|
|
238
261
|
</head>
|
|
239
262
|
<body>
|
|
240
|
-
|
|
241
|
-
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
242
|
-
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
243
|
-
<button type="submit">Unsubscribe</button>
|
|
244
|
-
</form>
|
|
263
|
+
${bodyHtml}
|
|
245
264
|
</body>
|
|
246
265
|
</html>`;
|
|
247
266
|
}
|
|
248
|
-
function
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<
|
|
252
|
-
<
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
function confirmationPage(token, locale) {
|
|
268
|
+
const lang = pageLocale(locale);
|
|
269
|
+
return htmlPage(lang, mailT(lang, "mail.unsubscribe.confirmTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmQuestion"))}</p>
|
|
270
|
+
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
271
|
+
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
272
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
|
|
273
|
+
</form>`);
|
|
274
|
+
}
|
|
275
|
+
function unsubscribedPage(resubscribeToken, locale) {
|
|
276
|
+
const lang = pageLocale(locale);
|
|
277
|
+
return htmlPage(lang, mailT(lang, "mail.unsubscribe.doneTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
|
|
258
278
|
<form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
|
|
259
279
|
<input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
|
|
260
|
-
<button type="submit"
|
|
261
|
-
</form
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
|
|
281
|
+
</form>`);
|
|
282
|
+
}
|
|
283
|
+
function resubscribedPage(locale) {
|
|
284
|
+
const lang = pageLocale(locale);
|
|
285
|
+
return htmlPage(lang, mailT(lang, "mail.unsubscribe.resubscribedTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`);
|
|
286
|
+
}
|
|
287
|
+
function failedPage(locale) {
|
|
288
|
+
const lang = pageLocale(locale);
|
|
289
|
+
const message = mailT(lang, "mail.unsubscribe.failed");
|
|
290
|
+
return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
|
|
291
|
+
}
|
|
292
|
+
function isFormRequest(contentType) {
|
|
293
|
+
return contentType?.includes("application/x-www-form-urlencoded") === true;
|
|
264
294
|
}
|
|
265
295
|
// One-click clients put the token in the query string; only the
|
|
266
296
|
// confirmation-page form submits it in the body.
|
|
@@ -304,10 +334,10 @@ export function createUnsubscribeRoutes(options) {
|
|
|
304
334
|
if (!token) {
|
|
305
335
|
throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
|
|
306
336
|
}
|
|
307
|
-
await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
308
|
-
return { token };
|
|
337
|
+
const verified = await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
338
|
+
return { token, locale: verified.locale };
|
|
309
339
|
},
|
|
310
|
-
handler: async (c, { token }) => c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
|
|
340
|
+
handler: async (c, { token, locale }) => c.html(confirmationPage(token, locale), 200, CONFIRMATION_PAGE_HEADERS),
|
|
311
341
|
});
|
|
312
342
|
const writeRoute = signatureRoute({
|
|
313
343
|
method: "POST",
|
|
@@ -320,11 +350,9 @@ export function createUnsubscribeRoutes(options) {
|
|
|
320
350
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
321
351
|
const succeeded = await dispatchUnsubscribeWrite(verified, deps);
|
|
322
352
|
if (!succeeded) {
|
|
323
|
-
return c.html(
|
|
353
|
+
return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
|
|
324
354
|
}
|
|
325
|
-
return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
|
|
326
|
-
"Cache-Control": "no-store",
|
|
327
|
-
});
|
|
355
|
+
return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale), 200, { "Cache-Control": "no-store" });
|
|
328
356
|
},
|
|
329
357
|
});
|
|
330
358
|
// Undo direction: token only from the JSON/form body, never the query
|
|
@@ -337,6 +365,14 @@ export function createUnsubscribeRoutes(options) {
|
|
|
337
365
|
verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
|
|
338
366
|
handler: async (c, verified, deps) => {
|
|
339
367
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
368
|
+
// The undo button on the unsubscribed page posts a form; a browser should land on a page.
|
|
369
|
+
if (isFormRequest(c.req.header("content-type"))) {
|
|
370
|
+
return outcome === "success"
|
|
371
|
+
? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
|
|
372
|
+
: c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
|
|
373
|
+
"Cache-Control": "no-store",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
340
376
|
if (outcome === "limit_reached") {
|
|
341
377
|
return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
|
|
342
378
|
"Cache-Control": "no-store",
|
|
@@ -9,7 +9,7 @@ export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
9
9
|
category: "storage",
|
|
10
10
|
recommended: false,
|
|
11
11
|
});
|
|
12
|
-
r.requires("file-derivatives");
|
|
12
|
+
r.requires({ features: ["file-derivatives"] });
|
|
13
13
|
const plugin = { render: renderImage };
|
|
14
14
|
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
|
|
15
15
|
});
|
|
@@ -90,8 +90,8 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
|
|
|
90
90
|
});
|
|
91
91
|
// tenant-lifecycle hosts EXT_TENANT_DATA — documentExtract.pages is
|
|
92
92
|
// tenant-subject ciphertext and needs the destroy hook below.
|
|
93
|
-
r.requires("config", "tenant-lifecycle");
|
|
94
|
-
r.entity("documentExtract", documentExtractEntity);
|
|
93
|
+
r.requires({ features: ["config", "tenant-lifecycle"] });
|
|
94
|
+
r.entity({ name: "documentExtract", ...documentExtractEntity });
|
|
95
95
|
r.useExtension(EXT_TENANT_DATA, "documentExtract", {
|
|
96
96
|
destroy: documentExtractTenantDestroyHook,
|
|
97
97
|
});
|
|
@@ -53,12 +53,12 @@ export function createFileDerivativesFeature(opts = {}) {
|
|
|
53
53
|
});
|
|
54
54
|
// Needs a storage provider to read the original + write the variant —
|
|
55
55
|
// without file-foundation there's nothing to derive from or write to.
|
|
56
|
-
r.requires("file-foundation");
|
|
56
|
+
r.requires({ features: ["file-foundation"] });
|
|
57
57
|
// variant() reads fileRefsTable, which "files" (not file-foundation)
|
|
58
58
|
// owns — without this, mounting file-derivatives + file-foundation
|
|
59
59
|
// alone boots fine but fails at request-time with a SQL error the first
|
|
60
60
|
// time variant() runs.
|
|
61
|
-
r.requires("files");
|
|
61
|
+
r.requires({ features: ["files"] });
|
|
62
62
|
r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
|
|
63
63
|
onRegister: () => {
|
|
64
64
|
// No side-effects at register-time — resolution (exact MIME match,
|
|
@@ -103,7 +103,7 @@ export function createFileDerivativesFeature(opts = {}) {
|
|
|
103
103
|
// tenant's public variants from staying reachable — require the
|
|
104
104
|
// dependency so a missing mount fails boot, not the tenant check
|
|
105
105
|
// silently no-op-ing at request time.
|
|
106
|
-
r.requires("tenant");
|
|
106
|
+
r.requires({ features: ["tenant"] });
|
|
107
107
|
r.queryHandler(publicVariantByFileRefQuery);
|
|
108
108
|
}
|
|
109
109
|
r.httpRoute({
|
|
@@ -27,7 +27,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
27
27
|
category: "storage",
|
|
28
28
|
recommended: false,
|
|
29
29
|
});
|
|
30
|
-
r.requires("config");
|
|
30
|
+
r.requires({ features: ["config"] });
|
|
31
31
|
r.extendsRegistrar(EXT_FILE_PROVIDER, {
|
|
32
32
|
onRegister: () => {
|
|
33
33
|
// No side-effects at register-time — registry stores the usage,
|
|
@@ -49,10 +49,10 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
49
49
|
category: "storage",
|
|
50
50
|
recommended: false,
|
|
51
51
|
});
|
|
52
|
-
// Kein
|
|
52
|
+
// Kein requires auf "config" oder "secrets" — der
|
|
53
53
|
// In-Memory-Provider hat keine Config + kein Secret. Nur die
|
|
54
54
|
// file-foundation muss da sein (Plugin-extension-point).
|
|
55
|
-
r.requires("file-foundation");
|
|
55
|
+
r.requires({ features: ["file-foundation"] });
|
|
56
56
|
const plugin = {
|
|
57
57
|
build: async (_ctx, tenantId) => {
|
|
58
58
|
// Returnt den per-tenant Storage. Identitätsstabil zwischen calls
|
|
@@ -32,9 +32,9 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
32
32
|
category: "storage",
|
|
33
33
|
recommended: false,
|
|
34
34
|
});
|
|
35
|
-
r.requires("config");
|
|
36
|
-
r.requires("secrets");
|
|
37
|
-
r.requires("file-foundation");
|
|
35
|
+
r.requires({ features: ["config"] });
|
|
36
|
+
r.requires({ features: ["secrets"] });
|
|
37
|
+
r.requires({ features: ["file-foundation"] });
|
|
38
38
|
const secretAccessKey = r.secret("s3.secretAccessKey", {
|
|
39
39
|
label: { en: "S3 secret access key" },
|
|
40
40
|
hint: {
|
|
@@ -32,10 +32,10 @@ export const fileProviderS3EnvFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
32
32
|
category: "storage",
|
|
33
33
|
recommended: false,
|
|
34
34
|
});
|
|
35
|
-
// No
|
|
35
|
+
// No requires on "config" or "secrets" — credentials come from
|
|
36
36
|
// env, not the per-tenant config + secrets store. Only the file-foundation
|
|
37
37
|
// plugin-extension-point must be mounted.
|
|
38
|
-
r.requires("file-foundation");
|
|
38
|
+
r.requires({ features: ["file-foundation"] });
|
|
39
39
|
const plugin = {
|
|
40
40
|
// tenantId ignored: one shared bucket serves all tenants (isolation via
|
|
41
41
|
// tenant-prefixed / UUID keys). Built fresh per call like file-provider-s3;
|
|
@@ -23,7 +23,7 @@ export function createFilesTenantDataFeature() {
|
|
|
23
23
|
category: "compliance",
|
|
24
24
|
recommended: false,
|
|
25
25
|
});
|
|
26
|
-
r.requires("tenant-lifecycle");
|
|
26
|
+
r.requires({ features: ["tenant-lifecycle"] });
|
|
27
27
|
r.optionalRequires("files");
|
|
28
28
|
r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy: fileRefTenantDestroyHook });
|
|
29
29
|
r.useExtension(EXT_STORAGE_PROVIDER, "fileRef", { destroyTenant: fileRefStorageDestroyHook });
|
package/dist/folders/feature.js
CHANGED
|
@@ -31,8 +31,8 @@ function registerFolders(r, access, toggleable, ownership, parents) {
|
|
|
31
31
|
if (toggleable !== undefined)
|
|
32
32
|
r.toggleable(toggleable);
|
|
33
33
|
const folderAssignmentEntity = createFolderAssignmentEntity(ownership, parents);
|
|
34
|
-
r.entity("folder", folderEntity);
|
|
35
|
-
r.entity("folder-assignment", folderAssignmentEntity);
|
|
34
|
+
r.entity({ name: "folder", ...folderEntity });
|
|
35
|
+
r.entity({ name: "folder-assignment", ...folderAssignmentEntity });
|
|
36
36
|
// Folder catalog — plain CRUD, no custom logic. update is rename (and, in a
|
|
37
37
|
// later stage, reparent: it accepts changes.parentId, optimistic-locked).
|
|
38
38
|
r.writeHandler(defineEntityCreateHandler("folder", folderEntity, {
|
|
@@ -11,7 +11,7 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
|
|
|
11
11
|
// OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
|
|
12
12
|
// tier), a hard r.requires would throw an "effectively disabled" boot
|
|
13
13
|
// warning even though the folder entities exist and the hooks work fine.
|
|
14
|
-
r.requires("user-data-rights");
|
|
14
|
+
r.requires({ features: ["user-data-rights"] });
|
|
15
15
|
r.optionalRequires("folders");
|
|
16
16
|
r.useExtension(EXT_USER_DATA, "folder", {
|
|
17
17
|
export: folderExportHook,
|
|
@@ -23,11 +23,11 @@ function registerFormDraft(r) {
|
|
|
23
23
|
// config is optional: the retention-days key only resolves when the
|
|
24
24
|
// config feature is mounted (ctx.configResolver present) — the cleanup
|
|
25
25
|
// job already falls back to FORM_DRAFT_DEFAULT_RETENTION_DAYS otherwise
|
|
26
|
-
// (see handlers/cleanup.job.ts). A hard r.requires("config") would break
|
|
26
|
+
// (see handlers/cleanup.job.ts). A hard r.requires({ features: ["config"] }) would break
|
|
27
27
|
// every app that mounts form-draft without config, which is a breaking
|
|
28
28
|
// change no minor/patch release should introduce silently.
|
|
29
29
|
r.optionalRequires("config");
|
|
30
|
-
r.entity("form-draft", formDraftEntity);
|
|
30
|
+
r.entity({ name: "form-draft", ...formDraftEntity });
|
|
31
31
|
r.writeHandler(saveDraftWrite);
|
|
32
32
|
r.writeHandler(discardDraftWrite);
|
|
33
33
|
r.queryHandler(getDraftQuery);
|
|
@@ -8,7 +8,7 @@ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engi
|
|
|
8
8
|
import { formDraftDeleteHook, formDraftExportHook } from "./hooks.js";
|
|
9
9
|
export const formDraftUserDataFeature = defineFeature("form-draft-user-data", (r) => {
|
|
10
10
|
r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `form-draft` feature's `form-draft` entity. Mounts the EXT_USER_DATA export hook so a user's saved drafts are included in the user-data export bundle, and a delete hook that physically removes the user's own draft rows on forget. Kept separate from `form-draft` so form-draft consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `form-draft`.");
|
|
11
|
-
r.requires("user-data-rights");
|
|
11
|
+
r.requires({ features: ["user-data-rights"] });
|
|
12
12
|
r.optionalRequires("form-draft");
|
|
13
13
|
r.useExtension(EXT_USER_DATA, "form-draft", {
|
|
14
14
|
export: formDraftExportHook,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { type OAuthAccessTokenManager } from "./oauth-access-token.js";
|
|
3
|
+
import type { InboundMailContext } from "./types.js";
|
|
3
4
|
export type InboundMailConnectRoutesOptions = {
|
|
4
5
|
/** HMAC-secret for the state param (deploy-time env — `scope:"system"`
|
|
5
6
|
* secrets don't exist in secrets-v1). */
|
|
@@ -21,6 +22,8 @@ export type InboundMailConnectRoutesOptions = {
|
|
|
21
22
|
* lock and primes the access-token cache from the code exchange. Omitted
|
|
22
23
|
* → plain secret write, no cache priming. */
|
|
23
24
|
readonly oauthTokens?: OAuthAccessTokenManager;
|
|
25
|
+
/** Handed to the provider plugin's context (`InboundMailContext.metricsFor`). */
|
|
26
|
+
readonly metricsFor?: InboundMailContext["metricsFor"];
|
|
24
27
|
};
|
|
25
28
|
/** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
|
|
26
29
|
* `extraRoutes: createInboundMailConnectRoutes(options)`. */
|
|
@@ -80,7 +80,12 @@ export function createInboundMailConnectRoutes(options) {
|
|
|
80
80
|
if (!deps.secrets) {
|
|
81
81
|
return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
|
|
82
82
|
}
|
|
83
|
-
const providerCtx = {
|
|
83
|
+
const providerCtx = {
|
|
84
|
+
registry: deps.registry,
|
|
85
|
+
secrets: deps.secrets,
|
|
86
|
+
config: undefined,
|
|
87
|
+
...(options.metricsFor && { metricsFor: options.metricsFor }),
|
|
88
|
+
};
|
|
84
89
|
let plugin;
|
|
85
90
|
try {
|
|
86
91
|
plugin = resolveInboundProviderForKey(providerCtx, state.providerKey);
|
|
@@ -62,7 +62,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
|
|
|
62
62
|
recommended: false,
|
|
63
63
|
});
|
|
64
64
|
// EXT_TENANT_DATA-Hook braucht den tenant-lifecycle-Host.
|
|
65
|
-
r.requires("tenant-lifecycle");
|
|
65
|
+
r.requires({ features: ["tenant-lifecycle"] });
|
|
66
66
|
// 5 fine-grained domain-events (Payload-Schemas: events.ts).
|
|
67
67
|
// piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
|
|
68
68
|
// "displayName" stays plaintext (often the mailbox owner's real name for
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
2
3
|
import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "./constants.js";
|
|
3
4
|
/**
|
|
4
|
-
* Slim
|
|
5
|
+
* Slim context for provider plugins.
|
|
5
6
|
*
|
|
6
|
-
* **
|
|
7
|
-
* config — tenant
|
|
8
|
-
* registry — extension
|
|
9
|
-
* secrets — tenant
|
|
10
|
-
* _userId —
|
|
11
|
-
*
|
|
7
|
+
* **Fields:**
|
|
8
|
+
* config — tenant config reads (host/port/... of the plugins)
|
|
9
|
+
* registry — extension lookup in the factory (not plugin-internal)
|
|
10
|
+
* secrets — tenant secret reads (IMAP password, OAuth refresh token)
|
|
11
|
+
* _userId — audit identity for secret reads. Handler path: the dispatcher
|
|
12
|
+
* sets the caller user ID; worker path: system identity.
|
|
13
|
+
* metricsFor — metrics handle of a feature by name, same behavior as
|
|
14
|
+
* HandlerContext.metricsFor: unregistered metrics are a silent
|
|
15
|
+
* no-op instead of a throw.
|
|
12
16
|
*/
|
|
13
17
|
export type InboundMailContext = {
|
|
14
18
|
readonly config?: ConfigAccessor;
|
|
15
19
|
readonly registry?: Registry;
|
|
16
20
|
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
17
21
|
readonly _userId?: string | undefined;
|
|
22
|
+
readonly metricsFor?: (featureName: string) => MetricsHandle;
|
|
18
23
|
};
|
|
19
24
|
/**
|
|
20
25
|
* Plaintext-Sicht auf ein verbundenes Postfach, wie Provider sie sehen.
|
|
@@ -141,6 +146,11 @@ export type InboundMailProviderPlugin = {
|
|
|
141
146
|
* whose provider declares `oauth` (authMethod oauth/xoauth2). Absent
|
|
142
147
|
* for password accounts and providers without an oauth flow. */
|
|
143
148
|
readonly accessToken?: string;
|
|
149
|
+
/** Batch lookup of the provider message ids not yet ingested for this
|
|
150
|
+
* account (committed seen anchors, one query per call). Call it after
|
|
151
|
+
* the listing/delta page and download MIME only for the returned ids.
|
|
152
|
+
* Ingest dedup stays the correctness anchor; this only saves downloads. */
|
|
153
|
+
readonly filterUnknownMessageIds?: (providerMessageIds: readonly string[]) => Promise<ReadonlySet<string>>;
|
|
144
154
|
}) => Promise<InboundFetchResult>;
|
|
145
155
|
/** Nur OAuth-Provider. */
|
|
146
156
|
readonly oauth?: InboundOAuthFlow;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// app owner still wires the supervisor in bin/server.ts:
|
|
15
15
|
//
|
|
16
16
|
// const supervisor = createInboundMailSupervisor({
|
|
17
|
-
// providerCtx: { registry: deps.registry, secrets },
|
|
17
|
+
// providerCtx: { registry: deps.registry, secrets, metricsFor: deps.metricsFor },
|
|
18
18
|
// db,
|
|
19
19
|
// dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
20
20
|
// deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
|
|
@@ -48,7 +48,7 @@ import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/ku
|
|
|
48
48
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
49
49
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
50
50
|
import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.js";
|
|
51
|
-
import { MAIL_ACCOUNT_PII_FIELDS, syncCursorTable } from "./entities.js";
|
|
51
|
+
import { MAIL_ACCOUNT_PII_FIELDS, seenMessageTable, syncCursorTable } from "./entities.js";
|
|
52
52
|
import { createOAuthAccessTokenManager, usesFoundationManagedOAuth, } from "./oauth-access-token.js";
|
|
53
53
|
import { mailAccountsProjectionTable } from "./projection.js";
|
|
54
54
|
import { resolveInboundProviderForAccount } from "./provider-factory.js";
|
|
@@ -255,6 +255,19 @@ export function createInboundMailSupervisor(deps) {
|
|
|
255
255
|
// ---------------------------------------------------------------
|
|
256
256
|
// Poll (Reconciliation).
|
|
257
257
|
// ---------------------------------------------------------------
|
|
258
|
+
function unknownMessageIdsFilterFor(account) {
|
|
259
|
+
return async (providerMessageIds) => {
|
|
260
|
+
if (providerMessageIds.length === 0)
|
|
261
|
+
return new Set();
|
|
262
|
+
const seen = await selectMany(deps.db, seenMessageTable, {
|
|
263
|
+
tenantId: account.tenantId,
|
|
264
|
+
accountId: account.id,
|
|
265
|
+
providerMessageId: { in: providerMessageIds },
|
|
266
|
+
}, { fields: ["providerMessageId"] });
|
|
267
|
+
const seenIds = new Set(seen.map((row) => row.providerMessageId));
|
|
268
|
+
return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
|
|
269
|
+
};
|
|
270
|
+
}
|
|
258
271
|
async function pollAccount(account, plugin) {
|
|
259
272
|
let cursor = await loadCursor(account.id);
|
|
260
273
|
let budget = maxMessagesPerPoll;
|
|
@@ -265,6 +278,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
265
278
|
const result = await plugin.fetch(deps.providerCtx, account, cursor, {
|
|
266
279
|
backfillWindowDays,
|
|
267
280
|
maxMessages: budget,
|
|
281
|
+
filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
|
|
268
282
|
...(accessToken !== undefined && { accessToken }),
|
|
269
283
|
});
|
|
270
284
|
await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
|
|
@@ -224,8 +224,8 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
224
224
|
category: "notifications",
|
|
225
225
|
recommended: false,
|
|
226
226
|
});
|
|
227
|
-
r.requires("inbound-mail-foundation");
|
|
228
|
-
r.requires("secrets");
|
|
227
|
+
r.requires({ features: ["inbound-mail-foundation"] });
|
|
228
|
+
r.requires({ features: ["secrets"] });
|
|
229
229
|
r.envSchema(mailAllowedPrivateHostsEnvSchema);
|
|
230
230
|
r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
|
|
231
231
|
});
|
|
@@ -110,6 +110,6 @@ export const inboundProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) =>
|
|
|
110
110
|
category: "notifications",
|
|
111
111
|
recommended: false,
|
|
112
112
|
});
|
|
113
|
-
r.requires("inbound-mail-foundation");
|
|
113
|
+
r.requires({ features: ["inbound-mail-foundation"] });
|
|
114
114
|
r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, inMemoryInboundMailPlugin);
|
|
115
115
|
});
|