@cosmicdrift/kumiko-bundled-features 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.
- package/dist/inbound-mail-foundation/handlers/connect-account.write.js +1 -1
- package/dist/inbound-mail-foundation/handlers/update-account.write.js +1 -1
- package/dist/inbound-mail-foundation/i18n.js +1 -0
- package/dist/inbound-mail-foundation/index.d.ts +1 -1
- package/dist/inbound-mail-foundation/index.js +1 -1
- package/dist/inbound-mail-foundation/screens.d.ts +1 -0
- package/dist/inbound-mail-foundation/screens.js +11 -0
- package/dist/inbound-mail-foundation/types.d.ts +1 -1
- package/dist/inbound-mail-foundation/watch-supervisor.js +16 -7
- package/dist/inbound-provider-imap/connect-mailbox.d.ts +8 -3
- package/dist/inbound-provider-imap/connect-mailbox.js +22 -21
- package/dist/inbound-provider-imap/constants.d.ts +10 -0
- package/dist/inbound-provider-imap/constants.js +10 -0
- package/dist/inbound-provider-imap/feature.js +7 -2
- package/dist/inbound-provider-imap/i18n.js +11 -2
- package/dist/inbound-provider-imap/reconnect-account.d.ts +15 -0
- package/dist/inbound-provider-imap/reconnect-account.js +20 -0
- package/dist/inbound-provider-imap/reconnect-mailbox.d.ts +12 -0
- package/dist/inbound-provider-imap/reconnect-mailbox.js +48 -0
- package/dist/inbound-provider-imap/reconnect-prefill.query.d.ts +2 -0
- package/dist/inbound-provider-imap/reconnect-prefill.query.js +31 -0
- package/dist/inbound-provider-imap/screens.d.ts +1 -0
- package/dist/inbound-provider-imap/screens.js +74 -3
- package/dist/inbound-provider-inmemory/feature.d.ts +4 -1
- package/dist/inbound-provider-inmemory/feature.js +24 -2
- package/dist/inbound-provider-inmemory/index.d.ts +1 -1
- package/dist/inbound-provider-inmemory/index.js +1 -1
- package/package.json +11 -11
- package/src/inbound-mail-foundation/changes.json +24 -0
- package/src/inbound-provider-imap/changes.json +12 -0
|
@@ -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",
|
|
@@ -11,7 +11,7 @@ import type { OutboundMailMessage, OutboundMailSent } from "./outbound-mail.ts";
|
|
|
11
11
|
* secrets — tenant secret reads (IMAP password, OAuth refresh token)
|
|
12
12
|
* auditUserId — audit identity for secret reads. Handler path: unset,
|
|
13
13
|
* requireSecretsContext falls back to ctx.user.id; worker/provider
|
|
14
|
-
* path:
|
|
14
|
+
* path: the supervisor sets the system identity.
|
|
15
15
|
* metricsFor — metrics handle of a feature by name, same behavior as
|
|
16
16
|
* HandlerContext.metricsFor: unregistered metrics are a silent
|
|
17
17
|
* no-op instead of a throw.
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
// racing a new holder.
|
|
45
45
|
import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
46
46
|
import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/db";
|
|
47
|
-
import {
|
|
47
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
48
|
+
import { systemScopedRunner, tenantScopedSecrets, } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
|
|
48
49
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
49
50
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
50
51
|
import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.js";
|
|
@@ -80,13 +81,21 @@ export function createInboundMailSupervisor(deps) {
|
|
|
80
81
|
// configured TTL — real deployments (default 90s) never hit it.
|
|
81
82
|
const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
|
|
82
83
|
const log = deps.log ?? (() => { });
|
|
84
|
+
// The supervisor runs outside any request: secret reads need an audit
|
|
85
|
+
// identity, and under enforced row-level security they need the account's
|
|
86
|
+
// tenant scope, or the credential row is invisible and reads as missing.
|
|
87
|
+
const providerCtx = {
|
|
88
|
+
...deps.providerCtx,
|
|
89
|
+
auditUserId: deps.providerCtx.auditUserId ?? SYSTEM_USER_ID,
|
|
90
|
+
...(deps.providerCtx.secrets && { secrets: tenantScopedSecrets(deps.providerCtx.secrets) }),
|
|
91
|
+
};
|
|
83
92
|
const systemDb = systemScopedRunner(deps.db, "mail sync supervisor polls accounts and cursors of all tenants outside a request");
|
|
84
93
|
const oauthTokens = deps.oauthTokens ??
|
|
85
|
-
(
|
|
94
|
+
(providerCtx.secrets
|
|
86
95
|
? createOAuthAccessTokenManager({
|
|
87
96
|
db: systemDb,
|
|
88
|
-
secrets:
|
|
89
|
-
providerCtx
|
|
97
|
+
secrets: providerCtx.secrets,
|
|
98
|
+
providerCtx,
|
|
90
99
|
})
|
|
91
100
|
: undefined);
|
|
92
101
|
// Undefined for non-OAuth accounts. A refresh failure propagates into the
|
|
@@ -294,7 +303,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
294
303
|
// exhausted.
|
|
295
304
|
for (;;) {
|
|
296
305
|
const accessToken = await resolveAccessToken(account, plugin);
|
|
297
|
-
const result = await plugin.fetch(
|
|
306
|
+
const result = await plugin.fetch(providerCtx, account, cursor, {
|
|
298
307
|
backfillWindowDays,
|
|
299
308
|
maxMessages: budget,
|
|
300
309
|
filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
|
|
@@ -317,7 +326,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
317
326
|
for (const account of accounts) {
|
|
318
327
|
let plugin;
|
|
319
328
|
try {
|
|
320
|
-
plugin = resolveInboundProviderForAccount(
|
|
329
|
+
plugin = resolveInboundProviderForAccount(providerCtx, account);
|
|
321
330
|
}
|
|
322
331
|
catch (err) {
|
|
323
332
|
log(`inbound-mail: ${describeError(err)}`);
|
|
@@ -461,7 +470,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
461
470
|
state.starting = true;
|
|
462
471
|
try {
|
|
463
472
|
const accessToken = await resolveAccessToken(account, plugin);
|
|
464
|
-
const stop = await plugin.watch(
|
|
473
|
+
const stop = await plugin.watch(providerCtx, account, {
|
|
465
474
|
onMessages: async (msgs) => {
|
|
466
475
|
try {
|
|
467
476
|
await ingestBatch(account, msgs, "watch");
|
|
@@ -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
|
|
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:
|
|
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:
|
|
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 {
|
|
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,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,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: {
|
|
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
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type InboundMailProviderPlugin, type RawInboundMessage } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
2
2
|
export declare const INMEMORY_PROVIDER_KEY = "inmemory";
|
|
3
3
|
/** Test helper: adds a message to the mailbox. If a watch is active, it is
|
|
4
4
|
* pushed immediately (IDLE simulation); the next fetch also returns it by
|
|
@@ -9,6 +9,9 @@ export declare function seedInboundMessage(accountId: string, msg: RawInboundMes
|
|
|
9
9
|
export declare function failNextFetchWith(accountId: string, err: unknown): void;
|
|
10
10
|
/** Test helper: injects a one-shot error into the next verify. */
|
|
11
11
|
export declare function failNextVerifyWith(accountId: string, err: unknown): void;
|
|
12
|
+
/** Test helper: every fetch reads this tenant secret first, like a real
|
|
13
|
+
* provider reads its stored credentials; a missing secret is an auth error. */
|
|
14
|
+
export declare function requireCredentialSecretOnFetch(accountId: string, key: string): void;
|
|
12
15
|
/** Test helper: simulates the live connection terminating (the supervisor
|
|
13
16
|
* must restart with backoff). */
|
|
14
17
|
export declare function emitWatchError(accountId: string, err: unknown): void;
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
//
|
|
11
11
|
// **Pattern basis:** mail-transport-inmemory (module-level state +
|
|
12
12
|
// test-helper exports). Not for production.
|
|
13
|
+
import { InboundAuthError, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
14
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
13
15
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
16
|
const FEATURE_NAME = "inbound-provider-inmemory";
|
|
15
17
|
export const INMEMORY_PROVIDER_KEY = "inmemory";
|
|
@@ -17,7 +19,13 @@ const accounts = new Map();
|
|
|
17
19
|
function stateFor(accountId) {
|
|
18
20
|
let state = accounts.get(accountId);
|
|
19
21
|
if (!state) {
|
|
20
|
-
state = {
|
|
22
|
+
state = {
|
|
23
|
+
log: [],
|
|
24
|
+
watch: null,
|
|
25
|
+
nextFetchError: null,
|
|
26
|
+
nextVerifyError: null,
|
|
27
|
+
credentialSecretKey: null,
|
|
28
|
+
};
|
|
21
29
|
accounts.set(accountId, state);
|
|
22
30
|
}
|
|
23
31
|
return state;
|
|
@@ -40,6 +48,17 @@ export function failNextFetchWith(accountId, err) {
|
|
|
40
48
|
export function failNextVerifyWith(accountId, err) {
|
|
41
49
|
stateFor(accountId).nextVerifyError = err;
|
|
42
50
|
}
|
|
51
|
+
/** Test helper: every fetch reads this tenant secret first, like a real
|
|
52
|
+
* provider reads its stored credentials; a missing secret is an auth error. */
|
|
53
|
+
export function requireCredentialSecretOnFetch(accountId, key) {
|
|
54
|
+
stateFor(accountId).credentialSecretKey = key;
|
|
55
|
+
}
|
|
56
|
+
async function readCredentialSecret(ctx, account, key) {
|
|
57
|
+
const secrets = requireSecretsContext({ secrets: ctx.secrets, auditUserId: ctx.auditUserId }, FEATURE_NAME);
|
|
58
|
+
const secret = await secrets.get(account.tenantId, key);
|
|
59
|
+
if (!secret)
|
|
60
|
+
throw new InboundAuthError(`${FEATURE_NAME}: no credential secret ${key}`);
|
|
61
|
+
}
|
|
43
62
|
/** Test helper: simulates the live connection terminating (the supervisor
|
|
44
63
|
* must restart with backoff). */
|
|
45
64
|
export function emitWatchError(accountId, err) {
|
|
@@ -75,13 +94,16 @@ export const inMemoryInboundMailPlugin = {
|
|
|
75
94
|
throw err;
|
|
76
95
|
}
|
|
77
96
|
},
|
|
78
|
-
fetch: async (
|
|
97
|
+
fetch: async (ctx, account, cursor, opts) => {
|
|
79
98
|
const state = stateFor(account.id);
|
|
80
99
|
if (state.nextFetchError) {
|
|
81
100
|
const err = state.nextFetchError;
|
|
82
101
|
state.nextFetchError = null;
|
|
83
102
|
throw err;
|
|
84
103
|
}
|
|
104
|
+
if (state.credentialSecretKey) {
|
|
105
|
+
await readCredentialSecret(ctx, account, state.credentialSecretKey);
|
|
106
|
+
}
|
|
85
107
|
const offset = offsetOf(cursor);
|
|
86
108
|
const slice = state.log.slice(offset, offset + opts.maxMessages);
|
|
87
109
|
const nextOffset = offset + slice.length;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createInboundProviderInMemoryFeature, emitWatchError, failNextFetchWith, failNextVerifyWith, INMEMORY_PROVIDER_KEY, isWatching, resetInboundInMemory, seedInboundMessage, } from "./feature.ts";
|
|
1
|
+
export { createInboundProviderInMemoryFeature, emitWatchError, failNextFetchWith, failNextVerifyWith, INMEMORY_PROVIDER_KEY, isWatching, requireCredentialSecretOnFetch, resetInboundInMemory, seedInboundMessage, } from "./feature.ts";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Public API of the inbound-provider-inmemory bundled-feature.
|
|
2
|
-
export { createInboundProviderInMemoryFeature, emitWatchError, failNextFetchWith, failNextVerifyWith, INMEMORY_PROVIDER_KEY, isWatching, resetInboundInMemory, seedInboundMessage, } from "./feature.js";
|
|
2
|
+
export { createInboundProviderInMemoryFeature, emitWatchError, failNextFetchWith, failNextVerifyWith, INMEMORY_PROVIDER_KEY, isWatching, requireCredentialSecretOnFetch, resetInboundInMemory, seedInboundMessage, } from "./feature.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
562
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
563
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
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.
|
|
1146
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1147
|
-
"@cosmicdrift/kumiko-locale-fr": "0.
|
|
1148
|
-
"@cosmicdrift/kumiko-locale-it": "0.
|
|
1149
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
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.
|
|
1152
|
+
"@cosmicdrift/kumiko-testing": "0.393.0"
|
|
1153
1153
|
},
|
|
1154
1154
|
"peerDependencies": {
|
|
1155
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
1155
|
+
"@cosmicdrift/kumiko-testing": "0.393.0"
|
|
1156
1156
|
},
|
|
1157
1157
|
"peerDependenciesMeta": {
|
|
1158
1158
|
"@cosmicdrift/kumiko-testing": {
|
|
@@ -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",
|
|
@@ -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",
|