@cosmicdrift/kumiko-bundled-features 0.391.1 → 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.
Files changed (100) hide show
  1. package/dist/admin-shell/feature.d.ts +3 -0
  2. package/dist/admin-shell/feature.js +47 -30
  3. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -2
  5. package/dist/cap-overview/handlers/caps-usage.query.js +1 -0
  6. package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -2
  7. package/dist/cap-overview/i18n.js +4 -0
  8. package/dist/cap-overview/types.d.ts +3 -1
  9. package/dist/cap-overview/web/cap-amount-format.d.ts +3 -0
  10. package/dist/cap-overview/web/cap-amount-format.js +30 -0
  11. package/dist/cap-overview/web/cap-cards-panel.js +63 -23
  12. package/dist/cap-overview/web/cap-usage-bar.js +9 -4
  13. package/dist/cap-overview/web/cap-usage-cell.js +4 -2
  14. package/dist/cap-overview/web/usage-math.d.ts +1 -0
  15. package/dist/cap-overview/web/usage-math.js +4 -1
  16. package/dist/channel-email/app-transport.d.ts +14 -0
  17. package/dist/channel-email/app-transport.js +20 -0
  18. package/dist/channel-email/feature.js +6 -0
  19. package/dist/channel-email/index.d.ts +1 -0
  20. package/dist/channel-email/index.js +1 -0
  21. package/dist/channel-email/smtp-transport.js +2 -19
  22. package/dist/compliance-profiles/schema/profile-selection.d.ts +8 -8
  23. package/dist/foundation-shared/index.d.ts +1 -0
  24. package/dist/foundation-shared/index.js +1 -0
  25. package/dist/foundation-shared/mail-display-name.d.ts +1 -0
  26. package/dist/foundation-shared/mail-display-name.js +18 -0
  27. package/dist/inbound-mail-foundation/account-record.d.ts +13 -0
  28. package/dist/inbound-mail-foundation/account-record.js +27 -0
  29. package/dist/inbound-mail-foundation/feature.js +1 -1
  30. package/dist/inbound-mail-foundation/i18n.js +17 -0
  31. package/dist/inbound-mail-foundation/index.d.ts +3 -1
  32. package/dist/inbound-mail-foundation/index.js +3 -1
  33. package/dist/inbound-mail-foundation/outbound-mail.d.ts +28 -0
  34. package/dist/inbound-mail-foundation/outbound-mail.js +52 -0
  35. package/dist/inbound-mail-foundation/probe.js +5 -28
  36. package/dist/inbound-mail-foundation/screens.d.ts +2 -0
  37. package/dist/inbound-mail-foundation/screens.js +14 -0
  38. package/dist/inbound-mail-foundation/send-from-account.d.ts +31 -0
  39. package/dist/inbound-mail-foundation/send-from-account.js +87 -0
  40. package/dist/inbound-mail-foundation/types.d.ts +4 -0
  41. package/dist/inbound-mail-foundation/watch-supervisor.js +10 -8
  42. package/dist/inbound-provider-imap/connect-mailbox.d.ts +13 -0
  43. package/dist/inbound-provider-imap/connect-mailbox.js +124 -0
  44. package/dist/inbound-provider-imap/constants.d.ts +41 -0
  45. package/dist/inbound-provider-imap/constants.js +39 -0
  46. package/dist/inbound-provider-imap/credential-document.d.ts +6 -0
  47. package/dist/inbound-provider-imap/credential-document.js +31 -1
  48. package/dist/inbound-provider-imap/feature.d.ts +1 -1
  49. package/dist/inbound-provider-imap/feature.js +13 -22
  50. package/dist/inbound-provider-imap/i18n.d.ts +3 -0
  51. package/dist/inbound-provider-imap/i18n.js +45 -0
  52. package/dist/inbound-provider-imap/imap-client.d.ts +1 -0
  53. package/dist/inbound-provider-imap/imap-client.js +1 -1
  54. package/dist/inbound-provider-imap/screens.d.ts +2 -0
  55. package/dist/inbound-provider-imap/screens.js +39 -0
  56. package/dist/inbound-provider-imap/send-mail.d.ts +2 -0
  57. package/dist/inbound-provider-imap/send-mail.js +48 -0
  58. package/dist/inbound-provider-imap/sent-folder.d.ts +8 -0
  59. package/dist/inbound-provider-imap/sent-folder.js +40 -0
  60. package/dist/inbound-provider-imap/smtp-client.d.ts +35 -0
  61. package/dist/inbound-provider-imap/smtp-client.js +106 -0
  62. package/dist/ledger/entity.d.ts +8 -8
  63. package/dist/mail-foundation/i18n.d.ts +9 -1
  64. package/dist/mail-foundation/i18n.js +9 -1
  65. package/dist/mail-foundation/probe.js +32 -16
  66. package/dist/readiness/built-in-probes.d.ts +2 -1
  67. package/dist/readiness/built-in-probes.js +12 -1
  68. package/dist/readiness/constants.d.ts +5 -0
  69. package/dist/readiness/constants.js +1 -0
  70. package/dist/readiness/i18n.js +7 -1
  71. package/dist/readiness/index.d.ts +1 -1
  72. package/dist/readiness/index.js +1 -1
  73. package/dist/readiness/schema/probe-run.d.ts +8 -8
  74. package/dist/readiness/screens.d.ts +3 -0
  75. package/dist/readiness/screens.js +25 -2
  76. package/dist/readiness/visible-results.d.ts +2 -1
  77. package/dist/template-resolver/table.d.ts +36 -36
  78. package/dist/template-resolver/user-content-table.d.ts +26 -26
  79. package/dist/tenant/invitation-table.d.ts +8 -8
  80. package/dist/tenant/schema/tenant.d.ts +10 -10
  81. package/dist/tier-engine/entity.d.ts +3 -3
  82. package/dist/tier-engine/feature.js +8 -4
  83. package/dist/tier-engine/tier-resolver.d.ts +3 -3
  84. package/dist/user/schema/user.d.ts +8 -8
  85. package/dist/user-data-rights/handlers/download-by-token.query.js +3 -3
  86. package/dist/user-data-rights/schema/download-token.js +5 -3
  87. package/dist/user-data-rights/schema/export-job.d.ts +8 -8
  88. package/dist/user-data-rights/token-helpers.d.ts +1 -2
  89. package/dist/user-data-rights/token-helpers.js +5 -4
  90. package/dist/waitlist/entity.d.ts +8 -8
  91. package/package.json +11 -11
  92. package/src/admin-shell/changes.json +6 -0
  93. package/src/cap-overview/changes.json +12 -0
  94. package/src/channel-email/changes.json +6 -0
  95. package/src/foundation-shared/changes.json +6 -0
  96. package/src/inbound-mail-foundation/changes.json +18 -0
  97. package/src/inbound-provider-imap/changes.json +12 -0
  98. package/src/mail-foundation/changes.json +6 -0
  99. package/src/readiness/changes.json +30 -0
  100. package/src/tier-engine/changes.json +6 -0
