@cosmicdrift/kumiko-samples 0.393.0 → 0.394.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +15 -5
- package/packages/bundled-features/src/account-security/README.md +1 -1
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/feature.ts +9 -1
- package/packages/bundled-features/src/audit/event-classification.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
- package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
- package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
- package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/packages/bundled-features/src/cap-overview/types.ts +2 -0
- package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
- package/packages/bundled-features/src/files/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +143 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
- package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
- package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
- package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
- package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -2
- package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -15
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
- package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
- package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
- package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +238 -89
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
- package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
- package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
- package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
- package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +30 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
- package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -49
- package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
- package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
- package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
- package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +4 -6
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
- package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
- package/packages/bundled-features/src/jobs/changes.json +18 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
- package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
- package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
- package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
- package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
- package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
- package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
- package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
- package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +87 -38
- package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
- package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
- package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
- package/samples/recipes/cross-feature-events/README.md +10 -0
- package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
- package/samples/recipes/dashboard-stats/README.md +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
- package/samples/recipes/env-composition/README.md +2 -2
- package/samples/recipes/env-composition/src/env.ts +2 -2
- package/samples/recipes/inbound-mail-triage/README.md +8 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
- package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
- package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
- package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
- package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
- package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SignatureExtraRouteVerifyRequest } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { ExtraRouteRejection } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import { errorJson } from "./connect-route-responses.ts";
|
|
5
|
+
import { normalizeMailAddress } from "./handlers/account-address.ts";
|
|
6
|
+
import type { OAuthCodeExchange } from "./inbound-oauth-flow.ts";
|
|
7
|
+
import { type OAuthStatePayload, verifyOAuthState } from "./oauth-state.ts";
|
|
8
|
+
|
|
9
|
+
export type VerifiedCallback = { readonly code: string; readonly state: OAuthStatePayload };
|
|
10
|
+
|
|
11
|
+
export function verifyCallbackRequest(
|
|
12
|
+
request: SignatureExtraRouteVerifyRequest,
|
|
13
|
+
stateSecret: string,
|
|
14
|
+
): VerifiedCallback {
|
|
15
|
+
const code = request.query["code"] ?? "";
|
|
16
|
+
const rawState = request.query["state"] ?? "";
|
|
17
|
+
if (!code || !rawState) {
|
|
18
|
+
throw new ExtraRouteRejection(
|
|
19
|
+
400,
|
|
20
|
+
{ error: { code: "invalid_callback", message: "missing code or state" } },
|
|
21
|
+
"inbound-mail oauth callback missing code or state",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const verified = verifyOAuthState(rawState, stateSecret);
|
|
25
|
+
if (!verified.ok) {
|
|
26
|
+
throw new ExtraRouteRejection(
|
|
27
|
+
400,
|
|
28
|
+
{ error: { code: "invalid_state", message: `state rejected: ${verified.reason}` } },
|
|
29
|
+
`inbound-mail oauth callback state rejected: ${verified.reason}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return { code, state: verified.payload };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The refresh token the account needs, or the response that refuses the exchange result.
|
|
36
|
+
export function refreshTokenOrRefusal(
|
|
37
|
+
c: Context,
|
|
38
|
+
state: OAuthStatePayload,
|
|
39
|
+
tokens: OAuthCodeExchange,
|
|
40
|
+
): string | Response {
|
|
41
|
+
// Personal only: a shared mailbox is often an M365 shared mailbox the admin opens with their own
|
|
42
|
+
// login (delegated access). A token without access to it shows up as auth_error on the first sync.
|
|
43
|
+
if (
|
|
44
|
+
state.ownerUserId &&
|
|
45
|
+
normalizeMailAddress(tokens.accountAddress) !== normalizeMailAddress(state.mailbox)
|
|
46
|
+
) {
|
|
47
|
+
return errorJson(
|
|
48
|
+
c,
|
|
49
|
+
403,
|
|
50
|
+
"mailbox_address_mismatch",
|
|
51
|
+
`the account you signed in with is not ${state.mailbox}; sign in with that mailbox's account`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
// No refresh token, no server-side sync. Consent must request offline_access (or
|
|
55
|
+
// access_type=offline); the provider's oauth block owns the scopes.
|
|
56
|
+
if (!tokens.refreshToken) {
|
|
57
|
+
return errorJson(
|
|
58
|
+
c,
|
|
59
|
+
502,
|
|
60
|
+
"no_refresh_token",
|
|
61
|
+
`provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return tokens.refreshToken;
|
|
65
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { InboundMailConnectErrors } from "./constants.ts";
|
|
4
|
+
|
|
5
|
+
const CONNECT_CONFLICT_MESSAGES: Readonly<Record<string, string>> = {
|
|
6
|
+
[InboundMailConnectErrors.addressAlreadyConnected.reason]:
|
|
7
|
+
"this mailbox address is already connected",
|
|
8
|
+
[InboundMailConnectErrors.addressDisconnected.reason]:
|
|
9
|
+
"this mailbox was disconnected before; reconnect it from the mailbox list instead",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function connectFailureResponse(c: Context, error: unknown) {
|
|
13
|
+
const reason = propertyOf(propertyOf(error, "details"), "reason");
|
|
14
|
+
const conflict = typeof reason === "string" ? CONNECT_CONFLICT_MESSAGES[reason] : undefined;
|
|
15
|
+
if (typeof reason === "string" && conflict) return errorJson(c, 409, reason, conflict);
|
|
16
|
+
return errorJson(c, 502, "account_create_failed", JSON.stringify(error ?? {}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function errorJson(
|
|
20
|
+
c: Context,
|
|
21
|
+
status: 400 | 401 | 403 | 404 | 409 | 500 | 502,
|
|
22
|
+
code: string,
|
|
23
|
+
message: string,
|
|
24
|
+
) {
|
|
25
|
+
return c.json({ error: { code, message } }, status);
|
|
26
|
+
}
|
|
@@ -13,30 +13,81 @@
|
|
|
13
13
|
import type {
|
|
14
14
|
ExtraRouteDefinition,
|
|
15
15
|
SignatureExtraRouteDeps,
|
|
16
|
-
SignatureExtraRouteVerifyRequest,
|
|
17
16
|
UserExtraRouteDeps,
|
|
18
17
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
19
|
-
import {
|
|
18
|
+
import { signatureRoute } from "@cosmicdrift/kumiko-framework/api";
|
|
20
19
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
21
20
|
import type { Context } from "hono";
|
|
22
21
|
import {
|
|
22
|
+
refreshTokenOrRefusal,
|
|
23
|
+
type VerifiedCallback,
|
|
24
|
+
verifyCallbackRequest,
|
|
25
|
+
} from "./connect-callback-verify.ts";
|
|
26
|
+
import { connectFailureResponse, errorJson } from "./connect-route-responses.ts";
|
|
27
|
+
import {
|
|
28
|
+
INBOUND_MAIL_CONNECT_PATH,
|
|
23
29
|
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
24
30
|
InboundMailAuthMethods,
|
|
25
31
|
InboundMailFoundationHandlers,
|
|
26
|
-
inboundCredentialSecretKey,
|
|
27
32
|
} from "./constants.ts";
|
|
33
|
+
import { isMailAdmin } from "./handlers/scope-visibility.ts";
|
|
34
|
+
import { readInboundMailRoles } from "./mail-roles.ts";
|
|
28
35
|
import {
|
|
29
|
-
inboundRefreshTokenSecretOptions,
|
|
30
36
|
type OAuthAccessTokenManager,
|
|
37
|
+
storeOAuthCallbackCredential,
|
|
31
38
|
} from "./oauth-access-token.ts";
|
|
32
|
-
import {
|
|
39
|
+
import { signOAuthState } from "./oauth-state.ts";
|
|
33
40
|
import { resolveInboundProviderForKey } from "./provider-factory.ts";
|
|
34
41
|
import type { InboundMailContext } from "./types.ts";
|
|
35
42
|
|
|
36
43
|
const DEFAULT_STATE_TTL_MINUTES = 15;
|
|
37
|
-
const DEFAULT_CONNECT_PATH = "/api/inbound-mail/connect";
|
|
38
44
|
const DEFAULT_CALLBACK_PATH = "/inbound-mail/oauth/callback";
|
|
39
45
|
|
|
46
|
+
// connect-account leaves a reopened mailbox off: switched on before its token is stored, the
|
|
47
|
+
// supervisor would sync it with the old, failing credential.
|
|
48
|
+
async function reactivateIfReopened(
|
|
49
|
+
c: Context,
|
|
50
|
+
deps: SignatureExtraRouteDeps,
|
|
51
|
+
tenantId: string,
|
|
52
|
+
accountId: string,
|
|
53
|
+
connected: unknown,
|
|
54
|
+
): Promise<Response | undefined> {
|
|
55
|
+
if (propertyOf(connected, "reconnected") !== true) return undefined;
|
|
56
|
+
const reactivated = await deps.dispatchSystemWrite({
|
|
57
|
+
handlerQn: InboundMailFoundationHandlers.reactivateAccount,
|
|
58
|
+
tenantId,
|
|
59
|
+
payload: { accountId },
|
|
60
|
+
});
|
|
61
|
+
return reactivated.isSuccess ? undefined : connectFailureResponse(c, reactivated.error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The callback connects as a system user, so connect-account's role rules (mail member at all,
|
|
65
|
+
// admin for a shared mailbox) have to hold here, before the signed state is handed out.
|
|
66
|
+
function connectRoleRefusal(
|
|
67
|
+
c: Context,
|
|
68
|
+
deps: UserExtraRouteDeps,
|
|
69
|
+
scope: "user" | "shared",
|
|
70
|
+
): Response | undefined {
|
|
71
|
+
const { memberRoles, adminRoles } = readInboundMailRoles(deps.registry);
|
|
72
|
+
if (!memberRoles.some((role) => deps.user.roles.includes(role))) {
|
|
73
|
+
return errorJson(
|
|
74
|
+
c,
|
|
75
|
+
403,
|
|
76
|
+
"mail_role_required",
|
|
77
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: connecting a mailbox needs one of the roles ${memberRoles.join(", ")}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (scope === "shared" && !isMailAdmin(deps.user, adminRoles)) {
|
|
81
|
+
return errorJson(
|
|
82
|
+
c,
|
|
83
|
+
403,
|
|
84
|
+
"shared_mailbox_requires_admin",
|
|
85
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: only ${adminRoles.join(", ")} connect a shared mailbox`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
40
91
|
export type InboundMailConnectRoutesOptions = {
|
|
41
92
|
/** HMAC-secret for the state param (deploy-time env — `scope:"system"`
|
|
42
93
|
* secrets don't exist in secrets-v1). */
|
|
@@ -62,36 +113,6 @@ export type InboundMailConnectRoutesOptions = {
|
|
|
62
113
|
readonly metricsFor?: InboundMailContext["metricsFor"];
|
|
63
114
|
};
|
|
64
115
|
|
|
65
|
-
function errorJson(c: Context, status: 400 | 401 | 404 | 500 | 502, code: string, message: string) {
|
|
66
|
-
return c.json({ error: { code, message } }, status);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
type VerifiedCallback = { readonly code: string; readonly state: OAuthStatePayload };
|
|
70
|
-
|
|
71
|
-
function verifyCallbackRequest(
|
|
72
|
-
request: SignatureExtraRouteVerifyRequest,
|
|
73
|
-
stateSecret: string,
|
|
74
|
-
): VerifiedCallback {
|
|
75
|
-
const code = request.query["code"] ?? "";
|
|
76
|
-
const rawState = request.query["state"] ?? "";
|
|
77
|
-
if (!code || !rawState) {
|
|
78
|
-
throw new ExtraRouteRejection(
|
|
79
|
-
400,
|
|
80
|
-
{ error: { code: "invalid_callback", message: "missing code or state" } },
|
|
81
|
-
"inbound-mail oauth callback missing code or state",
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
const verified = verifyOAuthState(rawState, stateSecret);
|
|
85
|
-
if (!verified.ok) {
|
|
86
|
-
throw new ExtraRouteRejection(
|
|
87
|
-
400,
|
|
88
|
-
{ error: { code: "invalid_state", message: `state rejected: ${verified.reason}` } },
|
|
89
|
-
`inbound-mail oauth callback state rejected: ${verified.reason}`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
return { code, state: verified.payload };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
116
|
/** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
|
|
96
117
|
* `extraRoutes: createInboundMailConnectRoutes(options)`. */
|
|
97
118
|
export function createInboundMailConnectRoutes(
|
|
@@ -101,7 +122,7 @@ export function createInboundMailConnectRoutes(
|
|
|
101
122
|
|
|
102
123
|
const connectRoute: ExtraRouteDefinition = {
|
|
103
124
|
method: "GET",
|
|
104
|
-
path: options.connectPath ??
|
|
125
|
+
path: options.connectPath ?? INBOUND_MAIL_CONNECT_PATH,
|
|
105
126
|
entry: "user",
|
|
106
127
|
handler: async (c: Context, deps: UserExtraRouteDeps): Promise<Response> => {
|
|
107
128
|
const providerKey = c.req.query("provider") ?? "";
|
|
@@ -116,6 +137,9 @@ export function createInboundMailConnectRoutes(
|
|
|
116
137
|
);
|
|
117
138
|
}
|
|
118
139
|
|
|
140
|
+
const refused = connectRoleRefusal(c, deps, scope);
|
|
141
|
+
if (refused) return refused;
|
|
142
|
+
|
|
119
143
|
let plugin: ReturnType<typeof resolveInboundProviderForKey>;
|
|
120
144
|
try {
|
|
121
145
|
plugin = resolveInboundProviderForKey({ registry: deps.registry }, providerKey);
|
|
@@ -213,17 +237,8 @@ export function createInboundMailConnectRoutes(
|
|
|
213
237
|
"token exchange with the provider failed",
|
|
214
238
|
);
|
|
215
239
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// request offline_access (or access_type=offline) — the provider's
|
|
219
|
-
// oauth block owns the scopes.
|
|
220
|
-
return errorJson(
|
|
221
|
-
c,
|
|
222
|
-
502,
|
|
223
|
-
"no_refresh_token",
|
|
224
|
-
`provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
240
|
+
const refreshToken = refreshTokenOrRefusal(c, state, tokens);
|
|
241
|
+
if (typeof refreshToken !== "string") return refreshToken;
|
|
227
242
|
|
|
228
243
|
// Account creation (programmatic SystemUser; the real owner comes from
|
|
229
244
|
// the HMAC-verified state → ownerUserIdOverride).
|
|
@@ -240,30 +255,46 @@ export function createInboundMailConnectRoutes(
|
|
|
240
255
|
},
|
|
241
256
|
});
|
|
242
257
|
if (!dispatched.isSuccess) {
|
|
243
|
-
return
|
|
258
|
+
return connectFailureResponse(c, dispatched.error);
|
|
244
259
|
}
|
|
245
260
|
const accountId = propertyOf(dispatched.data, "accountId");
|
|
246
261
|
if (typeof accountId !== "string") {
|
|
247
262
|
return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
|
|
248
263
|
}
|
|
249
264
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await options.oauthTokens.storeRefreshToken(
|
|
265
|
+
try {
|
|
266
|
+
await storeOAuthCallbackCredential(
|
|
267
|
+
options.oauthTokens,
|
|
268
|
+
deps.secrets,
|
|
255
269
|
{ id: accountId, tenantId: state.tenantId },
|
|
256
|
-
tokens
|
|
270
|
+
tokens,
|
|
271
|
+
refreshToken,
|
|
257
272
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
inboundRefreshTokenSecretOptions(accountId),
|
|
273
|
+
} catch {
|
|
274
|
+
return errorJson(
|
|
275
|
+
c,
|
|
276
|
+
502,
|
|
277
|
+
"credential_store_failed",
|
|
278
|
+
"the sign-in worked but its token could not be saved; the mailbox keeps its previous state, connect it again",
|
|
265
279
|
);
|
|
266
280
|
}
|
|
281
|
+
const refused = await reactivateIfReopened(
|
|
282
|
+
c,
|
|
283
|
+
deps,
|
|
284
|
+
state.tenantId,
|
|
285
|
+
accountId,
|
|
286
|
+
dispatched.data,
|
|
287
|
+
);
|
|
288
|
+
if (refused) return refused;
|
|
289
|
+
|
|
290
|
+
// Best effort: the account commit already woke the supervisor before the token existed.
|
|
291
|
+
// Without this event the first sync would wait for the next poll tick; a failure here
|
|
292
|
+
// only costs that delay, so the connect itself still succeeds.
|
|
293
|
+
await deps.dispatchSystemWrite({
|
|
294
|
+
handlerQn: InboundMailFoundationHandlers.markCredentialStored,
|
|
295
|
+
tenantId: state.tenantId,
|
|
296
|
+
payload: { accountId },
|
|
297
|
+
});
|
|
267
298
|
|
|
268
299
|
if (options.successRedirectUrl) {
|
|
269
300
|
const target = new URL(options.successRedirectUrl);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { INBOUND_MAIL_FOUNDATION_FEATURE } from "./web/constants.ts";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
INBOUND_MAIL_CONNECT_PATH,
|
|
5
|
+
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
6
|
+
INBOUND_MAIL_RECONNECT_OAUTH_ACTION,
|
|
7
|
+
} from "./web/constants.ts";
|
|
3
8
|
|
|
4
9
|
// Extension-point name for provider plugins (inbound-provider-imap,
|
|
5
10
|
// inbound-provider-m365-graph, inbound-provider-gmail-rest, ...).
|
|
@@ -20,6 +25,18 @@ export const InboundMailFoundationHandlers = {
|
|
|
20
25
|
/** Tenant admin disconnects a mailbox. The stream remains for auditing;
|
|
21
26
|
* the status becomes disconnected and the watch stops. */
|
|
22
27
|
disconnectAccount: "inbound-mail-foundation:write:disconnect-account",
|
|
28
|
+
/** The only way from disconnected back to active; also clears auth_error
|
|
29
|
+
* and degraded. update-account keeps rejecting disconnected accounts. */
|
|
30
|
+
reactivateAccount: "inbound-mail-foundation:write:reactivate-account",
|
|
31
|
+
setTrustedAuthservId: "inbound-mail-foundation:write:set-trusted-authserv-id",
|
|
32
|
+
/** Owner or admin asks for one quarantined mail to be loaded again on the next sync. */
|
|
33
|
+
retryQuarantinedMessage: "inbound-mail-foundation:write:retry-quarantined-message",
|
|
34
|
+
/** Marks all open quarantine entries of a mailbox as acknowledged. */
|
|
35
|
+
acknowledgeQuarantine: "inbound-mail-foundation:write:acknowledge-quarantine",
|
|
36
|
+
/** Supervisor-only: stores the content of an already imported snippet mail. */
|
|
37
|
+
attachMessageContent: "inbound-mail-foundation:write:attach-message-content",
|
|
38
|
+
/** OAuth callback only: the refresh token is stored now, so an active account can sync. */
|
|
39
|
+
markCredentialStored: "inbound-mail-foundation:write:mark-credential-stored",
|
|
23
40
|
} as const;
|
|
24
41
|
|
|
25
42
|
// Qualified query handler names.
|
|
@@ -29,6 +46,10 @@ export const InboundMailFoundationQueries = {
|
|
|
29
46
|
/** Tenant-scoped message list (inbox cockpit). PII is decrypted on read.
|
|
30
47
|
*/
|
|
31
48
|
listMessages: "inbound-mail-foundation:query:message:list",
|
|
49
|
+
/** Open quarantine entries of the mailboxes the caller may manage, with sender and subject from the header import. */
|
|
50
|
+
listQuarantine: "inbound-mail-foundation:query:quarantine:list",
|
|
51
|
+
/** Decrypted full text, attachment ids and content state of one message. */
|
|
52
|
+
messageContent: "inbound-mail-foundation:query:message:content",
|
|
32
53
|
} as const;
|
|
33
54
|
|
|
34
55
|
// Normalized account status values — provider-agnostic.
|
|
@@ -37,7 +58,7 @@ export const InboundMailAccountStatuses = {
|
|
|
37
58
|
active: "active",
|
|
38
59
|
/** Credentials or token are invalid; the tenant admin must reconnect. */
|
|
39
60
|
authError: "auth_error",
|
|
40
|
-
/**
|
|
61
|
+
/** Interrupted: transient failure, quarantined mail, missing file storage or KMS; see lastError. */
|
|
41
62
|
degraded: "degraded",
|
|
42
63
|
/** Disconnected by the tenant admin; no further syncs run. */
|
|
43
64
|
disconnected: "disconnected",
|
|
@@ -45,6 +66,34 @@ export const InboundMailAccountStatuses = {
|
|
|
45
66
|
export type InboundMailAccountStatus =
|
|
46
67
|
(typeof InboundMailAccountStatuses)[keyof typeof InboundMailAccountStatuses];
|
|
47
68
|
|
|
69
|
+
// Why a mailbox is degraded. Codes only, never provider error text (that carries hosts/tokens).
|
|
70
|
+
export const InboundMailLastErrors = {
|
|
71
|
+
connectionFailed: "connection_failed",
|
|
72
|
+
rateLimited: "rate_limited",
|
|
73
|
+
messageQuarantined: "message_quarantined",
|
|
74
|
+
fileStorageUnavailable: "file_storage_unavailable",
|
|
75
|
+
encryptionUnavailable: "encryption_unavailable",
|
|
76
|
+
ingestUnavailable: "ingest_unavailable",
|
|
77
|
+
} as const;
|
|
78
|
+
export type InboundMailLastError =
|
|
79
|
+
(typeof InboundMailLastErrors)[keyof typeof InboundMailLastErrors];
|
|
80
|
+
|
|
81
|
+
// Business reasons of failed connect writes; connect-routes maps both to 409 with the reason code.
|
|
82
|
+
export const InboundMailConnectErrors = {
|
|
83
|
+
addressAlreadyConnected: {
|
|
84
|
+
reason: "address_already_connected",
|
|
85
|
+
i18nKey: "inboundMail.accounts.errors.addressAlreadyConnected",
|
|
86
|
+
},
|
|
87
|
+
addressDisconnected: {
|
|
88
|
+
reason: "address_disconnected",
|
|
89
|
+
i18nKey: "inboundMail.accounts.errors.addressDisconnected",
|
|
90
|
+
},
|
|
91
|
+
credentialRequired: {
|
|
92
|
+
reason: "credential_required",
|
|
93
|
+
i18nKey: "inboundMail.accounts.errors.credentialRequired",
|
|
94
|
+
},
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
48
97
|
/**
|
|
49
98
|
* Tenant secret slot for an account: IMAP app password OR OAuth refresh
|
|
50
99
|
* token. The provider interprets the contents according to `authMethod`.
|
|
@@ -71,6 +120,20 @@ export const InboundMailAuthMethods = {
|
|
|
71
120
|
export type InboundMailAuthMethod =
|
|
72
121
|
(typeof InboundMailAuthMethods)[keyof typeof InboundMailAuthMethods];
|
|
73
122
|
|
|
123
|
+
/** Provider key of inbound-provider-imap: only its password mailboxes have a credential form. */
|
|
124
|
+
export const IMAP_PROVIDER_KEY = "imap";
|
|
125
|
+
|
|
126
|
+
// How a mailbox the caller may re-arm gets its credential back, computed by account:list because a
|
|
127
|
+
// screen row condition cannot combine provider, auth method, status and owner.
|
|
128
|
+
export const MailAccountReactivationPaths = {
|
|
129
|
+
/** The IMAP form of inbound-provider-imap (reconnect-mailbox). */
|
|
130
|
+
passwordForm: "password_form",
|
|
131
|
+
/** The provider's OAuth sign-in through the connect route; its callback reopens the account. */
|
|
132
|
+
oauthFlow: "oauth_flow",
|
|
133
|
+
} as const;
|
|
134
|
+
export type MailAccountReactivationPath =
|
|
135
|
+
(typeof MailAccountReactivationPaths)[keyof typeof MailAccountReactivationPaths];
|
|
136
|
+
|
|
74
137
|
// **Multi-provider:** no global `provider` config key
|
|
75
138
|
// (unlike mail-foundation, as with billing-foundation). A tenant can connect
|
|
76
139
|
// an IMAP mailbox and an M365 mailbox in parallel; the provider is stored per
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Reloads the content of mails that were imported as snippet only (event version 1, before full text
|
|
2
|
+
// storage existed). Runs after a successful poll with a small per-account budget, so a large backlog
|
|
3
|
+
// is worked off over several ticks without slowing the regular sync. "unavailable" is terminal:
|
|
4
|
+
// mails that are gone from the server stop being candidates.
|
|
5
|
+
|
|
6
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
9
|
+
import { inboundMessageAggregateId } from "./aggregate-id.ts";
|
|
10
|
+
import { InboundMailFoundationHandlers } from "./constants.ts";
|
|
11
|
+
import { seenMessageTable } from "./entities.ts";
|
|
12
|
+
import { normalizeRawInboundMessage } from "./normalize-inbound-message.ts";
|
|
13
|
+
import { inboundMessagesProjectionTable } from "./projection.ts";
|
|
14
|
+
import type { DispatchWrite } from "./supervisor-ingest.ts";
|
|
15
|
+
import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export const CONTENT_REFETCH_BUDGET_PER_TICK = 50;
|
|
18
|
+
const SEEN_PAGE_SIZE = 250;
|
|
19
|
+
const SEEN_MAX_PAGES = 4;
|
|
20
|
+
|
|
21
|
+
type SeenRow = { readonly providerMessageId: string; readonly seenAt: Temporal.Instant };
|
|
22
|
+
type MessageRow = {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly bodyRef?: string | null;
|
|
25
|
+
readonly contentState?: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function needsContent(row: MessageRow): boolean {
|
|
29
|
+
const bodyMissing = row.bodyRef === undefined || row.bodyRef === null || row.bodyRef === "";
|
|
30
|
+
return bodyMissing && (row.contentState == null || row.contentState === "snippet");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function findSnippetOnlyMessageIds(
|
|
34
|
+
db: DbConnection,
|
|
35
|
+
account: MailAccountRecord,
|
|
36
|
+
windowDays: number,
|
|
37
|
+
): Promise<string[]> {
|
|
38
|
+
const cutoff = Temporal.Now.instant().subtract({ hours: windowDays * 24 });
|
|
39
|
+
const found: string[] = [];
|
|
40
|
+
let before: Temporal.Instant | undefined;
|
|
41
|
+
for (
|
|
42
|
+
let page = 0;
|
|
43
|
+
page < SEEN_MAX_PAGES && found.length < CONTENT_REFETCH_BUDGET_PER_TICK;
|
|
44
|
+
page++
|
|
45
|
+
) {
|
|
46
|
+
const seen = await selectMany<SeenRow>(
|
|
47
|
+
db,
|
|
48
|
+
seenMessageTable,
|
|
49
|
+
{
|
|
50
|
+
tenantId: account.tenantId,
|
|
51
|
+
accountId: account.id,
|
|
52
|
+
seenAt: before ? { gte: cutoff, lt: before } : { gte: cutoff },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
fields: ["providerMessageId", "seenAt"],
|
|
56
|
+
orderBy: { col: "seenAt", direction: "desc" },
|
|
57
|
+
limit: SEEN_PAGE_SIZE,
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
if (seen.length === 0) break;
|
|
61
|
+
const aggregateToProvider = new Map(
|
|
62
|
+
seen.map((row) => [
|
|
63
|
+
inboundMessageAggregateId(account.id, row.providerMessageId),
|
|
64
|
+
row.providerMessageId,
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
const rows = await selectMany<MessageRow>(
|
|
68
|
+
db,
|
|
69
|
+
inboundMessagesProjectionTable,
|
|
70
|
+
{ tenantId: account.tenantId, id: { in: [...aggregateToProvider.keys()] } },
|
|
71
|
+
{ fields: ["id", "bodyRef", "contentState"] },
|
|
72
|
+
);
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
const providerMessageId = aggregateToProvider.get(row.id);
|
|
75
|
+
if (providerMessageId !== undefined && needsContent(row)) found.push(providerMessageId);
|
|
76
|
+
}
|
|
77
|
+
const oldest = seen[seen.length - 1];
|
|
78
|
+
if (seen.length < SEEN_PAGE_SIZE || !oldest) break;
|
|
79
|
+
before = oldest.seenAt;
|
|
80
|
+
}
|
|
81
|
+
return found.slice(0, CONTENT_REFETCH_BUDGET_PER_TICK);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function refetchSnippetOnlyMessages(args: {
|
|
85
|
+
readonly db: DbConnection;
|
|
86
|
+
readonly dispatchWrite: DispatchWrite;
|
|
87
|
+
readonly providerCtx: InboundMailContext;
|
|
88
|
+
readonly account: MailAccountRecord;
|
|
89
|
+
readonly plugin: InboundMailProviderPlugin;
|
|
90
|
+
readonly windowDays: number;
|
|
91
|
+
readonly accessToken?: string | undefined;
|
|
92
|
+
readonly signal?: AbortSignal | undefined;
|
|
93
|
+
}): Promise<void> {
|
|
94
|
+
const { account, plugin, dispatchWrite } = args;
|
|
95
|
+
// skip: the provider cannot reload content.
|
|
96
|
+
if (!plugin.fetchContent) return;
|
|
97
|
+
const ids = await findSnippetOnlyMessageIds(args.db, account, args.windowDays);
|
|
98
|
+
// skip: no snippet-only mail to reload.
|
|
99
|
+
if (ids.length === 0) return;
|
|
100
|
+
const result = await plugin.fetchContent(
|
|
101
|
+
args.providerCtx,
|
|
102
|
+
account,
|
|
103
|
+
ids,
|
|
104
|
+
args.accessToken !== undefined || args.signal !== undefined
|
|
105
|
+
? {
|
|
106
|
+
...(args.accessToken !== undefined && { accessToken: args.accessToken }),
|
|
107
|
+
...(args.signal !== undefined && { signal: args.signal }),
|
|
108
|
+
}
|
|
109
|
+
: undefined,
|
|
110
|
+
);
|
|
111
|
+
const attach = (providerMessageId: string, content: Record<string, unknown>) =>
|
|
112
|
+
dispatchWrite({
|
|
113
|
+
handlerQn: InboundMailFoundationHandlers.attachMessageContent,
|
|
114
|
+
tenantId: account.tenantId,
|
|
115
|
+
payload: { accountId: account.id, providerMessageId, ...content },
|
|
116
|
+
});
|
|
117
|
+
for (const raw of result.messages) {
|
|
118
|
+
const msg = normalizeRawInboundMessage(raw);
|
|
119
|
+
if (msg.bodyText === "" && msg.rawMime == null && msg.bodyHtml === null) {
|
|
120
|
+
// Nothing usable came back: attach would leave the mail at "snippet", i.e. a candidate on
|
|
121
|
+
// every tick. "unavailable" is terminal.
|
|
122
|
+
await attach(msg.providerMessageId, { outcome: "unavailable" });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
await attach(msg.providerMessageId, {
|
|
126
|
+
outcome: "fetched",
|
|
127
|
+
bodyText: msg.bodyText,
|
|
128
|
+
bodyTextTruncated: msg.bodyTextTruncated,
|
|
129
|
+
...(msg.rawMime != null && { rawMime: msg.rawMime }),
|
|
130
|
+
...(msg.bodyHtml !== null && { bodyHtml: msg.bodyHtml }),
|
|
131
|
+
attachments: msg.attachments,
|
|
132
|
+
skippedAttachments: msg.skippedAttachments,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const gone = [...result.missing, ...result.rejected.map((r) => r.providerMessageId)];
|
|
136
|
+
for (const providerMessageId of gone) await attach(providerMessageId, { outcome: "unavailable" });
|
|
137
|
+
}
|
|
@@ -2,7 +2,10 @@ import { collectPiiSubjectFields } from "@cosmicdrift/kumiko-framework/crypto";
|
|
|
2
2
|
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import {
|
|
4
4
|
access,
|
|
5
|
+
createBooleanField,
|
|
5
6
|
createEntity,
|
|
7
|
+
createFilesField,
|
|
8
|
+
createLongTextField,
|
|
6
9
|
createNumberField,
|
|
7
10
|
createTextField,
|
|
8
11
|
createTimestampField,
|
|
@@ -64,7 +67,15 @@ export const mailAccountEntity = createEntity({
|
|
|
64
67
|
personal: false,
|
|
65
68
|
reason: "technical_reference",
|
|
66
69
|
}),
|
|
70
|
+
// Code of InboundMailLastErrors, never provider error text. Null on rows from before the column.
|
|
71
|
+
lastError: createTextField({ maxLength: 50, personal: false, reason: "technical_reference" }),
|
|
67
72
|
watchState: createTextField({ maxLength: 100, personal: false, reason: "technical_reference" }),
|
|
73
|
+
// Lower-case authserv-id of the mailbox's own server; null = none set.
|
|
74
|
+
trustedAuthservId: createTextField({
|
|
75
|
+
maxLength: 255,
|
|
76
|
+
personal: false,
|
|
77
|
+
reason: "technical_reference",
|
|
78
|
+
}),
|
|
68
79
|
connectedAt: createTimestampField({ required: true }),
|
|
69
80
|
},
|
|
70
81
|
});
|
|
@@ -82,8 +93,7 @@ export const inboundMessageEntity = createEntity({
|
|
|
82
93
|
reason: "technical_reference",
|
|
83
94
|
}),
|
|
84
95
|
// Scope is inherited from the account at ingest time: messages from a
|
|
85
|
-
// personal mailbox are visible only to the owner
|
|
86
|
-
// without joining read_mail_accounts.
|
|
96
|
+
// personal mailbox are visible only to the owner, without joining read_mail_accounts.
|
|
87
97
|
ownerUserId: createTextField({ maxLength: 36, personal: false, reason: "pseudonymous_fk" }),
|
|
88
98
|
messageIdHeader: createTextField({
|
|
89
99
|
required: true,
|
|
@@ -106,7 +116,40 @@ export const inboundMessageEntity = createEntity({
|
|
|
106
116
|
receivedAt: createTimestampField({ required: true }),
|
|
107
117
|
bodyRef: createTextField({ maxLength: 500, personal: false, reason: "technical_reference" }),
|
|
108
118
|
scope: createTextField({ maxLength: 200, personal: false, reason: "technical_reference" }),
|
|
119
|
+
// Full plaintext, capped at 32_000 characters before encryption (ciphertext is ~2.3x plus envelope).
|
|
120
|
+
// Null on rows from before the column; list-messages never selects it.
|
|
121
|
+
bodyText: createLongTextField({ maxLength: 200_000, personal: "tenant", find: "none" }),
|
|
122
|
+
bodyTextTruncated: createBooleanField(),
|
|
123
|
+
// Bound to the message (entityType = the aggregate type), so the file guard answers with the
|
|
124
|
+
// mail's visibility. The fileRefs are bound at ingest, the projection copies the ids.
|
|
125
|
+
attachmentFileIds: createFilesField({ personal: false, reason: "technical_reference" }),
|
|
126
|
+
// JSON-stringified SkippedInboundAttachment[]: nullable JSON stays a text column
|
|
127
|
+
// (jsonb fields are NOT NULL), like to/cc.
|
|
128
|
+
skippedAttachments: createTextField({
|
|
129
|
+
maxLength: 20_000,
|
|
130
|
+
personal: false,
|
|
131
|
+
reason: "technical_reference",
|
|
132
|
+
}),
|
|
133
|
+
// JSON-stringified SenderAuthentication or "null"; a SQL null (rows from before the column) reads as null.
|
|
134
|
+
senderAuthentication: createTextField({
|
|
135
|
+
maxLength: 4000,
|
|
136
|
+
personal: false,
|
|
137
|
+
reason: "technical_reference",
|
|
138
|
+
}),
|
|
139
|
+
// "complete" | "snippet" | "unavailable"; null counts as "snippet".
|
|
140
|
+
contentState: createTextField({
|
|
141
|
+
maxLength: 20,
|
|
142
|
+
personal: false,
|
|
143
|
+
reason: "technical_reference",
|
|
144
|
+
}),
|
|
145
|
+
// One of INBOUND_CONTENT_UNAVAILABLE_REASONS; null while the content is there or not yet missing.
|
|
146
|
+
contentUnavailableReason: createTextField({
|
|
147
|
+
maxLength: 30,
|
|
148
|
+
personal: false,
|
|
149
|
+
reason: "technical_reference",
|
|
150
|
+
}),
|
|
109
151
|
},
|
|
152
|
+
attachedFileAccess: "record-only",
|
|
110
153
|
});
|
|
111
154
|
|
|
112
155
|
/** Thread-Rollup. PK = aggregateId = mailThreadAggregateId(tenantId,
|