@cosmicdrift/kumiko-samples 0.392.1 → 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.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +24 -0
  4. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  5. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +1 -1
  6. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +1 -0
  7. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +1 -0
  8. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +11 -0
  9. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +1 -1
  10. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +19 -7
  11. package/packages/bundled-features/src/inbound-provider-imap/changes.json +12 -0
  12. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +33 -24
  13. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +12 -0
  14. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +7 -2
  15. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +19 -2
  16. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +47 -0
  17. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +73 -0
  18. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +38 -0
  19. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +75 -2
  20. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +40 -8
  21. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +1 -0
  22. package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
  23. package/samples/recipes/inbound-mail-triage/README.md +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.392.1",
3
+ "version": "0.393.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.392.1",
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>",
@@ -1,4 +1,28 @@
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
+ },
14
+ {
15
+ "version": "0.392.2",
16
+ "type": "fix",
17
+ "title": "Mail sync no longer fails when reading IMAP credentials",
18
+ "detail": "createInboundMailSupervisor builds its provider context with auditUserId defaulting to the system user. Apps that did not set auditUserId in providerCtx got an internal error on every fetch and watch because the secrets context needs an audit identity. An explicitly set auditUserId is kept."
19
+ },
20
+ {
21
+ "version": "0.392.2",
22
+ "type": "fix",
23
+ "title": "Mail sync finds the stored IMAP credentials under row-level security",
24
+ "detail": "createInboundMailSupervisor runs outside any request, so secret reads ran without a tenant scope and the application role saw no row in read_tenant_secrets. The supervisor now wraps providerCtx.secrets with tenantScopedSecrets, which the framework exports from internal/row-level-security. inbound-provider-inmemory gains requireCredentialSecretOnFetch so tests can exercise credential reads; a new integration test runs the secrets against an application-role database."
25
+ },
2
26
  {
3
27
  "version": "0.392.0",
4
28
  "type": "fix",
@@ -37,7 +37,7 @@ export const connectAccountSchema = z.object({
37
37
  InboundMailAuthMethods.xoauth2,
38
38
  InboundMailAuthMethods.oauth,
39
39
  ]),
40
- displayName: z.string().max(200),
40
+ displayName: z.string().trim().max(200),
41
41
  /** Mailbox address in plaintext; it is encrypted here. */
42
42
  address: z.string().min(1).max(320),
43
43
  /** "shared" = shared across the tenant (info@) · "user" = the caller's
@@ -31,7 +31,7 @@ export const updateAccountSchema = z.object({
31
31
  .optional(),
32
32
  /** Ops-Signal ("idle", "watching", "backoff:3", ...). */
33
33
  watchState: z.string().max(100).optional(),
34
- displayName: z.string().max(200).optional(),
34
+ displayName: z.string().trim().max(200).optional(),
35
35
  /** Audit reason for metadata.headers, honored for the system identity only
36
36
  * ("watch_supervisor", "oauth_refresh"); user callers get a role-derived reason. */
37
37
  reason: z.string().min(1).max(100).optional(),
@@ -34,6 +34,7 @@ export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en
34
34
  en: "Disconnected",
35
35
  },
36
36
  "inboundMail.accounts.connectImap": { en: "Connect mailbox" },
37
+ "inboundMail.accounts.reconnect": { en: "Reconnect" },
37
38
  "inboundMail.accounts.edit": { en: "Rename" },
38
39
  "inboundMail.accounts.edit.submit": { en: "Save" },
39
40
  "inboundMail.accounts.disconnect": { en: "Disconnect" },
@@ -88,6 +88,7 @@ export {
88
88
  } from "./provider-factory.ts";
