@cosmicdrift/kumiko-samples 0.393.0 → 0.395.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 +36 -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 +61 -136
- 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-imap/watch-mailbox.ts +109 -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/changes.json +7 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
- package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
- package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
- package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
- package/packages/bundled-features/src/managed-pages/table.ts +6 -2
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
- package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
- package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
- package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -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/README.md +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +20 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
- package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
- 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-set.write.ts +24 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
- package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
- package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
- package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
- package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
- package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
- package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
- package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
- package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
- 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 +89 -40
- 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 +8 -1
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
- package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
- 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
package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts
CHANGED
|
@@ -14,10 +14,23 @@ import {
|
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
15
|
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
16
|
import * as z from "zod";
|
|
17
|
-
import { INBOUND_MESSAGE_PII_FIELDS } from "../entities.ts";
|
|
17
|
+
import { INBOUND_MESSAGE_PII_FIELDS, inboundMessageEntity } from "../entities.ts";
|
|
18
|
+
import type { InboundMailRoles } from "../mail-roles.ts";
|
|
18
19
|
import { inboundMessagesProjectionTable } from "../projection.ts";
|
|
19
20
|
import { isVisibleToCaller } from "./scope-visibility.ts";
|
|
20
21
|
|
|
22
|
+
// The full text is delivered only by message:content; selecting it here would also decrypt it.
|
|
23
|
+
const LIST_MESSAGE_COLUMNS = [
|
|
24
|
+
"id",
|
|
25
|
+
"tenantId",
|
|
26
|
+
"version",
|
|
27
|
+
"insertedAt",
|
|
28
|
+
"modifiedAt",
|
|
29
|
+
"insertedById",
|
|
30
|
+
"modifiedById",
|
|
31
|
+
...Object.keys(inboundMessageEntity.fields).filter((name) => name !== "bodyText"),
|
|
32
|
+
];
|
|
33
|
+
|
|
21
34
|
const listMessagesSchema = z
|
|
22
35
|
.object({
|
|
23
36
|
accountId: z.uuid().optional(),
|
|
@@ -27,33 +40,36 @@ const listMessagesSchema = z
|
|
|
27
40
|
.strict();
|
|
28
41
|
type ListMessagesPayload = z.infer<typeof listMessagesSchema>;
|
|
29
42
|
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
export function createListMessagesQuery({ memberRoles }: InboundMailRoles): QueryHandlerDef {
|
|
44
|
+
return {
|
|
45
|
+
name: "message:list",
|
|
46
|
+
schema: listMessagesSchema,
|
|
47
|
+
access: { roles: [...memberRoles] },
|
|
48
|
+
description:
|
|
49
|
+
"Lists received mail of the caller's tenant with sender, recipients, subject and snippet decrypted, optionally narrowed to one account, thread or folder scope and hiding messages of personal mailboxes the caller does not own; use it to read an inbox or a conversation.",
|
|
50
|
+
handler: async (query, ctx) => {
|
|
51
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
52
|
+
const payload = query.payload as ListMessagesPayload;
|
|
53
|
+
const filter: Record<string, unknown> = { tenantId: ctx.user.tenantId };
|
|
54
|
+
if (payload.accountId) filter["accountId"] = payload.accountId;
|
|
55
|
+
if (payload.threadKey) filter["threadKey"] = payload.threadKey;
|
|
56
|
+
if (payload.scope) filter["scope"] = payload.scope;
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
58
|
+
const allRows = await ctx.db.selectMany(inboundMessagesProjectionTable, filter, {
|
|
59
|
+
fields: LIST_MESSAGE_COLUMNS,
|
|
60
|
+
});
|
|
61
|
+
// Scope visibility: messages in personal mailboxes are visible only to the owner.
|
|
62
|
+
const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
|
|
63
|
+
const piiKms = configuredPiiSubjectKms();
|
|
64
|
+
if (!piiKms) return { rows };
|
|
65
|
+
const decryptedRows = await Promise.all(
|
|
66
|
+
rows.map((row) =>
|
|
67
|
+
decryptPiiFieldValues(row, INBOUND_MESSAGE_PII_FIELDS, piiKms, {
|
|
68
|
+
requestId: `inbound-mail-foundation:list-messages:${ctx.user.tenantId}`,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
return { rows: decryptedRows };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// quarantine:list — open quarantine entries with the sender and subject of the header-only import
|
|
2
|
+
// (looked up by the deterministic message aggregate id; null when the provider gave no headers).
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
configuredPiiSubjectKms,
|
|
6
|
+
decryptPiiFieldValues,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
+
import { MAX_LIST_LIMIT, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import * as z from "zod";
|
|
10
|
+
import { inboundMessageAggregateId } from "../aggregate-id.ts";
|
|
11
|
+
import { INBOUND_MESSAGE_PII_FIELDS } from "../entities.ts";
|
|
12
|
+
import type { InboundMailRoles } from "../mail-roles.ts";
|
|
13
|
+
import { inboundMessagesProjectionTable, mailAccountsProjectionTable } from "../projection.ts";
|
|
14
|
+
import { quarantineTable } from "../quarantine-entities.ts";
|
|
15
|
+
import { mayManageQuarantine } from "./scope-visibility.ts";
|
|
16
|
+
|
|
17
|
+
// The projectionList screen always sends `limit`; unknown keys stay rejected.
|
|
18
|
+
const listQuarantineSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
accountId: z.uuid().optional(),
|
|
21
|
+
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
|
|
25
|
+
function textOrNull(value: unknown): string | null {
|
|
26
|
+
return typeof value === "string" ? value : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createListQuarantineQuery({
|
|
30
|
+
memberRoles,
|
|
31
|
+
adminRoles,
|
|
32
|
+
}: InboundMailRoles): QueryHandlerDef {
|
|
33
|
+
return {
|
|
34
|
+
name: "quarantine:list",
|
|
35
|
+
schema: listQuarantineSchema,
|
|
36
|
+
access: { roles: [...memberRoles] },
|
|
37
|
+
agent: { expose: false },
|
|
38
|
+
description:
|
|
39
|
+
"Lists the open quarantine entries (mails the import could not take) of the mailboxes the caller may manage, newest first, with reason, retry state and the sender and subject of the header-only import when there is one; use it to review and retry quarantined mails.",
|
|
40
|
+
handler: async (query, ctx) => {
|
|
41
|
+
const { accountId, limit } = listQuarantineSchema.parse(query.payload);
|
|
42
|
+
const accounts = await ctx.db.selectMany(mailAccountsProjectionTable, {
|
|
43
|
+
tenantId: ctx.user.tenantId,
|
|
44
|
+
});
|
|
45
|
+
const managed = accounts.filter(
|
|
46
|
+
(row) =>
|
|
47
|
+
(accountId === undefined || row["id"] === accountId) &&
|
|
48
|
+
mayManageQuarantine(textOrNull(row["ownerUserId"]) || null, ctx.user, adminRoles),
|
|
49
|
+
);
|
|
50
|
+
if (managed.length === 0) return { rows: [] };
|
|
51
|
+
const accountName = new Map(managed.map((row) => [String(row["id"]), row["displayName"]]));
|
|
52
|
+
|
|
53
|
+
const entries = await ctx.db.selectMany(
|
|
54
|
+
quarantineTable,
|
|
55
|
+
{ accountId: { in: [...accountName.keys()] }, acknowledgedAt: null },
|
|
56
|
+
{ limit, orderBy: [{ col: "quarantinedAt", direction: "desc" }, { col: "id" }] },
|
|
57
|
+
);
|
|
58
|
+
if (entries.length === 0) return { rows: [] };
|
|
59
|
+
|
|
60
|
+
const headers = await ctx.db.selectMany(
|
|
61
|
+
inboundMessagesProjectionTable,
|
|
62
|
+
{
|
|
63
|
+
id: {
|
|
64
|
+
in: entries.map((entry) =>
|
|
65
|
+
inboundMessageAggregateId(
|
|
66
|
+
String(entry["accountId"]),
|
|
67
|
+
String(entry["providerMessageId"]),
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{ fields: ["id", "from", "subject"] },
|
|
73
|
+
);
|
|
74
|
+
const piiKms = configuredPiiSubjectKms();
|
|
75
|
+
const readableHeaders = new Map<string, Record<string, unknown>>();
|
|
76
|
+
for (const header of headers) {
|
|
77
|
+
const readable = piiKms
|
|
78
|
+
? await decryptPiiFieldValues(header, INBOUND_MESSAGE_PII_FIELDS, piiKms, {
|
|
79
|
+
requestId: `inbound-mail-foundation:list-quarantine:${ctx.user.tenantId}`,
|
|
80
|
+
})
|
|
81
|
+
: header;
|
|
82
|
+
readableHeaders.set(String(header["id"]), readable);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
rows: entries.map((entry) => {
|
|
87
|
+
const accountIdOfEntry = String(entry["accountId"]);
|
|
88
|
+
const header = readableHeaders.get(
|
|
89
|
+
inboundMessageAggregateId(accountIdOfEntry, String(entry["providerMessageId"])),
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
id: entry["id"],
|
|
93
|
+
accountId: accountIdOfEntry,
|
|
94
|
+
accountName: accountName.get(accountIdOfEntry) ?? null,
|
|
95
|
+
providerMessageId: entry["providerMessageId"],
|
|
96
|
+
reason: entry["reasonCode"],
|
|
97
|
+
quarantinedAt: entry["quarantinedAt"],
|
|
98
|
+
retryRequestedAt: entry["retryRequestedAt"] ?? null,
|
|
99
|
+
sender: textOrNull(header?.["from"]),
|
|
100
|
+
subject: textOrNull(header?.["subject"]),
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// mark-credential-stored — the OAuth callback stores the refresh token after the account commit,
|
|
2
|
+
// so the wake from connect-account can fire before the credential exists. This re-emits the
|
|
3
|
+
// unchanged active state with a fresh event, which the wake consumer turns into a sync.
|
|
4
|
+
|
|
5
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { InboundMailAccountStatuses } from "../constants.ts";
|
|
9
|
+
import {
|
|
10
|
+
MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
11
|
+
MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
12
|
+
type MailAccountEventHeaders,
|
|
13
|
+
} from "../events.ts";
|
|
14
|
+
import { loadCurrentMailAccountPayload } from "./account-state.ts";
|
|
15
|
+
|
|
16
|
+
export const CREDENTIAL_STORED_AUDIT_REASON = "credential_stored";
|
|
17
|
+
|
|
18
|
+
export const markCredentialStoredSchema = z.object({ accountId: z.uuid() });
|
|
19
|
+
|
|
20
|
+
export const markCredentialStoredHandler: WriteHandlerDef = {
|
|
21
|
+
name: "mark-credential-stored",
|
|
22
|
+
schema: markCredentialStoredSchema,
|
|
23
|
+
access: { roles: ["SystemAdmin"] },
|
|
24
|
+
agent: { expose: false },
|
|
25
|
+
description:
|
|
26
|
+
"Signals that the credential of an active mailbox was stored after the account was created, so the first sync can start right away.",
|
|
27
|
+
handler: async (event, ctx) => {
|
|
28
|
+
const payload = markCredentialStoredSchema.parse(event.payload);
|
|
29
|
+
const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
|
|
30
|
+
if (!current) return failNotFound("mail-account", payload.accountId);
|
|
31
|
+
if (current.status !== InboundMailAccountStatuses.active) {
|
|
32
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId, marked: false } };
|
|
33
|
+
}
|
|
34
|
+
const headers: MailAccountEventHeaders = {
|
|
35
|
+
providerName: current.provider,
|
|
36
|
+
reason: CREDENTIAL_STORED_AUDIT_REASON,
|
|
37
|
+
};
|
|
38
|
+
await ctx.events.unsafeAppend({
|
|
39
|
+
aggregateId: payload.accountId,
|
|
40
|
+
aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
41
|
+
type: MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
42
|
+
payload: current,
|
|
43
|
+
headers,
|
|
44
|
+
});
|
|
45
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId, marked: true } };
|
|
46
|
+
},
|
|
47
|
+
};
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// message:content — the decrypted full text, attachment ids and content state of one message.
|
|
2
|
+
// message:list deliberately omits all of it so inbox lists stay small.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
configuredPiiSubjectKms,
|
|
6
|
+
decryptPiiFieldValues,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
+
import type { HandlerContext, QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
|
+
import * as z from "zod";
|
|
11
|
+
import { INBOUND_MESSAGE_PII_FIELDS } from "../entities.ts";
|
|
12
|
+
import {
|
|
13
|
+
INBOUND_CONTENT_STATES,
|
|
14
|
+
INBOUND_CONTENT_UNAVAILABLE_REASONS,
|
|
15
|
+
type InboundContentState,
|
|
16
|
+
type InboundContentUnavailableReason,
|
|
17
|
+
skippedAttachmentSchema,
|
|
18
|
+
} from "../events.ts";
|
|
19
|
+
import type { InboundMailRoles } from "../mail-roles.ts";
|
|
20
|
+
import { inboundMessagesProjectionTable } from "../projection.ts";
|
|
21
|
+
import { type SenderAuthentication, senderAuthenticationFieldSchema } from "../sender-auth.ts";
|
|
22
|
+
import type { SkippedInboundAttachment } from "../types.ts";
|
|
23
|
+
import { isVisibleToCaller } from "./scope-visibility.ts";
|
|
24
|
+
|
|
25
|
+
export type InboundMessageContent = {
|
|
26
|
+
readonly messageId: string;
|
|
27
|
+
readonly bodyText: string;
|
|
28
|
+
readonly bodyTextTruncated: boolean;
|
|
29
|
+
readonly bodyRef: string;
|
|
30
|
+
readonly attachmentFileIds: readonly string[];
|
|
31
|
+
readonly skippedAttachments: readonly SkippedInboundAttachment[];
|
|
32
|
+
readonly contentState: InboundContentState;
|
|
33
|
+
readonly contentUnavailableReason: InboundContentUnavailableReason | null;
|
|
34
|
+
/** null = no trustworthy statement; see isSenderAuthenticated. */
|
|
35
|
+
readonly senderAuthentication: SenderAuthentication | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const messageContentSchema = z.object({ messageId: z.uuid() }).strict();
|
|
39
|
+
const fileIdsSchema = z.array(z.uuid());
|
|
40
|
+
const skippedListSchema = z.array(skippedAttachmentSchema);
|
|
41
|
+
|
|
42
|
+
// Columns hold JSON text (nullable JSON stays text); rows from before the columns read as empty.
|
|
43
|
+
function parseJsonColumn<T>(raw: unknown, schema: z.ZodType<T>, fallback: T): T {
|
|
44
|
+
if (typeof raw !== "string" || raw === "") return fallback;
|
|
45
|
+
return schema.parse(parseJsonOrThrow(raw, "inbound message content column"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function contentStateOfRow(raw: unknown): InboundContentState {
|
|
49
|
+
return INBOUND_CONTENT_STATES.find((state) => state === raw) ?? "snippet";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Same visibility and decryption as the query, for server-side consumers (apps).
|
|
53
|
+
export async function loadInboundMessageContent(
|
|
54
|
+
ctx: Pick<HandlerContext, "db" | "user">,
|
|
55
|
+
messageId: string,
|
|
56
|
+
): Promise<InboundMessageContent | null> {
|
|
57
|
+
const row = await ctx.db.fetchOne(inboundMessagesProjectionTable, { id: messageId });
|
|
58
|
+
if (!row || !isVisibleToCaller(row, ctx.user)) return null;
|
|
59
|
+
const piiKms = configuredPiiSubjectKms();
|
|
60
|
+
const readable = piiKms
|
|
61
|
+
? await decryptPiiFieldValues(row, INBOUND_MESSAGE_PII_FIELDS, piiKms, {
|
|
62
|
+
requestId: `inbound-mail-foundation:message-content:${ctx.user.tenantId}`,
|
|
63
|
+
})
|
|
64
|
+
: row;
|
|
65
|
+
const bodyText = readable["bodyText"];
|
|
66
|
+
return {
|
|
67
|
+
messageId,
|
|
68
|
+
bodyText: typeof bodyText === "string" ? bodyText : "",
|
|
69
|
+
bodyTextTruncated: row["bodyTextTruncated"] === true,
|
|
70
|
+
bodyRef: typeof row["bodyRef"] === "string" ? row["bodyRef"] : "",
|
|
71
|
+
attachmentFileIds: fileIdsSchema.parse(row["attachmentFileIds"] ?? []),
|
|
72
|
+
skippedAttachments: parseJsonColumn(row["skippedAttachments"], skippedListSchema, []),
|
|
73
|
+
contentState: contentStateOfRow(row["contentState"]),
|
|
74
|
+
contentUnavailableReason:
|
|
75
|
+
INBOUND_CONTENT_UNAVAILABLE_REASONS.find(
|
|
76
|
+
(reason) => reason === row["contentUnavailableReason"],
|
|
77
|
+
) ?? null,
|
|
78
|
+
senderAuthentication: parseJsonColumn(
|
|
79
|
+
row["senderAuthentication"],
|
|
80
|
+
senderAuthenticationFieldSchema,
|
|
81
|
+
null,
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createMessageContentQuery({ memberRoles }: InboundMailRoles): QueryHandlerDef {
|
|
87
|
+
return {
|
|
88
|
+
name: "message:content",
|
|
89
|
+
schema: messageContentSchema,
|
|
90
|
+
access: { roles: [...memberRoles] },
|
|
91
|
+
agent: { expose: false },
|
|
92
|
+
description:
|
|
93
|
+
"Returns the full decrypted text, the attachment file ids, the skipped attachments and the content state (complete, snippet or unavailable) of one received mail, or null when it does not exist or belongs to a personal mailbox the caller may not see; use it to read a mail beyond its snippet.",
|
|
94
|
+
handler: async (query, ctx) => {
|
|
95
|
+
const { messageId } = messageContentSchema.parse(query.payload);
|
|
96
|
+
return loadInboundMessageContent(ctx, messageId);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Content persistence shared by ingest-message and attach-message-content: the original
|
|
2
|
+
// message and the attachments go to file storage, the event only carries references.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
configuredPiiSubjectKms,
|
|
6
|
+
encryptPiiFieldValues,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
+
import type { HandlerContext, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
configuredMaxUploadBytes,
|
|
11
|
+
createFileRefFromBytes,
|
|
12
|
+
type FileContext,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
14
|
+
import * as z from "zod";
|
|
15
|
+
import { INBOUND_MESSAGE_PII_FIELDS, inboundMessageEntity } from "../entities.ts";
|
|
16
|
+
import {
|
|
17
|
+
INBOUND_MESSAGE_AGGREGATE_TYPE,
|
|
18
|
+
type InboundContentState,
|
|
19
|
+
skippedAttachmentSchema,
|
|
20
|
+
} from "../events.ts";
|
|
21
|
+
import {
|
|
22
|
+
INBOUND_ATTACHMENT_MAX_BYTES,
|
|
23
|
+
INBOUND_BODY_HTML_MAX_CHARS,
|
|
24
|
+
INBOUND_BODY_TEXT_MAX_CHARS,
|
|
25
|
+
INBOUND_RAW_MIME_MAX_BYTES,
|
|
26
|
+
INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT,
|
|
27
|
+
} from "../normalize-inbound-message.ts";
|
|
28
|
+
import type { RawInboundAttachment, SkippedInboundAttachment } from "../types.ts";
|
|
29
|
+
|
|
30
|
+
// Bytes only travel in-process (system dispatch); no JSON request can carry a Uint8Array.
|
|
31
|
+
export const inboundContentPayloadShape = {
|
|
32
|
+
bodyText: z.string().max(INBOUND_BODY_TEXT_MAX_CHARS).default(""),
|
|
33
|
+
bodyTextTruncated: z.boolean().default(false),
|
|
34
|
+
rawMime: z
|
|
35
|
+
.instanceof(Uint8Array)
|
|
36
|
+
.refine((bytes) => bytes.byteLength <= INBOUND_RAW_MIME_MAX_BYTES)
|
|
37
|
+
.optional(),
|
|
38
|
+
bodyHtml: z.string().max(INBOUND_BODY_HTML_MAX_CHARS).optional(),
|
|
39
|
+
attachments: z
|
|
40
|
+
.array(
|
|
41
|
+
z.object({
|
|
42
|
+
fileName: z.string().max(255),
|
|
43
|
+
mimeType: z.string().max(255),
|
|
44
|
+
data: z.instanceof(Uint8Array),
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
.max(50)
|
|
48
|
+
.optional(),
|
|
49
|
+
skippedAttachments: z.array(skippedAttachmentSchema).max(200).optional(),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type InboundContentInput = {
|
|
53
|
+
readonly accountId: string;
|
|
54
|
+
readonly messageAggId: string;
|
|
55
|
+
/** From the app's storeBody hook; wins over the default storage. */
|
|
56
|
+
readonly bodyRef: string;
|
|
57
|
+
readonly rawMime?: Uint8Array | undefined;
|
|
58
|
+
readonly bodyHtml?: string | undefined;
|
|
59
|
+
readonly attachments?: readonly RawInboundAttachment[] | undefined;
|
|
60
|
+
readonly skippedAttachments?: readonly SkippedInboundAttachment[] | undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type StoredInboundContent = {
|
|
64
|
+
readonly bodyRef: string;
|
|
65
|
+
readonly attachmentFileIds: string[];
|
|
66
|
+
readonly skippedAttachments: SkippedInboundAttachment[];
|
|
67
|
+
/** "unavailable" when something had to be stored but the dispatcher has no file storage. */
|
|
68
|
+
readonly contentStorage: "stored" | "unavailable";
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type ContentContext = Pick<HandlerContext, "db" | "services" | "registry" | "user">;
|
|
72
|
+
|
|
73
|
+
// Deterministic, so a re-ingest after a rollback overwrites the same object instead of orphaning one.
|
|
74
|
+
// Same keyspace the retention sweep deletes from (createFileProviderForTenant).
|
|
75
|
+
export function inboundMessageBodyKey(
|
|
76
|
+
tenantId: string,
|
|
77
|
+
accountId: string,
|
|
78
|
+
messageAggId: string,
|
|
79
|
+
extension: "eml" | "html",
|
|
80
|
+
): string {
|
|
81
|
+
return `${tenantId}/inbound-mail/${accountId}/${messageAggId}.${extension}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type OriginalToStore = {
|
|
85
|
+
readonly data: Uint8Array;
|
|
86
|
+
readonly extension: "eml" | "html";
|
|
87
|
+
readonly mimeType: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function originalOf(input: InboundContentInput): OriginalToStore | undefined {
|
|
91
|
+
if (input.rawMime !== undefined) {
|
|
92
|
+
return { data: input.rawMime, extension: "eml", mimeType: "message/rfc822" };
|
|
93
|
+
}
|
|
94
|
+
if (input.bodyHtml === undefined) return undefined;
|
|
95
|
+
return {
|
|
96
|
+
data: new TextEncoder().encode(input.bodyHtml),
|
|
97
|
+
extension: "html",
|
|
98
|
+
mimeType: "text/html",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The mail size limit is the cap; an app that configured a smaller upload limit keeps it, but the
|
|
103
|
+
// 10 MB default of createFileRefFromBytes must not cut a 25 MB mail attachment that was accepted above.
|
|
104
|
+
export function inboundAttachmentMaxSize(registry: Registry): number {
|
|
105
|
+
return Math.min(
|
|
106
|
+
INBOUND_ATTACHMENT_MAX_BYTES,
|
|
107
|
+
configuredMaxUploadBytes(registry) ?? INBOUND_ATTACHMENT_MAX_BYTES,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Binds an attachment fileRef to the mail row, so the file guard uses the mail's visibility. */
|
|
112
|
+
export function inboundAttachmentTarget(messageAggId: string) {
|
|
113
|
+
return {
|
|
114
|
+
entityType: INBOUND_MESSAGE_AGGREGATE_TYPE,
|
|
115
|
+
entityId: messageAggId,
|
|
116
|
+
fieldName: "attachmentFileIds",
|
|
117
|
+
} as const;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function needsFileStorage(input: InboundContentInput): boolean {
|
|
121
|
+
const hasOriginal = input.rawMime !== undefined || input.bodyHtml !== undefined;
|
|
122
|
+
return (input.attachments ?? []).length > 0 || (input.bodyRef === "" && hasOriginal);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What a dispatcher without file storage makes of the content: nothing is stored, attachments are skipped. */
|
|
126
|
+
export function contentWithoutFileStorage(input: InboundContentInput): StoredInboundContent {
|
|
127
|
+
const skipped = [...(input.skippedAttachments ?? [])];
|
|
128
|
+
for (const attachment of input.attachments ?? []) {
|
|
129
|
+
skipped.push({
|
|
130
|
+
mimeType: attachment.mimeType,
|
|
131
|
+
size: attachment.data.byteLength,
|
|
132
|
+
reason: "no_file_storage",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
bodyRef: input.bodyRef,
|
|
137
|
+
attachmentFileIds: [],
|
|
138
|
+
skippedAttachments: skipped.slice(0, INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT),
|
|
139
|
+
contentStorage: needsFileStorage(input) ? "unavailable" : "stored",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function storeOriginal(
|
|
144
|
+
files: FileContext,
|
|
145
|
+
tenantId: string,
|
|
146
|
+
input: InboundContentInput,
|
|
147
|
+
): Promise<string> {
|
|
148
|
+
const original = originalOf(input);
|
|
149
|
+
if (!original) return "";
|
|
150
|
+
const key = inboundMessageBodyKey(
|
|
151
|
+
tenantId,
|
|
152
|
+
input.accountId,
|
|
153
|
+
input.messageAggId,
|
|
154
|
+
original.extension,
|
|
155
|
+
);
|
|
156
|
+
await files.ref(key).write(original.data, original.mimeType);
|
|
157
|
+
return key;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Nested dispatches (an app handler calling ingest-message) have no pre-transaction phase, so the
|
|
161
|
+
// bytes are written here, inside the caller's transaction.
|
|
162
|
+
export async function storeInboundMessageContent(
|
|
163
|
+
ctx: ContentContext,
|
|
164
|
+
input: InboundContentInput,
|
|
165
|
+
): Promise<StoredInboundContent> {
|
|
166
|
+
const { files } = ctx.services;
|
|
167
|
+
if (!files) return contentWithoutFileStorage(input);
|
|
168
|
+
|
|
169
|
+
const skipped = [...(input.skippedAttachments ?? [])];
|
|
170
|
+
const bodyRef =
|
|
171
|
+
input.bodyRef !== "" ? input.bodyRef : await storeOriginal(files, ctx.user.tenantId, input);
|
|
172
|
+
const attachmentFileIds: string[] = [];
|
|
173
|
+
const maxSize = inboundAttachmentMaxSize(ctx.registry);
|
|
174
|
+
for (const attachment of input.attachments ?? []) {
|
|
175
|
+
const created = await createFileRefFromBytes(ctx, {
|
|
176
|
+
fileName: attachment.fileName,
|
|
177
|
+
mimeType: attachment.mimeType,
|
|
178
|
+
data: attachment.data,
|
|
179
|
+
attachTo: inboundAttachmentTarget(input.messageAggId),
|
|
180
|
+
maxSize,
|
|
181
|
+
});
|
|
182
|
+
if (created.isSuccess) {
|
|
183
|
+
attachmentFileIds.push(created.data.id);
|
|
184
|
+
} else {
|
|
185
|
+
skipped.push({
|
|
186
|
+
mimeType: attachment.mimeType,
|
|
187
|
+
size: attachment.data.byteLength,
|
|
188
|
+
reason: "rejected",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
bodyRef,
|
|
194
|
+
attachmentFileIds,
|
|
195
|
+
skippedAttachments: skipped.slice(0, INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT),
|
|
196
|
+
contentStorage: "stored",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 'inmc': serializes ingest and content attachment per message; disjoint from the thread-rollup namespace.
|
|
201
|
+
export const MESSAGE_LOCK_NAMESPACE = 0x696e6d63;
|
|
202
|
+
|
|
203
|
+
// Encrypts a single message PII field outside the batch encryption of ingest-message.
|
|
204
|
+
export async function encryptMessageBodyText(
|
|
205
|
+
tenantId: string,
|
|
206
|
+
messageAggId: string,
|
|
207
|
+
bodyText: string,
|
|
208
|
+
): Promise<string> {
|
|
209
|
+
const piiKms = configuredPiiSubjectKms();
|
|
210
|
+
if (!piiKms) return bodyText;
|
|
211
|
+
const encrypted = await encryptPiiFieldValues(
|
|
212
|
+
{ tenantId, bodyText },
|
|
213
|
+
inboundMessageEntity,
|
|
214
|
+
INBOUND_MESSAGE_PII_FIELDS,
|
|
215
|
+
piiKms,
|
|
216
|
+
{ requestId: `inbound-mail-foundation:message-content:${messageAggId}`, tenantId },
|
|
217
|
+
{ tenantId, entityName: INBOUND_MESSAGE_AGGREGATE_TYPE },
|
|
218
|
+
);
|
|
219
|
+
return encrypted.bodyText;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function contentStateOf(args: {
|
|
223
|
+
readonly bodyText: string;
|
|
224
|
+
readonly hasOriginal: boolean;
|
|
225
|
+
readonly contentStorage: StoredInboundContent["contentStorage"];
|
|
226
|
+
}): InboundContentState {
|
|
227
|
+
if (args.contentStorage === "unavailable") return "unavailable";
|
|
228
|
+
return args.bodyText.length > 0 || args.hasOriginal ? "complete" : "snippet";
|
|
229
|
+
}
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// inbound-message:detail — the record the file read guard asks for when someone opens a mail
|
|
2
|
+
// attachment (createRecordReadCheck runs "<entity>:detail" as the caller). It answers with
|
|
3
|
+
// visibility only: no PII column is selected, so opening a file never reaches the KMS.
|
|
4
|
+
|
|
5
|
+
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { INBOUND_MESSAGE_AGGREGATE_TYPE } from "../events.ts";
|
|
8
|
+
import type { InboundMailRoles } from "../mail-roles.ts";
|
|
9
|
+
import { inboundMessagesProjectionTable } from "../projection.ts";
|
|
10
|
+
import { isVisibleToCaller } from "./scope-visibility.ts";
|
|
11
|
+
|
|
12
|
+
const messageDetailSchema = z.object({ id: z.uuid() }).strict();
|
|
13
|
+
|
|
14
|
+
const DETAIL_COLUMNS = ["id", "tenantId", "accountId", "ownerUserId", "scope", "receivedAt"];
|
|
15
|
+
|
|
16
|
+
export function createMessageDetailQuery({ memberRoles }: InboundMailRoles): QueryHandlerDef {
|
|
17
|
+
return {
|
|
18
|
+
name: `${INBOUND_MESSAGE_AGGREGATE_TYPE}:detail`,
|
|
19
|
+
schema: messageDetailSchema,
|
|
20
|
+
access: { roles: [...memberRoles] },
|
|
21
|
+
agent: { expose: false },
|
|
22
|
+
description:
|
|
23
|
+
"Returns the non-personal envelope fields of one received mail, or null when it does not exist or belongs to a personal mailbox the caller does not own; the file read guard uses it to decide who may open the mail's attachments.",
|
|
24
|
+
handler: async (query, ctx) => {
|
|
25
|
+
const { id } = messageDetailSchema.parse(query.payload);
|
|
26
|
+
const [row] = await ctx.db.selectMany(
|
|
27
|
+
inboundMessagesProjectionTable,
|
|
28
|
+
{ id, tenantId: ctx.user.tenantId },
|
|
29
|
+
{ fields: DETAIL_COLUMNS },
|
|
30
|
+
);
|
|
31
|
+
return row && isVisibleToCaller(row, ctx.user) ? row : null;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|