@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
package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Pre-transaction half of the content storage: original and attachments are written while no
|
|
2
|
+
// transaction holds a connection, so a slow or failing file store never pins the database. The
|
|
3
|
+
// handler transaction only creates the fileRef rows (finalizePreparedContent).
|
|
4
|
+
|
|
5
|
+
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
|
+
import {
|
|
7
|
+
createFileRefFromUploadedBytes,
|
|
8
|
+
type UploadedFileBytes,
|
|
9
|
+
uploadFileBytesBeforeTransaction,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
11
|
+
import type {
|
|
12
|
+
BeforeTransactionContext,
|
|
13
|
+
BeforeTransactionResult,
|
|
14
|
+
ReservationRelease,
|
|
15
|
+
} from "@cosmicdrift/kumiko-types/handlers";
|
|
16
|
+
import { inboundMessageAggregateId } from "../aggregate-id.ts";
|
|
17
|
+
import { seenMessageTable } from "../entities.ts";
|
|
18
|
+
import { FileStorageWriteFailedError } from "../file-storage-failure.ts";
|
|
19
|
+
import { INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT } from "../normalize-inbound-message.ts";
|
|
20
|
+
import type { SkippedInboundAttachment } from "../types.ts";
|
|
21
|
+
import type { IngestMessagePayload } from "./ingest-message-schema.ts";
|
|
22
|
+
import {
|
|
23
|
+
contentWithoutFileStorage,
|
|
24
|
+
type InboundContentInput,
|
|
25
|
+
inboundAttachmentMaxSize,
|
|
26
|
+
inboundAttachmentTarget,
|
|
27
|
+
inboundMessageBodyKey,
|
|
28
|
+
originalOf,
|
|
29
|
+
type StoredInboundContent,
|
|
30
|
+
} from "./message-content.ts";
|
|
31
|
+
|
|
32
|
+
export type PreparedInboundContent = {
|
|
33
|
+
readonly bodyRef: string;
|
|
34
|
+
readonly uploads: readonly UploadedFileBytes[];
|
|
35
|
+
readonly skippedAttachments: readonly SkippedInboundAttachment[];
|
|
36
|
+
readonly contentStorage: StoredInboundContent["contentStorage"];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type PreparedIngest =
|
|
40
|
+
| { readonly duplicate: true }
|
|
41
|
+
| { readonly duplicate: false; readonly content: PreparedInboundContent };
|
|
42
|
+
|
|
43
|
+
type PrepareContext = Pick<BeforeTransactionContext, "db" | "services" | "registry" | "user">;
|
|
44
|
+
|
|
45
|
+
function storageFailure(cause: unknown) {
|
|
46
|
+
return writeFailure(new FileStorageWriteFailedError(cause instanceof Error ? cause : undefined));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function releaseAll(releases: readonly ReservationRelease[]): Promise<void> {
|
|
50
|
+
for (const release of releases) await release();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The deterministic original key is never released: a concurrent winner owns it and writing it
|
|
54
|
+
// again is idempotent.
|
|
55
|
+
export async function prepareInboundContent(
|
|
56
|
+
ctx: PrepareContext,
|
|
57
|
+
input: InboundContentInput,
|
|
58
|
+
): Promise<BeforeTransactionResult<PreparedInboundContent>> {
|
|
59
|
+
const { files } = ctx.services;
|
|
60
|
+
if (!files) {
|
|
61
|
+
const unavailable = contentWithoutFileStorage(input);
|
|
62
|
+
return {
|
|
63
|
+
isSuccess: true,
|
|
64
|
+
data: {
|
|
65
|
+
bodyRef: unavailable.bodyRef,
|
|
66
|
+
uploads: [],
|
|
67
|
+
skippedAttachments: unavailable.skippedAttachments,
|
|
68
|
+
contentStorage: unavailable.contentStorage,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let bodyRef = input.bodyRef;
|
|
74
|
+
const original = originalOf(input);
|
|
75
|
+
if (bodyRef === "" && original) {
|
|
76
|
+
bodyRef = inboundMessageBodyKey(
|
|
77
|
+
ctx.user.tenantId,
|
|
78
|
+
input.accountId,
|
|
79
|
+
input.messageAggId,
|
|
80
|
+
original.extension,
|
|
81
|
+
);
|
|
82
|
+
try {
|
|
83
|
+
await files.ref(bodyRef).write(original.data, original.mimeType);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return storageFailure(err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const uploads: UploadedFileBytes[] = [];
|
|
90
|
+
const releases: ReservationRelease[] = [];
|
|
91
|
+
const skipped = [...(input.skippedAttachments ?? [])];
|
|
92
|
+
const maxSize = inboundAttachmentMaxSize(ctx.registry);
|
|
93
|
+
for (const attachment of input.attachments ?? []) {
|
|
94
|
+
let uploaded: Awaited<ReturnType<typeof uploadFileBytesBeforeTransaction>>;
|
|
95
|
+
try {
|
|
96
|
+
uploaded = await uploadFileBytesBeforeTransaction(ctx, {
|
|
97
|
+
fileName: attachment.fileName,
|
|
98
|
+
mimeType: attachment.mimeType,
|
|
99
|
+
data: attachment.data,
|
|
100
|
+
attachTo: inboundAttachmentTarget(input.messageAggId),
|
|
101
|
+
maxSize,
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
await releaseAll(releases);
|
|
105
|
+
return storageFailure(err);
|
|
106
|
+
}
|
|
107
|
+
// A rejected attachment (size, MIME) is a property of the mail, not an outage.
|
|
108
|
+
if (!uploaded.isSuccess) {
|
|
109
|
+
skipped.push({
|
|
110
|
+
mimeType: attachment.mimeType,
|
|
111
|
+
size: attachment.data.byteLength,
|
|
112
|
+
reason: "rejected",
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
uploads.push(uploaded.data);
|
|
117
|
+
if (uploaded.release) releases.push(uploaded.release);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
isSuccess: true,
|
|
121
|
+
data: {
|
|
122
|
+
bodyRef,
|
|
123
|
+
uploads,
|
|
124
|
+
skippedAttachments: skipped.slice(0, INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT),
|
|
125
|
+
contentStorage: "stored",
|
|
126
|
+
},
|
|
127
|
+
release: () => releaseAll(releases),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Transaction half: creates the fileRef rows for the bytes the prepare phase wrote. */
|
|
132
|
+
export async function finalizePreparedContent(
|
|
133
|
+
ctx: Parameters<typeof createFileRefFromUploadedBytes>[0],
|
|
134
|
+
prepared: PreparedInboundContent,
|
|
135
|
+
): Promise<StoredInboundContent> {
|
|
136
|
+
const attachmentFileIds: string[] = [];
|
|
137
|
+
const skipped = [...prepared.skippedAttachments];
|
|
138
|
+
for (const upload of prepared.uploads) {
|
|
139
|
+
const created = await createFileRefFromUploadedBytes(ctx, upload);
|
|
140
|
+
if (created.isSuccess) attachmentFileIds.push(created.data.id);
|
|
141
|
+
else skipped.push({ mimeType: upload.mimeType, size: upload.size, reason: "rejected" });
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
bodyRef: prepared.bodyRef,
|
|
145
|
+
attachmentFileIds,
|
|
146
|
+
skippedAttachments: skipped.slice(0, INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT),
|
|
147
|
+
contentStorage: prepared.contentStorage,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Dedup read first: a duplicate uploads nothing. The in-transaction dedup stays the correctness anchor. */
|
|
152
|
+
export async function prepareIngestMessage(
|
|
153
|
+
event: { readonly payload: IngestMessagePayload },
|
|
154
|
+
ctx: PrepareContext,
|
|
155
|
+
): Promise<BeforeTransactionResult<PreparedIngest>> {
|
|
156
|
+
const { payload } = event;
|
|
157
|
+
const seen = await ctx.db.selectMany(seenMessageTable, {
|
|
158
|
+
accountId: payload.accountId,
|
|
159
|
+
providerMessageId: payload.providerMessageId,
|
|
160
|
+
});
|
|
161
|
+
if (seen.length > 0) return { isSuccess: true, data: { duplicate: true } };
|
|
162
|
+
const prepared = await prepareInboundContent(ctx, {
|
|
163
|
+
accountId: payload.accountId,
|
|
164
|
+
messageAggId: inboundMessageAggregateId(payload.accountId, payload.providerMessageId),
|
|
165
|
+
bodyRef: payload.bodyRef,
|
|
166
|
+
rawMime: payload.rawMime,
|
|
167
|
+
bodyHtml: payload.bodyHtml,
|
|
168
|
+
attachments: payload.attachments,
|
|
169
|
+
skippedAttachments: payload.skippedAttachments,
|
|
170
|
+
});
|
|
171
|
+
if (!prepared.isSuccess) return prepared;
|
|
172
|
+
return {
|
|
173
|
+
isSuccess: true,
|
|
174
|
+
data: { duplicate: false, content: prepared.data },
|
|
175
|
+
...(prepared.release && { release: prepared.release }),
|
|
176
|
+
};
|
|
177
|
+
}
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { senderAuthenticationFieldSchema } from "../sender-auth.ts";
|
|
3
|
+
import { inboundContentPayloadShape } from "./message-content.ts";
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Input schema — the normalized provider output (RawInboundMessage,
|
|
7
|
+
// types.ts) plus the envelope (accountId/providerName/providerCursor). The
|
|
8
|
+
// original and attachments ride in-process as bytes and are persisted to file
|
|
9
|
+
// storage here; the event itself stays blob-free (references only).
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
export const ingestMessageSchema = z.object({
|
|
13
|
+
accountId: z.uuid(),
|
|
14
|
+
/** Scope inherited from the account — the supervisor knows the
|
|
15
|
+
* MailAccountRecord and passes ownerUserId through. */
|
|
16
|
+
ownerUserId: z.string().max(36).nullable(),
|
|
17
|
+
providerName: z.string().min(1).max(50),
|
|
18
|
+
/** Provider-native Message-ID — idempotency anchor with accountId. */
|
|
19
|
+
providerMessageId: z.string().min(1).max(500),
|
|
20
|
+
/** RFC-5322 Message-ID without <>; null → deterministic fallback. */
|
|
21
|
+
messageIdHeader: z.string().min(1).max(500).nullable(),
|
|
22
|
+
providerThreadId: z.string().min(1).max(500).nullable(),
|
|
23
|
+
/** References/In-Reply-To chain, oldest first. */
|
|
24
|
+
references: z.array(z.string().min(1).max(500)).max(200),
|
|
25
|
+
from: z.string().min(1).max(2000),
|
|
26
|
+
to: z.array(z.string().max(2000)).max(200),
|
|
27
|
+
cc: z.array(z.string().max(2000)).max(200),
|
|
28
|
+
subject: z.string().max(4000),
|
|
29
|
+
snippet: z.string().max(4000),
|
|
30
|
+
receivedAtIso: z.string().min(1),
|
|
31
|
+
/** From the app's storeBody hook; empty lets the handler store the original itself. */
|
|
32
|
+
bodyRef: z.string().max(500),
|
|
33
|
+
scope: z.string().min(1).max(200),
|
|
34
|
+
/** Cursor snapshot at ingest time — used for debugging/replay and stored in
|
|
35
|
+
* metadata.headers, never in the payload. */
|
|
36
|
+
providerCursor: z.string().max(2000),
|
|
37
|
+
senderAuthentication: senderAuthenticationFieldSchema.optional(),
|
|
38
|
+
/** Set when the message is imported from its headers only because it sits in quarantine. */
|
|
39
|
+
contentUnavailableReason: z.literal("quarantined").optional(),
|
|
40
|
+
...inboundContentPayloadShape,
|
|
41
|
+
});
|
|
42
|
+
export type IngestMessagePayload = z.infer<typeof ingestMessageSchema>;
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// (watch callback + fetchSince sync). Accepts a normalized
|
|
3
3
|
// RawInboundMessage and performs atomically in ONE TX:
|
|
4
4
|
//
|
|
5
|
+
// 0. Per-message advisory lock, so a watch/poll race cannot store the same
|
|
6
|
+
// content twice (orphaned FileRefs) before the dedup checks run.
|
|
5
7
|
// 1. Idempotency check (cheap: store_mail_seen_messages; defense in
|
|
6
8
|
// depth: stream existence check on the deterministic aggregateId).
|
|
7
9
|
// 2. threadKey normalization (provider thread ID preferred, otherwise
|
|
8
10
|
// References chain root, otherwise Message-ID = single-message thread).
|
|
9
|
-
// 3.
|
|
11
|
+
// 3. Content storage (original + attachments, only after dedup) and PII
|
|
12
|
+
// encryption (from/to/cc/subject/snippet/bodyText) BEFORE the append —
|
|
10
13
|
// both the event log AND projection contain ciphertext.
|
|
11
14
|
// 4. inbound-message-received append (inline projection writes
|
|
12
15
|
// read_inbound_messages in the same TX). Savepoint-scoped
|
|
@@ -24,90 +27,43 @@ import {
|
|
|
24
27
|
configuredPiiSubjectKms,
|
|
25
28
|
encryptPiiFieldValues,
|
|
26
29
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
27
|
-
import {
|
|
28
|
-
import type {
|
|
29
|
-
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
+
import type { WriteResult } from "@cosmicdrift/kumiko-types/handlers";
|
|
30
32
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
31
|
-
import * as z from "zod";
|
|
32
33
|
import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id.ts";
|
|
33
|
-
import {
|
|
34
|
-
INBOUND_MESSAGE_PII_FIELDS,
|
|
35
|
-
inboundMessageEntity,
|
|
36
|
-
MAIL_THREAD_PII_FIELDS,
|
|
37
|
-
mailThreadEntity,
|
|
38
|
-
seenMessageTable,
|
|
39
|
-
} from "../entities.ts";
|
|
34
|
+
import { INBOUND_MESSAGE_PII_FIELDS, inboundMessageEntity, seenMessageTable } from "../entities.ts";
|
|
40
35
|
import {
|
|
41
36
|
INBOUND_MESSAGE_AGGREGATE_TYPE,
|
|
42
37
|
INBOUND_MESSAGE_RECEIVED_EVENT_QN,
|
|
43
38
|
type InboundMessageEventHeaders,
|
|
44
39
|
type InboundMessageEventPayload,
|
|
45
|
-
MAIL_THREAD_AGGREGATE_TYPE,
|
|
46
|
-
MAIL_THREAD_UPDATED_EVENT_QN,
|
|
47
|
-
type MailThreadEventPayload,
|
|
48
40
|
} from "../events.ts";
|
|
49
|
-
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/** Scope inherited from the account — the supervisor knows the
|
|
62
|
-
* MailAccountRecord and passes ownerUserId through. */
|
|
63
|
-
ownerUserId: z.string().max(36).nullable(),
|
|
64
|
-
providerName: z.string().min(1).max(50),
|
|
65
|
-
/** Provider-native Message-ID — idempotency anchor with accountId. */
|
|
66
|
-
providerMessageId: z.string().min(1).max(500),
|
|
67
|
-
/** RFC-5322 Message-ID without <>; null → deterministic fallback. */
|
|
68
|
-
messageIdHeader: z.string().min(1).max(500).nullable(),
|
|
69
|
-
providerThreadId: z.string().min(1).max(500).nullable(),
|
|
70
|
-
/** References/In-Reply-To chain, oldest first. */
|
|
71
|
-
references: z.array(z.string().min(1).max(500)).max(200),
|
|
72
|
-
from: z.string().min(1).max(2000),
|
|
73
|
-
to: z.array(z.string().max(2000)).max(200),
|
|
74
|
-
cc: z.array(z.string().max(2000)).max(200),
|
|
75
|
-
subject: z.string().max(4000),
|
|
76
|
-
snippet: z.string().max(4000),
|
|
77
|
-
receivedAtIso: z.string().min(1),
|
|
78
|
-
/** file-foundation-Ref; leer im snippet-only-Mode. */
|
|
79
|
-
bodyRef: z.string().max(500),
|
|
80
|
-
scope: z.string().min(1).max(200),
|
|
81
|
-
/** Cursor snapshot at ingest time — used for debugging/replay and stored in
|
|
82
|
-
* metadata.headers, never in the payload. */
|
|
83
|
-
providerCursor: z.string().max(2000),
|
|
84
|
-
});
|
|
85
|
-
type IngestMessagePayload = z.infer<typeof ingestMessageSchema>;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Normalizes threadKey:
|
|
89
|
-
* 1. Use the provider thread ID when present — prefix it with the provider
|
|
90
|
-
* so IMAP References keys and Graph conversationIds never collide.
|
|
91
|
-
* 2. Otherwise use the root of the References chain (the oldest Message-ID,
|
|
92
|
-
* which is the thread anchor shared by all replies).
|
|
93
|
-
* 3. Otherwise use the own Message-ID → single-message thread; a later
|
|
94
|
-
* reply carries it as the References root and joins the same thread.
|
|
95
|
-
*/
|
|
96
|
-
function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: string): string {
|
|
97
|
-
if (p.providerThreadId) return `${p.providerName}:${p.providerThreadId}`.slice(0, 500);
|
|
98
|
-
const referencesRoot = p.references[0];
|
|
99
|
-
if (referencesRoot) return `mid:${referencesRoot}`.slice(0, 500);
|
|
100
|
-
return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
|
|
101
|
-
}
|
|
41
|
+
import {
|
|
42
|
+
finalizePreparedContent,
|
|
43
|
+
type PreparedIngest,
|
|
44
|
+
prepareIngestMessage,
|
|
45
|
+
} from "./ingest-message-prepare.ts";
|
|
46
|
+
import { ingestMessageSchema } from "./ingest-message-schema.ts";
|
|
47
|
+
import {
|
|
48
|
+
contentStateOf,
|
|
49
|
+
MESSAGE_LOCK_NAMESPACE,
|
|
50
|
+
storeInboundMessageContent,
|
|
51
|
+
} from "./message-content.ts";
|
|
52
|
+
import { appendThreadRollup, buildThreadKey } from "./thread-rollup.ts";
|
|
102
53
|
|
|
103
|
-
const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
|
|
104
|
-
// pg_advisory_xact_lock namespace (int4) for the step-5 thread-rollup lock
|
|
105
|
-
// — 'inbm' as ASCII, keeps it disjoint from the framework's other fixed
|
|
106
|
-
// single-int advisory-lock keys (schema bootstrap, es-ops boot).
|
|
107
|
-
const THREAD_ROLLUP_LOCK_NAMESPACE = 0x696e626d;
|
|
108
54
|
const THREAD_ROLLUP_ADVISORY_LOCK_REASON =
|
|
109
55
|
"takes the per-thread advisory lock and counts the caller tenant's thread messages; TenantDb has no lock/count API";
|
|
110
|
-
|
|
56
|
+
type IngestMessageData =
|
|
57
|
+
| { readonly duplicate: true; readonly inboundMessageAggregateId: string }
|
|
58
|
+
| {
|
|
59
|
+
readonly duplicate: false;
|
|
60
|
+
readonly inboundMessageAggregateId: string;
|
|
61
|
+
readonly threadAggregateId: string;
|
|
62
|
+
readonly threadKey: string;
|
|
63
|
+
readonly contentStorage: "stored" | "unavailable";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const ingestMessageHandler = defineWriteHandler({
|
|
111
67
|
name: "ingest-message",
|
|
112
68
|
schema: ingestMessageSchema,
|
|
113
69
|
access: { roles: ["SystemAdmin"] },
|
|
@@ -116,12 +72,16 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
116
72
|
grants: ["unsafeRaw"],
|
|
117
73
|
reason: THREAD_ROLLUP_ADVISORY_LOCK_REASON,
|
|
118
74
|
},
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
75
|
+
// Nested (an app handler dispatching ingest-message) there is no phase; the handler then stores in its own transaction.
|
|
76
|
+
beforeTransactionWhenNested: "skip",
|
|
77
|
+
beforeTransaction: prepareIngestMessage,
|
|
78
|
+
handler: async (event, ctx): Promise<WriteResult<IngestMessageData>> => {
|
|
79
|
+
const { payload } = event;
|
|
122
80
|
const tenantId = event.user.tenantId;
|
|
123
81
|
const messageAggId = inboundMessageAggregateId(payload.accountId, payload.providerMessageId);
|
|
124
82
|
|
|
83
|
+
await ctx.db.advisoryXactLock(MESSAGE_LOCK_NAMESPACE, messageAggId);
|
|
84
|
+
|
|
125
85
|
// ---------------------------------------------------------------
|
|
126
86
|
// 1. Idempotency. Use the deduplication anchor row first because it is
|
|
127
87
|
// cheap. The stream check afterward provides defense in depth if
|
|
@@ -168,9 +128,26 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
168
128
|
// (primitives only, upcaster-safe). If no KMS is configured, pass
|
|
169
129
|
// through plaintext (engine-off behavior).
|
|
170
130
|
// ---------------------------------------------------------------
|
|
131
|
+
const prepared: PreparedIngest | undefined = event.prepared;
|
|
132
|
+
const content =
|
|
133
|
+
prepared?.duplicate === false
|
|
134
|
+
? await finalizePreparedContent(ctx, prepared.content)
|
|
135
|
+
: await storeInboundMessageContent(ctx, {
|
|
136
|
+
accountId: payload.accountId,
|
|
137
|
+
messageAggId,
|
|
138
|
+
bodyRef: payload.bodyRef,
|
|
139
|
+
rawMime: payload.rawMime,
|
|
140
|
+
bodyHtml: payload.bodyHtml,
|
|
141
|
+
attachments: payload.attachments,
|
|
142
|
+
skippedAttachments: payload.skippedAttachments,
|
|
143
|
+
});
|
|
144
|
+
const contentUnavailableReason =
|
|
145
|
+
payload.contentUnavailableReason ??
|
|
146
|
+
(content.contentStorage === "unavailable" ? ("no_file_storage" as const) : null);
|
|
171
147
|
const piiKms = configuredPiiSubjectKms();
|
|
172
148
|
const messagePlainPii = {
|
|
173
149
|
tenantId,
|
|
150
|
+
bodyText: payload.bodyText,
|
|
174
151
|
from: payload.from,
|
|
175
152
|
to: JSON.stringify(payload.to),
|
|
176
153
|
cc: JSON.stringify(payload.cc),
|
|
@@ -203,8 +180,22 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
203
180
|
subject: encryptedMessageFields.subject,
|
|
204
181
|
snippet: encryptedMessageFields.snippet,
|
|
205
182
|
receivedAtIso: payload.receivedAtIso,
|
|
206
|
-
bodyRef:
|
|
183
|
+
bodyRef: content.bodyRef,
|
|
207
184
|
scope: payload.scope,
|
|
185
|
+
senderAuthentication: payload.senderAuthentication ?? null,
|
|
186
|
+
bodyText: encryptedMessageFields.bodyText,
|
|
187
|
+
bodyTextTruncated: payload.bodyTextTruncated,
|
|
188
|
+
attachmentFileIds: content.attachmentFileIds,
|
|
189
|
+
skippedAttachments: content.skippedAttachments,
|
|
190
|
+
contentState:
|
|
191
|
+
payload.contentUnavailableReason !== undefined
|
|
192
|
+
? "unavailable"
|
|
193
|
+
: contentStateOf({
|
|
194
|
+
bodyText: payload.bodyText,
|
|
195
|
+
hasOriginal: payload.rawMime !== undefined || payload.bodyHtml !== undefined,
|
|
196
|
+
contentStorage: content.contentStorage,
|
|
197
|
+
}),
|
|
198
|
+
contentUnavailableReason,
|
|
208
199
|
};
|
|
209
200
|
const messageHeaders: InboundMessageEventHeaders = {
|
|
210
201
|
providerMessageId: payload.providerMessageId,
|
|
@@ -230,88 +221,15 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
230
221
|
};
|
|
231
222
|
}
|
|
232
223
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// ctx.events.tryAppend in a bounded retry loop — the apply stays a
|
|
236
|
-
// dumb, replay-idempotent UPSERT. lastMessageAtIso is not PII (a
|
|
237
|
-
// timestamp), so it stays plaintext-comparable; subject is
|
|
238
|
-
// encrypted once up front (constant across retries, doesn't
|
|
239
|
-
// depend on the thread's prior state).
|
|
240
|
-
//
|
|
241
|
-
// messageCount is a live COUNT, not previousCount+1, so two
|
|
242
|
-
// concurrent ingests on the same thread (Watch-Push vs.
|
|
243
|
-
// Poll-Reconciliation overlap, or two different messages of the
|
|
244
|
-
// same thread arriving at once) converge on the true count.
|
|
245
|
-
// acquireNamespacedAdvisoryLock below serializes this whole
|
|
246
|
-
// countWhere+append section per threadAggId — transaction-scoped,
|
|
247
|
-
// so it survives events.tryAppend's inner SAVEPOINT and releases at
|
|
248
|
-
// the enclosing TX's commit/rollback. Without it, a concurrent
|
|
249
|
-
// commit landing between countWhere and events.tryAppend's own fresh
|
|
250
|
-
// version-read makes the append succeed with a stale count instead
|
|
251
|
-
// of a VersionConflictError, so the retry loop below never sees it
|
|
252
|
-
// as a conflict to retry (the original gap). Unlike step 4, a
|
|
253
|
-
// lost version-conflict race here must NOT be treated as a no-op
|
|
254
|
-
// duplicate — skipping would leave this message's contribution to
|
|
255
|
-
// messageCount/lastMessageAt out of the thread forever. Exhausting
|
|
256
|
-
// all attempts throws, rolling back the whole TX (including the
|
|
257
|
-
// step-4 message append) for a clean re-ingest.
|
|
258
|
-
// ---------------------------------------------------------------
|
|
259
|
-
const threadPlainPii = { tenantId, subject: payload.subject };
|
|
260
|
-
const encryptedThreadFields = piiKms
|
|
261
|
-
? await encryptPiiFieldValues(
|
|
262
|
-
threadPlainPii,
|
|
263
|
-
mailThreadEntity,
|
|
264
|
-
MAIL_THREAD_PII_FIELDS,
|
|
265
|
-
piiKms,
|
|
266
|
-
{
|
|
267
|
-
requestId: `inbound-mail-foundation:ingest-message:thread:${threadAggId}`,
|
|
268
|
-
tenantId,
|
|
269
|
-
},
|
|
270
|
-
{ tenantId, entityName: MAIL_THREAD_AGGREGATE_TYPE },
|
|
271
|
-
)
|
|
272
|
-
: threadPlainPii;
|
|
273
|
-
|
|
274
|
-
const threadRollupRunner = ctx.db.unsafeRaw();
|
|
275
|
-
await acquireNamespacedAdvisoryLock(
|
|
276
|
-
threadRollupRunner,
|
|
277
|
-
THREAD_ROLLUP_LOCK_NAMESPACE,
|
|
224
|
+
await appendThreadRollup(ctx, {
|
|
225
|
+
tenantId,
|
|
278
226
|
threadAggId,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
286
|
-
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
287
|
-
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
288
|
-
const newLastAt =
|
|
289
|
-
payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
|
|
290
|
-
const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
|
|
291
|
-
tenantId,
|
|
292
|
-
threadKey,
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
const threadEventPayload: MailThreadEventPayload = {
|
|
296
|
-
threadKey,
|
|
297
|
-
subject: encryptedThreadFields.subject,
|
|
298
|
-
lastMessageAtIso: newLastAt,
|
|
299
|
-
messageCount,
|
|
300
|
-
};
|
|
301
|
-
const threadAppend = await ctx.events.tryAppend({
|
|
302
|
-
aggregateId: threadAggId,
|
|
303
|
-
aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
|
|
304
|
-
type: MAIL_THREAD_UPDATED_EVENT_QN,
|
|
305
|
-
payload: threadEventPayload,
|
|
306
|
-
headers: messageHeaders,
|
|
307
|
-
});
|
|
308
|
-
threadAppendOk = threadAppend.ok;
|
|
309
|
-
}
|
|
310
|
-
if (!threadAppendOk) {
|
|
311
|
-
throw new InternalError({
|
|
312
|
-
message: `ingest-message: thread-rollup append for "${threadAggId}" lost the version-conflict race ${THREAD_ROLLUP_MAX_ATTEMPTS} times in a row — aborting for a clean re-ingest instead of persisting a stale messageCount.`,
|
|
313
|
-
});
|
|
314
|
-
}
|
|
227
|
+
threadKey,
|
|
228
|
+
subject: payload.subject,
|
|
229
|
+
receivedAtIso: payload.receivedAtIso,
|
|
230
|
+
headers: messageHeaders,
|
|
231
|
+
piiKms,
|
|
232
|
+
});
|
|
315
233
|
|
|
316
234
|
// ---------------------------------------------------------------
|
|
317
235
|
// 6. Deduplication anchor. It runs in the same TX as the appends, so
|
|
@@ -331,7 +249,8 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
331
249
|
inboundMessageAggregateId: messageAggId,
|
|
332
250
|
threadAggregateId: threadAggId,
|
|
333
251
|
threadKey,
|
|
252
|
+
contentStorage: content.contentStorage,
|
|
334
253
|
},
|
|
335
254
|
};
|
|
336
255
|
},
|
|
337
|
-
};
|
|
256
|
+
});
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// billing-foundation list-subscriptions pattern.
|
|
4
4
|
//
|
|
5
5
|
// **Scope visibility:** authorized users can see shared mailboxes
|
|
6
|
-
// (ownerUserId=null); personal mailboxes are
|
|
7
|
-
//
|
|
6
|
+
// (ownerUserId=null); personal mailboxes are listed (metadata only) for their owner and the
|
|
7
|
+
// admin roles, but only the owner sees their quarantine. Filtering runs in memory
|
|
8
8
|
// after the tenant-scoped select because a tenant has only a single- to
|
|
9
9
|
// double-digit number of connected mailboxes.
|
|
10
10
|
import {
|
|
@@ -13,39 +13,102 @@ import {
|
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
14
14
|
import { MAX_LIST_LIMIT, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import * as z from "zod";
|
|
16
|
+
import {
|
|
17
|
+
IMAP_PROVIDER_KEY,
|
|
18
|
+
InboundMailAccountStatuses,
|
|
19
|
+
InboundMailAuthMethods,
|
|
20
|
+
type MailAccountReactivationPath,
|
|
21
|
+
MailAccountReactivationPaths,
|
|
22
|
+
} from "../constants.ts";
|
|
16
23
|
import { MAIL_ACCOUNT_PII_FIELDS } from "../entities.ts";
|
|
24
|
+
import type { InboundMailRoles } from "../mail-roles.ts";
|
|
17
25
|
import { mailAccountsProjectionTable } from "../projection.ts";
|
|
18
|
-
import {
|
|
26
|
+
import { quarantineTable } from "../quarantine-entities.ts";
|
|
27
|
+
import {
|
|
28
|
+
isAccountListableBy,
|
|
29
|
+
mayManageQuarantine,
|
|
30
|
+
mayReactivateMailbox,
|
|
31
|
+
} from "./scope-visibility.ts";
|
|
32
|
+
|
|
33
|
+
const REACTIVATABLE_STATUSES: ReadonlySet<unknown> = new Set([
|
|
34
|
+
InboundMailAccountStatuses.disconnected,
|
|
35
|
+
InboundMailAccountStatuses.authError,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Null where no way back exists for this caller, so no row action leads into a refusal.
|
|
39
|
+
function reactivationPathOf(
|
|
40
|
+
row: Readonly<Record<string, unknown>>,
|
|
41
|
+
user: { readonly id: string; readonly roles: readonly string[] },
|
|
42
|
+
adminRoles: readonly string[],
|
|
43
|
+
): MailAccountReactivationPath | null {
|
|
44
|
+
if (!REACTIVATABLE_STATUSES.has(row["status"])) return null;
|
|
45
|
+
if (!mayReactivateMailbox(row["ownerUserId"], user, adminRoles)) return null;
|
|
46
|
+
if (row["authMethod"] === InboundMailAuthMethods.oauth)
|
|
47
|
+
return MailAccountReactivationPaths.oauthFlow;
|
|
48
|
+
const isPasswordImap =
|
|
49
|
+
row["provider"] === IMAP_PROVIDER_KEY && row["authMethod"] === InboundMailAuthMethods.password;
|
|
50
|
+
return isPasswordImap ? MailAccountReactivationPaths.passwordForm : null;
|
|
51
|
+
}
|
|
19
52
|
|
|
20
53
|
// The projectionList screen always sends `limit`; unknown keys stay rejected.
|
|
21
54
|
const listAccountsSchema = z
|
|
22
55
|
.object({ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT) })
|
|
23
56
|
.strict();
|
|
24
57
|
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
export function createListAccountsQuery({
|
|
59
|
+
memberRoles,
|
|
60
|
+
adminRoles,
|
|
61
|
+
}: InboundMailRoles): QueryHandlerDef {
|
|
62
|
+
return {
|
|
63
|
+
name: "account:list",
|
|
64
|
+
schema: listAccountsSchema,
|
|
65
|
+
access: { roles: [...memberRoles] },
|
|
66
|
+
description:
|
|
67
|
+
"Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers and the quarantine of personal mailboxes the caller does not own; use it for the mail connect and settings views.",
|
|
68
|
+
handler: async (query, ctx) => {
|
|
69
|
+
const { limit } = listAccountsSchema.parse(query.payload);
|
|
70
|
+
const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
|
|
71
|
+
tenantId: ctx.user.tenantId,
|
|
72
|
+
});
|
|
73
|
+
const rows = allRows
|
|
74
|
+
.filter((row) => isAccountListableBy(row, ctx.user, adminRoles))
|
|
75
|
+
.sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))
|
|
76
|
+
.slice(0, limit);
|
|
77
|
+
// One grouped count for all mailboxes instead of a query per row.
|
|
78
|
+
const openQuarantine = await ctx.db.aggregate(
|
|
79
|
+
quarantineTable,
|
|
80
|
+
{ measure: { fn: "count" }, groupBy: [{ field: "accountId" }] },
|
|
81
|
+
{ acknowledgedAt: null },
|
|
82
|
+
);
|
|
83
|
+
const quarantinedCountOf = new Map(
|
|
84
|
+
openQuarantine.map((group) => [String(group.keys[0]), group.value ?? 0]),
|
|
85
|
+
);
|
|
86
|
+
// The screen's row condition cannot compare a count or combine fields, so it gets booleans.
|
|
87
|
+
const withQuarantine = (row: Record<string, unknown>) => {
|
|
88
|
+
const owner = row["ownerUserId"];
|
|
89
|
+
const mayManage = mayManageQuarantine(
|
|
90
|
+
typeof owner === "string" && owner !== "" ? owner : null,
|
|
91
|
+
ctx.user,
|
|
92
|
+
adminRoles,
|
|
93
|
+
);
|
|
94
|
+
const quarantinedCount = mayManage ? (quarantinedCountOf.get(String(row["id"])) ?? 0) : 0;
|
|
95
|
+
return {
|
|
96
|
+
...row,
|
|
97
|
+
quarantinedCount,
|
|
98
|
+
hasOpenQuarantine: quarantinedCount > 0,
|
|
99
|
+
reactivateVia: reactivationPathOf(row, ctx.user, adminRoles),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
const piiKms = configuredPiiSubjectKms();
|
|
103
|
+
if (!piiKms) return { rows: rows.map(withQuarantine) };
|
|
104
|
+
const decryptedRows = await Promise.all(
|
|
105
|
+
rows.map((row) =>
|
|
106
|
+
decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
|
|
107
|
+
requestId: `inbound-mail-foundation:list-accounts:${ctx.user.tenantId}`,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
return { rows: decryptedRows.map(withQuarantine) };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|