@@ -0,0 +1,124 @@
1
+ // connect-mailbox: the form path for a password IMAP mailbox. The IMAP login and then the SMTP
2
+ // login of the outgoing server are checked before the transaction opens; only then the account
3
+ // stream and its credential secret are written, both on the request transaction, so a failure
4
+ // leaves neither behind.
5
+ import { InboundAuthError, InboundMailAuthMethods, InboundMailFoundationHandlers, inboundCredentialSecretKey, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
6
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
7
+ import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
+ import * as z from "zod";
10
+ import { IMAP_CONNECT_ERRORS, IMAP_PROVIDER_KEY, SMTP_CONNECT_ERRORS } from "./constants.js";
11
+ import { imapCredentialDocumentSchema, SMTP_IMPLICIT_TLS_PORT, smtpHostSchema, } from "./credential-document.js";
12
+ import { connectOnce, IMAP_HOST_UNREACHABLE_MESSAGE } from "./imap-client.js";
13
+ import { SmtpFailureError, verifySmtpLogin } from "./smtp-client.js";
14
+ // No `secure` and no SMTP port input: the password never crosses the wire in clear text, so a
15
+ // payload's secure:false or smtpPort is stripped like any unknown key and both logins always use
16
+ // implicit TLS (SMTP on port 465).
17
+ const connectMailboxSchema = imapCredentialDocumentSchema
18
+ .pick({ host: true, port: true, user: true })
19
+ .extend({
20
+ displayName: z.string().min(1).max(200),
21
+ address: z.email().max(320),
22
+ password: z.string().min(1).max(1000),
23
+ smtpHost: z.union([z.literal(""), smtpHostSchema]).nullish(),
24
+ });
25
+ const connectedAccountSchema = z.object({ accountId: z.string() });
26
+ // Matched on the mapped error text, which also carries the host, so only markers no host name
27
+ // contains: a bare "tls" or "ssl" would misread a host like ssl.example.com.
28
+ // BoringSSL (Bun) spells the handshake error WRONG_VERSION_NUMBER, OpenSSL (Node) with spaces.
29
+ const TLS_FAILURE = /certificate|self[- ]signed|wrong[ _]version[ _]number|EPROTO|ERR_SSL|ERR_TLS/i;
30
+ // A fixed preview: any slice of the JSON document would expose the host or the user.
31
+ const CREDENTIAL_PREVIEW = "IMAP login";
32
+ const SECRET_TRANSACTION_REASON = "the credential secret is written on the request transaction so a failed account connect rolls it back too";
33
+ // Most hosters name the outgoing server like the incoming one, or with smtp. for imap.
34
+ export function deriveSmtpHost(imapHost) {
35
+ return /^imap\./i.test(imapHost) ? `smtp.${imapHost.slice("imap.".length)}` : imapHost;
36
+ }
37
+ function credentialDocumentOf(payload) {
38
+ const { host, port, user, password } = payload;
39
+ const smtpHost = payload.smtpHost || deriveSmtpHost(host);
40
+ return { host, port, secure: true, user, password, smtpHost, smtpPort: SMTP_IMPLICIT_TLS_PORT };
41
+ }
42
+ // The raw error carries the host and the server response; the tenant only gets the category.
43
+ // A blocked host and an unresolvable one share one text on purpose (see createImapClient).
44
+ function imapConnectFailureOf(err) {
45
+ const message = err instanceof Error ? err.message : "";
46
+ if (message === IMAP_HOST_UNREACHABLE_MESSAGE)
47
+ return "hostUnreachable";
48
+ if (err instanceof InboundAuthError)
49
+ return "loginRejected";
50
+ if (TLS_FAILURE.test(message))
51
+ return "tlsFailed";
52
+ return "connectFailed";
53
+ }
54
+ // verify never sends a mail, so a refused message cannot happen here; anything else answering
55
+ // with an SMTP reply is a server we could not use.
56
+ function smtpConnectFailureOf(failure) {
57
+ return failure === "rejected" ? "connectFailed" : failure;
58
+ }
59
+ async function checkSmtpLogin(doc) {
60
+ const { smtpHost, smtpPort, user, password } = doc;
61
+ // A derived host from an odd IMAP host name could not be stored as a valid document.
62
+ if (!smtpHost || !password || !smtpHostSchema.safeParse(smtpHost).success) {
63
+ return "hostUnreachable";
64
+ }
65
+ try {
66
+ await verifySmtpLogin({ host: smtpHost, port: smtpPort, user, password });
67
+ return undefined;
68
+ }
69
+ catch (err) {
70
+ if (err instanceof SmtpFailureError)
71
+ return smtpConnectFailureOf(err.failure);
72
+ throw err;
73
+ }
74
+ }
75
+ export const connectMailboxHandler = defineWriteHandler({
76
+ name: "connect-mailbox",
77
+ description: "Connects a shared IMAP mailbox with a password over implicit TLS: checks the IMAP login and the SMTP login of the outgoing server (given, or derived from the IMAP host) first, then creates the mail account and stores both servers, user and password as its credential secret in one step.",
78
+ // The payload carries the plaintext password: as a tool call it would reach the model provider.
79
+ agent: { expose: false },
80
+ schema: connectMailboxSchema,
81
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
82
+ // Each call opens a connection to a host the caller picks; the cap keeps the form from being
83
+ // used to scan ports or to guess passwords at someone else's mail server.
84
+ rateLimit: { per: "tenant+handler", limit: 10, windowSeconds: 600 },
85
+ escapeHatch: { grants: ["unsafeRaw"], reason: SECRET_TRANSACTION_REASON },
86
+ beforeTransaction: async (event, ctx) => {
87
+ const doc = credentialDocumentOf(event.payload);
88
+ try {
89
+ await connectOnce(doc, true);
90
+ }
91
+ catch (err) {
92
+ const { reason, i18nKey } = IMAP_CONNECT_ERRORS[imapConnectFailureOf(err)];
93
+ ctx.obs.log.warn("imap connect check failed", { reason });
94
+ return writeFailure(new UnprocessableError(reason, { i18nKey }));
95
+ }
96
+ const smtpFailure = await checkSmtpLogin(doc);
97
+ if (smtpFailure) {
98
+ const { reason, i18nKey } = SMTP_CONNECT_ERRORS[smtpFailure];
99
+ ctx.obs.log.warn("smtp connect check failed", { reason });
100
+ return writeFailure(new UnprocessableError(reason, { i18nKey }));
101
+ }
102
+ return { isSuccess: true, data: null };
103
+ },
104
+ handler: async (event, ctx) => {
105
+ const { displayName, address } = event.payload;
106
+ const connected = await ctx.dispatch.write(InboundMailFoundationHandlers.connectAccount, {
107
+ provider: IMAP_PROVIDER_KEY,
108
+ authMethod: InboundMailAuthMethods.password,
109
+ displayName,
110
+ address,
111
+ scope: "shared",
112
+ });
113
+ if (!connected.isSuccess)
114
+ return connected;
115
+ const { accountId } = connectedAccountSchema.parse(connected.data);
116
+ const secrets = requireSecretsContext({ secrets: ctx.services.secrets, user: ctx.user }, "connect-mailbox").withRunner(ctx.db.unsafeRaw());
117
+ await secrets.set(event.user.tenantId, inboundCredentialSecretKey(accountId), JSON.stringify(credentialDocumentOf(event.payload)), {
118
+ redact: () => CREDENTIAL_PREVIEW,
119
+ hint: `IMAP credentials for inbound mail account ${accountId}`,
120
+ updatedBy: event.user.id,
121
+ });
122
+ return { isSuccess: true, data: { accountId } };
123
+ },
124
+ });
@@ -0,0 +1,41 @@
1
+ export declare const FEATURE_NAME = "inbound-provider-imap";
2
+ export declare const IMAP_PROVIDER_KEY = "imap";
3
+ export declare const ImapHandlers: {
4
+ readonly connectMailbox: "inbound-provider-imap:write:connect-mailbox";
5
+ };
6
+ export declare const IMAP_CONNECT_ERRORS: {
7
+ readonly hostUnreachable: {
8
+ readonly reason: "imap_host_unreachable";
9
+ readonly i18nKey: "inboundMail.imap.connect.errors.hostUnreachable";
10
+ };
11
+ readonly loginRejected: {
12
+ readonly reason: "imap_login_rejected";
13
+ readonly i18nKey: "inboundMail.imap.connect.errors.loginRejected";
14
+ };
15
+ readonly tlsFailed: {
16
+ readonly reason: "imap_tls_failed";
17
+ readonly i18nKey: "inboundMail.imap.connect.errors.tlsFailed";
18
+ };
19
+ readonly connectFailed: {
20
+ readonly reason: "imap_connect_failed";
21
+ readonly i18nKey: "inboundMail.imap.connect.errors.connectFailed";
22
+ };
23
+ };
24
+ export declare const SMTP_CONNECT_ERRORS: {
25
+ readonly hostUnreachable: {
26
+ readonly reason: "smtp_host_unreachable";
27
+ readonly i18nKey: "inboundMail.imap.connect.errors.smtpHostUnreachable";
28
+ };
29
+ readonly loginRejected: {
30
+ readonly reason: "smtp_login_rejected";
31
+ readonly i18nKey: "inboundMail.imap.connect.errors.smtpLoginRejected";
32
+ };
33
+ readonly tlsFailed: {
34
+ readonly reason: "smtp_tls_failed";
35
+ readonly i18nKey: "inboundMail.imap.connect.errors.smtpTlsFailed";
36
+ };
37
+ readonly connectFailed: {
38
+ readonly reason: "smtp_connect_failed";
39
+ readonly i18nKey: "inboundMail.imap.connect.errors.smtpConnectFailed";
40
+ };
41
+ };
@@ -0,0 +1,39 @@
1
+ export const FEATURE_NAME = "inbound-provider-imap";
2
+ export const IMAP_PROVIDER_KEY = "imap";
3
+ export const ImapHandlers = {
4
+ connectMailbox: "inbound-provider-imap:write:connect-mailbox",
5
+ };
6
+ export const IMAP_CONNECT_ERRORS = {
7
+ hostUnreachable: {
8
+ reason: "imap_host_unreachable",
9
+ i18nKey: "inboundMail.imap.connect.errors.hostUnreachable",
10
+ },
11
+ loginRejected: {
12
+ reason: "imap_login_rejected",
13
+ i18nKey: "inboundMail.imap.connect.errors.loginRejected",
14
+ },
15
+ tlsFailed: { reason: "imap_tls_failed", i18nKey: "inboundMail.imap.connect.errors.tlsFailed" },
16
+ connectFailed: {
17
+ reason: "imap_connect_failed",
18
+ i18nKey: "inboundMail.imap.connect.errors.connectFailed",
19
+ },
20
+ };
21
+ // Same categories as IMAP_CONNECT_ERRORS, for the outgoing server checked after the IMAP login.
22
+ export const SMTP_CONNECT_ERRORS = {
23
+ hostUnreachable: {
24
+ reason: "smtp_host_unreachable",
25
+ i18nKey: "inboundMail.imap.connect.errors.smtpHostUnreachable",
26
+ },
27
+ loginRejected: {
28
+ reason: "smtp_login_rejected",
29
+ i18nKey: "inboundMail.imap.connect.errors.smtpLoginRejected",
30
+ },
31
+ tlsFailed: {
32
+ reason: "smtp_tls_failed",
33
+ i18nKey: "inboundMail.imap.connect.errors.smtpTlsFailed",
34
+ },
35
+ connectFailed: {
36
+ reason: "smtp_connect_failed",
37
+ i18nKey: "inboundMail.imap.connect.errors.smtpConnectFailed",
38
+ },
39
+ };
@@ -1,4 +1,7 @@
1
+ import { type InboundMailContext, type MailAccountRecord } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
1
2
  import * as z from "zod";
3
+ export declare const SMTP_IMPLICIT_TLS_PORT = 465;
4
+ export declare const smtpHostSchema: z.ZodString;
2
5
  export declare const imapCredentialDocumentSchema: z.ZodObject<{
3
6
  host: z.ZodString;
4
7
  port: z.ZodDefault<z.ZodNumber>;
@@ -6,6 +9,8 @@ export declare const imapCredentialDocumentSchema: z.ZodObject<{
6
9
  user: z.ZodString;
7
10
  password: z.ZodOptional<z.ZodString>;
8
11
  accessToken: z.ZodOptional<z.ZodString>;
12
+ smtpHost: z.ZodOptional<z.ZodString>;
13
+ smtpPort: z.ZodDefault<z.ZodNumber>;
9
14
  }, z.core.$strip>;
10
15
  export type ImapCredentialDocument = z.infer<typeof imapCredentialDocumentSchema>;
11
16
  export type ImapCredentialRejection = "not_json" | "invalid_schema" | "missing_credentials";
@@ -18,3 +23,4 @@ export type ParseCredentialResult = {
18
23
  readonly detail?: string;
19
24
  };
20
25
  export declare function parseImapCredentialDocument(raw: string): ParseCredentialResult;
26
+ export declare function readCredentialDocument(ctx: InboundMailContext, account: MailAccountRecord): Promise<ImapCredentialDocument>;
@@ -9,12 +9,27 @@
9
9
  // at rest.
10
10
  //
11
11
  // The account's authMethod determines the interpretation:
12
- // password → { host, port, secure, user, password }
12
+ // password → { host, port, secure, user, password, smtpHost?, smtpPort }
13
13
  // xoauth2 → { host, port, secure, user, accessToken } — V1 fallback:
14
14
  // OAuth providers (M365/Gmail) handle token acquisition and
15
15
  // refresh; the imap provider only consumes a valid access
16
16
  // token from the slot.
17
+ //
18
+ // smtpHost is the outgoing server for sending from the mailbox, with the same user and password
19
+ // over implicit TLS. Documents written before sending existed have none: sending reports
20
+ // "not set up" instead of guessing a host.
21
+ import { InboundAuthError, inboundCredentialSecretKey, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
22
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
17
23
  import * as z from "zod";
24
+ import { FEATURE_NAME } from "./constants.js";
25
+ export const SMTP_IMPLICIT_TLS_PORT = 465;
26
+ // A bare host name: a port, path or whitespace would point the connect somewhere the guard did
27
+ // not check.
28
+ export const smtpHostSchema = z
29
+ .string()
30
+ .min(1)
31
+ .max(255)
32
+ .regex(/^[^\s:/@]+$/);
18
33
  export const imapCredentialDocumentSchema = z.object({
19
34
  host: z.string().min(1).max(255),
20
35
  port: z.number().int().min(1).max(65535).default(993),
@@ -22,6 +37,8 @@ export const imapCredentialDocumentSchema = z.object({
22
37
  user: z.string().min(1).max(320),
23
38
  password: z.string().min(1).optional(),
24
39
  accessToken: z.string().min(1).optional(),
40
+ smtpHost: smtpHostSchema.optional(),
41
+ smtpPort: z.number().int().min(1).max(65535).default(SMTP_IMPLICIT_TLS_PORT),
25
42
  });
26
43
  export function parseImapCredentialDocument(raw) {
27
44
  let json;
@@ -40,3 +57,16 @@ export function parseImapCredentialDocument(raw) {
40
57
  }
41
58
  return { ok: true, doc: parsed.data };
42
59
  }
60
+ export async function readCredentialDocument(ctx, account) {
61
+ const secrets = requireSecretsContext({ secrets: ctx.secrets, auditUserId: ctx.auditUserId }, FEATURE_NAME);
62
+ const secret = await secrets.get(account.tenantId, inboundCredentialSecretKey(account.id));
63
+ if (!secret) {
64
+ throw new InboundAuthError(`${FEATURE_NAME}: no credential document in secret slot for account ${account.id} — ` +
65
+ `store the IMAP connection JSON under ${inboundCredentialSecretKey(account.id)} first`);
66
+ }
67
+ const parsed = parseImapCredentialDocument(secret.reveal());
68
+ if (!parsed.ok) {
69
+ throw new InboundAuthError(`${FEATURE_NAME}: account ${account.id}: ${parsed.reason}${parsed.detail ? `: ${parsed.detail}` : ""}`);
70
+ }
71
+ return parsed.doc;
72
+ }
@@ -1,5 +1,5 @@
1
1
  import { type InboundMailProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
- export declare const IMAP_PROVIDER_KEY = "imap";
2
+ export { IMAP_PROVIDER_KEY } from "./constants.ts";
3
3
  export { setImapMailHostLookup } from "./imap-client.ts";
4
4
  /** Exported for the live test (imap-live.integration.test.ts) —
5
5
  * applications use the feature, never the plugin directly. */
@@ -23,33 +23,20 @@
23
23
  // (credential-document.ts), not tenant configuration, because one tenant
24
24
  // can have multiple accounts with different hosts.
25
25
  import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
26
- import { INBOUND_MAIL_PROVIDER_EXTENSION, InboundAuthError, inboundCredentialSecretKey, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
27
- import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
26
+ import { INBOUND_MAIL_PROVIDER_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
28
27
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
29
28
  import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
30
29
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
31
- import { parseImapCredentialDocument } from "./credential-document.js";
30
+ import { connectMailboxHandler } from "./connect-mailbox.js";
31
+ import { FEATURE_NAME, IMAP_PROVIDER_KEY } from "./constants.js";
32
+ import { readCredentialDocument } from "./credential-document.js";
33
+ import { INBOUND_PROVIDER_IMAP_I18N } from "./i18n.js";
32
34
  import { assertUidValidity, coerceDate, connectOnce, createImapClient, IMAP_MAILBOX, imapMailHostGuard, mapImapError, parseImapCursor, toRawInboundMessage, } from "./imap-client.js";
33
- const FEATURE_NAME = "inbound-provider-imap";
34
- export const IMAP_PROVIDER_KEY = "imap";
35
+ import { imapConnectScreen } from "./screens.js";
36
+ import { sendFromImapAccount } from "./send-mail.js";
37
+ export { IMAP_PROVIDER_KEY } from "./constants.js";
35
38
  export { setImapMailHostLookup } from "./imap-client.js";
36
39
  // =============================================================================
37
- // Credential-Read — per-Account-Slot, Worker-tauglich (slim ctx).
38
- // =============================================================================
39
- async function readCredentialDocument(ctx, account) {
40
- const secrets = requireSecretsContext({ secrets: ctx.secrets, auditUserId: ctx.auditUserId }, FEATURE_NAME);
41
- const secret = await secrets.get(account.tenantId, inboundCredentialSecretKey(account.id));
42
- if (!secret) {
43
- throw new InboundAuthError(`${FEATURE_NAME}: no credential document in secret slot for account ${account.id} — ` +
44
- `store the IMAP connection JSON under ${inboundCredentialSecretKey(account.id)} first`);
45
- }
46
- const parsed = parseImapCredentialDocument(secret.reveal());
47
- if (!parsed.ok) {
48
- throw new InboundAuthError(`${FEATURE_NAME}: account ${account.id}: ${parsed.reason}${parsed.detail ? `: ${parsed.detail}` : ""}`);
49
- }
50
- return parsed.doc;
51
- }
52
- // =============================================================================
53
40
  // fetch — inkrementeller UID-Sync
54
41
  // =============================================================================
55
42
  async function fetchMessages(ctx, account, cursor, opts) {
@@ -204,13 +191,14 @@ export const imapInboundMailPlugin = {
204
191
  probe: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), true),
205
192
  fetch: fetchMessages,
206
193
  watch: watchMailbox,
194
+ send: sendFromImapAccount,
207
195
  // No oauth block: password authentication runs through the credential
208
196
  // form (connect-account + Secret-Write); XOAUTH2 token flows belong to
209
197
  // the OAuth providers (M365/Gmail).
210
198
  };
211
199
  export function createInboundProviderImapFeature() {
212
200
  return defineFeature(FEATURE_NAME, (r) => {
213
- r.describe('Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Store the per-account connection JSON (host/port/secure/user/password) in the account credential secret slot before the first sync.');
201
+ r.describe('Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a mailbox through the "Connect mailbox" form on the mail accounts list (`connect-mailbox` checks the IMAP login and the SMTP login of the outgoing server first, then creates the account and its credential secret in one transaction); programmatic setups store the per-account connection JSON (host/port/secure/user/password, plus smtpHost/smtpPort for sending) in the account credential secret slot before the first sync. Implements the provider `send`: `sendMailFromAccount` sends over the SMTP server of the mailbox itself (implicit TLS, same login) with the mailbox address as From and stores a copy in its sent folder.');
214
202
  r.uiHints({
215
203
  displayLabel: "Inbound Mail · IMAP",
216
204
  category: "notifications",
@@ -220,5 +208,8 @@ export function createInboundProviderImapFeature() {
220
208
  r.requires({ features: ["secrets"] });
221
209
  r.envSchema(mailAllowedPrivateHostsEnvSchema);
222
210
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
211
+ r.writeHandler(connectMailboxHandler);
212
+ r.screen(imapConnectScreen);
213
+ r.translations({ keys: INBOUND_PROVIDER_IMAP_I18N });
223
214
  }, { dedupeOptions: {} });
224
215
  }
@@ -0,0 +1,3 @@
1
+ export declare const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, {
2
+ readonly en: string;
3
+ }>>;
@@ -0,0 +1,45 @@
1
+ import { IMAP_CONNECT_SCREEN_ID } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
+ import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
4
+ import { FEATURE_NAME, IMAP_CONNECT_ERRORS, SMTP_CONNECT_ERRORS } from "./constants.js";
5
+ const formFieldKey = (field) => fieldLabelKey(FEATURE_NAME, ACTION_FORM_ENTITY, field);
6
+ export const INBOUND_PROVIDER_IMAP_I18N = {
7
+ [`screen:${IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
8
+ [formFieldKey("displayName")]: { en: "Name" },
9
+ [formFieldKey("address")]: { en: "Email address" },
10
+ [formFieldKey("host")]: { en: "IMAP server" },
11
+ [formFieldKey("port")]: { en: "Port" },
12
+ [formFieldKey("user")]: { en: "User name" },
13
+ [formFieldKey("password")]: { en: "Password" },
14
+ [formFieldKey("smtpHost")]: { en: "Outgoing server (SMTP)" },
15
+ "inboundMail.imap.connect.server": { en: "Server sign-in" },
16
+ "inboundMail.imap.connect.outgoing": { en: "Sending" },
17
+ "inboundMail.imap.connect.outgoingHint": {
18
+ en: "Replies go out from this address over your own mail server, with the same user name and password. Leave the field empty if the server is called like the IMAP server, with smtp. instead of imap.",
19
+ },
20
+ "inboundMail.imap.connect.submit": { en: "Check and connect" },
21
+ [IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
22
+ en: "The IMAP server cannot be reached or is not allowed. Check the server name.",
23
+ },
24
+ [IMAP_CONNECT_ERRORS.loginRejected.i18nKey]: {
25
+ en: "The IMAP server rejected the sign-in. Check the user name and password.",
26
+ },
27
+ [IMAP_CONNECT_ERRORS.tlsFailed.i18nKey]: {
28
+ en: "The encrypted connection failed. Check the port and the TLS setting.",
29
+ },
30
+ [IMAP_CONNECT_ERRORS.connectFailed.i18nKey]: {
31
+ en: "No connection to the IMAP server. Check the server name and port.",
32
+ },
33
+ [SMTP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
34
+ en: "Receiving works, but sending does not: the outgoing server cannot be reached or is not allowed. Check its name.",
35
+ },
36
+ [SMTP_CONNECT_ERRORS.loginRejected.i18nKey]: {
37
+ en: "Receiving works, but sending does not: the outgoing server rejected the sign-in.",
38
+ },
39
+ [SMTP_CONNECT_ERRORS.tlsFailed.i18nKey]: {
40
+ en: "Receiving works, but sending does not: the encrypted connection to the outgoing server on port 465 failed.",
41
+ },
42
+ [SMTP_CONNECT_ERRORS.connectFailed.i18nKey]: {
43
+ en: "Receiving works, but sending does not: no connection to the outgoing server on port 465.",
44
+ },
45
+ };
@@ -3,6 +3,7 @@ import { type RawInboundMessage, type SyncCursorPayload } from "@cosmicdrift/kum
3
3
  import { ImapFlow } from "imapflow";
4
4
  import type { ImapCredentialDocument } from "./credential-document.ts";
5
5
  export declare const IMAP_MAILBOX = "INBOX";
6
+ export declare const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed";
6
7
  export declare function setImapMailHostLookup(fn: MailHostGuardOptions["lookupFn"]): void;
7
8
  export declare function imapMailHostGuard(): MailHostGuardOptions;
8
9
  export declare function createImapClient(doc: ImapCredentialDocument, hostGuard?: MailHostGuardOptions): Promise<ImapFlow>;
@@ -16,7 +16,7 @@ const log = createFallbackLogger("inbound-provider-imap");
16
16
  // was blocked (private/reserved range) or merely failed to resolve, and
17
17
  // must never include the host or the underlying error text. Server-side
18
18
  // detail goes to `log` only.
19
- const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed";
19
+ export const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed";
20
20
  // =============================================================================
21
21
  // Client-Factory + Fehler-Mapping
22
22
  // =============================================================================
@@ -0,0 +1,2 @@
1
+ import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ export declare const imapConnectScreen: ActionFormScreenDefinition;
@@ -0,0 +1,39 @@
1
+ import { IMAP_CONNECT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_QN, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
+ import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { ImapHandlers } from "./constants.js";
4
+ // Reached from the toolbar of the foundation's mail account list, never from the nav.
5
+ export const imapConnectScreen = {
6
+ id: IMAP_CONNECT_SCREEN_ID,
7
+ type: "actionForm",
8
+ agent: { expose: false },
9
+ handler: ImapHandlers.connectMailbox,
10
+ fields: {
11
+ displayName: { type: "text", required: true, maxLength: 200 },
12
+ address: { type: "text", required: true, maxLength: 320, format: "email" },
13
+ host: { type: "text", required: true, maxLength: 255 },
14
+ port: { type: "number", integer: true, required: true, min: 1, max: 65535, default: 993 },
15
+ user: { type: "text", required: true, maxLength: 320 },
16
+ password: { type: "text", required: true, format: "password", sensitive: true },
17
+ smtpHost: { type: "text", maxLength: 255 },
18
+ },
19
+ layout: {
20
+ sections: [
21
+ // The name-based icon guess reads "address" as a postal address.
22
+ { fields: ["displayName", { field: "address", icon: "mail" }] },
23
+ {
24
+ title: i18nKey("inboundMail.imap.connect.server"),
25
+ fields: ["host", "port", "user", "password"],
26
+ },
27
+ {
28
+ title: i18nKey("inboundMail.imap.connect.outgoing"),
29
+ description: i18nKey("inboundMail.imap.connect.outgoingHint"),
30
+ fields: ["smtpHost"],
31
+ },
32
+ ],
33
+ },
34
+ submitLabel: i18nKey("inboundMail.imap.connect.submit"),
35
+ redirect: MAIL_ACCOUNT_LIST_SCREEN_QN,
36
+ cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_QN,
37
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
38
+ dormant: true,
39
+ };
@@ -0,0 +1,2 @@
1
+ import { type InboundMailContext, type MailAccountRecord, type OutboundMailMessage, type OutboundMailSent } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
+ export declare function sendFromImapAccount(ctx: InboundMailContext, account: MailAccountRecord, message: OutboundMailMessage): Promise<OutboundMailSent>;
@@ -0,0 +1,48 @@
1
+ // send for the imap provider: SMTP with the mailbox's own login, From = the mailbox address.
2
+ // The MIME is built once, so the copy in the sent folder is byte for byte what went out.
3
+ import { sanitizeMailDisplayName } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
4
+ import { InboundAuthError, InboundTransientError, MailSendNotConfiguredError, MailSendRejectedError, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
5
+ import MailComposer from "nodemailer/lib/mail-composer";
6
+ import { FEATURE_NAME } from "./constants.js";
7
+ import { readCredentialDocument } from "./credential-document.js";
8
+ import { appendToSentFolder } from "./sent-folder.js";
9
+ import { SmtpFailureError, sendRawViaSmtp } from "./smtp-client.js";
10
+ function toFoundationSendError(err) {
11
+ switch (err.failure) {
12
+ case "loginRejected":
13
+ return new InboundAuthError(err.message);
14
+ case "rejected":
15
+ return new MailSendRejectedError(err.message);
16
+ default:
17
+ return new InboundTransientError(err.message);
18
+ }
19
+ }
20
+ function stripAngles(id) {
21
+ return id.replace(/^<|>$/g, "");
22
+ }
23
+ export async function sendFromImapAccount(ctx, account, message) {
24
+ const doc = await readCredentialDocument(ctx, account);
25
+ if (!doc.smtpHost || !doc.password) {
26
+ throw new MailSendNotConfiguredError(`${FEATURE_NAME}: account ${account.id} has no outgoing server stored`);
27
+ }
28
+ const displayName = sanitizeMailDisplayName(account.displayName);
29
+ const mime = new MailComposer({
30
+ from: displayName ? { name: displayName, address: account.address } : account.address,
31
+ to: [...message.to],
32
+ ...(message.cc && { cc: [...message.cc] }),
33
+ subject: message.subject,
34
+ ...(message.text !== undefined && { text: message.text }),
35
+ ...(message.html !== undefined && { html: message.html }),
36
+ ...(message.inReplyTo && { inReplyTo: message.inReplyTo }),
37
+ ...(message.references && { references: [...message.references] }),
38
+ }).compile();
39
+ const raw = await mime.build();
40
+ try {
41
+ await sendRawViaSmtp({ host: doc.smtpHost, port: doc.smtpPort, user: doc.user, password: doc.password }, { from: account.address, to: [...message.to, ...(message.cc ?? [])] }, raw);
42
+ }
43
+ catch (err) {
44
+ throw err instanceof SmtpFailureError ? toFoundationSendError(err) : err;
45
+ }
46
+ await appendToSentFolder(doc, raw, account.id);
47
+ return { messageIdHeader: stripAngles(mime.messageId()) };
48
+ }
@@ -0,0 +1,8 @@
1
+ import type { ImapCredentialDocument } from "./credential-document.ts";
2
+ type ListedMailbox = {
3
+ readonly path: string;
4
+ readonly specialUse?: string;
5
+ };
6
+ export declare function pickSentMailboxPath(mailboxes: readonly ListedMailbox[]): string | undefined;
7
+ export declare function appendToSentFolder(doc: ImapCredentialDocument, raw: Buffer, accountId: string): Promise<void>;
8
+ export {};
@@ -0,0 +1,40 @@
1
+ // Stores the copy of a sent mail in the mailbox's sent folder, so the customer finds the reply in
2
+ // their own mail client. Best effort: the mail is already out, a failed copy is only logged.
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { createImapClient, imapMailHostGuard } from "./imap-client.js";
5
+ const log = createFallbackLogger("inbound-provider-imap");
6
+ const SENT_SPECIAL_USE = "\\Sent";
7
+ const SENT_FALLBACK_PATHS = ["Sent", "Gesendet", "INBOX.Sent"];
8
+ export function pickSentMailboxPath(mailboxes) {
9
+ const flagged = mailboxes.find((mailbox) => mailbox.specialUse === SENT_SPECIAL_USE);
10
+ if (flagged)
11
+ return flagged.path;
12
+ for (const name of SENT_FALLBACK_PATHS) {
13
+ const match = mailboxes.find((mailbox) => mailbox.path.toLowerCase() === name.toLowerCase());
14
+ if (match)
15
+ return match.path;
16
+ }
17
+ return undefined;
18
+ }
19
+ export async function appendToSentFolder(doc, raw, accountId) {
20
+ let client;
21
+ try {
22
+ client = await createImapClient(doc, imapMailHostGuard());
23
+ await client.connect();
24
+ const path = pickSentMailboxPath(await client.list());
25
+ if (!path) {
26
+ log.warn("mailbox has no sent folder, sent copy not stored", { accountId });
27
+ return;
28
+ }
29
+ await client.append(path, raw, ["\\Seen"]);
30
+ }
31
+ catch (err) {
32
+ log.warn("storing the sent copy failed", {
33
+ accountId,
34
+ reason: err instanceof Error ? err.message : String(err),
35
+ });
36
+ }
37
+ finally {
38
+ await client?.logout().catch(() => { });
39
+ }
40
+ }
@@ -0,0 +1,35 @@
1
+ import { type MailConnectTarget } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
2
+ export type SmtpLogin = {
3
+ readonly host: string;
4
+ readonly port: number;
5
+ readonly user: string;
6
+ readonly password: string;
7
+ };
8
+ export type SmtpFailure = "hostUnreachable" | "loginRejected" | "tlsFailed" | "connectFailed" | "rejected";
9
+ export declare class SmtpFailureError extends Error {
10
+ readonly failure: SmtpFailure;
11
+ constructor(failure: SmtpFailure, detail: string);
12
+ }
13
+ export declare function classifySmtpError(err: unknown): SmtpFailure;
14
+ export declare function smtpTransportOptions(login: SmtpLogin, target: MailConnectTarget): {
15
+ readonly host: string;
16
+ readonly port: number;
17
+ readonly secure: true;
18
+ readonly servername?: string | undefined;
19
+ readonly auth: {
20
+ readonly user: string;
21
+ readonly pass: string;
22
+ };
23
+ readonly connectionTimeout: 10000;
24
+ readonly greetingTimeout: 10000;
25
+ readonly socketTimeout: 30000;
26
+ readonly disableFileAccess: true;
27
+ readonly disableUrlAccess: true;
28
+ readonly logger: false;
29
+ readonly debug: false;
30
+ };
31
+ export declare function verifySmtpLogin(login: SmtpLogin): Promise<void>;
32
+ export declare function sendRawViaSmtp(login: SmtpLogin, envelope: {
33
+ readonly from: string;
34
+ readonly to: readonly string[];
35
+ }, raw: Buffer): Promise<void>;