@cosmicdrift/kumiko-bundled-features 0.391.0 → 0.392.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-shell/feature.d.ts +3 -0
- package/dist/admin-shell/feature.js +47 -30
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -2
- package/dist/cap-overview/handlers/caps-usage.query.js +1 -0
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -2
- package/dist/cap-overview/i18n.js +4 -0
- package/dist/cap-overview/types.d.ts +3 -1
- package/dist/cap-overview/web/cap-amount-format.d.ts +3 -0
- package/dist/cap-overview/web/cap-amount-format.js +30 -0
- package/dist/cap-overview/web/cap-cards-panel.js +63 -23
- package/dist/cap-overview/web/cap-usage-bar.js +9 -4
- package/dist/cap-overview/web/cap-usage-cell.js +4 -2
- package/dist/cap-overview/web/usage-math.d.ts +1 -0
- package/dist/cap-overview/web/usage-math.js +4 -1
- package/dist/channel-email/app-transport.d.ts +14 -0
- package/dist/channel-email/app-transport.js +20 -0
- package/dist/channel-email/feature.js +6 -0
- package/dist/channel-email/index.d.ts +1 -0
- package/dist/channel-email/index.js +1 -0
- package/dist/channel-email/smtp-transport.js +2 -19
- package/dist/compliance-profiles/schema/profile-selection.d.ts +8 -8
- package/dist/foundation-shared/index.d.ts +1 -0
- package/dist/foundation-shared/index.js +1 -0
- package/dist/foundation-shared/mail-display-name.d.ts +1 -0
- package/dist/foundation-shared/mail-display-name.js +18 -0
- package/dist/inbound-mail-foundation/account-record.d.ts +13 -0
- package/dist/inbound-mail-foundation/account-record.js +27 -0
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/i18n.js +17 -0
- package/dist/inbound-mail-foundation/index.d.ts +3 -1
- package/dist/inbound-mail-foundation/index.js +3 -1
- package/dist/inbound-mail-foundation/outbound-mail.d.ts +28 -0
- package/dist/inbound-mail-foundation/outbound-mail.js +52 -0
- package/dist/inbound-mail-foundation/probe.js +5 -28
- package/dist/inbound-mail-foundation/screens.d.ts +2 -0
- package/dist/inbound-mail-foundation/screens.js +14 -0
- package/dist/inbound-mail-foundation/send-from-account.d.ts +31 -0
- package/dist/inbound-mail-foundation/send-from-account.js +87 -0
- package/dist/inbound-mail-foundation/types.d.ts +4 -0
- package/dist/inbound-mail-foundation/watch-supervisor.js +10 -8
- package/dist/inbound-provider-imap/connect-mailbox.d.ts +13 -0
- package/dist/inbound-provider-imap/connect-mailbox.js +124 -0
- package/dist/inbound-provider-imap/constants.d.ts +41 -0
- package/dist/inbound-provider-imap/constants.js +39 -0
- package/dist/inbound-provider-imap/credential-document.d.ts +6 -0
- package/dist/inbound-provider-imap/credential-document.js +31 -1
- package/dist/inbound-provider-imap/feature.d.ts +1 -1
- package/dist/inbound-provider-imap/feature.js +13 -22
- package/dist/inbound-provider-imap/i18n.d.ts +3 -0
- package/dist/inbound-provider-imap/i18n.js +45 -0
- package/dist/inbound-provider-imap/imap-client.d.ts +1 -0
- package/dist/inbound-provider-imap/imap-client.js +1 -1
- package/dist/inbound-provider-imap/screens.d.ts +2 -0
- package/dist/inbound-provider-imap/screens.js +39 -0
- package/dist/inbound-provider-imap/send-mail.d.ts +2 -0
- package/dist/inbound-provider-imap/send-mail.js +48 -0
- package/dist/inbound-provider-imap/sent-folder.d.ts +8 -0
- package/dist/inbound-provider-imap/sent-folder.js +40 -0
- package/dist/inbound-provider-imap/smtp-client.d.ts +35 -0
- package/dist/inbound-provider-imap/smtp-client.js +106 -0
- package/dist/ledger/entity.d.ts +8 -8
- package/dist/mail-foundation/i18n.d.ts +9 -1
- package/dist/mail-foundation/i18n.js +9 -1
- package/dist/mail-foundation/probe.js +32 -16
- package/dist/readiness/built-in-probes.d.ts +2 -1
- package/dist/readiness/built-in-probes.js +12 -1
- package/dist/readiness/constants.d.ts +5 -0
- package/dist/readiness/constants.js +1 -0
- package/dist/readiness/i18n.js +7 -1
- package/dist/readiness/index.d.ts +1 -1
- package/dist/readiness/index.js +1 -1
- package/dist/readiness/schema/probe-run.d.ts +8 -8
- package/dist/readiness/screens.d.ts +3 -0
- package/dist/readiness/screens.js +25 -2
- package/dist/readiness/visible-results.d.ts +2 -1
- package/dist/template-resolver/table.d.ts +36 -36
- package/dist/template-resolver/user-content-table.d.ts +26 -26
- package/dist/tenant/invitation-table.d.ts +8 -8
- package/dist/tenant/schema/tenant.d.ts +10 -10
- package/dist/tier-engine/entity.d.ts +3 -3
- package/dist/tier-engine/feature.js +8 -4
- package/dist/tier-engine/tier-resolver.d.ts +3 -3
- package/dist/user/schema/user.d.ts +8 -8
- package/dist/user-data-rights/handlers/download-by-token.query.js +3 -3
- package/dist/user-data-rights/schema/download-token.js +5 -3
- package/dist/user-data-rights/schema/export-job.d.ts +8 -8
- package/dist/user-data-rights/token-helpers.d.ts +1 -2
- package/dist/user-data-rights/token-helpers.js +5 -4
- package/dist/waitlist/entity.d.ts +8 -8
- package/package.json +11 -11
- package/src/admin-shell/changes.json +6 -0
- package/src/cap-overview/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/foundation-shared/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +18 -0
- package/src/inbound-provider-imap/changes.json +12 -0
- package/src/mail-foundation/changes.json +6 -0
- package/src/readiness/changes.json +30 -0
- package/src/tier-engine/changes.json +6 -0
- package/src/workflow-runner/changes.json +6 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MailAccountRecord } from "./types.ts";
|
|
2
|
+
export type MailAccountRow = {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly tenantId: string;
|
|
5
|
+
readonly provider: string;
|
|
6
|
+
readonly authMethod: string;
|
|
7
|
+
readonly ownerUserId?: string | null;
|
|
8
|
+
readonly address: string;
|
|
9
|
+
readonly displayName?: string | null;
|
|
10
|
+
readonly status?: string | null;
|
|
11
|
+
readonly watchState?: string | null;
|
|
12
|
+
};
|
|
13
|
+
export declare function toMailAccountRecord(row: MailAccountRow, tenantId: string, requestId: string): Promise<MailAccountRecord>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
import { InboundMailAccountStatuses } from "./constants.js";
|
|
3
|
+
import { MAIL_ACCOUNT_PII_FIELDS } from "./entities.js";
|
|
4
|
+
function isAccountStatus(value) {
|
|
5
|
+
return Object.values(InboundMailAccountStatuses).some((status) => status === value);
|
|
6
|
+
}
|
|
7
|
+
// Providers never see ciphertext, so the address is decrypted before the call.
|
|
8
|
+
export async function toMailAccountRecord(row, tenantId, requestId) {
|
|
9
|
+
const piiKms = configuredPiiSubjectKms();
|
|
10
|
+
const decrypted = piiKms
|
|
11
|
+
? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, { requestId })
|
|
12
|
+
: row;
|
|
13
|
+
const address = decrypted["address"];
|
|
14
|
+
if (typeof address !== "string")
|
|
15
|
+
throw new Error("mailbox address did not decrypt to text");
|
|
16
|
+
return {
|
|
17
|
+
id: row.id,
|
|
18
|
+
tenantId,
|
|
19
|
+
provider: row.provider,
|
|
20
|
+
authMethod: row.authMethod,
|
|
21
|
+
ownerUserId: row.ownerUserId ?? null,
|
|
22
|
+
address,
|
|
23
|
+
displayName: row.displayName ?? "",
|
|
24
|
+
status: isAccountStatus(row.status) ? row.status : InboundMailAccountStatuses.active,
|
|
25
|
+
watchState: row.watchState ?? "idle",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -60,7 +60,7 @@ import { INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON, inboundMessageTenantDestroy
|
|
|
60
60
|
export function createInboundMailFoundationFeature() {
|
|
61
61
|
return defineFeature(INBOUND_MAIL_FOUNDATION_FEATURE, (r) => {
|
|
62
62
|
r.adoptsBundledLists();
|
|
63
|
-
r.describe("Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push). The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.");
|
|
63
|
+
r.describe("Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push) and send. `sendMailFromAccount(ctx, { accountId, message })` sends from a connected mailbox through its provider (From = the mailbox address, no fallback to the app sender) and answers typed failures (not configured, login rejected, server unreachable, rejected, invalid message); app write handlers call it, it is no endpoint of its own. The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.");
|
|
64
64
|
r.uiHints({
|
|
65
65
|
displayLabel: "Inbound Mail · Foundation",
|
|
66
66
|
category: "notifications",
|
|
@@ -3,6 +3,7 @@ import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
|
3
3
|
import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.js";
|
|
4
4
|
import { INBOUND_PROBE_I18N } from "./probe-keys.js";
|
|
5
5
|
import { MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX, } from "./screens.js";
|
|
6
|
+
import { MAIL_SEND_ERRORS } from "./send-from-account.js";
|
|
6
7
|
const formFieldKey = (field) => fieldLabelKey(INBOUND_MAIL_FOUNDATION_FEATURE, ACTION_FORM_ENTITY, field);
|
|
7
8
|
export const INBOUND_MAIL_FOUNDATION_I18N = {
|
|
8
9
|
...INBOUND_PROBE_I18N,
|
|
@@ -25,6 +26,7 @@ export const INBOUND_MAIL_FOUNDATION_I18N = {
|
|
|
25
26
|
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.disconnected}`]: {
|
|
26
27
|
en: "Disconnected",
|
|
27
28
|
},
|
|
29
|
+
"inboundMail.accounts.connectImap": { en: "Connect mailbox" },
|
|
28
30
|
"inboundMail.accounts.edit": { en: "Rename" },
|
|
29
31
|
"inboundMail.accounts.edit.submit": { en: "Save" },
|
|
30
32
|
"inboundMail.accounts.disconnect": { en: "Disconnect" },
|
|
@@ -33,4 +35,19 @@ export const INBOUND_MAIL_FOUNDATION_I18N = {
|
|
|
33
35
|
},
|
|
34
36
|
[formFieldKey("accountId")]: { en: "Mailbox" },
|
|
35
37
|
[formFieldKey("displayName")]: { en: "Name" },
|
|
38
|
+
[MAIL_SEND_ERRORS.notConfigured.i18nKey]: {
|
|
39
|
+
en: "Sending is not set up for this mailbox. Connect the mailbox again with its outgoing server.",
|
|
40
|
+
},
|
|
41
|
+
[MAIL_SEND_ERRORS.loginRejected.i18nKey]: {
|
|
42
|
+
en: "The outgoing mail server rejected the sign-in. Check the mailbox password.",
|
|
43
|
+
},
|
|
44
|
+
[MAIL_SEND_ERRORS.serverUnreachable.i18nKey]: {
|
|
45
|
+
en: "The outgoing mail server cannot be reached. Try again later.",
|
|
46
|
+
},
|
|
47
|
+
[MAIL_SEND_ERRORS.rejected.i18nKey]: {
|
|
48
|
+
en: "The outgoing mail server refused the mail. Check the recipient address.",
|
|
49
|
+
},
|
|
50
|
+
[MAIL_SEND_ERRORS.invalidMessage.i18nKey]: {
|
|
51
|
+
en: "The mail was not sent: a recipient, the subject or the reply reference is invalid.",
|
|
52
|
+
},
|
|
36
53
|
};
|
|
@@ -6,8 +6,10 @@ export { INBOUND_MESSAGE_AGGREGATE_TYPE, INBOUND_MESSAGE_RECEIVED_EVENT_QN, INBO
|
|
|
6
6
|
export { createInboundMailFoundationFeature } from "./feature.ts";
|
|
7
7
|
export { createOAuthAccessTokenManager, inboundRefreshTokenSecretOptions, type OAuthAccessTokenManager, type OAuthAccessTokenManagerDeps, usesFoundationManagedOAuth, } from "./oauth-access-token.ts";
|
|
8
8
|
export { type OAuthStatePayload, signOAuthState, type VerifyOAuthStateResult, verifyOAuthState, } from "./oauth-state.ts";
|
|
9
|
+
export { isMailSendNotConfiguredError, isMailSendRejectedError, MailSendNotConfiguredError, MailSendRejectedError, type OutboundMailMessage, type OutboundMailSent, outboundMailMessageSchema, } from "./outbound-mail.ts";
|
|
9
10
|
export { inboundMessagesProjectionTable, mailAccountsProjectionTable, mailThreadsProjectionTable, } from "./projection.ts";
|
|
10
11
|
export { resolveInboundProviderForAccount, resolveInboundProviderForKey, } from "./provider-factory.ts";
|
|
11
|
-
export { MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, } from "./screens.ts";
|
|
12
|
+
export { IMAP_CONNECT_SCREEN_ID, MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_QN, } from "./screens.ts";
|
|
13
|
+
export { MAIL_SEND_ERRORS, type MailSendFailure, type SendMailFromAccountContext, type SendMailFromAccountInput, sendMailFromAccount, } from "./send-from-account.ts";
|
|
12
14
|
export { InboundAuthError, InboundCursorInvalidError, type InboundFetchResult, type InboundMailContext, type InboundMailProviderPlugin, type InboundOAuthFlow, InboundRateLimitError, InboundTransientError, isInboundAuthError, isInboundCursorInvalidError, isInboundMailProviderPlugin, isInboundRateLimitError, isInboundTransientError, type MailAccountRecord, type OAuthTokenSet, type RawInboundMessage, type SyncCursorPayload, } from "./types.ts";
|
|
13
15
|
export { createInboundMailSupervisor, type InboundMailSupervisor, type InboundMailSupervisorDeps, } from "./watch-supervisor.ts";
|
|
@@ -7,8 +7,10 @@ export { INBOUND_MESSAGE_AGGREGATE_TYPE, INBOUND_MESSAGE_RECEIVED_EVENT_QN, INBO
|
|
|
7
7
|
export { createInboundMailFoundationFeature } from "./feature.js";
|
|
8
8
|
export { createOAuthAccessTokenManager, inboundRefreshTokenSecretOptions, usesFoundationManagedOAuth, } from "./oauth-access-token.js";
|
|
9
9
|
export { signOAuthState, verifyOAuthState, } from "./oauth-state.js";
|
|
10
|
+
export { isMailSendNotConfiguredError, isMailSendRejectedError, MailSendNotConfiguredError, MailSendRejectedError, outboundMailMessageSchema, } from "./outbound-mail.js";
|
|
10
11
|
export { inboundMessagesProjectionTable, mailAccountsProjectionTable, mailThreadsProjectionTable, } from "./projection.js";
|
|
11
12
|
export { resolveInboundProviderForAccount, resolveInboundProviderForKey, } from "./provider-factory.js";
|
|
12
|
-
export { MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, } from "./screens.js";
|
|
13
|
+
export { IMAP_CONNECT_SCREEN_ID, MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_QN, } from "./screens.js";
|
|
14
|
+
export { MAIL_SEND_ERRORS, sendMailFromAccount, } from "./send-from-account.js";
|
|
13
15
|
export { InboundAuthError, InboundCursorInvalidError, InboundRateLimitError, InboundTransientError, isInboundAuthError, isInboundCursorInvalidError, isInboundMailProviderPlugin, isInboundRateLimitError, isInboundTransientError, } from "./types.js";
|
|
14
16
|
export { createInboundMailSupervisor, } from "./watch-supervisor.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export declare const outboundMailMessageSchema: z.ZodObject<{
|
|
3
|
+
to: z.ZodArray<z.ZodEmail>;
|
|
4
|
+
cc: z.ZodOptional<z.ZodArray<z.ZodEmail>>;
|
|
5
|
+
subject: z.ZodString;
|
|
6
|
+
text: z.ZodOptional<z.ZodString>;
|
|
7
|
+
html: z.ZodOptional<z.ZodString>;
|
|
8
|
+
inReplyTo: z.ZodOptional<z.ZodString>;
|
|
9
|
+
references: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
export type OutboundMailMessage = z.infer<typeof outboundMailMessageSchema>;
|
|
12
|
+
export type OutboundMailSent = {
|
|
13
|
+
/** Message-ID header of the sent mail, without angle brackets. */
|
|
14
|
+
readonly messageIdHeader: string;
|
|
15
|
+
};
|
|
16
|
+
/** The mailbox has no outgoing server stored, for example a credential document from before
|
|
17
|
+
* sending existed. The tenant has to connect the mailbox again. */
|
|
18
|
+
export declare class MailSendNotConfiguredError extends Error {
|
|
19
|
+
readonly kind: "mail-send-not-configured-error";
|
|
20
|
+
constructor(message: string);
|
|
21
|
+
}
|
|
22
|
+
/** The outgoing server accepted the login but refused the mail (recipient, size, policy). */
|
|
23
|
+
export declare class MailSendRejectedError extends Error {
|
|
24
|
+
readonly kind: "mail-send-rejected-error";
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
27
|
+
export declare function isMailSendNotConfiguredError(e: unknown): e is MailSendNotConfiguredError;
|
|
28
|
+
export declare function isMailSendRejectedError(e: unknown): e is MailSendRejectedError;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Outbound half of the provider contract: a reply sent from a connected mailbox through the
|
|
2
|
+
// customer's own mail server, never through the platform sender.
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
const MAX_RECIPIENTS = 50;
|
|
5
|
+
const MAX_REFERENCES = 100;
|
|
6
|
+
// Message ids travel without angle brackets, like RawInboundMessage.references. Anything that
|
|
7
|
+
// could close the bracket or start a new header is refused instead of being encoded.
|
|
8
|
+
const messageIdSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.max(998)
|
|
12
|
+
.regex(/^[^\s<>]+$/);
|
|
13
|
+
export const outboundMailMessageSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
to: z.array(z.email().max(320)).min(1).max(MAX_RECIPIENTS),
|
|
16
|
+
cc: z.array(z.email().max(320)).max(MAX_RECIPIENTS).optional(),
|
|
17
|
+
subject: z
|
|
18
|
+
.string()
|
|
19
|
+
.max(998)
|
|
20
|
+
.regex(/^[^\r\n]*$/),
|
|
21
|
+
text: z.string().optional(),
|
|
22
|
+
html: z.string().optional(),
|
|
23
|
+
inReplyTo: messageIdSchema.optional(),
|
|
24
|
+
references: z.array(messageIdSchema).max(MAX_REFERENCES).optional(),
|
|
25
|
+
})
|
|
26
|
+
.refine((message) => message.text !== undefined || message.html !== undefined, {
|
|
27
|
+
message: "needs text or html",
|
|
28
|
+
});
|
|
29
|
+
// Same `kind` discriminant as the inbound error classes (see types.ts).
|
|
30
|
+
/** The mailbox has no outgoing server stored, for example a credential document from before
|
|
31
|
+
* sending existed. The tenant has to connect the mailbox again. */
|
|
32
|
+
export class MailSendNotConfiguredError extends Error {
|
|
33
|
+
kind = "mail-send-not-configured-error";
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "MailSendNotConfiguredError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The outgoing server accepted the login but refused the mail (recipient, size, policy). */
|
|
40
|
+
export class MailSendRejectedError extends Error {
|
|
41
|
+
kind = "mail-send-rejected-error";
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "MailSendRejectedError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function isMailSendNotConfiguredError(e) {
|
|
48
|
+
return e instanceof Error && "kind" in e && e.kind === "mail-send-not-configured-error";
|
|
49
|
+
}
|
|
50
|
+
export function isMailSendRejectedError(e) {
|
|
51
|
+
return e instanceof Error && "kind" in e && e.kind === "mail-send-rejected-error";
|
|
52
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
1
|
import { createSystemUser, ProbeFailure, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { createConfigAccessor } from "../config/index.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { toMailAccountRecord } from "./account-record.js";
|
|
4
|
+
import { InboundMailAccountStatuses } from "./constants.js";
|
|
6
5
|
import { INBOUND_PROBE_KEYS } from "./probe-keys.js";
|
|
7
6
|
import { mailAccountsProjectionTable } from "./projection.js";
|
|
8
7
|
import { resolveInboundProviderForAccount } from "./provider-factory.js";
|
|
8
|
+
import { MAIL_ACCOUNT_LIST_SCREEN_QN } from "./screens.js";
|
|
9
9
|
import { isInboundAuthError } from "./types.js";
|
|
10
10
|
const SHORT_ID_LENGTH = 8;
|
|
11
11
|
function providerContext(ctx) {
|
|
@@ -20,29 +20,6 @@ function providerContext(ctx) {
|
|
|
20
20
|
}),
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
// Providers never see ciphertext, so the address is decrypted before the call.
|
|
24
|
-
async function toAccountRecord(row, tenantId) {
|
|
25
|
-
const piiKms = configuredPiiSubjectKms();
|
|
26
|
-
const decrypted = piiKms
|
|
27
|
-
? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
|
|
28
|
-
requestId: "inbound-mail-foundation:probe",
|
|
29
|
-
})
|
|
30
|
-
: row;
|
|
31
|
-
const address = decrypted["address"];
|
|
32
|
-
if (typeof address !== "string")
|
|
33
|
-
throw new Error("mailbox address did not decrypt to text");
|
|
34
|
-
return {
|
|
35
|
-
id: row.id,
|
|
36
|
-
tenantId,
|
|
37
|
-
provider: row.provider,
|
|
38
|
-
authMethod: row.authMethod,
|
|
39
|
-
ownerUserId: row.ownerUserId ?? null,
|
|
40
|
-
address,
|
|
41
|
-
displayName: row.displayName ?? "",
|
|
42
|
-
status: InboundMailAccountStatuses.active,
|
|
43
|
-
watchState: row.watchState ?? "idle",
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
23
|
export function registerMailboxProbe(r) {
|
|
47
24
|
r.probe({
|
|
48
25
|
name: "mailboxes",
|
|
@@ -50,7 +27,7 @@ export function registerMailboxProbe(r) {
|
|
|
50
27
|
cost: "free",
|
|
51
28
|
area: INBOUND_PROBE_KEYS.area,
|
|
52
29
|
label: INBOUND_PROBE_KEYS.label,
|
|
53
|
-
fixScreen:
|
|
30
|
+
fixScreen: MAIL_ACCOUNT_LIST_SCREEN_QN,
|
|
54
31
|
// The address is PII, so the target label carries only the tenant-authored display name.
|
|
55
32
|
targets: async (ctx) => {
|
|
56
33
|
const rows = await ctx.db.selectMany(mailAccountsProjectionTable, {
|
|
@@ -71,7 +48,7 @@ export function registerMailboxProbe(r) {
|
|
|
71
48
|
: undefined;
|
|
72
49
|
if (!row)
|
|
73
50
|
return { status: "skipped", message: { key: INBOUND_PROBE_KEYS.gone } };
|
|
74
|
-
const account = await
|
|
51
|
+
const account = await toMailAccountRecord(row, ctx.tenantId, "inbound-mail-foundation:probe");
|
|
75
52
|
const providerCtx = providerContext(ctx);
|
|
76
53
|
const plugin = resolveInboundProviderForAccount(providerCtx, account);
|
|
77
54
|
try {
|
|
@@ -2,6 +2,8 @@ import { type ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framewo
|
|
|
2
2
|
import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
export declare const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
|
|
4
4
|
export declare const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
|
|
5
|
+
export declare const MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:mail-accounts";
|
|
6
|
+
export declare const IMAP_CONNECT_SCREEN_ID = "mail-account-connect-imap";
|
|
5
7
|
export declare const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
|
|
6
8
|
export declare const mailAccountListScreen: ProjectionListScreenDefinition;
|
|
7
9
|
export declare const mailAccountEditScreen: ActionFormScreenDefinition;
|
|
@@ -2,6 +2,11 @@ import { i18nKey, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { InboundMailAccountStatuses, InboundMailFoundationHandlers, InboundMailFoundationQueries, } from "./constants.js";
|
|
3
3
|
export const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
|
|
4
4
|
export const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
|
|
5
|
+
export const MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:mail-accounts";
|
|
6
|
+
// Registered by inbound-provider-imap, which needs the IMAP client to check the login. The
|
|
7
|
+
// renderer drops a navigate action whose target screen is not mounted, so the toolbar button
|
|
8
|
+
// only shows in apps that mount that provider.
|
|
9
|
+
export const IMAP_CONNECT_SCREEN_ID = "mail-account-connect-imap";
|
|
5
10
|
export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
|
|
6
11
|
// Same roles as the update/disconnect handlers: a screen rule wider than the
|
|
7
12
|
// handlers would only render actions that 403.
|
|
@@ -60,6 +65,15 @@ export const mailAccountListScreen = {
|
|
|
60
65
|
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
61
66
|
},
|
|
62
67
|
],
|
|
68
|
+
toolbarActions: [
|
|
69
|
+
{
|
|
70
|
+
kind: "navigate",
|
|
71
|
+
id: "connect-imap",
|
|
72
|
+
label: i18nKey("inboundMail.accounts.connectImap"),
|
|
73
|
+
screen: IMAP_CONNECT_SCREEN_ID,
|
|
74
|
+
style: "primary",
|
|
75
|
+
},
|
|
76
|
+
],
|
|
63
77
|
access: MAIL_ACCOUNT_ADMIN_ACCESS,
|
|
64
78
|
dormant: true,
|
|
65
79
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HandlerContext, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type OutboundMailMessage, type OutboundMailSent } from "./outbound-mail.ts";
|
|
3
|
+
export declare const MAIL_SEND_ERRORS: {
|
|
4
|
+
readonly notConfigured: {
|
|
5
|
+
readonly reason: "mail_send_not_configured";
|
|
6
|
+
readonly i18nKey: "inboundMail.send.errors.notConfigured";
|
|
7
|
+
};
|
|
8
|
+
readonly loginRejected: {
|
|
9
|
+
readonly reason: "mail_send_login_rejected";
|
|
10
|
+
readonly i18nKey: "inboundMail.send.errors.loginRejected";
|
|
11
|
+
};
|
|
12
|
+
readonly serverUnreachable: {
|
|
13
|
+
readonly reason: "mail_send_server_unreachable";
|
|
14
|
+
readonly i18nKey: "inboundMail.send.errors.serverUnreachable";
|
|
15
|
+
};
|
|
16
|
+
readonly rejected: {
|
|
17
|
+
readonly reason: "mail_send_rejected";
|
|
18
|
+
readonly i18nKey: "inboundMail.send.errors.rejected";
|
|
19
|
+
};
|
|
20
|
+
readonly invalidMessage: {
|
|
21
|
+
readonly reason: "mail_send_invalid_message";
|
|
22
|
+
readonly i18nKey: "inboundMail.send.errors.invalidMessage";
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export type MailSendFailure = keyof typeof MAIL_SEND_ERRORS;
|
|
26
|
+
export type SendMailFromAccountContext = Pick<HandlerContext, "db" | "registry" | "user" | "services" | "obs">;
|
|
27
|
+
export type SendMailFromAccountInput = {
|
|
28
|
+
readonly accountId: string;
|
|
29
|
+
readonly message: OutboundMailMessage;
|
|
30
|
+
};
|
|
31
|
+
export declare function sendMailFromAccount(ctx: SendMailFromAccountContext, input: SendMailFromAccountInput): Promise<WriteResult<OutboundMailSent>>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Sends a mail from a connected mailbox through the customer's own mail server. A plain server
|
|
2
|
+
// function rather than a handler: the caller's own write handler owns access and agent exposure,
|
|
3
|
+
// and the message (recipients, body) never becomes a dispatcher payload of its own.
|
|
4
|
+
//
|
|
5
|
+
// There is no fallback to the platform sender: a mailbox that cannot send answers with a typed
|
|
6
|
+
// failure, so a reply never leaves under an address the recipient did not write to.
|
|
7
|
+
import { failNotFound, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { toMailAccountRecord } from "./account-record.js";
|
|
9
|
+
import { InboundMailAccountStatuses } from "./constants.js";
|
|
10
|
+
import { isVisibleToCaller } from "./handlers/scope-visibility.js";
|
|
11
|
+
import { isMailSendNotConfiguredError, isMailSendRejectedError, outboundMailMessageSchema, } from "./outbound-mail.js";
|
|
12
|
+
import { mailAccountsProjectionTable } from "./projection.js";
|
|
13
|
+
import { resolveInboundProviderForAccount } from "./provider-factory.js";
|
|
14
|
+
import { isInboundAuthError, isInboundRateLimitError, isInboundTransientError, } from "./types.js";
|
|
15
|
+
export const MAIL_SEND_ERRORS = {
|
|
16
|
+
notConfigured: {
|
|
17
|
+
reason: "mail_send_not_configured",
|
|
18
|
+
i18nKey: "inboundMail.send.errors.notConfigured",
|
|
19
|
+
},
|
|
20
|
+
loginRejected: {
|
|
21
|
+
reason: "mail_send_login_rejected",
|
|
22
|
+
i18nKey: "inboundMail.send.errors.loginRejected",
|
|
23
|
+
},
|
|
24
|
+
serverUnreachable: {
|
|
25
|
+
reason: "mail_send_server_unreachable",
|
|
26
|
+
i18nKey: "inboundMail.send.errors.serverUnreachable",
|
|
27
|
+
},
|
|
28
|
+
rejected: { reason: "mail_send_rejected", i18nKey: "inboundMail.send.errors.rejected" },
|
|
29
|
+
invalidMessage: {
|
|
30
|
+
reason: "mail_send_invalid_message",
|
|
31
|
+
i18nKey: "inboundMail.send.errors.invalidMessage",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const HANDLER_NAME = "inbound-mail-foundation:send-from-account";
|
|
35
|
+
function sendFailure(failure) {
|
|
36
|
+
const { reason, i18nKey } = MAIL_SEND_ERRORS[failure];
|
|
37
|
+
return writeFailure(new UnprocessableError(reason, { i18nKey }));
|
|
38
|
+
}
|
|
39
|
+
function sendFailureOf(err) {
|
|
40
|
+
if (isMailSendNotConfiguredError(err))
|
|
41
|
+
return "notConfigured";
|
|
42
|
+
if (isInboundAuthError(err))
|
|
43
|
+
return "loginRejected";
|
|
44
|
+
if (isInboundTransientError(err) || isInboundRateLimitError(err))
|
|
45
|
+
return "serverUnreachable";
|
|
46
|
+
if (isMailSendRejectedError(err))
|
|
47
|
+
return "rejected";
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
// A disconnected mailbox and someone else's personal mailbox are reported as missing, so the
|
|
51
|
+
// caller cannot tell them apart from an id that never existed.
|
|
52
|
+
function isSendableBy(row, user) {
|
|
53
|
+
return row.status !== InboundMailAccountStatuses.disconnected && isVisibleToCaller(row, user);
|
|
54
|
+
}
|
|
55
|
+
export async function sendMailFromAccount(ctx, input) {
|
|
56
|
+
const message = outboundMailMessageSchema.safeParse(input.message);
|
|
57
|
+
if (!message.success)
|
|
58
|
+
return sendFailure("invalidMessage");
|
|
59
|
+
const row = await ctx.db.fetchOne(mailAccountsProjectionTable, {
|
|
60
|
+
id: input.accountId,
|
|
61
|
+
tenantId: ctx.user.tenantId,
|
|
62
|
+
});
|
|
63
|
+
if (!row || !isSendableBy(row, ctx.user))
|
|
64
|
+
return failNotFound("mail-account", input.accountId);
|
|
65
|
+
const account = await toMailAccountRecord(row, ctx.user.tenantId, `${HANDLER_NAME}:${row.id}`);
|
|
66
|
+
const providerCtx = {
|
|
67
|
+
registry: ctx.registry,
|
|
68
|
+
...(ctx.services.secrets && { secrets: ctx.services.secrets }),
|
|
69
|
+
auditUserId: ctx.user.id,
|
|
70
|
+
};
|
|
71
|
+
const plugin = resolveInboundProviderForAccount(providerCtx, account, HANDLER_NAME);
|
|
72
|
+
if (!plugin.send)
|
|
73
|
+
return sendFailure("notConfigured");
|
|
74
|
+
try {
|
|
75
|
+
return { isSuccess: true, data: await plugin.send(providerCtx, account, message.data) };
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const failure = sendFailureOf(err);
|
|
79
|
+
if (!failure)
|
|
80
|
+
throw err;
|
|
81
|
+
ctx.obs.log.warn("mail send from account failed", {
|
|
82
|
+
accountId: account.id,
|
|
83
|
+
reason: MAIL_SEND_ERRORS[failure].reason,
|
|
84
|
+
});
|
|
85
|
+
return sendFailure(failure);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
3
3
|
import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "./constants.ts";
|
|
4
|
+
import type { OutboundMailMessage, OutboundMailSent } from "./outbound-mail.ts";
|
|
4
5
|
/**
|
|
5
6
|
* Slim context for provider plugins.
|
|
6
7
|
*
|
|
@@ -157,6 +158,9 @@ export type InboundMailProviderPlugin = {
|
|
|
157
158
|
}) => Promise<InboundFetchResult>;
|
|
158
159
|
/** Nur OAuth-Provider. */
|
|
159
160
|
readonly oauth?: InboundOAuthFlow;
|
|
161
|
+
/** Sends from the mailbox address through the customer's own server. Throws
|
|
162
|
+
* MailSendNotConfiguredError, InboundAuthError, InboundTransientError or MailSendRejectedError. */
|
|
163
|
+
readonly send?: (ctx: InboundMailContext, account: MailAccountRecord, message: OutboundMailMessage) => Promise<OutboundMailSent>;
|
|
160
164
|
/** Optional live push (IMAP IDLE, later Graph/Gmail webhooks).
|
|
161
165
|
* Returns stop(). Polling remains reconciliation: watch optimizes
|
|
162
166
|
* latency but is never the correctness anchor. `onError` reports a dead
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
// racing a new holder.
|
|
45
45
|
import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
46
46
|
import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/db";
|
|
47
|
+
import { systemScopedRunner } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
|
|
47
48
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
48
49
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
49
50
|
import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.js";
|
|
@@ -79,10 +80,11 @@ export function createInboundMailSupervisor(deps) {
|
|
|
79
80
|
// configured TTL — real deployments (default 90s) never hit it.
|
|
80
81
|
const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
|
|
81
82
|
const log = deps.log ?? (() => { });
|
|
83
|
+
const systemDb = systemScopedRunner(deps.db, "mail sync supervisor polls accounts and cursors of all tenants outside a request");
|
|
82
84
|
const oauthTokens = deps.oauthTokens ??
|
|
83
85
|
(deps.providerCtx.secrets
|
|
84
86
|
? createOAuthAccessTokenManager({
|
|
85
|
-
db:
|
|
87
|
+
db: systemDb,
|
|
86
88
|
secrets: deps.providerCtx.secrets,
|
|
87
89
|
providerCtx: deps.providerCtx,
|
|
88
90
|
})
|
|
@@ -107,7 +109,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
107
109
|
// decrypted (providers never work with ciphertext).
|
|
108
110
|
// ---------------------------------------------------------------
|
|
109
111
|
async function listActiveAccounts() {
|
|
110
|
-
const rows = await selectMany(
|
|
112
|
+
const rows = await selectMany(systemDb, mailAccountsProjectionTable, {
|
|
111
113
|
status: InboundMailAccountStatuses.active,
|
|
112
114
|
});
|
|
113
115
|
const piiKms = configuredPiiSubjectKms();
|
|
@@ -152,7 +154,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
152
154
|
// Cursor-Persistenz (unmanaged direct-write store).
|
|
153
155
|
// ---------------------------------------------------------------
|
|
154
156
|
async function loadCursor(accountId) {
|
|
155
|
-
const row = await fetchOne(
|
|
157
|
+
const row = await fetchOne(systemDb, syncCursorTable, {
|
|
156
158
|
accountId,
|
|
157
159
|
scope: CURSOR_SCOPE,
|
|
158
160
|
});
|
|
@@ -170,16 +172,16 @@ export function createInboundMailSupervisor(deps) {
|
|
|
170
172
|
const accountId = account.id;
|
|
171
173
|
const now = Temporal.Now.instant().toString();
|
|
172
174
|
const serialized = JSON.stringify(cursor);
|
|
173
|
-
const existing = await fetchOne(
|
|
175
|
+
const existing = await fetchOne(systemDb, syncCursorTable, {
|
|
174
176
|
accountId,
|
|
175
177
|
scope: CURSOR_SCOPE,
|
|
176
178
|
});
|
|
177
179
|
if (existing) {
|
|
178
|
-
await updateMany(
|
|
180
|
+
await updateMany(systemDb, syncCursorTable, { cursor: serialized, updatedAt: now }, { accountId, scope: CURSOR_SCOPE });
|
|
179
181
|
// skip: the update path is complete; do not fall through to the insert branch.
|
|
180
182
|
return;
|
|
181
183
|
}
|
|
182
|
-
await insertOne(
|
|
184
|
+
await insertOne(systemDb, syncCursorTable, {
|
|
183
185
|
id: crypto.randomUUID(),
|
|
184
186
|
// Direct-write store: no executor stamps the NOT NULL tenant_id for us.
|
|
185
187
|
tenantId: account.tenantId,
|
|
@@ -192,7 +194,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
192
194
|
async function resetCursor(accountId) {
|
|
193
195
|
// No deleteMany import is needed: an empty cursor parses to null, so the
|
|
194
196
|
// next fetch runs as a backfill.
|
|
195
|
-
await updateMany(
|
|
197
|
+
await updateMany(systemDb, syncCursorTable, { cursor: "", updatedAt: Temporal.Now.instant().toString() }, { accountId, scope: CURSOR_SCOPE });
|
|
196
198
|
}
|
|
197
199
|
// ---------------------------------------------------------------
|
|
198
200
|
// Ingest: every message goes through the standard write handler.
|
|
@@ -271,7 +273,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
271
273
|
// One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
|
|
272
274
|
const seenIds = new Set();
|
|
273
275
|
for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
|
|
274
|
-
const seen = await selectMany(
|
|
276
|
+
const seen = await selectMany(systemDb, seenMessageTable, {
|
|
275
277
|
tenantId: account.tenantId,
|
|
276
278
|
accountId: account.id,
|
|
277
279
|
providerMessageId: {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export declare function deriveSmtpHost(imapHost: string): string;
|
|
3
|
+
export declare const connectMailboxHandler: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"connect-mailbox", z.ZodObject<{
|
|
4
|
+
host: z.ZodString;
|
|
5
|
+
port: z.ZodDefault<z.ZodNumber>;
|
|
6
|
+
user: z.ZodString;
|
|
7
|
+
displayName: z.ZodString;
|
|
8
|
+
address: z.ZodEmail;
|
|
9
|
+
password: z.ZodString;
|
|
10
|
+
smtpHost: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodLiteral<"">, z.ZodString]>>>;
|
|
11
|
+
}, z.core.$strip>, {
|
|
12
|
+
accountId: string;
|
|
13
|
+
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, null>;
|