@cosmicdrift/kumiko-samples 0.391.0 → 0.392.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/admin-shell/changes.json +6 -0
- package/packages/bundled-features/src/admin-shell/feature.ts +60 -31
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -2
- package/packages/bundled-features/src/cap-overview/changes.json +12 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +3 -2
- package/packages/bundled-features/src/cap-overview/i18n.ts +4 -0
- package/packages/bundled-features/src/cap-overview/types.ts +4 -1
- package/packages/bundled-features/src/cap-overview/web/cap-amount-format.ts +37 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +138 -54
- package/packages/bundled-features/src/cap-overview/web/cap-usage-bar.tsx +18 -4
- package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -3
- package/packages/bundled-features/src/cap-overview/web/usage-math.ts +5 -1
- package/packages/bundled-features/src/channel-email/app-transport.ts +37 -0
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +6 -0
- package/packages/bundled-features/src/channel-email/index.ts +5 -0
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +2 -22
- package/packages/bundled-features/src/foundation-shared/changes.json +6 -0
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-display-name.ts +20 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +17 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +8 -45
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +14 -0
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +118 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +13 -8
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +12 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +156 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +42 -0
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +49 -1
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +12 -35
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +48 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +44 -0
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +73 -0
- package/packages/bundled-features/src/inbound-provider-imap/sent-folder.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +131 -0
- package/packages/bundled-features/src/legal-pages/README.md +5 -10
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +9 -1
- package/packages/bundled-features/src/mail-foundation/probe.ts +43 -22
- package/packages/bundled-features/src/readiness/built-in-probes.ts +15 -2
- package/packages/bundled-features/src/readiness/changes.json +30 -0
- package/packages/bundled-features/src/readiness/constants.ts +2 -0
- package/packages/bundled-features/src/readiness/i18n.ts +7 -1
- package/packages/bundled-features/src/readiness/index.ts +6 -1
- package/packages/bundled-features/src/readiness/screens.ts +25 -2
- package/packages/bundled-features/src/readiness/visible-results.ts +2 -1
- package/packages/bundled-features/src/renderer-foundation/README.md +19 -21
- package/packages/bundled-features/src/template-resolver/README.md +144 -165
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +17 -6
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/schema/download-token.ts +5 -3
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +5 -4
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/samples/apps/cap-billing-demo/bin/server.ts +7 -7
- package/samples/apps/cap-billing-demo/src/feature.ts +38 -38
- package/samples/apps/cap-billing-demo/src/run-config.ts +20 -20
- package/samples/apps/cap-billing-demo/src/tier-map.ts +14 -14
- package/samples/apps/config-demo/src/app/server.ts +4 -4
- package/samples/apps/config-demo/src/features/demo/feature.ts +4 -4
- package/samples/apps/marketing-demo/playwright.config.ts +3 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +2 -2
- package/samples/apps/marketing-demo/src/app/seed-data.ts +9 -9
- package/samples/apps/marketing-demo/src/app/seed.ts +14 -14
- package/samples/apps/marketing-demo/src/app/server.ts +7 -7
- package/samples/apps/marketing-demo/src/app/shell.tsx +3 -3
- package/samples/apps/marketing-demo/src/features/assets/feature.ts +3 -3
- package/samples/apps/marketing-demo/src/features/assets/i18n.ts +7 -7
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +2 -2
- package/samples/apps/showcase/src/app/client.tsx +3 -3
- package/samples/apps/showcase/src/app/seed-items.ts +14 -14
- package/samples/apps/showcase/src/app/server.ts +7 -7
- package/samples/apps/showcase/src/features/demos/feature.ts +13 -13
- package/samples/apps/showcase/src/features/demos/i18n.ts +2 -2
- package/samples/apps/showcase/src/features/demos/web/components/page.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/index.ts +4 -4
- package/samples/apps/showcase/src/features/demos/web/pages/dialog.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/inputs.tsx +8 -8
- package/samples/apps/showcase/src/features/demos/web/pages/layout.tsx +5 -5
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +3 -3
- package/samples/apps/showcase/src/features/items/feature.ts +8 -8
- package/samples/apps/showcase/src/features/items/i18n.ts +3 -3
- package/samples/apps/showcase/src/features/items/index.ts +3 -3
- package/samples/apps/showcase/src/features/items/schema/index.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +27 -27
- package/samples/apps/showcase/src/features/items/web/index.ts +6 -6
- package/samples/apps/styleguide/src/app/seed.ts +3 -3
- package/samples/apps/styleguide/src/app/server.ts +2 -2
- package/samples/apps/styleguide/src/app/shell.tsx +4 -4
- package/samples/apps/styleguide/src/features/content/web.tsx +6 -6
- package/samples/apps/styleguide/src/features/examples/feature.ts +8 -8
- package/samples/apps/styleguide/src/features/gallery/feature.ts +9 -9
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +12 -12
- package/samples/apps/styleguide/src/features/widgets/feature.ts +2 -2
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +3 -3
- package/samples/apps/styleguide/src/features/widgets/web/DashboardKpiIcon.tsx +4 -4
- package/samples/apps/ui-walkthrough/playwright.config.ts +6 -6
- package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +5 -5
- package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +13 -13
- package/samples/apps/ui-walkthrough/src/app/client.tsx +2 -2
- package/samples/apps/ui-walkthrough/src/app/server.ts +14 -14
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -3
- package/samples/apps/use-all-bundled/bin/main.ts +5 -5
- package/samples/apps/use-all-bundled/feature-manifest.json +10 -4
- package/samples/apps/use-all-bundled/kumiko/schema.ts +7 -7
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +20 -20
- package/samples/apps/use-all-bundled/src/app/auth-constants.ts +4 -4
- package/samples/apps/use-all-bundled/src/app/client.tsx +4 -4
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +12 -12
- package/samples/apps/use-all-bundled/src/app/seed.ts +6 -6
- package/samples/apps/user-data-rights-demo/src/feature.ts +23 -23
- package/samples/apps/workspaces/playwright.config.ts +5 -5
- package/samples/apps/workspaces/src/app/auth-constants.ts +3 -3
- package/samples/apps/workspaces/src/app/server.ts +16 -16
- package/samples/apps/workspaces/src/app/shell.tsx +2 -2
- package/samples/apps/workspaces/src/features/demo/schema/order.ts +4 -4
- package/samples/apps/workspaces/src/features/demo-driver/feature.ts +5 -5
- package/samples/recipes/apex-surface-auth/src/feature.ts +31 -31
- package/samples/recipes/designer-demo/src/feature.ts +6 -6
- package/samples/recipes/encrypted-tenant-config/src/feature.ts +20 -20
- package/samples/recipes/lane-routing/src/feature.ts +5 -5
- package/samples/recipes/legal-pages/src/feature.ts +29 -29
- package/samples/recipes/record-detail-layout/src/feature.ts +21 -2
- package/samples/recipes/seed-migration/scripts/smoke.ts +20 -20
- package/samples/recipes/seed-migration/seeds/2026-01-01-initial-tenants.ts +11 -11
- package/samples/recipes/seed-migration/seeds/2026-02-01-fix-admin-roles.ts +13 -13
- package/samples/recipes/user-data-rights/src/feature.ts +13 -13
|
@@ -49,6 +49,7 @@ 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
53
|
import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
53
54
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
54
55
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
@@ -185,11 +186,15 @@ export function createInboundMailSupervisor(
|
|
|
185
186
|
// configured TTL — real deployments (default 90s) never hit it.
|
|
186
187
|
const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
|
|
187
188
|
const log = deps.log ?? (() => {});
|
|
189
|
+
const systemDb = systemScopedRunner(
|
|
190
|
+
deps.db,
|
|
191
|
+
"mail sync supervisor polls accounts and cursors of all tenants outside a request",
|
|
192
|
+
);
|
|
188
193
|
const oauthTokens =
|
|
189
194
|
deps.oauthTokens ??
|
|
190
195
|
(deps.providerCtx.secrets
|
|
191
196
|
? createOAuthAccessTokenManager({
|
|
192
|
-
db:
|
|
197
|
+
db: systemDb,
|
|
193
198
|
secrets: deps.providerCtx.secrets,
|
|
194
199
|
providerCtx: deps.providerCtx,
|
|
195
200
|
})
|
|
@@ -219,7 +224,7 @@ export function createInboundMailSupervisor(
|
|
|
219
224
|
// decrypted (providers never work with ciphertext).
|
|
220
225
|
// ---------------------------------------------------------------
|
|
221
226
|
async function listActiveAccounts(): Promise<readonly MailAccountRecord[]> {
|
|
222
|
-
const rows = await selectMany<ActiveMailAccountRow>(
|
|
227
|
+
const rows = await selectMany<ActiveMailAccountRow>(systemDb, mailAccountsProjectionTable, {
|
|
223
228
|
status: InboundMailAccountStatuses.active,
|
|
224
229
|
});
|
|
225
230
|
const piiKms = configuredPiiSubjectKms();
|
|
@@ -264,7 +269,7 @@ export function createInboundMailSupervisor(
|
|
|
264
269
|
// Cursor-Persistenz (unmanaged direct-write store).
|
|
265
270
|
// ---------------------------------------------------------------
|
|
266
271
|
async function loadCursor(accountId: string): Promise<SyncCursorPayload | null> {
|
|
267
|
-
const row = await fetchOne<{ cursor: string }>(
|
|
272
|
+
const row = await fetchOne<{ cursor: string }>(systemDb, syncCursorTable, {
|
|
268
273
|
accountId,
|
|
269
274
|
scope: CURSOR_SCOPE,
|
|
270
275
|
});
|
|
@@ -284,13 +289,13 @@ export function createInboundMailSupervisor(
|
|
|
284
289
|
const accountId = account.id;
|
|
285
290
|
const now = Temporal.Now.instant().toString();
|
|
286
291
|
const serialized = JSON.stringify(cursor);
|
|
287
|
-
const existing = await fetchOne<{ id: string }>(
|
|
292
|
+
const existing = await fetchOne<{ id: string }>(systemDb, syncCursorTable, {
|
|
288
293
|
accountId,
|
|
289
294
|
scope: CURSOR_SCOPE,
|
|
290
295
|
});
|
|
291
296
|
if (existing) {
|
|
292
297
|
await updateMany(
|
|
293
|
-
|
|
298
|
+
systemDb,
|
|
294
299
|
syncCursorTable,
|
|
295
300
|
{ cursor: serialized, updatedAt: now },
|
|
296
301
|
{ accountId, scope: CURSOR_SCOPE },
|
|
@@ -298,7 +303,7 @@ export function createInboundMailSupervisor(
|
|
|
298
303
|
// skip: the update path is complete; do not fall through to the insert branch.
|
|
299
304
|
return;
|
|
300
305
|
}
|
|
301
|
-
await insertOne(
|
|
306
|
+
await insertOne(systemDb, syncCursorTable, {
|
|
302
307
|
id: crypto.randomUUID(),
|
|
303
308
|
// Direct-write store: no executor stamps the NOT NULL tenant_id for us.
|
|
304
309
|
tenantId: account.tenantId,
|
|
@@ -313,7 +318,7 @@ export function createInboundMailSupervisor(
|
|
|
313
318
|
// No deleteMany import is needed: an empty cursor parses to null, so the
|
|
314
319
|
// next fetch runs as a backfill.
|
|
315
320
|
await updateMany(
|
|
316
|
-
|
|
321
|
+
systemDb,
|
|
317
322
|
syncCursorTable,
|
|
318
323
|
{ cursor: "", updatedAt: Temporal.Now.instant().toString() },
|
|
319
324
|
{ accountId, scope: CURSOR_SCOPE },
|
|
@@ -414,7 +419,7 @@ export function createInboundMailSupervisor(
|
|
|
414
419
|
const seenIds = new Set<string>();
|
|
415
420
|
for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
|
|
416
421
|
const seen = await selectMany<{ providerMessageId: string }>(
|
|
417
|
-
|
|
422
|
+
systemDb,
|
|
418
423
|
seenMessageTable,
|
|
419
424
|
{
|
|
420
425
|
tenantId: account.tenantId,
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "IMAP mailboxes can be connected from a form",
|
|
6
|
+
"detail": "The new write handler inbound-provider-imap:write:connect-mailbox (TenantAdmin, SystemAdmin, not exposed to the agent, 10 calls per tenant in 10 minutes) checks the IMAP login before the transaction, then creates a shared mail account and stores host, port, user and password in its credential secret in the same transaction. It always connects with implicit TLS (default port 993); there is no plaintext option, and a secure:false in the payload is ignored. A failed check stores nothing and answers with one of four texts: host not reachable or not allowed, login rejected, TLS failed, no connection. The form screen mail-account-connect-imap has a masked password field and no TLS toggle. New keys in all locale packages."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.392.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "IMAP mailboxes send over their own SMTP server",
|
|
12
|
+
"detail": "The connect form gets an optional field \"Outgoing server (SMTP)\". Left empty, the IMAP host is used, with smtp. in place of a leading imap.. connect-mailbox now also checks the SMTP login on port 465 (implicit TLS, same user and password, same host guard) and answers a failure with its own text (\"Receiving works, but sending does not: ...\"). The credential document gains smtpHost and smtpPort (default 465). The provider implements send: it sends over SMTP with From = the mailbox address and display name, then stores the same message in the sent folder (special use \\Sent, else Sent, Gesendet or INBOX.Sent); a failed copy is only logged. Accounts stored without smtpHost answer mail_send_not_configured. New keys in all locale packages."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.390.1",
|
|
4
16
|
"type": "fix",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// connect-mailbox: the form path for a password IMAP mailbox. The IMAP login and then the SMTP
|
|
2
|
+
// login of the outgoing server are checked before the transaction opens; only then the account
|
|
3
|
+
// stream and its credential secret are written, both on the request transaction, so a failure
|
|
4
|
+
// leaves neither behind.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
InboundAuthError,
|
|
8
|
+
InboundMailAuthMethods,
|
|
9
|
+
InboundMailFoundationHandlers,
|
|
10
|
+
inboundCredentialSecretKey,
|
|
11
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
12
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
13
|
+
import {
|
|
14
|
+
type BeforeTransactionResult,
|
|
15
|
+
defineWriteHandler,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
18
|
+
import * as z from "zod";
|
|
19
|
+
import { IMAP_CONNECT_ERRORS, IMAP_PROVIDER_KEY, SMTP_CONNECT_ERRORS } from "./constants.ts";
|
|
20
|
+
import {
|
|
21
|
+
type ImapCredentialDocument,
|
|
22
|
+
imapCredentialDocumentSchema,
|
|
23
|
+
SMTP_IMPLICIT_TLS_PORT,
|
|
24
|
+
smtpHostSchema,
|
|
25
|
+
} from "./credential-document.ts";
|
|
26
|
+
import { connectOnce, IMAP_HOST_UNREACHABLE_MESSAGE } from "./imap-client.ts";
|
|
27
|
+
import { type SmtpFailure, SmtpFailureError, verifySmtpLogin } from "./smtp-client.ts";
|
|
28
|
+
|
|
29
|
+
// No `secure` and no SMTP port input: the password never crosses the wire in clear text, so a
|
|
30
|
+
// payload's secure:false or smtpPort is stripped like any unknown key and both logins always use
|
|
31
|
+
// implicit TLS (SMTP on port 465).
|
|
32
|
+
const connectMailboxSchema = imapCredentialDocumentSchema
|
|
33
|
+
.pick({ host: true, port: true, user: true })
|
|
34
|
+
.extend({
|
|
35
|
+
displayName: z.string().min(1).max(200),
|
|
36
|
+
address: z.email().max(320),
|
|
37
|
+
password: z.string().min(1).max(1000),
|
|
38
|
+
smtpHost: z.union([z.literal(""), smtpHostSchema]).nullish(),
|
|
39
|
+
});
|
|
40
|
+
type ConnectMailboxPayload = z.infer<typeof connectMailboxSchema>;
|
|
41
|
+
|
|
42
|
+
const connectedAccountSchema = z.object({ accountId: z.string() });
|
|
43
|
+
|
|
44
|
+
// Matched on the mapped error text, which also carries the host, so only markers no host name
|
|
45
|
+
// contains: a bare "tls" or "ssl" would misread a host like ssl.example.com.
|
|
46
|
+
// BoringSSL (Bun) spells the handshake error WRONG_VERSION_NUMBER, OpenSSL (Node) with spaces.
|
|
47
|
+
const TLS_FAILURE = /certificate|self[- ]signed|wrong[ _]version[ _]number|EPROTO|ERR_SSL|ERR_TLS/i;
|
|
48
|
+
|
|
49
|
+
// A fixed preview: any slice of the JSON document would expose the host or the user.
|
|
50
|
+
const CREDENTIAL_PREVIEW = "IMAP login";
|
|
51
|
+
|
|
52
|
+
const SECRET_TRANSACTION_REASON =
|
|
53
|
+
"the credential secret is written on the request transaction so a failed account connect rolls it back too";
|
|
54
|
+
|
|
55
|
+
type ConnectFailure = keyof typeof IMAP_CONNECT_ERRORS;
|
|
56
|
+
|
|
57
|
+
// Most hosters name the outgoing server like the incoming one, or with smtp. for imap.
|
|
58
|
+
export function deriveSmtpHost(imapHost: string): string {
|
|
59
|
+
return /^imap\./i.test(imapHost) ? `smtp.${imapHost.slice("imap.".length)}` : imapHost;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function credentialDocumentOf(payload: ConnectMailboxPayload): ImapCredentialDocument {
|
|
63
|
+
const { host, port, user, password } = payload;
|
|
64
|
+
const smtpHost = payload.smtpHost || deriveSmtpHost(host);
|
|
65
|
+
return { host, port, secure: true, user, password, smtpHost, smtpPort: SMTP_IMPLICIT_TLS_PORT };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The raw error carries the host and the server response; the tenant only gets the category.
|
|
69
|
+
// A blocked host and an unresolvable one share one text on purpose (see createImapClient).
|
|
70
|
+
function imapConnectFailureOf(err: unknown): ConnectFailure {
|
|
71
|
+
const message = err instanceof Error ? err.message : "";
|
|
72
|
+
if (message === IMAP_HOST_UNREACHABLE_MESSAGE) return "hostUnreachable";
|
|
73
|
+
if (err instanceof InboundAuthError) return "loginRejected";
|
|
74
|
+
if (TLS_FAILURE.test(message)) return "tlsFailed";
|
|
75
|
+
return "connectFailed";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// verify never sends a mail, so a refused message cannot happen here; anything else answering
|
|
79
|
+
// with an SMTP reply is a server we could not use.
|
|
80
|
+
function smtpConnectFailureOf(failure: SmtpFailure): ConnectFailure {
|
|
81
|
+
return failure === "rejected" ? "connectFailed" : failure;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function checkSmtpLogin(doc: ImapCredentialDocument): Promise<ConnectFailure | undefined> {
|
|
85
|
+
const { smtpHost, smtpPort, user, password } = doc;
|
|
86
|
+
// A derived host from an odd IMAP host name could not be stored as a valid document.
|
|
87
|
+
if (!smtpHost || !password || !smtpHostSchema.safeParse(smtpHost).success) {
|
|
88
|
+
return "hostUnreachable";
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await verifySmtpLogin({ host: smtpHost, port: smtpPort, user, password });
|
|
92
|
+
return undefined;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof SmtpFailureError) return smtpConnectFailureOf(err.failure);
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const connectMailboxHandler = defineWriteHandler({
|
|
100
|
+
name: "connect-mailbox",
|
|
101
|
+
description:
|
|
102
|
+
"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.",
|
|
103
|
+
// The payload carries the plaintext password: as a tool call it would reach the model provider.
|
|
104
|
+
agent: { expose: false },
|
|
105
|
+
schema: connectMailboxSchema,
|
|
106
|
+
access: { roles: ["SystemAdmin", "TenantAdmin"] },
|
|
107
|
+
// Each call opens a connection to a host the caller picks; the cap keeps the form from being
|
|
108
|
+
// used to scan ports or to guess passwords at someone else's mail server.
|
|
109
|
+
rateLimit: { per: "tenant+handler", limit: 10, windowSeconds: 600 },
|
|
110
|
+
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(new UnprocessableError(reason, { i18nKey }));
|
|
125
|
+
}
|
|
126
|
+
return { isSuccess: true, data: null };
|
|
127
|
+
},
|
|
128
|
+
handler: async (event, ctx) => {
|
|
129
|
+
const { displayName, address } = event.payload;
|
|
130
|
+
const connected = await ctx.dispatch.write(InboundMailFoundationHandlers.connectAccount, {
|
|
131
|
+
provider: IMAP_PROVIDER_KEY,
|
|
132
|
+
authMethod: InboundMailAuthMethods.password,
|
|
133
|
+
displayName,
|
|
134
|
+
address,
|
|
135
|
+
scope: "shared",
|
|
136
|
+
});
|
|
137
|
+
if (!connected.isSuccess) return connected;
|
|
138
|
+
const { accountId } = connectedAccountSchema.parse(connected.data);
|
|
139
|
+
|
|
140
|
+
const secrets = requireSecretsContext(
|
|
141
|
+
{ secrets: ctx.services.secrets, user: ctx.user },
|
|
142
|
+
"connect-mailbox",
|
|
143
|
+
).withRunner(ctx.db.unsafeRaw());
|
|
144
|
+
await secrets.set(
|
|
145
|
+
event.user.tenantId,
|
|
146
|
+
inboundCredentialSecretKey(accountId),
|
|
147
|
+
JSON.stringify(credentialDocumentOf(event.payload)),
|
|
148
|
+
{
|
|
149
|
+
redact: () => CREDENTIAL_PREVIEW,
|
|
150
|
+
hint: `IMAP credentials for inbound mail account ${accountId}`,
|
|
151
|
+
updatedBy: event.user.id,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
return { isSuccess: true, data: { accountId } };
|
|
155
|
+
},
|
|
156
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const FEATURE_NAME = "inbound-provider-imap";
|
|
2
|
+
export const IMAP_PROVIDER_KEY = "imap";
|
|
3
|
+
|
|
4
|
+
export const ImapHandlers = {
|
|
5
|
+
connectMailbox: "inbound-provider-imap:write:connect-mailbox",
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export const IMAP_CONNECT_ERRORS = {
|
|
9
|
+
hostUnreachable: {
|
|
10
|
+
reason: "imap_host_unreachable",
|
|
11
|
+
i18nKey: "inboundMail.imap.connect.errors.hostUnreachable",
|
|
12
|
+
},
|
|
13
|
+
loginRejected: {
|
|
14
|
+
reason: "imap_login_rejected",
|
|
15
|
+
i18nKey: "inboundMail.imap.connect.errors.loginRejected",
|
|
16
|
+
},
|
|
17
|
+
tlsFailed: { reason: "imap_tls_failed", i18nKey: "inboundMail.imap.connect.errors.tlsFailed" },
|
|
18
|
+
connectFailed: {
|
|
19
|
+
reason: "imap_connect_failed",
|
|
20
|
+
i18nKey: "inboundMail.imap.connect.errors.connectFailed",
|
|
21
|
+
},
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
// Same categories as IMAP_CONNECT_ERRORS, for the outgoing server checked after the IMAP login.
|
|
25
|
+
export const SMTP_CONNECT_ERRORS = {
|
|
26
|
+
hostUnreachable: {
|
|
27
|
+
reason: "smtp_host_unreachable",
|
|
28
|
+
i18nKey: "inboundMail.imap.connect.errors.smtpHostUnreachable",
|
|
29
|
+
},
|
|
30
|
+
loginRejected: {
|
|
31
|
+
reason: "smtp_login_rejected",
|
|
32
|
+
i18nKey: "inboundMail.imap.connect.errors.smtpLoginRejected",
|
|
33
|
+
},
|
|
34
|
+
tlsFailed: {
|
|
35
|
+
reason: "smtp_tls_failed",
|
|
36
|
+
i18nKey: "inboundMail.imap.connect.errors.smtpTlsFailed",
|
|
37
|
+
},
|
|
38
|
+
connectFailed: {
|
|
39
|
+
reason: "smtp_connect_failed",
|
|
40
|
+
i18nKey: "inboundMail.imap.connect.errors.smtpConnectFailed",
|
|
41
|
+
},
|
|
42
|
+
} as const satisfies Record<keyof typeof IMAP_CONNECT_ERRORS, unknown>;
|
|
@@ -9,13 +9,35 @@
|
|
|
9
9
|
// at rest.
|
|
10
10
|
//
|
|
11
11
|
// The account's authMethod determines the interpretation:
|
|
12
|
-
// password → { host, port, secure, user, password }
|
|
12
|
+
// password → { host, port, secure, user, password, smtpHost?, smtpPort }
|
|
13
13
|
// xoauth2 → { host, port, secure, user, accessToken } — V1 fallback:
|
|
14
14
|
// OAuth providers (M365/Gmail) handle token acquisition and
|
|
15
15
|
// refresh; the imap provider only consumes a valid access
|
|
16
16
|
// token from the slot.
|
|
17
|
+
//
|
|
18
|
+
// smtpHost is the outgoing server for sending from the mailbox, with the same user and password
|
|
19
|
+
// over implicit TLS. Documents written before sending existed have none: sending reports
|
|
20
|
+
// "not set up" instead of guessing a host.
|
|
17
21
|
|
|
22
|
+
import {
|
|
23
|
+
InboundAuthError,
|
|
24
|
+
type InboundMailContext,
|
|
25
|
+
inboundCredentialSecretKey,
|
|
26
|
+
type MailAccountRecord,
|
|
27
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
28
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
18
29
|
import * as z from "zod";
|
|
30
|
+
import { FEATURE_NAME } from "./constants.ts";
|
|
31
|
+
|
|
32
|
+
export const SMTP_IMPLICIT_TLS_PORT = 465;
|
|
33
|
+
|
|
34
|
+
// A bare host name: a port, path or whitespace would point the connect somewhere the guard did
|
|
35
|
+
// not check.
|
|
36
|
+
export const smtpHostSchema = z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.max(255)
|
|
40
|
+
.regex(/^[^\s:/@]+$/);
|
|
19
41
|
|
|
20
42
|
export const imapCredentialDocumentSchema = z.object({
|
|
21
43
|
host: z.string().min(1).max(255),
|
|
@@ -24,6 +46,8 @@ export const imapCredentialDocumentSchema = z.object({
|
|
|
24
46
|
user: z.string().min(1).max(320),
|
|
25
47
|
password: z.string().min(1).optional(),
|
|
26
48
|
accessToken: z.string().min(1).optional(),
|
|
49
|
+
smtpHost: smtpHostSchema.optional(),
|
|
50
|
+
smtpPort: z.number().int().min(1).max(65535).default(SMTP_IMPLICIT_TLS_PORT),
|
|
27
51
|
});
|
|
28
52
|
export type ImapCredentialDocument = z.infer<typeof imapCredentialDocumentSchema>;
|
|
29
53
|
|
|
@@ -49,3 +73,27 @@ export function parseImapCredentialDocument(raw: string): ParseCredentialResult
|
|
|
49
73
|
}
|
|
50
74
|
return { ok: true, doc: parsed.data };
|
|
51
75
|
}
|
|
76
|
+
|
|
77
|
+
export async function readCredentialDocument(
|
|
78
|
+
ctx: InboundMailContext,
|
|
79
|
+
account: MailAccountRecord,
|
|
80
|
+
): Promise<ImapCredentialDocument> {
|
|
81
|
+
const secrets = requireSecretsContext(
|
|
82
|
+
{ secrets: ctx.secrets, auditUserId: ctx.auditUserId },
|
|
83
|
+
FEATURE_NAME,
|
|
84
|
+
);
|
|
85
|
+
const secret = await secrets.get(account.tenantId, inboundCredentialSecretKey(account.id));
|
|
86
|
+
if (!secret) {
|
|
87
|
+
throw new InboundAuthError(
|
|
88
|
+
`${FEATURE_NAME}: no credential document in secret slot for account ${account.id} — ` +
|
|
89
|
+
`store the IMAP connection JSON under ${inboundCredentialSecretKey(account.id)} first`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const parsed = parseImapCredentialDocument(secret.reveal());
|
|
93
|
+
if (!parsed.ok) {
|
|
94
|
+
throw new InboundAuthError(
|
|
95
|
+
`${FEATURE_NAME}: account ${account.id}: ${parsed.reason}${parsed.detail ? `: ${parsed.detail}` : ""}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return parsed.doc;
|
|
99
|
+
}
|
|
@@ -26,20 +26,20 @@
|
|
|
26
26
|
import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
27
27
|
import {
|
|
28
28
|
INBOUND_MAIL_PROVIDER_EXTENSION,
|
|
29
|
-
InboundAuthError,
|
|
30
29
|
type InboundFetchResult,
|
|
31
30
|
type InboundMailContext,
|
|
32
31
|
type InboundMailProviderPlugin,
|
|
33
|
-
inboundCredentialSecretKey,
|
|
34
32
|
type MailAccountRecord,
|
|
35
33
|
type RawInboundMessage,
|
|
36
34
|
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
37
|
-
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
38
35
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
39
36
|
import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
|
|
40
37
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
41
38
|
import type { ImapFlow, MailboxObject } from "imapflow";
|
|
42
|
-
import {
|
|
39
|
+
import { connectMailboxHandler } from "./connect-mailbox.ts";
|
|
40
|
+
import { FEATURE_NAME, IMAP_PROVIDER_KEY } from "./constants.ts";
|
|
41
|
+
import { readCredentialDocument } from "./credential-document.ts";
|
|
42
|
+
import { INBOUND_PROVIDER_IMAP_I18N } from "./i18n.ts";
|
|
43
43
|
import {
|
|
44
44
|
assertUidValidity,
|
|
45
45
|
coerceDate,
|
|
@@ -51,40 +51,13 @@ import {
|
|
|
51
51
|
parseImapCursor,
|
|
52
52
|
toRawInboundMessage,
|
|
53
53
|
} from "./imap-client.ts";
|
|
54
|
+
import { imapConnectScreen } from "./screens.ts";
|
|
55
|
+
import { sendFromImapAccount } from "./send-mail.ts";
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
export const IMAP_PROVIDER_KEY = "imap";
|
|
57
|
+
export { IMAP_PROVIDER_KEY } from "./constants.ts";
|
|
57
58
|
|
|
58
59
|
export { setImapMailHostLookup } from "./imap-client.ts";
|
|
59
60
|
|
|
60
|
-
// =============================================================================
|
|
61
|
-
// Credential-Read — per-Account-Slot, Worker-tauglich (slim ctx).
|
|
62
|
-
// =============================================================================
|
|
63
|
-
|
|
64
|
-
async function readCredentialDocument(
|
|
65
|
-
ctx: InboundMailContext,
|
|
66
|
-
account: MailAccountRecord,
|
|
67
|
-
): Promise<ImapCredentialDocument> {
|
|
68
|
-
const secrets = requireSecretsContext(
|
|
69
|
-
{ secrets: ctx.secrets, auditUserId: ctx.auditUserId },
|
|
70
|
-
FEATURE_NAME,
|
|
71
|
-
);
|
|
72
|
-
const secret = await secrets.get(account.tenantId, inboundCredentialSecretKey(account.id));
|
|
73
|
-
if (!secret) {
|
|
74
|
-
throw new InboundAuthError(
|
|
75
|
-
`${FEATURE_NAME}: no credential document in secret slot for account ${account.id} — ` +
|
|
76
|
-
`store the IMAP connection JSON under ${inboundCredentialSecretKey(account.id)} first`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
const parsed = parseImapCredentialDocument(secret.reveal());
|
|
80
|
-
if (!parsed.ok) {
|
|
81
|
-
throw new InboundAuthError(
|
|
82
|
-
`${FEATURE_NAME}: account ${account.id}: ${parsed.reason}${parsed.detail ? `: ${parsed.detail}` : ""}`,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
return parsed.doc;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
61
|
// =============================================================================
|
|
89
62
|
// fetch — inkrementeller UID-Sync
|
|
90
63
|
// =============================================================================
|
|
@@ -264,6 +237,7 @@ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
|
|
|
264
237
|
probe: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), true),
|
|
265
238
|
fetch: fetchMessages,
|
|
266
239
|
watch: watchMailbox,
|
|
240
|
+
send: sendFromImapAccount,
|
|
267
241
|
// No oauth block: password authentication runs through the credential
|
|
268
242
|
// form (connect-account + Secret-Write); XOAUTH2 token flows belong to
|
|
269
243
|
// the OAuth providers (M365/Gmail).
|
|
@@ -274,7 +248,7 @@ export function createInboundProviderImapFeature() {
|
|
|
274
248
|
FEATURE_NAME,
|
|
275
249
|
(r) => {
|
|
276
250
|
r.describe(
|
|
277
|
-
'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.
|
|
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.',
|
|
278
252
|
);
|
|
279
253
|
r.uiHints({
|
|
280
254
|
displayLabel: "Inbound Mail · IMAP",
|
|
@@ -285,6 +259,9 @@ export function createInboundProviderImapFeature() {
|
|
|
285
259
|
r.requires({ features: ["secrets"] });
|
|
286
260
|
r.envSchema(mailAllowedPrivateHostsEnvSchema);
|
|
287
261
|
r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
|
|
262
|
+
r.writeHandler(connectMailboxHandler);
|
|
263
|
+
r.screen(imapConnectScreen);
|
|
264
|
+
r.translations({ keys: INBOUND_PROVIDER_IMAP_I18N });
|
|
288
265
|
},
|
|
289
266
|
{ dedupeOptions: {} },
|
|
290
267
|
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { IMAP_CONNECT_SCREEN_ID } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
2
|
+
import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
4
|
+
import { FEATURE_NAME, IMAP_CONNECT_ERRORS, SMTP_CONNECT_ERRORS } from "./constants.ts";
|
|
5
|
+
|
|
6
|
+
const formFieldKey = (field: string): string =>
|
|
7
|
+
fieldLabelKey(FEATURE_NAME, ACTION_FORM_ENTITY, field);
|
|
8
|
+
|
|
9
|
+
export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en: string }>> = {
|
|
10
|
+
[`screen:${IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
|
|
11
|
+
[formFieldKey("displayName")]: { en: "Name" },
|
|
12
|
+
[formFieldKey("address")]: { en: "Email address" },
|
|
13
|
+
[formFieldKey("host")]: { en: "IMAP server" },
|
|
14
|
+
[formFieldKey("port")]: { en: "Port" },
|
|
15
|
+
[formFieldKey("user")]: { en: "User name" },
|
|
16
|
+
[formFieldKey("password")]: { en: "Password" },
|
|
17
|
+
[formFieldKey("smtpHost")]: { en: "Outgoing server (SMTP)" },
|
|
18
|
+
"inboundMail.imap.connect.server": { en: "Server sign-in" },
|
|
19
|
+
"inboundMail.imap.connect.outgoing": { en: "Sending" },
|
|
20
|
+
"inboundMail.imap.connect.outgoingHint": {
|
|
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.",
|
|
22
|
+
},
|
|
23
|
+
"inboundMail.imap.connect.submit": { en: "Check and connect" },
|
|
24
|
+
[IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
|
|
25
|
+
en: "The IMAP server cannot be reached or is not allowed. Check the server name.",
|
|
26
|
+
},
|
|
27
|
+
[IMAP_CONNECT_ERRORS.loginRejected.i18nKey]: {
|
|
28
|
+
en: "The IMAP server rejected the sign-in. Check the user name and password.",
|
|
29
|
+
},
|
|
30
|
+
[IMAP_CONNECT_ERRORS.tlsFailed.i18nKey]: {
|
|
31
|
+
en: "The encrypted connection failed. Check the port and the TLS setting.",
|
|
32
|
+
},
|
|
33
|
+
[IMAP_CONNECT_ERRORS.connectFailed.i18nKey]: {
|
|
34
|
+
en: "No connection to the IMAP server. Check the server name and port.",
|
|
35
|
+
},
|
|
36
|
+
[SMTP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
|
|
37
|
+
en: "Receiving works, but sending does not: the outgoing server cannot be reached or is not allowed. Check its name.",
|
|
38
|
+
},
|
|
39
|
+
[SMTP_CONNECT_ERRORS.loginRejected.i18nKey]: {
|
|
40
|
+
en: "Receiving works, but sending does not: the outgoing server rejected the sign-in.",
|
|
41
|
+
},
|
|
42
|
+
[SMTP_CONNECT_ERRORS.tlsFailed.i18nKey]: {
|
|
43
|
+
en: "Receiving works, but sending does not: the encrypted connection to the outgoing server on port 465 failed.",
|
|
44
|
+
},
|
|
45
|
+
[SMTP_CONNECT_ERRORS.connectFailed.i18nKey]: {
|
|
46
|
+
en: "Receiving works, but sending does not: no connection to the outgoing server on port 465.",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -32,7 +32,7 @@ const log = createFallbackLogger("inbound-provider-imap");
|
|
|
32
32
|
// was blocked (private/reserved range) or merely failed to resolve, and
|
|
33
33
|
// must never include the host or the underlying error text. Server-side
|
|
34
34
|
// detail goes to `log` only.
|
|
35
|
-
const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed";
|
|
35
|
+
export const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed";
|
|
36
36
|
|
|
37
37
|
// =============================================================================
|
|
38
38
|
// Client-Factory + Fehler-Mapping
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IMAP_CONNECT_SCREEN_ID,
|
|
3
|
+
MAIL_ACCOUNT_LIST_SCREEN_QN,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
5
|
+
import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import { ImapHandlers } from "./constants.ts";
|
|
8
|
+
|
|
9
|
+
// Reached from the toolbar of the foundation's mail account list, never from the nav.
|
|
10
|
+
export const imapConnectScreen: ActionFormScreenDefinition = {
|
|
11
|
+
id: IMAP_CONNECT_SCREEN_ID,
|
|
12
|
+
type: "actionForm",
|
|
13
|
+
agent: { expose: false },
|
|
14
|
+
handler: ImapHandlers.connectMailbox,
|
|
15
|
+
fields: {
|
|
16
|
+
displayName: { type: "text", required: true, maxLength: 200 },
|
|
17
|
+
address: { type: "text", required: true, maxLength: 320, format: "email" },
|
|
18
|
+
host: { type: "text", required: true, maxLength: 255 },
|
|
19
|
+
port: { type: "number", integer: true, required: true, min: 1, max: 65535, default: 993 },
|
|
20
|
+
user: { type: "text", required: true, maxLength: 320 },
|
|
21
|
+
password: { type: "text", required: true, format: "password", sensitive: true },
|
|
22
|
+
smtpHost: { type: "text", maxLength: 255 },
|
|
23
|
+
},
|
|
24
|
+
layout: {
|
|
25
|
+
sections: [
|
|
26
|
+
// The name-based icon guess reads "address" as a postal address.
|
|
27
|
+
{ fields: ["displayName", { field: "address", icon: "mail" }] },
|
|
28
|
+
{
|
|
29
|
+
title: i18nKey("inboundMail.imap.connect.server"),
|
|
30
|
+
fields: ["host", "port", "user", "password"],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
title: i18nKey("inboundMail.imap.connect.outgoing"),
|
|
34
|
+
description: i18nKey("inboundMail.imap.connect.outgoingHint"),
|
|
35
|
+
fields: ["smtpHost"],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
submitLabel: i18nKey("inboundMail.imap.connect.submit"),
|
|
40
|
+
redirect: MAIL_ACCOUNT_LIST_SCREEN_QN,
|
|
41
|
+
cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_QN,
|
|
42
|
+
access: { roles: ["SystemAdmin", "TenantAdmin"] },
|
|
43
|
+
dormant: true,
|
|
44
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// send for the imap provider: SMTP with the mailbox's own login, From = the mailbox address.
|
|
2
|
+
// The MIME is built once, so the copy in the sent folder is byte for byte what went out.
|
|
3
|
+
|
|
4
|
+
import { sanitizeMailDisplayName } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
5
|
+
import {
|
|
6
|
+
InboundAuthError,
|
|
7
|
+
type InboundMailContext,
|
|
8
|
+
InboundTransientError,
|
|
9
|
+
type MailAccountRecord,
|
|
10
|
+
MailSendNotConfiguredError,
|
|
11
|
+
MailSendRejectedError,
|
|
12
|
+
type OutboundMailMessage,
|
|
13
|
+
type OutboundMailSent,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
15
|
+
import MailComposer from "nodemailer/lib/mail-composer";
|
|
16
|
+
import { FEATURE_NAME } from "./constants.ts";
|
|
17
|
+
import { readCredentialDocument } from "./credential-document.ts";
|
|
18
|
+
import { appendToSentFolder } from "./sent-folder.ts";
|
|
19
|
+
import { SmtpFailureError, sendRawViaSmtp } from "./smtp-client.ts";
|
|
20
|
+
|
|
21
|
+
function toFoundationSendError(err: SmtpFailureError): Error {
|
|
22
|
+
switch (err.failure) {
|
|
23
|
+
case "loginRejected":
|
|
24
|
+
return new InboundAuthError(err.message);
|
|
25
|
+
case "rejected":
|
|
26
|
+
return new MailSendRejectedError(err.message);
|
|
27
|
+
default:
|
|
28
|
+
return new InboundTransientError(err.message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stripAngles(id: string): string {
|
|
33
|
+
return id.replace(/^<|>$/g, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function sendFromImapAccount(
|
|
37
|
+
ctx: InboundMailContext,
|
|
38
|
+
account: MailAccountRecord,
|
|
39
|
+
message: OutboundMailMessage,
|
|
40
|
+
): Promise<OutboundMailSent> {
|
|
41
|
+
const doc = await readCredentialDocument(ctx, account);
|
|
42
|
+
if (!doc.smtpHost || !doc.password) {
|
|
43
|
+
throw new MailSendNotConfiguredError(
|
|
44
|
+
`${FEATURE_NAME}: account ${account.id} has no outgoing server stored`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const displayName = sanitizeMailDisplayName(account.displayName);
|
|
49
|
+
const mime = new MailComposer({
|
|
50
|
+
from: displayName ? { name: displayName, address: account.address } : account.address,
|
|
51
|
+
to: [...message.to],
|
|
52
|
+
...(message.cc && { cc: [...message.cc] }),
|
|
53
|
+
subject: message.subject,
|
|
54
|
+
...(message.text !== undefined && { text: message.text }),
|
|
55
|
+
...(message.html !== undefined && { html: message.html }),
|
|
56
|
+
...(message.inReplyTo && { inReplyTo: message.inReplyTo }),
|
|
57
|
+
...(message.references && { references: [...message.references] }),
|
|
58
|
+
}).compile();
|
|
59
|
+
const raw = await mime.build();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await sendRawViaSmtp(
|
|
63
|
+
{ host: doc.smtpHost, port: doc.smtpPort, user: doc.user, password: doc.password },
|
|
64
|
+
{ from: account.address, to: [...message.to, ...(message.cc ?? [])] },
|
|
65
|
+
raw,
|
|
66
|
+
);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw err instanceof SmtpFailureError ? toFoundationSendError(err) : err;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await appendToSentFolder(doc, raw, account.id);
|
|
72
|
+
return { messageIdHeader: stripAngles(mime.messageId()) };
|
|
73
|
+
}
|