@cosmicdrift/kumiko-samples 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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/admin-shell/changes.json +6 -0
- package/packages/bundled-features/src/admin-shell/feature.ts +60 -31
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -2
- package/packages/bundled-features/src/cap-overview/changes.json +12 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +3 -2
- package/packages/bundled-features/src/cap-overview/i18n.ts +4 -0
- package/packages/bundled-features/src/cap-overview/types.ts +4 -1
- package/packages/bundled-features/src/cap-overview/web/cap-amount-format.ts +37 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +138 -54
- package/packages/bundled-features/src/cap-overview/web/cap-usage-bar.tsx +18 -4
- package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -3
- package/packages/bundled-features/src/cap-overview/web/usage-math.ts +5 -1
- package/packages/bundled-features/src/channel-email/app-transport.ts +37 -0
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +6 -0
- package/packages/bundled-features/src/channel-email/index.ts +5 -0
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +2 -22
- package/packages/bundled-features/src/foundation-shared/changes.json +6 -0
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-display-name.ts +20 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +17 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +8 -45
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +14 -0
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +118 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +13 -8
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +12 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +156 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +42 -0
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +49 -1
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +12 -35
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +48 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +44 -0
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +73 -0
- package/packages/bundled-features/src/inbound-provider-imap/sent-folder.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +131 -0
- package/packages/bundled-features/src/legal-pages/README.md +5 -10
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +9 -1
- package/packages/bundled-features/src/mail-foundation/probe.ts +43 -22
- package/packages/bundled-features/src/readiness/built-in-probes.ts +15 -2
- package/packages/bundled-features/src/readiness/changes.json +30 -0
- package/packages/bundled-features/src/readiness/constants.ts +2 -0
- package/packages/bundled-features/src/readiness/i18n.ts +7 -1
- package/packages/bundled-features/src/readiness/index.ts +6 -1
- package/packages/bundled-features/src/readiness/screens.ts +25 -2
- package/packages/bundled-features/src/readiness/visible-results.ts +2 -1
- package/packages/bundled-features/src/renderer-foundation/README.md +19 -21
- package/packages/bundled-features/src/template-resolver/README.md +144 -165
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +17 -6
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/schema/download-token.ts +5 -3
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +5 -4
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/samples/apps/cap-billing-demo/bin/server.ts +7 -7
- package/samples/apps/cap-billing-demo/src/feature.ts +38 -38
- package/samples/apps/cap-billing-demo/src/run-config.ts +20 -20
- package/samples/apps/cap-billing-demo/src/tier-map.ts +14 -14
- package/samples/apps/config-demo/src/app/server.ts +4 -4
- package/samples/apps/config-demo/src/features/demo/feature.ts +4 -4
- package/samples/apps/marketing-demo/playwright.config.ts +3 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +2 -2
- package/samples/apps/marketing-demo/src/app/seed-data.ts +9 -9
- package/samples/apps/marketing-demo/src/app/seed.ts +14 -14
- package/samples/apps/marketing-demo/src/app/server.ts +7 -7
- package/samples/apps/marketing-demo/src/app/shell.tsx +3 -3
- package/samples/apps/marketing-demo/src/features/assets/feature.ts +3 -3
- package/samples/apps/marketing-demo/src/features/assets/i18n.ts +7 -7
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +2 -2
- package/samples/apps/showcase/src/app/client.tsx +3 -3
- package/samples/apps/showcase/src/app/seed-items.ts +14 -14
- package/samples/apps/showcase/src/app/server.ts +7 -7
- package/samples/apps/showcase/src/features/demos/feature.ts +13 -13
- package/samples/apps/showcase/src/features/demos/i18n.ts +2 -2
- package/samples/apps/showcase/src/features/demos/web/components/page.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/index.ts +4 -4
- package/samples/apps/showcase/src/features/demos/web/pages/dialog.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/inputs.tsx +8 -8
- package/samples/apps/showcase/src/features/demos/web/pages/layout.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +3 -3
- package/samples/apps/showcase/src/features/items/feature.ts +8 -8
- package/samples/apps/showcase/src/features/items/i18n.ts +3 -3
- package/samples/apps/showcase/src/features/items/index.ts +3 -3
- package/samples/apps/showcase/src/features/items/schema/index.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +27 -27
- package/samples/apps/showcase/src/features/items/web/index.ts +6 -6
- package/samples/apps/styleguide/src/app/seed.ts +3 -3
- package/samples/apps/styleguide/src/app/server.ts +2 -2
- package/samples/apps/styleguide/src/app/shell.tsx +4 -4
- package/samples/apps/styleguide/src/features/content/web.tsx +6 -6
- package/samples/apps/styleguide/src/features/examples/feature.ts +8 -8
- package/samples/apps/styleguide/src/features/gallery/feature.ts +9 -9
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +12 -12
- package/samples/apps/styleguide/src/features/widgets/feature.ts +2 -2
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +3 -3
- package/samples/apps/styleguide/src/features/widgets/web/DashboardKpiIcon.tsx +4 -4
- package/samples/apps/ui-walkthrough/playwright.config.ts +6 -6
- package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +5 -5
- package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +13 -13
- package/samples/apps/ui-walkthrough/src/app/client.tsx +2 -2
- package/samples/apps/ui-walkthrough/src/app/server.ts +14 -14
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -3
- package/samples/apps/use-all-bundled/bin/main.ts +5 -5
- package/samples/apps/use-all-bundled/feature-manifest.json +10 -4
- package/samples/apps/use-all-bundled/kumiko/schema.ts +7 -7
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +20 -20
- package/samples/apps/use-all-bundled/src/app/auth-constants.ts +4 -4
- package/samples/apps/use-all-bundled/src/app/client.tsx +4 -4
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +12 -12
- package/samples/apps/use-all-bundled/src/app/seed.ts +6 -6
- package/samples/apps/user-data-rights-demo/src/feature.ts +23 -23
- package/samples/apps/workspaces/playwright.config.ts +5 -5
- package/samples/apps/workspaces/src/app/auth-constants.ts +3 -3
- package/samples/apps/workspaces/src/app/server.ts +16 -16
- package/samples/apps/workspaces/src/app/shell.tsx +2 -2
- package/samples/apps/workspaces/src/features/demo/schema/order.ts +4 -4
- package/samples/apps/workspaces/src/features/demo-driver/feature.ts +5 -5
- package/samples/recipes/apex-surface-auth/src/feature.ts +31 -31
- package/samples/recipes/designer-demo/src/feature.ts +6 -6
- package/samples/recipes/encrypted-tenant-config/src/feature.ts +20 -20
- package/samples/recipes/lane-routing/src/feature.ts +5 -5
- package/samples/recipes/legal-pages/src/feature.ts +29 -29
- package/samples/recipes/record-detail-layout/src/feature.ts +21 -2
- package/samples/recipes/seed-migration/scripts/smoke.ts +20 -20
- package/samples/recipes/seed-migration/seeds/2026-01-01-initial-tenants.ts +11 -11
- package/samples/recipes/seed-migration/seeds/2026-02-01-fix-admin-roles.ts +13 -13
- package/samples/recipes/user-data-rights/src/feature.ts +13 -13
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { EmailTransport } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
// The app transport otherwise lives only in the channel closure; publishing it lets other
|
|
5
|
+
// features reach the same sender a tenant without its own provider falls back to.
|
|
6
|
+
export const EMAIL_APP_TRANSPORT_EXTENSION = "emailAppTransport" as const;
|
|
7
|
+
export const EMAIL_APP_TRANSPORT_USAGE = "app" as const;
|
|
8
|
+
|
|
9
|
+
export type EmailAppTransport = { readonly transport: EmailTransport | null };
|
|
10
|
+
|
|
11
|
+
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
12
|
+
interface KumikoExtensionOptionsMap {
|
|
13
|
+
[EMAIL_APP_TRANSPORT_EXTENSION]: EmailAppTransport;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isEmailAppTransport(options: unknown): options is EmailAppTransport {
|
|
18
|
+
return (
|
|
19
|
+
typeof options === "object" &&
|
|
20
|
+
options !== null &&
|
|
21
|
+
"transport" in options &&
|
|
22
|
+
typeof options.transport === "object" &&
|
|
23
|
+
options.transport !== null &&
|
|
24
|
+
"send" in options.transport &&
|
|
25
|
+
typeof options.transport.send === "function"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The app-wide transport of channel-email, null when the app has none or no channel-email. */
|
|
30
|
+
export function findEmailAppTransport(
|
|
31
|
+
registry: Pick<Registry, "getExtensionUsages">,
|
|
32
|
+
): EmailTransport | null {
|
|
33
|
+
const usage = registry
|
|
34
|
+
.getExtensionUsages(EMAIL_APP_TRANSPORT_EXTENSION)
|
|
35
|
+
.find((candidate) => candidate.entityName === EMAIL_APP_TRANSPORT_USAGE);
|
|
36
|
+
return usage !== undefined && isEmailAppTransport(usage.options) ? usage.options.transport : null;
|
|
37
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "channel-email publishes its app transport",
|
|
6
|
+
"detail": "channel-email declares the emailAppTransport extension and registers its app transport under it; findEmailAppTransport(registry) returns that transport, or null when the app has none or does not mount channel-email."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -4,11 +4,13 @@ import {
|
|
|
4
4
|
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
5
5
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { featureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
|
|
7
|
+
import { EMAIL_APP_TRANSPORT_EXTENSION, EMAIL_APP_TRANSPORT_USAGE } from "./app-transport.ts";
|
|
7
8
|
import { createEmailChannel, type EmailChannelOptions } from "./email-channel.ts";
|
|
8
9
|
import { smtpEnvSchema } from "./env.ts";
|
|
9
10
|
|
|
10
11
|
export function createChannelEmailFeature(options: EmailChannelOptions) {
|
|
11
12
|
const channel = createEmailChannel(options);
|
|
13
|
+
const appTransport = options.transport ?? null;
|
|
12
14
|
|
|
13
15
|
const feature = defineFeature("channel-email", (r) => {
|
|
14
16
|
r.describe(
|
|
@@ -23,6 +25,10 @@ export function createChannelEmailFeature(options: EmailChannelOptions) {
|
|
|
23
25
|
r.envSchema(smtpEnvSchema);
|
|
24
26
|
|
|
25
27
|
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
28
|
+
r.extendsRegistrar(EMAIL_APP_TRANSPORT_EXTENSION, { onRegister: () => {} });
|
|
29
|
+
r.useExtension(EMAIL_APP_TRANSPORT_EXTENSION, EMAIL_APP_TRANSPORT_USAGE, {
|
|
30
|
+
transport: appTransport,
|
|
31
|
+
});
|
|
26
32
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, {
|
|
27
33
|
mode: channel.mode,
|
|
28
34
|
resolve: channel.resolve,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// high-volume apps the caller must layer its own implementation with a
|
|
19
19
|
// dedicated queue (BullMQ + retry) on top.
|
|
20
20
|
|
|
21
|
+
import { sanitizeMailDisplayName } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
21
22
|
import { createTransport, type Transporter } from "nodemailer";
|
|
22
23
|
import addressparser from "nodemailer/lib/addressparser";
|
|
23
24
|
import type { EmailAttachment, EmailMessage, EmailTransport } from "./types.ts";
|
|
@@ -51,32 +52,11 @@ export type SmtpTransportOptions = {
|
|
|
51
52
|
readonly servername?: string;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
function isControlCharacter(char: string): boolean {
|
|
55
|
-
const code = char.charCodeAt(0);
|
|
56
|
-
return code <= 0x1f || code === 0x7f;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Mail clients often show only the display name: an embedded address ("<", ">", "@") would
|
|
60
|
-
// pass for a foreign sender on the platform's own SPF/DKIM-signed domain.
|
|
61
|
-
function isUnsafeDisplayNameCharacter(char: string): boolean {
|
|
62
|
-
return isControlCharacter(char) || char === "<" || char === ">" || char === "@";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Control characters in a display name would let a caller inject headers.
|
|
66
|
-
function sanitizedDisplayName(name: string | undefined): string | undefined {
|
|
67
|
-
const cleaned = name
|
|
68
|
-
?.split("")
|
|
69
|
-
.map((char) => (isUnsafeDisplayNameCharacter(char) ? " " : char))
|
|
70
|
-
.join("")
|
|
71
|
-
.trim();
|
|
72
|
-
return cleaned === undefined || cleaned === "" ? undefined : cleaned;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
55
|
function fromHeader(
|
|
76
56
|
address: string,
|
|
77
57
|
fromName: string | undefined,
|
|
78
58
|
): string | { readonly name: string; readonly address: string } {
|
|
79
|
-
const name =
|
|
59
|
+
const name = sanitizeMailDisplayName(fromName);
|
|
80
60
|
if (name === undefined) return address;
|
|
81
61
|
const [parsed] = addressparser(address, { flatten: true });
|
|
82
62
|
return parsed?.address ? { name, address: parsed.address } : address;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "sanitizeMailDisplayName is shared",
|
|
6
|
+
"detail": "The display-name sanitizer from channel-email moved to foundation-shared as sanitizeMailDisplayName, so the IMAP sender uses the same rule. No behavior change."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -5,6 +5,7 @@ export { buildInfoEnvSchema } from "./build-info-env.ts";
|
|
|
5
5
|
export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.ts";
|
|
6
6
|
export { demoAdminEnvSchema } from "./demo-admin-env.ts";
|
|
7
7
|
export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.ts";
|
|
8
|
+
export { sanitizeMailDisplayName } from "./mail-display-name.ts";
|
|
8
9
|
export {
|
|
9
10
|
BlockedHostError,
|
|
10
11
|
HostResolutionError,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function isControlCharacter(char: string): boolean {
|
|
2
|
+
const code = char.charCodeAt(0);
|
|
3
|
+
return code <= 0x1f || code === 0x7f;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Mail clients often show only the display name: an embedded address ("<", ">", "@") would
|
|
7
|
+
// pass for a foreign sender on the sending domain's own SPF/DKIM-signed mail.
|
|
8
|
+
function isUnsafeDisplayNameCharacter(char: string): boolean {
|
|
9
|
+
return isControlCharacter(char) || char === "<" || char === ">" || char === "@";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Control characters in a display name would let a caller inject headers.
|
|
13
|
+
export function sanitizeMailDisplayName(name: string | undefined): string | undefined {
|
|
14
|
+
const cleaned = name
|
|
15
|
+
?.split("")
|
|
16
|
+
.map((char) => (isUnsafeDisplayNameCharacter(char) ? " " : char))
|
|
17
|
+
.join("")
|
|
18
|
+
.trim();
|
|
19
|
+
return cleaned === undefined || cleaned === "" ? undefined : cleaned;
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configuredPiiSubjectKms,
|
|
3
|
+
decryptPiiFieldValues,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
5
|
+
import { type InboundMailAccountStatus, InboundMailAccountStatuses } from "./constants.ts";
|
|
6
|
+
import { MAIL_ACCOUNT_PII_FIELDS } from "./entities.ts";
|
|
7
|
+
import type { MailAccountRecord } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export type MailAccountRow = {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly tenantId: string;
|
|
12
|
+
readonly provider: string;
|
|
13
|
+
readonly authMethod: string;
|
|
14
|
+
readonly ownerUserId?: string | null;
|
|
15
|
+
readonly address: string;
|
|
16
|
+
readonly displayName?: string | null;
|
|
17
|
+
readonly status?: string | null;
|
|
18
|
+
readonly watchState?: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function isAccountStatus(value: unknown): value is InboundMailAccountStatus {
|
|
22
|
+
return Object.values(InboundMailAccountStatuses).some((status) => status === value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Providers never see ciphertext, so the address is decrypted before the call.
|
|
26
|
+
export async function toMailAccountRecord(
|
|
27
|
+
row: MailAccountRow,
|
|
28
|
+
tenantId: string,
|
|
29
|
+
requestId: string,
|
|
30
|
+
): Promise<MailAccountRecord> {
|
|
31
|
+
const piiKms = configuredPiiSubjectKms();
|
|
32
|
+
const decrypted = piiKms
|
|
33
|
+
? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, { requestId })
|
|
34
|
+
: row;
|
|
35
|
+
const address = decrypted["address"];
|
|
36
|
+
if (typeof address !== "string") throw new Error("mailbox address did not decrypt to text");
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
tenantId,
|
|
40
|
+
provider: row.provider,
|
|
41
|
+
authMethod: row.authMethod,
|
|
42
|
+
ownerUserId: row.ownerUserId ?? null,
|
|
43
|
+
address,
|
|
44
|
+
displayName: row.displayName ?? "",
|
|
45
|
+
status: isAccountStatus(row.status) ? row.status : InboundMailAccountStatuses.active,
|
|
46
|
+
watchState: row.watchState ?? "idle",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Mail polling works with the row-level-security enforcing pool",
|
|
6
|
+
"detail": "createInboundMailSupervisor opens its own system scope for the cross-tenant reads and writes of accounts, cursors and seen messages. Before, passing the enforcing pool as db made every poll see no accounts."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.392.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "The mail account list links to the IMAP connect form",
|
|
12
|
+
"detail": "mailAccountListScreen gets a \"Connect mailbox\" toolbar action that opens the screen mail-account-connect-imap. The button shows only when the app mounts inbound-provider-imap, which registers that screen. The Systemcheck fix link for inbound mail now leads to a list where a mailbox can be added."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.392.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Apps can send from a connected mailbox",
|
|
18
|
+
"detail": "The provider contract gets an optional send(ctx, account, message). The new server function sendMailFromAccount(ctx, { accountId, message }) is called from an app's own write handler and sends through the provider of the mailbox, with the mailbox address as From and In-Reply-To and References for replies. It returns WriteResult<{ messageIdHeader }>; failures are typed UnprocessableErrors with the reasons mail_send_not_configured, mail_send_login_rejected, mail_send_server_unreachable, mail_send_rejected and mail_send_invalid_message (i18n keys inboundMail.send.errors.*). A missing, disconnected or someone else's personal mailbox answers not found. There is no fallback to the app sender. New exports: sendMailFromAccount, MAIL_SEND_ERRORS, outboundMailMessageSchema, OutboundMailMessage, OutboundMailSent, MailSendNotConfiguredError, MailSendRejectedError. New keys in all locale packages."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.391.0",
|
|
4
22
|
"type": "improvement",
|
|
@@ -106,7 +106,7 @@ export function createInboundMailFoundationFeature() {
|
|
|
106
106
|
(r) => {
|
|
107
107
|
r.adoptsBundledLists();
|
|
108
108
|
r.describe(
|
|
109
|
-
"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.",
|
|
109
|
+
"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.",
|
|
110
110
|
);
|
|
111
111
|
r.uiHints({
|
|
112
112
|
displayLabel: "Inbound Mail · Foundation",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
8
8
|
MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX,
|
|
9
9
|
} from "./screens.ts";
|
|
10
|
+
import { MAIL_SEND_ERRORS } from "./send-from-account.ts";
|
|
10
11
|
|
|
11
12
|
const formFieldKey = (field: string): string =>
|
|
12
13
|
fieldLabelKey(INBOUND_MAIL_FOUNDATION_FEATURE, ACTION_FORM_ENTITY, field);
|
|
@@ -32,6 +33,7 @@ export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en
|
|
|
32
33
|
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.disconnected}`]: {
|
|
33
34
|
en: "Disconnected",
|
|
34
35
|
},
|
|
36
|
+
"inboundMail.accounts.connectImap": { en: "Connect mailbox" },
|
|
35
37
|
"inboundMail.accounts.edit": { en: "Rename" },
|
|
36
38
|
"inboundMail.accounts.edit.submit": { en: "Save" },
|
|
37
39
|
"inboundMail.accounts.disconnect": { en: "Disconnect" },
|
|
@@ -40,4 +42,19 @@ export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en
|
|
|
40
42
|
},
|
|
41
43
|
[formFieldKey("accountId")]: { en: "Mailbox" },
|
|
42
44
|
[formFieldKey("displayName")]: { en: "Name" },
|
|
45
|
+
[MAIL_SEND_ERRORS.notConfigured.i18nKey]: {
|
|
46
|
+
en: "Sending is not set up for this mailbox. Connect the mailbox again with its outgoing server.",
|
|
47
|
+
},
|
|
48
|
+
[MAIL_SEND_ERRORS.loginRejected.i18nKey]: {
|
|
49
|
+
en: "The outgoing mail server rejected the sign-in. Check the mailbox password.",
|
|
50
|
+
},
|
|
51
|
+
[MAIL_SEND_ERRORS.serverUnreachable.i18nKey]: {
|
|
52
|
+
en: "The outgoing mail server cannot be reached. Try again later.",
|
|
53
|
+
},
|
|
54
|
+
[MAIL_SEND_ERRORS.rejected.i18nKey]: {
|
|
55
|
+
en: "The outgoing mail server refused the mail. Check the recipient address.",
|
|
56
|
+
},
|
|
57
|
+
[MAIL_SEND_ERRORS.invalidMessage.i18nKey]: {
|
|
58
|
+
en: "The mail was not sent: a recipient, the subject or the reply reference is invalid.",
|
|
59
|
+
},
|
|
43
60
|
};
|
|
@@ -68,6 +68,15 @@ export {
|
|
|
68
68
|
type VerifyOAuthStateResult,
|
|
69
69
|
verifyOAuthState,
|
|
70
70
|
} from "./oauth-state.ts";
|
|
71
|
+
export {
|
|
72
|
+
isMailSendNotConfiguredError,
|
|
73
|
+
isMailSendRejectedError,
|
|
74
|
+
MailSendNotConfiguredError,
|
|
75
|
+
MailSendRejectedError,
|
|
76
|
+
type OutboundMailMessage,
|
|
77
|
+
type OutboundMailSent,
|
|
78
|
+
outboundMailMessageSchema,
|
|
79
|
+
} from "./outbound-mail.ts";
|
|
71
80
|
export {
|
|
72
81
|
inboundMessagesProjectionTable,
|
|
73
82
|
mailAccountsProjectionTable,
|
|
@@ -78,9 +87,18 @@ export {
|
|
|
78
87
|
resolveInboundProviderForKey,
|
|
79
88
|
} from "./provider-factory.ts";
|
|
80
89
|
export {
|
|
90
|
+
IMAP_CONNECT_SCREEN_ID,
|
|
81
91
|
MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
82
92
|
MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
93
|
+
MAIL_ACCOUNT_LIST_SCREEN_QN,
|
|
83
94
|
} from "./screens.ts";
|
|
95
|
+
export {
|
|
96
|
+
MAIL_SEND_ERRORS,
|
|
97
|
+
type MailSendFailure,
|
|
98
|
+
type SendMailFromAccountContext,
|
|
99
|
+
type SendMailFromAccountInput,
|
|
100
|
+
sendMailFromAccount,
|
|
101
|
+
} from "./send-from-account.ts";
|
|
84
102
|
export {
|
|
85
103
|
InboundAuthError,
|
|
86
104
|
InboundCursorInvalidError,
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
|
|
6
|
+
const MAX_RECIPIENTS = 50;
|
|
7
|
+
const MAX_REFERENCES = 100;
|
|
8
|
+
|
|
9
|
+
// Message ids travel without angle brackets, like RawInboundMessage.references. Anything that
|
|
10
|
+
// could close the bracket or start a new header is refused instead of being encoded.
|
|
11
|
+
const messageIdSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(998)
|
|
15
|
+
.regex(/^[^\s<>]+$/);
|
|
16
|
+
|
|
17
|
+
export const outboundMailMessageSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
to: z.array(z.email().max(320)).min(1).max(MAX_RECIPIENTS),
|
|
20
|
+
cc: z.array(z.email().max(320)).max(MAX_RECIPIENTS).optional(),
|
|
21
|
+
subject: z
|
|
22
|
+
.string()
|
|
23
|
+
.max(998)
|
|
24
|
+
.regex(/^[^\r\n]*$/),
|
|
25
|
+
text: z.string().optional(),
|
|
26
|
+
html: z.string().optional(),
|
|
27
|
+
inReplyTo: messageIdSchema.optional(),
|
|
28
|
+
references: z.array(messageIdSchema).max(MAX_REFERENCES).optional(),
|
|
29
|
+
})
|
|
30
|
+
.refine((message) => message.text !== undefined || message.html !== undefined, {
|
|
31
|
+
message: "needs text or html",
|
|
32
|
+
});
|
|
33
|
+
export type OutboundMailMessage = z.infer<typeof outboundMailMessageSchema>;
|
|
34
|
+
|
|
35
|
+
export type OutboundMailSent = {
|
|
36
|
+
/** Message-ID header of the sent mail, without angle brackets. */
|
|
37
|
+
readonly messageIdHeader: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Same `kind` discriminant as the inbound error classes (see types.ts).
|
|
41
|
+
|
|
42
|
+
/** The mailbox has no outgoing server stored, for example a credential document from before
|
|
43
|
+
* sending existed. The tenant has to connect the mailbox again. */
|
|
44
|
+
export class MailSendNotConfiguredError extends Error {
|
|
45
|
+
readonly kind = "mail-send-not-configured-error" as const;
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "MailSendNotConfiguredError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The outgoing server accepted the login but refused the mail (recipient, size, policy). */
|
|
53
|
+
export class MailSendRejectedError extends Error {
|
|
54
|
+
readonly kind = "mail-send-rejected-error" as const;
|
|
55
|
+
constructor(message: string) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "MailSendRejectedError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isMailSendNotConfiguredError(e: unknown): e is MailSendNotConfiguredError {
|
|
62
|
+
return e instanceof Error && "kind" in e && e.kind === "mail-send-not-configured-error";
|
|
63
|
+
}
|
|
64
|
+
export function isMailSendRejectedError(e: unknown): e is MailSendRejectedError {
|
|
65
|
+
return e instanceof Error && "kind" in e && e.kind === "mail-send-rejected-error";
|
|
66
|
+
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
configuredPiiSubjectKms,
|
|
3
|
-
decryptPiiFieldValues,
|
|
4
|
-
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
5
1
|
import {
|
|
6
2
|
createSystemUser,
|
|
7
3
|
type FeatureRegistrar,
|
|
@@ -9,26 +5,16 @@ import {
|
|
|
9
5
|
ProbeFailure,
|
|
10
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
7
|
import { createConfigAccessor } from "../config/index.ts";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
8
|
+
import { type MailAccountRow, toMailAccountRecord } from "./account-record.ts";
|
|
9
|
+
import { InboundMailAccountStatuses } from "./constants.ts";
|
|
14
10
|
import { INBOUND_PROBE_KEYS } from "./probe-keys.ts";
|
|
15
11
|
import { mailAccountsProjectionTable } from "./projection.ts";
|
|
16
12
|
import { resolveInboundProviderForAccount } from "./provider-factory.ts";
|
|
17
|
-
import {
|
|
13
|
+
import { MAIL_ACCOUNT_LIST_SCREEN_QN } from "./screens.ts";
|
|
14
|
+
import { type InboundMailContext, isInboundAuthError } from "./types.ts";
|
|
18
15
|
|
|
19
16
|
const SHORT_ID_LENGTH = 8;
|
|
20
17
|
|
|
21
|
-
type AccountRow = {
|
|
22
|
-
readonly id: string;
|
|
23
|
-
readonly tenantId: string;
|
|
24
|
-
readonly provider: string;
|
|
25
|
-
readonly authMethod: string;
|
|
26
|
-
readonly ownerUserId?: string | null;
|
|
27
|
-
readonly address: string;
|
|
28
|
-
readonly displayName?: string | null;
|
|
29
|
-
readonly watchState?: string | null;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
18
|
function providerContext(ctx: ProbeContext): InboundMailContext {
|
|
33
19
|
const { configResolver, secrets } = ctx.services;
|
|
34
20
|
const auditUserId = createSystemUser(ctx.tenantId).id;
|
|
@@ -42,29 +28,6 @@ function providerContext(ctx: ProbeContext): InboundMailContext {
|
|
|
42
28
|
};
|
|
43
29
|
}
|
|
44
30
|
|
|
45
|
-
// Providers never see ciphertext, so the address is decrypted before the call.
|
|
46
|
-
async function toAccountRecord(row: AccountRow, tenantId: string): Promise<MailAccountRecord> {
|
|
47
|
-
const piiKms = configuredPiiSubjectKms();
|
|
48
|
-
const decrypted = piiKms
|
|
49
|
-
? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
|
|
50
|
-
requestId: "inbound-mail-foundation:probe",
|
|
51
|
-
})
|
|
52
|
-
: row;
|
|
53
|
-
const address = decrypted["address"];
|
|
54
|
-
if (typeof address !== "string") throw new Error("mailbox address did not decrypt to text");
|
|
55
|
-
return {
|
|
56
|
-
id: row.id,
|
|
57
|
-
tenantId,
|
|
58
|
-
provider: row.provider,
|
|
59
|
-
authMethod: row.authMethod,
|
|
60
|
-
ownerUserId: row.ownerUserId ?? null,
|
|
61
|
-
address,
|
|
62
|
-
displayName: row.displayName ?? "",
|
|
63
|
-
status: InboundMailAccountStatuses.active,
|
|
64
|
-
watchState: row.watchState ?? "idle",
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
31
|
export function registerMailboxProbe(r: FeatureRegistrar<"inbound-mail-foundation">): void {
|
|
69
32
|
r.probe({
|
|
70
33
|
name: "mailboxes",
|
|
@@ -72,10 +35,10 @@ export function registerMailboxProbe(r: FeatureRegistrar<"inbound-mail-foundatio
|
|
|
72
35
|
cost: "free",
|
|
73
36
|
area: INBOUND_PROBE_KEYS.area,
|
|
74
37
|
label: INBOUND_PROBE_KEYS.label,
|
|
75
|
-
fixScreen:
|
|
38
|
+
fixScreen: MAIL_ACCOUNT_LIST_SCREEN_QN,
|
|
76
39
|
// The address is PII, so the target label carries only the tenant-authored display name.
|
|
77
40
|
targets: async (ctx) => {
|
|
78
|
-
const rows = await ctx.db.selectMany<
|
|
41
|
+
const rows = await ctx.db.selectMany<MailAccountRow>(mailAccountsProjectionTable, {
|
|
79
42
|
status: InboundMailAccountStatuses.active,
|
|
80
43
|
});
|
|
81
44
|
return rows.map((row) => ({
|
|
@@ -89,10 +52,10 @@ export function registerMailboxProbe(r: FeatureRegistrar<"inbound-mail-foundatio
|
|
|
89
52
|
},
|
|
90
53
|
run: async (ctx, target) => {
|
|
91
54
|
const row = target
|
|
92
|
-
? await ctx.db.fetchOne<
|
|
55
|
+
? await ctx.db.fetchOne<MailAccountRow>(mailAccountsProjectionTable, { id: target.id })
|
|
93
56
|
: undefined;
|
|
94
57
|
if (!row) return { status: "skipped", message: { key: INBOUND_PROBE_KEYS.gone } };
|
|
95
|
-
const account = await
|
|
58
|
+
const account = await toMailAccountRecord(row, ctx.tenantId, "inbound-mail-foundation:probe");
|
|
96
59
|
const providerCtx = providerContext(ctx);
|
|
97
60
|
const plugin = resolveInboundProviderForAccount(providerCtx, account);
|
|
98
61
|
try {
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
|
|
13
13
|
export const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
|
|
14
14
|
export const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
|
|
15
|
+
export const MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:mail-accounts";
|
|
16
|
+
// Registered by inbound-provider-imap, which needs the IMAP client to check the login. The
|
|
17
|
+
// renderer drops a navigate action whose target screen is not mounted, so the toolbar button
|
|
18
|
+
// only shows in apps that mount that provider.
|
|
19
|
+
export const IMAP_CONNECT_SCREEN_ID = "mail-account-connect-imap";
|
|
15
20
|
|
|
16
21
|
export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
|
|
17
22
|
|
|
@@ -73,6 +78,15 @@ export const mailAccountListScreen: ProjectionListScreenDefinition = {
|
|
|
73
78
|
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
74
79
|
},
|
|
75
80
|
],
|
|
81
|
+
toolbarActions: [
|
|
82
|
+
{
|
|
83
|
+
kind: "navigate",
|
|
84
|
+
id: "connect-imap",
|
|
85
|
+
label: i18nKey("inboundMail.accounts.connectImap"),
|
|
86
|
+
screen: IMAP_CONNECT_SCREEN_ID,
|
|
87
|
+
style: "primary",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
76
90
|
access: MAIL_ACCOUNT_ADMIN_ACCESS,
|
|
77
91
|
dormant: true,
|
|
78
92
|
};
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
|
|
8
|
+
import type { HandlerContext, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
failNotFound,
|
|
11
|
+
UnprocessableError,
|
|
12
|
+
writeFailure,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
14
|
+
import { type MailAccountRow, toMailAccountRecord } from "./account-record.ts";
|
|
15
|
+
import { InboundMailAccountStatuses } from "./constants.ts";
|
|
16
|
+
import { isVisibleToCaller } from "./handlers/scope-visibility.ts";
|
|
17
|
+
import {
|
|
18
|
+
isMailSendNotConfiguredError,
|
|
19
|
+
isMailSendRejectedError,
|
|
20
|
+
type OutboundMailMessage,
|
|
21
|
+
type OutboundMailSent,
|
|
22
|
+
outboundMailMessageSchema,
|
|
23
|
+
} from "./outbound-mail.ts";
|
|
24
|
+
import { mailAccountsProjectionTable } from "./projection.ts";
|
|
25
|
+
import { resolveInboundProviderForAccount } from "./provider-factory.ts";
|
|
26
|
+
import {
|
|
27
|
+
type InboundMailContext,
|
|
28
|
+
isInboundAuthError,
|
|
29
|
+
isInboundRateLimitError,
|
|
30
|
+
isInboundTransientError,
|
|
31
|
+
} from "./types.ts";
|
|
32
|
+
|
|
33
|
+
export const MAIL_SEND_ERRORS = {
|
|
34
|
+
notConfigured: {
|
|
35
|
+
reason: "mail_send_not_configured",
|
|
36
|
+
i18nKey: "inboundMail.send.errors.notConfigured",
|
|
37
|
+
},
|
|
38
|
+
loginRejected: {
|
|
39
|
+
reason: "mail_send_login_rejected",
|
|
40
|
+
i18nKey: "inboundMail.send.errors.loginRejected",
|
|
41
|
+
},
|
|
42
|
+
serverUnreachable: {
|
|
43
|
+
reason: "mail_send_server_unreachable",
|
|
44
|
+
i18nKey: "inboundMail.send.errors.serverUnreachable",
|
|
45
|
+
},
|
|
46
|
+
rejected: { reason: "mail_send_rejected", i18nKey: "inboundMail.send.errors.rejected" },
|
|
47
|
+
invalidMessage: {
|
|
48
|
+
reason: "mail_send_invalid_message",
|
|
49
|
+
i18nKey: "inboundMail.send.errors.invalidMessage",
|
|
50
|
+
},
|
|
51
|
+
} as const;
|
|
52
|
+
export type MailSendFailure = keyof typeof MAIL_SEND_ERRORS;
|
|
53
|
+
|
|
54
|
+
export type SendMailFromAccountContext = Pick<
|
|
55
|
+
HandlerContext,
|
|
56
|
+
"db" | "registry" | "user" | "services" | "obs"
|
|
57
|
+
>;
|
|
58
|
+
|
|
59
|
+
export type SendMailFromAccountInput = {
|
|
60
|
+
readonly accountId: string;
|
|
61
|
+
readonly message: OutboundMailMessage;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const HANDLER_NAME = "inbound-mail-foundation:send-from-account";
|
|
65
|
+
|
|
66
|
+
function sendFailure(failure: MailSendFailure): WriteResult<never> {
|
|
67
|
+
const { reason, i18nKey } = MAIL_SEND_ERRORS[failure];
|
|
68
|
+
return writeFailure(new UnprocessableError(reason, { i18nKey }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sendFailureOf(err: unknown): MailSendFailure | undefined {
|
|
72
|
+
if (isMailSendNotConfiguredError(err)) return "notConfigured";
|
|
73
|
+
if (isInboundAuthError(err)) return "loginRejected";
|
|
74
|
+
if (isInboundTransientError(err) || isInboundRateLimitError(err)) return "serverUnreachable";
|
|
75
|
+
if (isMailSendRejectedError(err)) return "rejected";
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A disconnected mailbox and someone else's personal mailbox are reported as missing, so the
|
|
80
|
+
// caller cannot tell them apart from an id that never existed.
|
|
81
|
+
function isSendableBy(row: MailAccountRow, user: SendMailFromAccountContext["user"]): boolean {
|
|
82
|
+
return row.status !== InboundMailAccountStatuses.disconnected && isVisibleToCaller(row, user);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function sendMailFromAccount(
|
|
86
|
+
ctx: SendMailFromAccountContext,
|
|
87
|
+
input: SendMailFromAccountInput,
|
|
88
|
+
): Promise<WriteResult<OutboundMailSent>> {
|
|
89
|
+
const message = outboundMailMessageSchema.safeParse(input.message);
|
|
90
|
+
if (!message.success) return sendFailure("invalidMessage");
|
|
91
|
+
|
|
92
|
+
const row = await ctx.db.fetchOne<MailAccountRow>(mailAccountsProjectionTable, {
|
|
93
|
+
id: input.accountId,
|
|
94
|
+
tenantId: ctx.user.tenantId,
|
|
95
|
+
});
|
|
96
|
+
if (!row || !isSendableBy(row, ctx.user)) return failNotFound("mail-account", input.accountId);
|
|
97
|
+
|
|
98
|
+
const account = await toMailAccountRecord(row, ctx.user.tenantId, `${HANDLER_NAME}:${row.id}`);
|
|
99
|
+
const providerCtx: InboundMailContext = {
|
|
100
|
+
registry: ctx.registry,
|
|
101
|
+
...(ctx.services.secrets && { secrets: ctx.services.secrets }),
|
|
102
|
+
auditUserId: ctx.user.id,
|
|
103
|
+
};
|
|
104
|
+
const plugin = resolveInboundProviderForAccount(providerCtx, account, HANDLER_NAME);
|
|
105
|
+
if (!plugin.send) return sendFailure("notConfigured");
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
return { isSuccess: true, data: await plugin.send(providerCtx, account, message.data) };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const failure = sendFailureOf(err);
|
|
111
|
+
if (!failure) throw err;
|
|
112
|
+
ctx.obs.log.warn("mail send from account failed", {
|
|
113
|
+
accountId: account.id,
|
|
114
|
+
reason: MAIL_SEND_ERRORS[failure].reason,
|
|
115
|
+
});
|
|
116
|
+
return sendFailure(failure);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
15
15
|
import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "./constants.ts";
|
|
16
|
+
import type { OutboundMailMessage, OutboundMailSent } from "./outbound-mail.ts";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Slim context for provider plugins.
|
|
@@ -186,6 +187,13 @@ export type InboundMailProviderPlugin = {
|
|
|
186
187
|
) => Promise<InboundFetchResult>;
|
|
187
188
|
/** Nur OAuth-Provider. */
|
|
188
189
|
readonly oauth?: InboundOAuthFlow;
|
|
190
|
+
/** Sends from the mailbox address through the customer's own server. Throws
|
|
191
|
+
* MailSendNotConfiguredError, InboundAuthError, InboundTransientError or MailSendRejectedError. */
|
|
192
|
+
readonly send?: (
|
|
193
|
+
ctx: InboundMailContext,
|
|
194
|
+
account: MailAccountRecord,
|
|
195
|
+
message: OutboundMailMessage,
|
|
196
|
+
) => Promise<OutboundMailSent>;
|
|
189
197
|
/** Optional live push (IMAP IDLE, later Graph/Gmail webhooks).
|
|
190
198
|
* Returns stop(). Polling remains reconciliation: watch optimizes
|
|
191
199
|
* latency but is never the correctness anchor. `onError` reports a dead
|