89
89
  export {
90
90
  IMAP_CONNECT_SCREEN_ID,
91
+ IMAP_RECONNECT_SCREEN_ID,
91
92
  MAIL_ACCOUNT_EDIT_SCREEN_ID,
92
93
  MAIL_ACCOUNT_LIST_SCREEN_ID,
93
94
  MAIL_ACCOUNT_LIST_SCREEN_QN,
@@ -17,6 +17,8 @@ export const MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:mail-
17
17
  // renderer drops a navigate action whose target screen is not mounted, so the toolbar button
18
18
  // only shows in apps that mount that provider.
19
19
  export const IMAP_CONNECT_SCREEN_ID = "mail-account-connect-imap";
20
+ // Same reason for the row action "Reconnect" on an account with a sign-in error.
21
+ export const IMAP_RECONNECT_SCREEN_ID = "mail-account-reconnect-imap";
20
22
 
21
23
  export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
22
24
 
@@ -67,6 +69,15 @@ export const mailAccountListScreen: ProjectionListScreenDefinition = {
67
69
  params: { map: { accountId: "id", displayName: "displayName" } },
68
70
  visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
69
71
  },
72
+ {
73
+ kind: "navigate",
74
+ id: "reconnect",
75
+ label: i18nKey("inboundMail.accounts.reconnect"),
76
+ screen: IMAP_RECONNECT_SCREEN_ID,
77
+ entityId: "id",
78
+ // The handler rejects accounts of other providers; the row condition cannot combine fields.
79
+ visible: { field: "status", eq: InboundMailAccountStatuses.authError },
80
+ },
70
81
  {
71
82
  kind: "writeHandler",
72
83
  id: "disconnect",
@@ -24,7 +24,7 @@ import type { OutboundMailMessage, OutboundMailSent } from "./outbound-mail.ts";
24
24
  * secrets — tenant secret reads (IMAP password, OAuth refresh token)
25
25
  * auditUserId — audit identity for secret reads. Handler path: unset,
26
26
  * requireSecretsContext falls back to ctx.user.id; worker/provider
27
- * path: system identity set by the resolver.
27
+ * path: the supervisor sets the system identity.
28
28
  * metricsFor — metrics handle of a feature by name, same behavior as
29
29
  * HandlerContext.metricsFor: unregistered metrics are a silent
30
30
  * no-op instead of a throw.
@@ -49,7 +49,11 @@ import {
49
49
  } from "@cosmicdrift/kumiko-framework/crypto";
50
50
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
51
51
  import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/db";
52
- import { systemScopedRunner } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
52
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
53
+ import {
54
+ systemScopedRunner,
55
+ tenantScopedSecrets,
56
+ } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
53
57
  import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
54
58
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
55
59
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
@@ -186,17 +190,25 @@ export function createInboundMailSupervisor(
186
190
  // configured TTL — real deployments (default 90s) never hit it.
187
191
  const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
188
192
  const log = deps.log ?? (() => {});
193
+ // The supervisor runs outside any request: secret reads need an audit
194
+ // identity, and under enforced row-level security they need the account's
195
+ // tenant scope, or the credential row is invisible and reads as missing.
196
+ const providerCtx: InboundMailContext = {
197
+ ...deps.providerCtx,
198
+ auditUserId: deps.providerCtx.auditUserId ?? SYSTEM_USER_ID,
199
+ ...(deps.providerCtx.secrets && { secrets: tenantScopedSecrets(deps.providerCtx.secrets) }),
200
+ };
189
201
  const systemDb = systemScopedRunner(
190
202
  deps.db,
191
203
  "mail sync supervisor polls accounts and cursors of all tenants outside a request",
192
204
  );
193
205
  const oauthTokens =
194
206
  deps.oauthTokens ??
195
- (deps.providerCtx.secrets
207
+ (providerCtx.secrets
196
208
  ? createOAuthAccessTokenManager({
197
209
  db: systemDb,
198
- secrets: deps.providerCtx.secrets,
199
- providerCtx: deps.providerCtx,
210
+ secrets: providerCtx.secrets,
211
+ providerCtx,
200
212
  })
201
213
  : undefined);
202
214
 
@@ -447,7 +459,7 @@ export function createInboundMailSupervisor(
447
459
  // exhausted.
448
460
  for (;;) {
449
461
  const accessToken = await resolveAccessToken(account, plugin);
450
- const result = await plugin.fetch(deps.providerCtx, account, cursor, {
462
+ const result = await plugin.fetch(providerCtx, account, cursor, {
451
463
  backfillWindowDays,
452
464
  maxMessages: budget,
453
465
  filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
@@ -469,7 +481,7 @@ export function createInboundMailSupervisor(
469
481
  for (const account of accounts) {
470
482
  let plugin: InboundMailProviderPlugin;
471
483
  try {
472
- plugin = resolveInboundProviderForAccount(deps.providerCtx, account);
484
+ plugin = resolveInboundProviderForAccount(providerCtx, account);
473
485
  } catch (err) {
474
486
  log(`inbound-mail: ${describeError(err)}`);
475
487
  continue;
@@ -642,7 +654,7 @@ export function createInboundMailSupervisor(
642
654
  try {
643
655
  const accessToken = await resolveAccessToken(account, plugin);
644
656
  const stop = await plugin.watch(
645
- deps.providerCtx,
657
+ providerCtx,
646
658
  account,
647
659
  {
648
660
  onMessages: async (msgs) => {
@@ -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",
@@ -28,16 +28,19 @@ import { type SmtpFailure, SmtpFailureError, verifySmtpLogin } from "./smtp-clie
28
28
  // No `secure`: the password never crosses the wire in clear text, so a payload's secure:false is
29
29
  // stripped like any unknown key. IMAP uses implicit TLS; SMTP uses implicit TLS on 465 and
30
30
  // enforced STARTTLS on every other port.
31
- const connectMailboxSchema = imapCredentialDocumentSchema
31
+ export const connectMailboxSchema = imapCredentialDocumentSchema
32
32
  .pick({ host: true, port: true, user: true })
33
33
  .extend({
34
- displayName: z.string().min(1).max(200),
34
+ displayName: z.string().trim().min(1).max(200),
35
35
  address: z.email().max(320),
36
36
  password: z.string().min(1).max(1000),
37
37
  smtpHost: z.union([z.literal(""), smtpHostSchema]).nullish(),
38
38
  smtpPort: z.number().int().min(1).max(65535),
39
39
  });
40
- type ConnectMailboxPayload = z.infer<typeof connectMailboxSchema>;
40
+ type ConnectMailboxLoginFields = Pick<
41
+ z.infer<typeof connectMailboxSchema>,
42
+ "host" | "port" | "user" | "password" | "smtpHost" | "smtpPort"
43
+ >;
41
44
 
42
45
  const connectedAccountSchema = z.object({ accountId: z.string() });
43
46
 
@@ -47,7 +50,7 @@ const connectedAccountSchema = z.object({ accountId: z.string() });
47
50
  const TLS_FAILURE = /certificate|self[- ]signed|wrong[ _]version[ _]number|EPROTO|ERR_SSL|ERR_TLS/i;
48
51
 
49
52
  // A fixed preview: any slice of the JSON document would expose the host or the user.
50
- const CREDENTIAL_PREVIEW = "IMAP login";
53
+ export const CREDENTIAL_PREVIEW = "IMAP login";
51
54
 
52
55
  const SECRET_TRANSACTION_REASON =
53
56
  "the credential secret is written on the request transaction so a failed account connect rolls it back too";
@@ -59,7 +62,7 @@ export function deriveSmtpHost(imapHost: string): string {
59
62
  return /^imap\./i.test(imapHost) ? `smtp.${imapHost.slice("imap.".length)}` : imapHost;
60
63
  }
61
64
 
62
- export function credentialDocumentOf(payload: ConnectMailboxPayload): ImapCredentialDocument {
65
+ export function credentialDocumentOf(payload: ConnectMailboxLoginFields): ImapCredentialDocument {
63
66
  const { host, port, user, password, smtpPort } = payload;
64
67
  const smtpHost = payload.smtpHost || deriveSmtpHost(host);
65
68
  return { host, port, secure: true, user, password, smtpHost, smtpPort };
@@ -96,6 +99,29 @@ async function checkSmtpLogin(doc: ImapCredentialDocument): Promise<ConnectFailu
96
99
  }
97
100
  }
98
101
 
102
+ // Checks the IMAP login, then the SMTP login of the outgoing server; shared by connect and reconnect.
103
+ export async function checkMailboxLogin(
104
+ doc: ImapCredentialDocument,
105
+ log: { warn(message: string, data?: Record<string, unknown>): void },
106
+ ): Promise<BeforeTransactionResult<null>> {
107
+ try {
108
+ await connectOnce(doc, true);
109
+ } catch (err) {
110
+ const { reason, i18nKey } = IMAP_CONNECT_ERRORS[imapConnectFailureOf(err)];
111
+ log.warn("imap connect check failed", { reason });
112
+ return writeFailure(new UnprocessableError(reason, { i18nKey }));
113
+ }
114
+ const smtpFailure = await checkSmtpLogin(doc);
115
+ if (smtpFailure) {
116
+ const { reason, i18nKey } = SMTP_CONNECT_ERRORS[smtpFailure];
117
+ log.warn("smtp connect check failed", { reason });
118
+ return writeFailure(
119
+ new UnprocessableError(reason, { i18nKey, i18nParams: { port: doc.smtpPort } }),
120
+ );
121
+ }
122
+ return { isSuccess: true, data: null };
123
+ }
124
+
99
125
  export const connectMailboxHandler = defineWriteHandler({
100
126
  name: "connect-mailbox",
101
127
  description:
@@ -108,25 +134,8 @@ export const connectMailboxHandler = defineWriteHandler({
108
134
  // used to scan ports or to guess passwords at someone else's mail server.
109
135
  rateLimit: { per: "tenant+handler", limit: 10, windowSeconds: 600 },
110
136
  escapeHatch: { grants: ["unsafeRaw"], reason: SECRET_TRANSACTION_REASON },
111
- beforeTransaction: async (event, ctx): Promise<BeforeTransactionResult<null>> => {
112
- const doc = credentialDocumentOf(event.payload);
113
- try {
114
- await connectOnce(doc, true);
115
- } catch (err) {
116
- const { reason, i18nKey } = IMAP_CONNECT_ERRORS[imapConnectFailureOf(err)];
117
- ctx.obs.log.warn("imap connect check failed", { reason });
118
- return writeFailure(new UnprocessableError(reason, { i18nKey }));
119
- }
120
- const smtpFailure = await checkSmtpLogin(doc);
121
- if (smtpFailure) {
122
- const { reason, i18nKey } = SMTP_CONNECT_ERRORS[smtpFailure];
123
- ctx.obs.log.warn("smtp connect check failed", { reason });
124
- return writeFailure(
125
- new UnprocessableError(reason, { i18nKey, i18nParams: { port: doc.smtpPort } }),
126
- );
127
- }
128
- return { isSuccess: true, data: null };
129
- },
137
+ beforeTransaction: (event, ctx) =>
138
+ checkMailboxLogin(credentialDocumentOf(event.payload), ctx.obs.log),
130
139
  handler: async (event, ctx) => {
131
140
  const { displayName, address } = event.payload;
132
141
  const connected = await ctx.dispatch.write(InboundMailFoundationHandlers.connectAccount, {
@@ -3,6 +3,18 @@ export const IMAP_PROVIDER_KEY = "imap";
3
3
 
4
4
  export const ImapHandlers = {
5
5
  connectMailbox: "inbound-provider-imap:write:connect-mailbox",
6
+ reconnectMailbox: "inbound-provider-imap:write:reconnect-mailbox",
7
+ } as const;
8
+
9
+ export const ImapQueries = {
10
+ reconnectPrefill: "inbound-provider-imap:query:reconnect-prefill",
11
+ } as const;
12
+
13
+ export const IMAP_RECONNECT_ERRORS = {
14
+ notPasswordImap: {
15
+ reason: "reconnect_not_password_imap",
16
+ i18nKey: "inboundMail.imap.reconnect.errors.notPasswordImap",
17
+ },
6
18
  } as const;
7
19
 
8
20
  export const IMAP_CONNECT_ERRORS = {
@@ -51,7 +51,9 @@ import {
51
51
  parseImapCursor,
52
52
  toRawInboundMessage,
53
53
  } from "./imap-client.ts";
54
- import { imapConnectScreen } from "./screens.ts";
54
+ import { reconnectMailboxHandler } from "./reconnect-mailbox.ts";
55
+ import { reconnectPrefillQuery } from "./reconnect-prefill.query.ts";
56
+ import { imapConnectScreen, imapReconnectScreen } from "./screens.ts";
55
57
  import { sendFromImapAccount } from "./send-mail.ts";
56
58
 
57
59
  export { IMAP_PROVIDER_KEY } from "./constants.ts";
@@ -248,7 +250,7 @@ export function createInboundProviderImapFeature() {
248
250
  FEATURE_NAME,
249
251
  (r) => {
250
252
  r.describe(
251
- '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.',
253
+ '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.',
252
254
  );
253
255
  r.uiHints({
254
256
  displayLabel: "Inbound Mail · IMAP",
@@ -260,7 +262,10 @@ export function createInboundProviderImapFeature() {
260
262
  r.envSchema(mailAllowedPrivateHostsEnvSchema);
261
263
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
262
264
  r.writeHandler(connectMailboxHandler);
265
+ r.writeHandler(reconnectMailboxHandler);
266
+ r.queryHandler(reconnectPrefillQuery);
263
267
  r.screen(imapConnectScreen);
268
+ r.screen(imapReconnectScreen);
264
269
  r.translations({ keys: INBOUND_PROVIDER_IMAP_I18N });
265
270
  },
266
271
  { dedupeOptions: {} },
@@ -1,13 +1,23 @@
1
- import { IMAP_CONNECT_SCREEN_ID } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
1
+ import {
2
+ IMAP_CONNECT_SCREEN_ID,
3
+ IMAP_RECONNECT_SCREEN_ID,
4
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
5
  import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
3
6
  import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
4
- import { FEATURE_NAME, IMAP_CONNECT_ERRORS, SMTP_CONNECT_ERRORS } from "./constants.ts";
7
+ import {
8
+ FEATURE_NAME,
9
+ IMAP_CONNECT_ERRORS,
10
+ IMAP_RECONNECT_ERRORS,
11
+ SMTP_CONNECT_ERRORS,
12
+ } from "./constants.ts";
5
13
 
6
14
  const formFieldKey = (field: string): string =>
7
15
  fieldLabelKey(FEATURE_NAME, ACTION_FORM_ENTITY, field);
8
16
 
9
17
  export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en: string }>> = {
10
18
  [`screen:${IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
19
+ [`screen:${IMAP_RECONNECT_SCREEN_ID}.title`]: { en: "Reconnect mailbox" },
20
+ [formFieldKey("accountId")]: { en: "Mailbox" },
11
21
  [formFieldKey("displayName")]: { en: "Name" },
12
22
  [formFieldKey("address")]: { en: "Email address" },
13
23
  [formFieldKey("host")]: { en: "IMAP server" },
@@ -22,6 +32,13 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
22
32
  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.",
23
33
  },
24
34
  "inboundMail.imap.connect.submit": { en: "Check and connect" },
35
+ "inboundMail.imap.reconnect.hint": {
36
+ en: "The sign-in is checked before saving. Then the system fetches the mail again.",
37
+ },
38
+ "inboundMail.imap.reconnect.submit": { en: "Check and reconnect" },
39
+ [IMAP_RECONNECT_ERRORS.notPasswordImap.i18nKey]: {
40
+ en: "Only password IMAP mailboxes can be reconnected here.",
41
+ },
25
42
  [IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
26
43
  en: "The IMAP server cannot be reached or is not allowed. Check the server name.",
27
44
  },
@@ -0,0 +1,47 @@
1
+ // Loads the mailbox a reconnect targets. The db is tenant-scoped, so another tenant's account id
2
+ // finds nothing.
3
+
4
+ import {
5
+ InboundMailAccountStatuses,
6
+ InboundMailAuthMethods,
7
+ mailAccountsProjectionTable,
8
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
9
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
10
+ import {
11
+ failNotFound,
12
+ failUnprocessable,
13
+ UnprocessableError,
14
+ type WriteFailure,
15
+ writeFailure,
16
+ } from "@cosmicdrift/kumiko-framework/errors";
17
+ import { IMAP_PROVIDER_KEY, IMAP_RECONNECT_ERRORS } from "./constants.ts";
18
+
19
+ type ReconnectAccountRow = {
20
+ readonly id: string;
21
+ readonly provider: string;
22
+ readonly authMethod: string;
23
+ readonly displayName: string | null;
24
+ readonly status: string | null;
25
+ };
26
+
27
+ export type ReconnectAccountLookup =
28
+ | { readonly isSuccess: true; readonly row: ReconnectAccountRow }
29
+ | WriteFailure;
30
+
31
+ export async function findReconnectableAccount(
32
+ db: Pick<TenantDb, "fetchOne">,
33
+ accountId: string,
34
+ ): Promise<ReconnectAccountLookup> {
35
+ const row = await db.fetchOne<ReconnectAccountRow>(mailAccountsProjectionTable, {
36
+ id: accountId,
37
+ });
38
+ if (!row) return failNotFound("mail-account", accountId);
39
+ if (row.status === InboundMailAccountStatuses.disconnected) {
40
+ return failUnprocessable("account_disconnected", { accountId });
41
+ }
42
+ if (row.provider !== IMAP_PROVIDER_KEY || row.authMethod !== InboundMailAuthMethods.password) {
43
+ const { reason, i18nKey } = IMAP_RECONNECT_ERRORS.notPasswordImap;
44
+ return writeFailure(new UnprocessableError(reason, { i18nKey, details: { accountId } }));
45
+ }
46
+ return { isSuccess: true, row };
47
+ }
@@ -0,0 +1,73 @@
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
+
6
+ import {
7
+ InboundMailAccountStatuses,
8
+ InboundMailFoundationHandlers,
9
+ inboundCredentialSecretKey,
10
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
11
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
12
+ import {
13
+ type BeforeTransactionResult,
14
+ defineWriteHandler,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import * as z from "zod";
17
+ import {
18
+ CREDENTIAL_PREVIEW,
19
+ checkMailboxLogin,
20
+ connectMailboxSchema,
21
+ credentialDocumentOf,
22
+ } from "./connect-mailbox.ts";
23
+ import { findReconnectableAccount } from "./reconnect-account.ts";
24
+
25
+ const reconnectMailboxSchema = connectMailboxSchema
26
+ .pick({ host: true, port: true, user: true, password: true, smtpHost: true, smtpPort: true })
27
+ .extend({ accountId: z.uuid() });
28
+
29
+ const SECRET_TRANSACTION_REASON =
30
+ "the credential secret is written on the request transaction so a failed reconnect rolls the account status back too";
31
+
32
+ export const reconnectMailboxHandler = defineWriteHandler({
33
+ name: "reconnect-mailbox",
34
+ description:
35
+ "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.",
36
+ // The payload carries the plaintext password: as a tool call it would reach the model provider.
37
+ agent: { expose: false },
38
+ schema: reconnectMailboxSchema,
39
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
40
+ // Same cap as connect: each call opens a connection to a host the caller picks.
41
+ rateLimit: { per: "tenant+handler", limit: 10, windowSeconds: 600 },
42
+ escapeHatch: { grants: ["unsafeRaw"], reason: SECRET_TRANSACTION_REASON },
43
+ beforeTransaction: async (event, ctx): Promise<BeforeTransactionResult<null>> => {
44
+ const lookup = await findReconnectableAccount(ctx.db, event.payload.accountId);
45
+ if (!lookup.isSuccess) return lookup;
46
+ return checkMailboxLogin(credentialDocumentOf(event.payload), ctx.obs.log);
47
+ },
48
+ handler: async (event, ctx) => {
49
+ const { accountId } = event.payload;
50
+ const reactivated = await ctx.dispatch.write(InboundMailFoundationHandlers.updateAccount, {
51
+ accountId,
52
+ status: InboundMailAccountStatuses.active,
53
+ watchState: "idle",
54
+ });
55
+ if (!reactivated.isSuccess) return reactivated;
56
+
57
+ const secrets = requireSecretsContext(
58
+ { secrets: ctx.services.secrets, user: ctx.user },
59
+ "reconnect-mailbox",
60
+ ).withRunner(ctx.db.unsafeRaw());
61
+ await secrets.set(
62
+ event.user.tenantId,
63
+ inboundCredentialSecretKey(accountId),
64
+ JSON.stringify(credentialDocumentOf(event.payload)),
65
+ {
66
+ redact: () => CREDENTIAL_PREVIEW,
67
+ hint: `IMAP credentials for inbound mail account ${accountId}`,
68
+ updatedBy: event.user.id,
69
+ },
70
+ );
71
+ return { isSuccess: true, data: { accountId } };
72
+ },
73
+ });
@@ -0,0 +1,38 @@
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
+
4
+ import { inboundCredentialSecretKey } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
5
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
6
+ import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
7
+ import * as z from "zod";
8
+ import { FEATURE_NAME } from "./constants.ts";
9
+ import { parseImapCredentialDocument } from "./credential-document.ts";
10
+ import { findReconnectableAccount } from "./reconnect-account.ts";
11
+
12
+ const reconnectPrefillSchema = z.object({ accountId: z.uuid() }).strict();
13
+
14
+ export const reconnectPrefillQuery: QueryHandlerDef = {
15
+ name: "reconnect-prefill",
16
+ schema: reconnectPrefillSchema,
17
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
18
+ // Only the reconnect form needs these settings; the agent has no write to use them with.
19
+ agent: { expose: false },
20
+ description:
21
+ "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.",
22
+ handler: async (query, ctx) => {
23
+ const { accountId } = reconnectPrefillSchema.parse(query.payload);
24
+ const lookup = await findReconnectableAccount(ctx.db, accountId);
25
+ if (!lookup.isSuccess) return null;
26
+
27
+ const secrets = requireSecretsContext(
28
+ { secrets: ctx.services.secrets, user: ctx.user },
29
+ FEATURE_NAME,
30
+ );
31
+ const stored = await secrets.get(ctx.user.tenantId, inboundCredentialSecretKey(accountId));
32
+ const parsed = stored ? parseImapCredentialDocument(stored.reveal()) : undefined;
33
+ const displayName = lookup.row.displayName ?? "";
34
+ if (!parsed?.ok) return { accountId, displayName };
35
+ const { host, port, user, smtpHost, smtpPort } = parsed.doc;
36
+ return { accountId, displayName, host, port, user, smtpHost, smtpPort };
37
+ },
38
+ };
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  IMAP_CONNECT_SCREEN_ID,
3
+ IMAP_RECONNECT_SCREEN_ID,
3
4
  MAIL_ACCOUNT_LIST_SCREEN_QN,
4
5
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
5
6
  import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
7
- import { ImapHandlers, SMTP_SUBMISSION_PORT } from "./constants.ts";
8
+ import { ImapHandlers, ImapQueries, SMTP_SUBMISSION_PORT } from "./constants.ts";
8
9
 
9
10
  // Reached from the toolbar of the foundation's mail account list, never from the nav.
10
11
  export const imapConnectScreen: ActionFormScreenDefinition = {
@@ -16,13 +17,22 @@ export const imapConnectScreen: ActionFormScreenDefinition = {
16
17
  displayName: { type: "text", required: true, maxLength: 200 },
17
18
  address: { type: "text", required: true, maxLength: 320, format: "email" },
18
19
  host: { type: "text", required: true, maxLength: 255 },
19
- port: { type: "number", integer: true, required: true, min: 1, max: 65535, default: 993 },
20
+ port: {
21
+ type: "number",
22
+ integer: true,
23
+ grouping: false,
24
+ required: true,
25
+ min: 1,
26
+ max: 65535,
27
+ default: 993,
28
+ },
20
29
  user: { type: "text", required: true, maxLength: 320 },
21
30
  password: { type: "text", required: true, format: "password", sensitive: true },
22
31
  smtpHost: { type: "text", maxLength: 255 },
23
32
  smtpPort: {
24
33
  type: "number",
25
34
  integer: true,
35
+ grouping: false,
26
36
  required: true,
27
37
  min: 1,
28
38
  max: 65535,
@@ -50,3 +60,66 @@ export const imapConnectScreen: ActionFormScreenDefinition = {
50
60
  access: { roles: ["SystemAdmin", "TenantAdmin"] },
51
61
  dormant: true,
52
62
  };
63
+
64
+ // Reached from the "Reconnect" row action of an account with a sign-in error. The record id comes
65
+ // from the path; the load query returns the stored server settings, never the password.
66
+ export const imapReconnectScreen: ActionFormScreenDefinition = {
67
+ id: IMAP_RECONNECT_SCREEN_ID,
68
+ type: "actionForm",
69
+ agent: { expose: false },
70
+ handler: ImapHandlers.reconnectMailbox,
71
+ load: { query: ImapQueries.reconnectPrefill, idParam: "accountId" },
72
+ fields: {
73
+ accountId: { type: "text", required: true },
74
+ displayName: { type: "text", maxLength: 200 },
75
+ host: { type: "text", required: true, maxLength: 255 },
76
+ port: {
77
+ type: "number",
78
+ integer: true,
79
+ grouping: false,
80
+ required: true,
81
+ min: 1,
82
+ max: 65535,
83
+ default: 993,
84
+ },
85
+ user: { type: "text", required: true, maxLength: 320 },
86
+ password: { type: "text", required: true, format: "password", sensitive: true },
87
+ smtpHost: { type: "text", maxLength: 255 },
88
+ smtpPort: {
89
+ type: "number",
90
+ integer: true,
91
+ grouping: false,
92
+ required: true,
93
+ min: 1,
94
+ max: 65535,
95
+ default: SMTP_SUBMISSION_PORT,
96
+ },
97
+ },
98
+ layout: {
99
+ sections: [
100
+ {
101
+ title: i18nKey("inboundMail.imap.connect.server"),
102
+ description: i18nKey("inboundMail.imap.reconnect.hint"),
103
+ // The id only travels in the payload; the admin recognises the mailbox by its name.
104
+ fields: [
105
+ { field: "accountId", visible: false },
106
+ { field: "displayName", readOnly: true, submit: false },
107
+ "host",
108
+ "port",
109
+ "user",
110
+ "password",
111
+ ],
112
+ },
113
+ {
114
+ title: i18nKey("inboundMail.imap.connect.outgoing"),
115
+ description: i18nKey("inboundMail.imap.connect.outgoingHint"),
116
+ fields: ["smtpHost", "smtpPort"],
117
+ },
118
+ ],
119
+ },
120
+ submitLabel: i18nKey("inboundMail.imap.reconnect.submit"),
121
+ redirect: MAIL_ACCOUNT_LIST_SCREEN_QN,
122
+ cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_QN,
123
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
124
+ dormant: true,
125
+ };
@@ -11,13 +11,16 @@
11
11
  // **Pattern basis:** mail-transport-inmemory (module-level state +
12
12
  // test-helper exports). Not for production.
13
13
 
14
- import type {
15
- InboundFetchResult,
16
- InboundMailProviderPlugin,
17
- MailAccountRecord,
18
- RawInboundMessage,
19
- SyncCursorPayload,
14
+ import {
15
+ InboundAuthError,
16
+ type InboundFetchResult,
17
+ type InboundMailContext,
18
+ type InboundMailProviderPlugin,
19
+ type MailAccountRecord,
20
+ type RawInboundMessage,
21
+ type SyncCursorPayload,
20
22
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
23
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
21
24
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
22
25
 
23
26
  const FEATURE_NAME = "inbound-provider-inmemory";
@@ -38,6 +41,7 @@ type AccountState = {
38
41
  watch: WatchHandlers | null;
39
42
  nextFetchError: unknown;
40
43
  nextVerifyError: unknown;
44
+ credentialSecretKey: string | null;
41
45
  };
42
46
 
43
47
  const accounts = new Map<string, AccountState>();
@@ -45,7 +49,13 @@ const accounts = new Map<string, AccountState>();
45
49
  function stateFor(accountId: string): AccountState {
46
50
  let state = accounts.get(accountId);
47
51
  if (!state) {
48
- state = { log: [], watch: null, nextFetchError: null, nextVerifyError: null };
52
+ state = {
53
+ log: [],
54
+ watch: null,
55
+ nextFetchError: null,
56
+ nextVerifyError: null,
57
+ credentialSecretKey: null,
58
+ };
49
59
  accounts.set(accountId, state);
50
60
  }
51
61
  return state;
@@ -71,6 +81,25 @@ export function failNextVerifyWith(accountId: string, err: unknown): void {
71
81
  stateFor(accountId).nextVerifyError = err;
72
82
  }
73
83
 
84
+ /** Test helper: every fetch reads this tenant secret first, like a real
85
+ * provider reads its stored credentials; a missing secret is an auth error. */
86
+ export function requireCredentialSecretOnFetch(accountId: string, key: string): void {
87
+ stateFor(accountId).credentialSecretKey = key;
88
+ }
89
+
90
+ async function readCredentialSecret(
91
+ ctx: InboundMailContext,
92
+ account: MailAccountRecord,
93
+ key: string,
94
+ ): Promise<void> {
95
+ const secrets = requireSecretsContext(
96
+ { secrets: ctx.secrets, auditUserId: ctx.auditUserId },
97
+ FEATURE_NAME,
98
+ );
99
+ const secret = await secrets.get(account.tenantId, key);
100
+ if (!secret) throw new InboundAuthError(`${FEATURE_NAME}: no credential secret ${key}`);
101
+ }
102
+
74
103
  /** Test helper: simulates the live connection terminating (the supervisor
75
104
  * must restart with backoff). */
76
105
  export function emitWatchError(accountId: string, err: unknown): void {
@@ -111,13 +140,16 @@ export const inMemoryInboundMailPlugin: InboundMailProviderPlugin = {
111
140
  throw err;
112
141
  }
113
142
  },
114
- fetch: async (_ctx, account, cursor, opts): Promise<InboundFetchResult> => {
143
+ fetch: async (ctx, account, cursor, opts): Promise<InboundFetchResult> => {
115
144
  const state = stateFor(account.id);
116
145
  if (state.nextFetchError) {
117
146
  const err = state.nextFetchError;
118
147
  state.nextFetchError = null;
119
148
  throw err;
120
149
  }
150
+ if (state.credentialSecretKey) {
151
+ await readCredentialSecret(ctx, account, state.credentialSecretKey);
152
+ }
121
153
  const offset = offsetOf(cursor);
122
154
  const slice = state.log.slice(offset, offset + opts.maxMessages);
123
155
  const nextOffset = offset + slice.length;
@@ -7,6 +7,7 @@ export {
7
7
  failNextVerifyWith,
8
8
  INMEMORY_PROVIDER_KEY,
9
9
  isWatching,
10
+ requireCredentialSecretOnFetch,
10
11
  resetInboundInMemory,
11
12
  seedInboundMessage,
12
13
  } from "./feature.ts";
@@ -1434,7 +1434,7 @@
1434
1434
  },
1435
1435
  {
1436
1436
  "name": "inbound-provider-imap",
1437
- "description": "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.",
1437
+ "description": "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.",
1438
1438
  "toggleableDefault": null,
1439
1439
  "requires": [
1440
1440
  "inbound-mail-foundation",
@@ -1453,7 +1453,8 @@
1453
1453
  "configKeys": [],
1454
1454
  "secrets": [],
1455
1455
  "writeHandlers": [
1456
- "inbound-provider-imap:write:connect-mailbox"
1456
+ "inbound-provider-imap:write:connect-mailbox",
1457
+ "inbound-provider-imap:write:reconnect-mailbox"
1457
1458
  ],
1458
1459
  "uiHints": {
1459
1460
  "displayLabel": "Inbound Mail · IMAP",
@@ -24,6 +24,10 @@ mail-triage ← this sample (the app-side cons
24
24
  3. The handler-trigger fans the write out to the worker lane; the triage job records `{ from, subject, scope, threadHint }`.
25
25
  4. A replay of the same `providerMessageId` reports `duplicate: true`, creates no second row — and the keyed triage store stays at one item.
26
26
 
27
+ ## Sign-in errors
28
+
29
+ When the provider rejects the stored login, the supervisor sets the account to `auth_error` and stops fetching. With `inbound-provider-imap` mounted, the mail accounts list offers **Reconnect** on that row: the form `mail-account-reconnect-imap` is prefilled with the stored server settings (never the password), `inbound-provider-imap:write:reconnect-mailbox` checks the new login and puts the same account back to `active`. Threads, sync cursor and seen messages stay, so the next poll continues where the account stopped instead of ingesting the mailbox again. The cases are covered in `inbound-provider-imap/__tests__/reconnect-mailbox.integration.test.ts`; they need greenmail, which this sample's in-memory provider does not.
30
+
27
31
  ## Tests
28
32
 
29
33
  - E2E through `createAllInOneEntrypoint` (HTTP → dispatcher → BullMQ worker lane, no framework-internal shortcuts): connect → ingest → triage item appears.