@cosmicdrift/kumiko-bundled-features 0.392.2 → 0.393.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.
@@ -26,7 +26,7 @@ export const connectAccountSchema = z.object({
26
26
  InboundMailAuthMethods.xoauth2,
27
27
  InboundMailAuthMethods.oauth,
28
28
  ]),
29
- displayName: z.string().max(200),
29
+ displayName: z.string().trim().max(200),
30
30
  /** Mailbox address in plaintext; it is encrypted here. */
31
31
  address: z.string().min(1).max(320),
32
32
  /** "shared" = shared across the tenant (info@) · "user" = the caller's
@@ -20,7 +20,7 @@ export const updateAccountSchema = z.object({
20
20
  .optional(),
21
21
  /** Ops-Signal ("idle", "watching", "backoff:3", ...). */
22
22
  watchState: z.string().max(100).optional(),
23
- displayName: z.string().max(200).optional(),
23
+ displayName: z.string().trim().max(200).optional(),
24
24
  /** Audit reason for metadata.headers, honored for the system identity only
25
25
  * ("watch_supervisor", "oauth_refresh"); user callers get a role-derived reason. */
26
26
  reason: z.string().min(1).max(100).optional(),
@@ -27,6 +27,7 @@ export const INBOUND_MAIL_FOUNDATION_I18N = {
27
27
  en: "Disconnected",
28
28
  },
29
29
  "inboundMail.accounts.connectImap": { en: "Connect mailbox" },
30
+ "inboundMail.accounts.reconnect": { en: "Reconnect" },
30
31
  "inboundMail.accounts.edit": { en: "Rename" },
31
32
  "inboundMail.accounts.edit.submit": { en: "Save" },
32
33
  "inboundMail.accounts.disconnect": { en: "Disconnect" },
@@ -9,7 +9,7 @@ export { type OAuthStatePayload, signOAuthState, type VerifyOAuthStateResult, ve
9
9
  export { isMailSendNotConfiguredError, isMailSendRejectedError, MailSendNotConfiguredError, MailSendRejectedError, type OutboundMailMessage, type OutboundMailSent, outboundMailMessageSchema, } from "./outbound-mail.ts";
10
10
  export { inboundMessagesProjectionTable, mailAccountsProjectionTable, mailThreadsProjectionTable, } from "./projection.ts";
11
11
  export { resolveInboundProviderForAccount, resolveInboundProviderForKey, } from "./provider-factory.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";
12
+ export { IMAP_CONNECT_SCREEN_ID, IMAP_RECONNECT_SCREEN_ID, MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_QN, } from "./screens.ts";
13
13
  export { MAIL_SEND_ERRORS, type MailSendFailure, type SendMailFromAccountContext, type SendMailFromAccountInput, sendMailFromAccount, } from "./send-from-account.ts";
14
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";
15
15
  export { createInboundMailSupervisor, type InboundMailSupervisor, type InboundMailSupervisorDeps, } from "./watch-supervisor.ts";
@@ -10,7 +10,7 @@ export { signOAuthState, verifyOAuthState, } from "./oauth-state.js";
10
10
  export { isMailSendNotConfiguredError, isMailSendRejectedError, MailSendNotConfiguredError, MailSendRejectedError, outboundMailMessageSchema, } from "./outbound-mail.js";
11
11
  export { inboundMessagesProjectionTable, mailAccountsProjectionTable, mailThreadsProjectionTable, } from "./projection.js";
12
12
  export { resolveInboundProviderForAccount, resolveInboundProviderForKey, } from "./provider-factory.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";
13
+ export { IMAP_CONNECT_SCREEN_ID, IMAP_RECONNECT_SCREEN_ID, MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_QN, } from "./screens.js";
14
14
  export { MAIL_SEND_ERRORS, sendMailFromAccount, } from "./send-from-account.js";
15
15
  export { InboundAuthError, InboundCursorInvalidError, InboundRateLimitError, InboundTransientError, isInboundAuthError, isInboundCursorInvalidError, isInboundMailProviderPlugin, isInboundRateLimitError, isInboundTransientError, } from "./types.js";
16
16
  export { createInboundMailSupervisor, } from "./watch-supervisor.js";
@@ -4,6 +4,7 @@ export declare const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
4
4
  export declare const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
5
5
  export declare const MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:mail-accounts";
6
6
  export declare const IMAP_CONNECT_SCREEN_ID = "mail-account-connect-imap";
7
+ export declare const IMAP_RECONNECT_SCREEN_ID = "mail-account-reconnect-imap";
7
8
  export declare const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
8
9
  export declare const mailAccountListScreen: ProjectionListScreenDefinition;
9
10
  export declare const mailAccountEditScreen: ActionFormScreenDefinition;
@@ -7,6 +7,8 @@ export const MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:mail-
7
7
  // renderer drops a navigate action whose target screen is not mounted, so the toolbar button
8
8
  // only shows in apps that mount that provider.
9
9
  export const IMAP_CONNECT_SCREEN_ID = "mail-account-connect-imap";
10
+ // Same reason for the row action "Reconnect" on an account with a sign-in error.
11
+ export const IMAP_RECONNECT_SCREEN_ID = "mail-account-reconnect-imap";
10
12
  export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
11
13
  // Same roles as the update/disconnect handlers: a screen rule wider than the
12
14
  // handlers would only render actions that 403.
@@ -54,6 +56,15 @@ export const mailAccountListScreen = {
54
56
  params: { map: { accountId: "id", displayName: "displayName" } },
55
57
  visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
56
58
  },
59
+ {
60
+ kind: "navigate",
61
+ id: "reconnect",
62
+ label: i18nKey("inboundMail.accounts.reconnect"),
63
+ screen: IMAP_RECONNECT_SCREEN_ID,
64
+ entityId: "id",
65
+ // The handler rejects accounts of other providers; the row condition cannot combine fields.
66
+ visible: { field: "status", eq: InboundMailAccountStatuses.authError },
67
+ },
57
68
  {
58
69
  kind: "writeHandler",
59
70
  id: "disconnect",
@@ -1,6 +1,7 @@
1
+ import { type BeforeTransactionResult } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import * as z from "zod";
2
3
  import { type ImapCredentialDocument } from "./credential-document.ts";
3
- declare const connectMailboxSchema: z.ZodObject<{
4
+ export declare const connectMailboxSchema: z.ZodObject<{
4
5
  host: z.ZodString;
5
6
  port: z.ZodDefault<z.ZodNumber>;
6
7
  user: z.ZodString;
@@ -10,9 +11,13 @@ declare const connectMailboxSchema: z.ZodObject<{
10
11
  smtpHost: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodLiteral<"">, z.ZodString]>>>;
11
12
  smtpPort: z.ZodNumber;
12
13
  }, z.core.$strip>;
13
- type ConnectMailboxPayload = z.infer<typeof connectMailboxSchema>;
14
+ type ConnectMailboxLoginFields = Pick<z.infer<typeof connectMailboxSchema>, "host" | "port" | "user" | "password" | "smtpHost" | "smtpPort">;
15
+ export declare const CREDENTIAL_PREVIEW = "IMAP login";
14
16
  export declare function deriveSmtpHost(imapHost: string): string;
15
- export declare function credentialDocumentOf(payload: ConnectMailboxPayload): ImapCredentialDocument;
17
+ export declare function credentialDocumentOf(payload: ConnectMailboxLoginFields): ImapCredentialDocument;
18
+ export declare function checkMailboxLogin(doc: ImapCredentialDocument, log: {
19
+ warn(message: string, data?: Record<string, unknown>): void;
20
+ }): Promise<BeforeTransactionResult<null>>;
16
21
  export declare const connectMailboxHandler: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"connect-mailbox", z.ZodObject<{
17
22
  host: z.ZodString;
18
23
  port: z.ZodDefault<z.ZodNumber>;
@@ -14,10 +14,10 @@ import { SmtpFailureError, verifySmtpLogin } from "./smtp-client.js";
14
14
  // No `secure`: the password never crosses the wire in clear text, so a payload's secure:false is
15
15
  // stripped like any unknown key. IMAP uses implicit TLS; SMTP uses implicit TLS on 465 and
16
16
  // enforced STARTTLS on every other port.
17
- const connectMailboxSchema = imapCredentialDocumentSchema
17
+ export const connectMailboxSchema = imapCredentialDocumentSchema
18
18
  .pick({ host: true, port: true, user: true })
19
19
  .extend({
20
- displayName: z.string().min(1).max(200),
20
+ displayName: z.string().trim().min(1).max(200),
21
21
  address: z.email().max(320),
22
22
  password: z.string().min(1).max(1000),
23
23
  smtpHost: z.union([z.literal(""), smtpHostSchema]).nullish(),
@@ -29,7 +29,7 @@ const connectedAccountSchema = z.object({ accountId: z.string() });
29
29
  // BoringSSL (Bun) spells the handshake error WRONG_VERSION_NUMBER, OpenSSL (Node) with spaces.
30
30
  const TLS_FAILURE = /certificate|self[- ]signed|wrong[ _]version[ _]number|EPROTO|ERR_SSL|ERR_TLS/i;
31
31
  // A fixed preview: any slice of the JSON document would expose the host or the user.
32
- const CREDENTIAL_PREVIEW = "IMAP login";
32
+ export const CREDENTIAL_PREVIEW = "IMAP login";
33
33
  const SECRET_TRANSACTION_REASON = "the credential secret is written on the request transaction so a failed account connect rolls it back too";
34
34
  // Most hosters name the outgoing server like the incoming one, or with smtp. for imap.
35
35
  export function deriveSmtpHost(imapHost) {
@@ -73,6 +73,24 @@ async function checkSmtpLogin(doc) {
73
73
  throw err;
74
74
  }
75
75
  }
76
+ // Checks the IMAP login, then the SMTP login of the outgoing server; shared by connect and reconnect.
77
+ export async function checkMailboxLogin(doc, log) {
78
+ try {
79
+ await connectOnce(doc, true);
80
+ }
81
+ catch (err) {
82
+ const { reason, i18nKey } = IMAP_CONNECT_ERRORS[imapConnectFailureOf(err)];
83
+ log.warn("imap connect check failed", { reason });
84
+ return writeFailure(new UnprocessableError(reason, { i18nKey }));
85
+ }
86
+ const smtpFailure = await checkSmtpLogin(doc);
87
+ if (smtpFailure) {
88
+ const { reason, i18nKey } = SMTP_CONNECT_ERRORS[smtpFailure];
89
+ log.warn("smtp connect check failed", { reason });
90
+ return writeFailure(new UnprocessableError(reason, { i18nKey, i18nParams: { port: doc.smtpPort } }));
91
+ }
92
+ return { isSuccess: true, data: null };
93
+ }
76
94
  export const connectMailboxHandler = defineWriteHandler({
77
95
  name: "connect-mailbox",
78
96
  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.",
@@ -84,24 +102,7 @@ export const connectMailboxHandler = defineWriteHandler({
84
102
  // used to scan ports or to guess passwords at someone else's mail server.
85
103
  rateLimit: { per: "tenant+handler", limit: 10, windowSeconds: 600 },
86
104
  escapeHatch: { grants: ["unsafeRaw"], reason: SECRET_TRANSACTION_REASON },
87
- beforeTransaction: async (event, ctx) => {
88
- const doc = credentialDocumentOf(event.payload);
89
- try {
90
- await connectOnce(doc, true);
91
- }
92
- catch (err) {
93
- const { reason, i18nKey } = IMAP_CONNECT_ERRORS[imapConnectFailureOf(err)];
94
- ctx.obs.log.warn("imap connect check failed", { reason });
95
- return writeFailure(new UnprocessableError(reason, { i18nKey }));
96
- }
97
- const smtpFailure = await checkSmtpLogin(doc);
98
- if (smtpFailure) {
99
- const { reason, i18nKey } = SMTP_CONNECT_ERRORS[smtpFailure];
100
- ctx.obs.log.warn("smtp connect check failed", { reason });
101
- return writeFailure(new UnprocessableError(reason, { i18nKey, i18nParams: { port: doc.smtpPort } }));
102
- }
103
- return { isSuccess: true, data: null };
104
- },
105
+ beforeTransaction: (event, ctx) => checkMailboxLogin(credentialDocumentOf(event.payload), ctx.obs.log),
105
106
  handler: async (event, ctx) => {
106
107
  const { displayName, address } = event.payload;
107
108
  const connected = await ctx.dispatch.write(InboundMailFoundationHandlers.connectAccount, {
@@ -2,6 +2,16 @@ export declare const FEATURE_NAME = "inbound-provider-imap";
2
2
  export declare const IMAP_PROVIDER_KEY = "imap";
3
3
  export declare const ImapHandlers: {
4
4
  readonly connectMailbox: "inbound-provider-imap:write:connect-mailbox";
5
+ readonly reconnectMailbox: "inbound-provider-imap:write:reconnect-mailbox";
6
+ };
7
+ export declare const ImapQueries: {
8
+ readonly reconnectPrefill: "inbound-provider-imap:query:reconnect-prefill";
9
+ };
10
+ export declare const IMAP_RECONNECT_ERRORS: {
11
+ readonly notPasswordImap: {
12
+ readonly reason: "reconnect_not_password_imap";
13
+ readonly i18nKey: "inboundMail.imap.reconnect.errors.notPasswordImap";
14
+ };
5
15
  };
6
16
  export declare const IMAP_CONNECT_ERRORS: {
7
17
  readonly hostUnreachable: {
@@ -2,6 +2,16 @@ export const FEATURE_NAME = "inbound-provider-imap";
2
2
  export const IMAP_PROVIDER_KEY = "imap";
3
3
  export const ImapHandlers = {
4
4
  connectMailbox: "inbound-provider-imap:write:connect-mailbox",
5
+ reconnectMailbox: "inbound-provider-imap:write:reconnect-mailbox",
6
+ };
7
+ export const ImapQueries = {
8
+ reconnectPrefill: "inbound-provider-imap:query:reconnect-prefill",
9
+ };
10
+ export const IMAP_RECONNECT_ERRORS = {
11
+ notPasswordImap: {
12
+ reason: "reconnect_not_password_imap",
13
+ i18nKey: "inboundMail.imap.reconnect.errors.notPasswordImap",
14
+ },
5
15
  };
6
16
  export const IMAP_CONNECT_ERRORS = {
7
17
  hostUnreachable: {
@@ -32,7 +32,9 @@ import { FEATURE_NAME, IMAP_PROVIDER_KEY } from "./constants.js";
32
32
  import { readCredentialDocument } from "./credential-document.js";
33
33
  import { INBOUND_PROVIDER_IMAP_I18N } from "./i18n.js";
34
34
  import { assertUidValidity, coerceDate, connectOnce, createImapClient, IMAP_MAILBOX, imapMailHostGuard, mapImapError, parseImapCursor, toRawInboundMessage, } from "./imap-client.js";
35
- import { imapConnectScreen } from "./screens.js";
35
+ import { reconnectMailboxHandler } from "./reconnect-mailbox.js";
36
+ import { reconnectPrefillQuery } from "./reconnect-prefill.query.js";
37
+ import { imapConnectScreen, imapReconnectScreen } from "./screens.js";
36
38
  import { sendFromImapAccount } from "./send-mail.js";
37
39
  export { IMAP_PROVIDER_KEY } from "./constants.js";
38
40
  export { setImapMailHostLookup } from "./imap-client.js";
@@ -198,7 +200,7 @@ export const imapInboundMailPlugin = {
198
200
  };
199
201
  export function createInboundProviderImapFeature() {
200
202
  return defineFeature(FEATURE_NAME, (r) => {
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.');
203
+ 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; `reconnect-mailbox` does the same check for an existing mailbox with a sign-in error and overwrites its credential secret); 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.');
202
204
  r.uiHints({
203
205
  displayLabel: "Inbound Mail · IMAP",
204
206
  category: "notifications",
@@ -209,7 +211,10 @@ export function createInboundProviderImapFeature() {
209
211
  r.envSchema(mailAllowedPrivateHostsEnvSchema);
210
212
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
211
213
  r.writeHandler(connectMailboxHandler);
214
+ r.writeHandler(reconnectMailboxHandler);
215
+ r.queryHandler(reconnectPrefillQuery);
212
216
  r.screen(imapConnectScreen);
217
+ r.screen(imapReconnectScreen);
213
218
  r.translations({ keys: INBOUND_PROVIDER_IMAP_I18N });
214
219
  }, { dedupeOptions: {} });
215
220
  }
@@ -1,10 +1,12 @@
1
- import { IMAP_CONNECT_SCREEN_ID } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
1
+ import { IMAP_CONNECT_SCREEN_ID, IMAP_RECONNECT_SCREEN_ID, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
2
  import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
4
- import { FEATURE_NAME, IMAP_CONNECT_ERRORS, SMTP_CONNECT_ERRORS } from "./constants.js";
4
+ import { FEATURE_NAME, IMAP_CONNECT_ERRORS, IMAP_RECONNECT_ERRORS, SMTP_CONNECT_ERRORS, } from "./constants.js";
5
5
  const formFieldKey = (field) => fieldLabelKey(FEATURE_NAME, ACTION_FORM_ENTITY, field);
6
6
  export const INBOUND_PROVIDER_IMAP_I18N = {
7
7
  [`screen:${IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
8
+ [`screen:${IMAP_RECONNECT_SCREEN_ID}.title`]: { en: "Reconnect mailbox" },
9
+ [formFieldKey("accountId")]: { en: "Mailbox" },
8
10
  [formFieldKey("displayName")]: { en: "Name" },
9
11
  [formFieldKey("address")]: { en: "Email address" },
10
12
  [formFieldKey("host")]: { en: "IMAP server" },
@@ -19,6 +21,13 @@ export const INBOUND_PROVIDER_IMAP_I18N = {
19
21
  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. Usually 587, even when your provider lists 465/SSL.",
20
22
  },
21
23
  "inboundMail.imap.connect.submit": { en: "Check and connect" },
24
+ "inboundMail.imap.reconnect.hint": {
25
+ en: "The sign-in is checked before saving. Then the system fetches the mail again.",
26
+ },
27
+ "inboundMail.imap.reconnect.submit": { en: "Check and reconnect" },
28
+ [IMAP_RECONNECT_ERRORS.notPasswordImap.i18nKey]: {
29
+ en: "Only password IMAP mailboxes can be reconnected here.",
30
+ },
22
31
  [IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
23
32
  en: "The IMAP server cannot be reached or is not allowed. Check the server name.",
24
33
  },
@@ -0,0 +1,15 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ type ReconnectAccountRow = {
4
+ readonly id: string;
5
+ readonly provider: string;
6
+ readonly authMethod: string;
7
+ readonly displayName: string | null;
8
+ readonly status: string | null;
9
+ };
10
+ export type ReconnectAccountLookup = {
11
+ readonly isSuccess: true;
12
+ readonly row: ReconnectAccountRow;
13
+ } | WriteFailure;
14
+ export declare function findReconnectableAccount(db: Pick<TenantDb, "fetchOne">, accountId: string): Promise<ReconnectAccountLookup>;
15
+ export {};
@@ -0,0 +1,20 @@
1
+ // Loads the mailbox a reconnect targets. The db is tenant-scoped, so another tenant's account id
2
+ // finds nothing.
3
+ import { InboundMailAccountStatuses, InboundMailAuthMethods, mailAccountsProjectionTable, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
4
+ import { failNotFound, failUnprocessable, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { IMAP_PROVIDER_KEY, IMAP_RECONNECT_ERRORS } from "./constants.js";
6
+ export async function findReconnectableAccount(db, accountId) {
7
+ const row = await db.fetchOne(mailAccountsProjectionTable, {
8
+ id: accountId,
9
+ });
10
+ if (!row)
11
+ return failNotFound("mail-account", accountId);
12
+ if (row.status === InboundMailAccountStatuses.disconnected) {
13
+ return failUnprocessable("account_disconnected", { accountId });
14
+ }
15
+ if (row.provider !== IMAP_PROVIDER_KEY || row.authMethod !== InboundMailAuthMethods.password) {
16
+ const { reason, i18nKey } = IMAP_RECONNECT_ERRORS.notPasswordImap;
17
+ return writeFailure(new UnprocessableError(reason, { i18nKey, details: { accountId } }));
18
+ }
19
+ return { isSuccess: true, row };
20
+ }
@@ -0,0 +1,12 @@
1
+ import * as z from "zod";
2
+ export declare const reconnectMailboxHandler: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"reconnect-mailbox", z.ZodObject<{
3
+ host: z.ZodString;
4
+ port: z.ZodDefault<z.ZodNumber>;
5
+ user: z.ZodString;
6
+ password: z.ZodString;
7
+ smtpHost: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodLiteral<"">, z.ZodString]>>>;
8
+ smtpPort: z.ZodNumber;
9
+ accountId: z.ZodUUID;
10
+ }, z.core.$strip>, {
11
+ accountId: string;
12
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, null>;
@@ -0,0 +1,48 @@
1
+ // reconnect-mailbox: new credentials for an existing password IMAP mailbox, typically after the
2
+ // account went to auth_error. The login is checked before the transaction opens; only then the
3
+ // account goes back to active and the credential secret of the same account is overwritten, both
4
+ // on the request transaction, so a failure leaves status and secret as they were.
5
+ import { InboundMailAccountStatuses, 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 * as z from "zod";
9
+ import { CREDENTIAL_PREVIEW, checkMailboxLogin, connectMailboxSchema, credentialDocumentOf, } from "./connect-mailbox.js";
10
+ import { findReconnectableAccount } from "./reconnect-account.js";
11
+ const reconnectMailboxSchema = connectMailboxSchema
12
+ .pick({ host: true, port: true, user: true, password: true, smtpHost: true, smtpPort: true })
13
+ .extend({ accountId: z.uuid() });
14
+ const SECRET_TRANSACTION_REASON = "the credential secret is written on the request transaction so a failed reconnect rolls the account status back too";
15
+ export const reconnectMailboxHandler = defineWriteHandler({
16
+ name: "reconnect-mailbox",
17
+ description: "Replaces the credentials of an existing password IMAP mailbox: checks the IMAP login and the SMTP login of the outgoing server first, then overwrites the credential secret of the same account and sets it back to active, keeping its threads and sync cursor.",
18
+ // The payload carries the plaintext password: as a tool call it would reach the model provider.
19
+ agent: { expose: false },
20
+ schema: reconnectMailboxSchema,
21
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
22
+ // Same cap as connect: each call opens a connection to a host the caller picks.
23
+ rateLimit: { per: "tenant+handler", limit: 10, windowSeconds: 600 },
24
+ escapeHatch: { grants: ["unsafeRaw"], reason: SECRET_TRANSACTION_REASON },
25
+ beforeTransaction: async (event, ctx) => {
26
+ const lookup = await findReconnectableAccount(ctx.db, event.payload.accountId);
27
+ if (!lookup.isSuccess)
28
+ return lookup;
29
+ return checkMailboxLogin(credentialDocumentOf(event.payload), ctx.obs.log);
30
+ },
31
+ handler: async (event, ctx) => {
32
+ const { accountId } = event.payload;
33
+ const reactivated = await ctx.dispatch.write(InboundMailFoundationHandlers.updateAccount, {
34
+ accountId,
35
+ status: InboundMailAccountStatuses.active,
36
+ watchState: "idle",
37
+ });
38
+ if (!reactivated.isSuccess)
39
+ return reactivated;
40
+ const secrets = requireSecretsContext({ secrets: ctx.services.secrets, user: ctx.user }, "reconnect-mailbox").withRunner(ctx.db.unsafeRaw());
41
+ await secrets.set(event.user.tenantId, inboundCredentialSecretKey(accountId), JSON.stringify(credentialDocumentOf(event.payload)), {
42
+ redact: () => CREDENTIAL_PREVIEW,
43
+ hint: `IMAP credentials for inbound mail account ${accountId}`,
44
+ updatedBy: event.user.id,
45
+ });
46
+ return { isSuccess: true, data: { accountId } };
47
+ },
48
+ });
@@ -0,0 +1,2 @@
1
+ import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare const reconnectPrefillQuery: QueryHandlerDef;
@@ -0,0 +1,31 @@
1
+ // Initial values of the reconnect form: the server settings of the stored credential, never the
2
+ // password. A missing or unreadable credential leaves the fields empty for the admin to fill in.
3
+ import { inboundCredentialSecretKey } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
4
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
5
+ import * as z from "zod";
6
+ import { FEATURE_NAME } from "./constants.js";
7
+ import { parseImapCredentialDocument } from "./credential-document.js";
8
+ import { findReconnectableAccount } from "./reconnect-account.js";
9
+ const reconnectPrefillSchema = z.object({ accountId: z.uuid() }).strict();
10
+ export const reconnectPrefillQuery = {
11
+ name: "reconnect-prefill",
12
+ schema: reconnectPrefillSchema,
13
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
14
+ // Only the reconnect form needs these settings; the agent has no write to use them with.
15
+ agent: { expose: false },
16
+ description: "Returns the server settings (IMAP host and port, user name, SMTP host and port) stored for a password IMAP mailbox without its password, or null when the mailbox is not found, disconnected or not a password IMAP mailbox; use it to prefill the reconnect form.",
17
+ handler: async (query, ctx) => {
18
+ const { accountId } = reconnectPrefillSchema.parse(query.payload);
19
+ const lookup = await findReconnectableAccount(ctx.db, accountId);
20
+ if (!lookup.isSuccess)
21
+ return null;
22
+ const secrets = requireSecretsContext({ secrets: ctx.services.secrets, user: ctx.user }, FEATURE_NAME);
23
+ const stored = await secrets.get(ctx.user.tenantId, inboundCredentialSecretKey(accountId));
24
+ const parsed = stored ? parseImapCredentialDocument(stored.reveal()) : undefined;
25
+ const displayName = lookup.row.displayName ?? "";
26
+ if (!parsed?.ok)
27
+ return { accountId, displayName };
28
+ const { host, port, user, smtpHost, smtpPort } = parsed.doc;
29
+ return { accountId, displayName, host, port, user, smtpHost, smtpPort };
30
+ },
31
+ };
@@ -1,2 +1,3 @@
1
1
  import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  export declare const imapConnectScreen: ActionFormScreenDefinition;
3
+ export declare const imapReconnectScreen: ActionFormScreenDefinition;
@@ -1,6 +1,6 @@
1
- import { IMAP_CONNECT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_QN, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
1
+ import { IMAP_CONNECT_SCREEN_ID, IMAP_RECONNECT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_QN, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
2
  import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
3
- import { ImapHandlers, SMTP_SUBMISSION_PORT } from "./constants.js";
3
+ import { ImapHandlers, ImapQueries, SMTP_SUBMISSION_PORT } from "./constants.js";
4
4
  // Reached from the toolbar of the foundation's mail account list, never from the nav.
5
5
  export const imapConnectScreen = {
6
6
  id: IMAP_CONNECT_SCREEN_ID,
@@ -11,13 +11,22 @@ export const imapConnectScreen = {
11
11
  displayName: { type: "text", required: true, maxLength: 200 },
12
12
  address: { type: "text", required: true, maxLength: 320, format: "email" },
13
13
  host: { type: "text", required: true, maxLength: 255 },
14
- port: { type: "number", integer: true, required: true, min: 1, max: 65535, default: 993 },
14
+ port: {
15
+ type: "number",
16
+ integer: true,
17
+ grouping: false,
18
+ required: true,
19
+ min: 1,
20
+ max: 65535,
21
+ default: 993,
22
+ },
15
23
  user: { type: "text", required: true, maxLength: 320 },
16
24
  password: { type: "text", required: true, format: "password", sensitive: true },
17
25
  smtpHost: { type: "text", maxLength: 255 },
18
26
  smtpPort: {
19
27
  type: "number",
20
28
  integer: true,
29
+ grouping: false,
21
30
  required: true,
22
31
  min: 1,
23
32
  max: 65535,
@@ -45,3 +54,65 @@ export const imapConnectScreen = {
45
54
  access: { roles: ["SystemAdmin", "TenantAdmin"] },
46
55
  dormant: true,
47
56
  };
57
+ // Reached from the "Reconnect" row action of an account with a sign-in error. The record id comes
58
+ // from the path; the load query returns the stored server settings, never the password.
59
+ export const imapReconnectScreen = {
60
+ id: IMAP_RECONNECT_SCREEN_ID,
61
+ type: "actionForm",
62
+ agent: { expose: false },
63
+ handler: ImapHandlers.reconnectMailbox,
64
+ load: { query: ImapQueries.reconnectPrefill, idParam: "accountId" },
65
+ fields: {
66
+ accountId: { type: "text", required: true },
67
+ displayName: { type: "text", maxLength: 200 },
68
+ host: { type: "text", required: true, maxLength: 255 },
69
+ port: {
70
+ type: "number",
71
+ integer: true,
72
+ grouping: false,
73
+ required: true,
74
+ min: 1,
75
+ max: 65535,
76
+ default: 993,
77
+ },
78
+ user: { type: "text", required: true, maxLength: 320 },
79
+ password: { type: "text", required: true, format: "password", sensitive: true },
80
+ smtpHost: { type: "text", maxLength: 255 },
81
+ smtpPort: {
82
+ type: "number",
83
+ integer: true,
84
+ grouping: false,
85
+ required: true,
86
+ min: 1,
87
+ max: 65535,
88
+ default: SMTP_SUBMISSION_PORT,
89
+ },
90
+ },
91
+ layout: {
92
+ sections: [
93
+ {
94
+ title: i18nKey("inboundMail.imap.connect.server"),
95
+ description: i18nKey("inboundMail.imap.reconnect.hint"),
96
+ // The id only travels in the payload; the admin recognises the mailbox by its name.
97
+ fields: [
98
+ { field: "accountId", visible: false },
99
+ { field: "displayName", readOnly: true, submit: false },
100
+ "host",
101
+ "port",
102
+ "user",
103
+ "password",
104
+ ],
105
+ },
106
+ {
107
+ title: i18nKey("inboundMail.imap.connect.outgoing"),
108
+ description: i18nKey("inboundMail.imap.connect.outgoingHint"),
109
+ fields: ["smtpHost", "smtpPort"],
110
+ },
111
+ ],
112
+ },
113
+ submitLabel: i18nKey("inboundMail.imap.reconnect.submit"),
114
+ redirect: MAIL_ACCOUNT_LIST_SCREEN_QN,
115
+ cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_QN,
116
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
117
+ dormant: true,
118
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.392.2",
3
+ "version": "0.393.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -558,9 +558,9 @@
558
558
  }
559
559
  },
560
560
  "dependencies": {
561
- "@cosmicdrift/kumiko-framework": "0.392.2",
562
- "@cosmicdrift/kumiko-renderer": "0.392.2",
563
- "@cosmicdrift/kumiko-types": "0.392.2",
561
+ "@cosmicdrift/kumiko-framework": "0.393.0",
562
+ "@cosmicdrift/kumiko-renderer": "0.393.0",
563
+ "@cosmicdrift/kumiko-types": "0.393.0",
564
564
  "@mollie/api-client": "^4.5.0",
565
565
  "@node-rs/argon2": "^2.0.2",
566
566
  "@tiptap/core": "^3.31.3",
@@ -1142,17 +1142,17 @@
1142
1142
  ],
1143
1143
  "devDependencies": {
1144
1144
  "@testing-library/user-event": "^14.6.1",
1145
- "@cosmicdrift/kumiko-locale-de": "0.392.2",
1146
- "@cosmicdrift/kumiko-locale-es": "0.392.2",
1147
- "@cosmicdrift/kumiko-locale-fr": "0.392.2",
1148
- "@cosmicdrift/kumiko-locale-it": "0.392.2",
1149
- "@cosmicdrift/kumiko-renderer-mail-html": "0.392.2",
1145
+ "@cosmicdrift/kumiko-locale-de": "0.393.0",
1146
+ "@cosmicdrift/kumiko-locale-es": "0.393.0",
1147
+ "@cosmicdrift/kumiko-locale-fr": "0.393.0",
1148
+ "@cosmicdrift/kumiko-locale-it": "0.393.0",
1149
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.393.0",
1150
1150
  "jsqr": "^1.4.0",
1151
1151
  "unpdf": "^1.8.1",
1152
- "@cosmicdrift/kumiko-testing": "0.392.2"
1152
+ "@cosmicdrift/kumiko-testing": "0.393.0"
1153
1153
  },
1154
1154
  "peerDependencies": {
1155
- "@cosmicdrift/kumiko-testing": "0.392.2"
1155
+ "@cosmicdrift/kumiko-testing": "0.393.0"
1156
1156
  },
1157
1157
  "peerDependenciesMeta": {
1158
1158
  "@cosmicdrift/kumiko-testing": {
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.393.0",
4
+ "type": "improvement",
5
+ "title": "The mail account list offers Reconnect for accounts with a sign-in error",
6
+ "detail": "A new row action Reconnect opens the screen mail-account-reconnect-imap of inbound-provider-imap. It shows for every account with status auth_error and, like the Connect mailbox button, only renders in apps that mount that provider. New export IMAP_RECONNECT_SCREEN_ID. New key in all locale packages."
7
+ },
8
+ {
9
+ "version": "0.393.0",
10
+ "type": "fix",
11
+ "title": "Mailbox names are stored without surrounding spaces",
12
+ "detail": "connect-account and update-account trim displayName before storing it, so a name pasted with a leading space no longer shows and sorts with it. connect-account still accepts an empty name as before."
13
+ },
2
14
  {
3
15
  "version": "0.392.2",
4
16
  "type": "fix",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.393.0",
4
+ "type": "improvement",
5
+ "title": "IMAP mailboxes with a sign-in error can be reconnected",
6
+ "detail": "The new write handler inbound-provider-imap:write:reconnect-mailbox (TenantAdmin, SystemAdmin, not exposed to the agent, 10 calls per tenant in 10 minutes) takes the account id plus host, port, user, password and the outgoing server, checks the IMAP and SMTP login like connect-mailbox, then overwrites the credential secret of the same account and sets it back to active in one transaction. A failed check changes nothing. It answers not found for an unknown account or one of another tenant, account_disconnected for a disconnected one and reconnect_not_password_imap for other providers. The form screen mail-account-reconnect-imap is prefilled by the query inbound-provider-imap:query:reconnect-prefill with the stored server settings, never the password. New keys in all locale packages."
7
+ },
8
+ {
9
+ "version": "0.393.0",
10
+ "type": "fix",
11
+ "title": "IMAP forms trim the mailbox name and show ports without digit grouping",
12
+ "detail": "connect-mailbox trims displayName and still requires a non-empty name. The port fields of the connect and reconnect forms show 993 or 13993 instead of 13.993 in locales with digit grouping."
13
+ },
2
14
  {
3
15
  "version": "0.392.1",
4
16
  "type": "fix",