@cosmicdrift/kumiko-samples 0.392.0 → 0.392.2
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/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +0 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +12 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +19 -7
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +10 -8
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -4
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +4 -3
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +10 -2
- package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +6 -2
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +40 -8
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.392.
|
|
3
|
+
"version": "0.392.2",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.392.
|
|
3
|
+
"version": "0.392.2",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Cap cards \"view all\" link uses the primary link style",
|
|
6
|
+
"detail": "The link under the cap cards no longer renders in muted grey; it uses the default link variant like other text links."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.392.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.2",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Mail sync no longer fails when reading IMAP credentials",
|
|
6
|
+
"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."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.392.2",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Mail sync finds the stored IMAP credentials under row-level security",
|
|
12
|
+
"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."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.392.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -24,7 +24,7 @@ import type { OutboundMailMessage, OutboundMailSent } from "./outbound-mail.ts";
|
|
|
24
24
|
* secrets — tenant secret reads (IMAP password, OAuth refresh token)
|
|
25
25
|
* auditUserId — audit identity for secret reads. Handler path: unset,
|
|
26
26
|
* requireSecretsContext falls back to ctx.user.id; worker/provider
|
|
27
|
-
* path:
|
|
27
|
+
* path: the supervisor sets the system identity.
|
|
28
28
|
* metricsFor — metrics handle of a feature by name, same behavior as
|
|
29
29
|
* HandlerContext.metricsFor: unregistered metrics are a silent
|
|
30
30
|
* no-op instead of a throw.
|
|
@@ -49,7 +49,11 @@ import {
|
|
|
49
49
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
50
50
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
51
51
|
import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/db";
|
|
52
|
-
import {
|
|
52
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
53
|
+
import {
|
|
54
|
+
systemScopedRunner,
|
|
55
|
+
tenantScopedSecrets,
|
|
56
|
+
} from "@cosmicdrift/kumiko-framework/internal/row-level-security";
|
|
53
57
|
import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
54
58
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
55
59
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
@@ -186,17 +190,25 @@ export function createInboundMailSupervisor(
|
|
|
186
190
|
// configured TTL — real deployments (default 90s) never hit it.
|
|
187
191
|
const renewIntervalMs = Math.max(50, Math.floor((leaseTtlSeconds * 1000) / 3));
|
|
188
192
|
const log = deps.log ?? (() => {});
|
|
193
|
+
// The supervisor runs outside any request: secret reads need an audit
|
|
194
|
+
// identity, and under enforced row-level security they need the account's
|
|
195
|
+
// tenant scope, or the credential row is invisible and reads as missing.
|
|
196
|
+
const providerCtx: InboundMailContext = {
|
|
197
|
+
...deps.providerCtx,
|
|
198
|
+
auditUserId: deps.providerCtx.auditUserId ?? SYSTEM_USER_ID,
|
|
199
|
+
...(deps.providerCtx.secrets && { secrets: tenantScopedSecrets(deps.providerCtx.secrets) }),
|
|
200
|
+
};
|
|
189
201
|
const systemDb = systemScopedRunner(
|
|
190
202
|
deps.db,
|
|
191
203
|
"mail sync supervisor polls accounts and cursors of all tenants outside a request",
|
|
192
204
|
);
|
|
193
205
|
const oauthTokens =
|
|
194
206
|
deps.oauthTokens ??
|
|
195
|
-
(
|
|
207
|
+
(providerCtx.secrets
|
|
196
208
|
? createOAuthAccessTokenManager({
|
|
197
209
|
db: systemDb,
|
|
198
|
-
secrets:
|
|
199
|
-
providerCtx
|
|
210
|
+
secrets: providerCtx.secrets,
|
|
211
|
+
providerCtx,
|
|
200
212
|
})
|
|
201
213
|
: undefined);
|
|
202
214
|
|
|
@@ -447,7 +459,7 @@ export function createInboundMailSupervisor(
|
|
|
447
459
|
// exhausted.
|
|
448
460
|
for (;;) {
|
|
449
461
|
const accessToken = await resolveAccessToken(account, plugin);
|
|
450
|
-
const result = await plugin.fetch(
|
|
462
|
+
const result = await plugin.fetch(providerCtx, account, cursor, {
|
|
451
463
|
backfillWindowDays,
|
|
452
464
|
maxMessages: budget,
|
|
453
465
|
filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
|
|
@@ -469,7 +481,7 @@ export function createInboundMailSupervisor(
|
|
|
469
481
|
for (const account of accounts) {
|
|
470
482
|
let plugin: InboundMailProviderPlugin;
|
|
471
483
|
try {
|
|
472
|
-
plugin = resolveInboundProviderForAccount(
|
|
484
|
+
plugin = resolveInboundProviderForAccount(providerCtx, account);
|
|
473
485
|
} catch (err) {
|
|
474
486
|
log(`inbound-mail: ${describeError(err)}`);
|
|
475
487
|
continue;
|
|
@@ -642,7 +654,7 @@ export function createInboundMailSupervisor(
|
|
|
642
654
|
try {
|
|
643
655
|
const accessToken = await resolveAccessToken(account, plugin);
|
|
644
656
|
const stop = await plugin.watch(
|
|
645
|
-
|
|
657
|
+
providerCtx,
|
|
646
658
|
account,
|
|
647
659
|
{
|
|
648
660
|
onMessages: async (msgs) => {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Mailboxes connect and send over SMTP port 587 with STARTTLS",
|
|
6
|
+
"detail": "The connect form gets a required field \"SMTP port\", preset to 587, and stores it as smtpPort in the credential document (the schema still accepts any stored port, default now 587). Port 465 uses implicit TLS; every other port uses STARTTLS with requireTLS, so the password is never sent in clear text and the connection fails when the server offers no STARTTLS. The certificate is checked against the host name on the STARTTLS upgrade as well. The SMTP failure texts name the entered port. New key for the field label in all locale packages."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.392.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -20,15 +20,14 @@ import { IMAP_CONNECT_ERRORS, IMAP_PROVIDER_KEY, SMTP_CONNECT_ERRORS } from "./c
|
|
|
20
20
|
import {
|
|
21
21
|
type ImapCredentialDocument,
|
|
22
22
|
imapCredentialDocumentSchema,
|
|
23
|
-
SMTP_IMPLICIT_TLS_PORT,
|
|
24
23
|
smtpHostSchema,
|
|
25
24
|
} from "./credential-document.ts";
|
|
26
25
|
import { connectOnce, IMAP_HOST_UNREACHABLE_MESSAGE } from "./imap-client.ts";
|
|
27
26
|
import { type SmtpFailure, SmtpFailureError, verifySmtpLogin } from "./smtp-client.ts";
|
|
28
27
|
|
|
29
|
-
// No `secure
|
|
30
|
-
//
|
|
31
|
-
//
|
|
28
|
+
// No `secure`: the password never crosses the wire in clear text, so a payload's secure:false is
|
|
29
|
+
// stripped like any unknown key. IMAP uses implicit TLS; SMTP uses implicit TLS on 465 and
|
|
30
|
+
// enforced STARTTLS on every other port.
|
|
32
31
|
const connectMailboxSchema = imapCredentialDocumentSchema
|
|
33
32
|
.pick({ host: true, port: true, user: true })
|
|
34
33
|
.extend({
|
|
@@ -36,6 +35,7 @@ const connectMailboxSchema = imapCredentialDocumentSchema
|
|
|
36
35
|
address: z.email().max(320),
|
|
37
36
|
password: z.string().min(1).max(1000),
|
|
38
37
|
smtpHost: z.union([z.literal(""), smtpHostSchema]).nullish(),
|
|
38
|
+
smtpPort: z.number().int().min(1).max(65535),
|
|
39
39
|
});
|
|
40
40
|
type ConnectMailboxPayload = z.infer<typeof connectMailboxSchema>;
|
|
41
41
|
|
|
@@ -59,10 +59,10 @@ export function deriveSmtpHost(imapHost: string): string {
|
|
|
59
59
|
return /^imap\./i.test(imapHost) ? `smtp.${imapHost.slice("imap.".length)}` : imapHost;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function credentialDocumentOf(payload: ConnectMailboxPayload): ImapCredentialDocument {
|
|
63
|
-
const { host, port, user, password } = payload;
|
|
62
|
+
export function credentialDocumentOf(payload: ConnectMailboxPayload): ImapCredentialDocument {
|
|
63
|
+
const { host, port, user, password, smtpPort } = payload;
|
|
64
64
|
const smtpHost = payload.smtpHost || deriveSmtpHost(host);
|
|
65
|
-
return { host, port, secure: true, user, password, smtpHost, smtpPort
|
|
65
|
+
return { host, port, secure: true, user, password, smtpHost, smtpPort };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// The raw error carries the host and the server response; the tenant only gets the category.
|
|
@@ -121,7 +121,9 @@ export const connectMailboxHandler = defineWriteHandler({
|
|
|
121
121
|
if (smtpFailure) {
|
|
122
122
|
const { reason, i18nKey } = SMTP_CONNECT_ERRORS[smtpFailure];
|
|
123
123
|
ctx.obs.log.warn("smtp connect check failed", { reason });
|
|
124
|
-
return writeFailure(
|
|
124
|
+
return writeFailure(
|
|
125
|
+
new UnprocessableError(reason, { i18nKey, i18nParams: { port: doc.smtpPort } }),
|
|
126
|
+
);
|
|
125
127
|
}
|
|
126
128
|
return { isSuccess: true, data: null };
|
|
127
129
|
},
|
|
@@ -27,9 +27,7 @@ import {
|
|
|
27
27
|
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
28
28
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
29
29
|
import * as z from "zod";
|
|
30
|
-
import { FEATURE_NAME } from "./constants.ts";
|
|
31
|
-
|
|
32
|
-
export const SMTP_IMPLICIT_TLS_PORT = 465;
|
|
30
|
+
import { FEATURE_NAME, SMTP_SUBMISSION_PORT } from "./constants.ts";
|
|
33
31
|
|
|
34
32
|
// A bare host name: a port, path or whitespace would point the connect somewhere the guard did
|
|
35
33
|
// not check.
|
|
@@ -47,7 +45,7 @@ export const imapCredentialDocumentSchema = z.object({
|
|
|
47
45
|
password: z.string().min(1).optional(),
|
|
48
46
|
accessToken: z.string().min(1).optional(),
|
|
49
47
|
smtpHost: smtpHostSchema.optional(),
|
|
50
|
-
smtpPort: z.number().int().min(1).max(65535).default(
|
|
48
|
+
smtpPort: z.number().int().min(1).max(65535).default(SMTP_SUBMISSION_PORT),
|
|
51
49
|
});
|
|
52
50
|
export type ImapCredentialDocument = z.infer<typeof imapCredentialDocumentSchema>;
|
|
53
51
|
|
|
@@ -15,10 +15,11 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
|
|
|
15
15
|
[formFieldKey("user")]: { en: "User name" },
|
|
16
16
|
[formFieldKey("password")]: { en: "Password" },
|
|
17
17
|
[formFieldKey("smtpHost")]: { en: "Outgoing server (SMTP)" },
|
|
18
|
+
[formFieldKey("smtpPort")]: { en: "SMTP port" },
|
|
18
19
|
"inboundMail.imap.connect.server": { en: "Server sign-in" },
|
|
19
20
|
"inboundMail.imap.connect.outgoing": { en: "Sending" },
|
|
20
21
|
"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
|
+
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.",
|
|
22
23
|
},
|
|
23
24
|
"inboundMail.imap.connect.submit": { en: "Check and connect" },
|
|
24
25
|
[IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
|
|
@@ -40,9 +41,9 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
|
|
|
40
41
|
en: "Receiving works, but sending does not: the outgoing server rejected the sign-in.",
|
|
41
42
|
},
|
|
42
43
|
[SMTP_CONNECT_ERRORS.tlsFailed.i18nKey]: {
|
|
43
|
-
en: "Receiving works, but sending does not: the encrypted connection to the outgoing server on port
|
|
44
|
+
en: "Receiving works, but sending does not: the encrypted connection to the outgoing server on port {port} failed.",
|
|
44
45
|
},
|
|
45
46
|
[SMTP_CONNECT_ERRORS.connectFailed.i18nKey]: {
|
|
46
|
-
en: "Receiving works, but sending does not: no connection to the outgoing server on port
|
|
47
|
+
en: "Receiving works, but sending does not: no connection to the outgoing server on port {port}.",
|
|
47
48
|
},
|
|
48
49
|
};
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
5
5
|
import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
-
import { ImapHandlers } from "./constants.ts";
|
|
7
|
+
import { ImapHandlers, SMTP_SUBMISSION_PORT } from "./constants.ts";
|
|
8
8
|
|
|
9
9
|
// Reached from the toolbar of the foundation's mail account list, never from the nav.
|
|
10
10
|
export const imapConnectScreen: ActionFormScreenDefinition = {
|
|
@@ -20,6 +20,14 @@ export const imapConnectScreen: ActionFormScreenDefinition = {
|
|
|
20
20
|
user: { type: "text", required: true, maxLength: 320 },
|
|
21
21
|
password: { type: "text", required: true, format: "password", sensitive: true },
|
|
22
22
|
smtpHost: { type: "text", maxLength: 255 },
|
|
23
|
+
smtpPort: {
|
|
24
|
+
type: "number",
|
|
25
|
+
integer: true,
|
|
26
|
+
required: true,
|
|
27
|
+
min: 1,
|
|
28
|
+
max: 65535,
|
|
29
|
+
default: SMTP_SUBMISSION_PORT,
|
|
30
|
+
},
|
|
23
31
|
},
|
|
24
32
|
layout: {
|
|
25
33
|
sections: [
|
|
@@ -32,7 +40,7 @@ export const imapConnectScreen: ActionFormScreenDefinition = {
|
|
|
32
40
|
{
|
|
33
41
|
title: i18nKey("inboundMail.imap.connect.outgoing"),
|
|
34
42
|
description: i18nKey("inboundMail.imap.connect.outgoingHint"),
|
|
35
|
-
fields: ["smtpHost"],
|
|
43
|
+
fields: ["smtpHost", "smtpPort"],
|
|
36
44
|
},
|
|
37
45
|
],
|
|
38
46
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// nodemailer wiring for sending from a connected mailbox: one short-lived, non-pooled connection
|
|
2
|
-
// per call,
|
|
2
|
+
// per call, TLS enforced (implicit on 465, STARTTLS otherwise), pinned to the address the mail host guard approved.
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
BlockedHostError,
|
|
@@ -13,6 +13,8 @@ import { imapMailHostGuard } from "./imap-client.ts";
|
|
|
13
13
|
|
|
14
14
|
const log = createFallbackLogger("inbound-provider-imap");
|
|
15
15
|
|
|
16
|
+
export const SMTP_IMPLICIT_TLS_PORT = 465;
|
|
17
|
+
|
|
16
18
|
// nodemailer's defaults wait minutes; the connect form holds a request open meanwhile.
|
|
17
19
|
const CONNECTION_TIMEOUT_MS = 10_000;
|
|
18
20
|
const GREETING_TIMEOUT_MS = 10_000;
|
|
@@ -65,7 +67,9 @@ export function smtpTransportOptions(login: SmtpLogin, target: MailConnectTarget
|
|
|
65
67
|
return {
|
|
66
68
|
host: target.host,
|
|
67
69
|
port: login.port,
|
|
68
|
-
secure:
|
|
70
|
+
secure: login.port === SMTP_IMPLICIT_TLS_PORT,
|
|
71
|
+
// Without it nodemailer sends AUTH in clear text when the server offers no STARTTLS.
|
|
72
|
+
...(login.port !== SMTP_IMPLICIT_TLS_PORT && { requireTLS: true }),
|
|
69
73
|
...(target.servername && { servername: target.servername }),
|
|
70
74
|
auth: { user: login.user, pass: login.password },
|
|
71
75
|
connectionTimeout: CONNECTION_TIMEOUT_MS,
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
// **Pattern basis:** mail-transport-inmemory (module-level state +
|
|
12
12
|
// test-helper exports). Not for production.
|
|
13
13
|
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
import {
|
|
15
|
+
InboundAuthError,
|
|
16
|
+
type InboundFetchResult,
|
|
17
|
+
type InboundMailContext,
|
|
18
|
+
type InboundMailProviderPlugin,
|
|
19
|
+
type MailAccountRecord,
|
|
20
|
+
type RawInboundMessage,
|
|
21
|
+
type SyncCursorPayload,
|
|
20
22
|
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
23
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
21
24
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
25
|
|
|
23
26
|
const FEATURE_NAME = "inbound-provider-inmemory";
|
|
@@ -38,6 +41,7 @@ type AccountState = {
|
|
|
38
41
|
watch: WatchHandlers | null;
|
|
39
42
|
nextFetchError: unknown;
|
|
40
43
|
nextVerifyError: unknown;
|
|
44
|
+
credentialSecretKey: string | null;
|
|
41
45
|
};
|
|
42
46
|
|
|
43
47
|
const accounts = new Map<string, AccountState>();
|
|
@@ -45,7 +49,13 @@ const accounts = new Map<string, AccountState>();
|
|
|
45
49
|
function stateFor(accountId: string): AccountState {
|
|
46
50
|
let state = accounts.get(accountId);
|
|
47
51
|
if (!state) {
|
|
48
|
-
state = {
|
|
52
|
+
state = {
|
|
53
|
+
log: [],
|
|
54
|
+
watch: null,
|
|
55
|
+
nextFetchError: null,
|
|
56
|
+
nextVerifyError: null,
|
|
57
|
+
credentialSecretKey: null,
|
|
58
|
+
};
|
|
49
59
|
accounts.set(accountId, state);
|
|
50
60
|
}
|
|
51
61
|
return state;
|
|
@@ -71,6 +81,25 @@ export function failNextVerifyWith(accountId: string, err: unknown): void {
|
|
|
71
81
|
stateFor(accountId).nextVerifyError = err;
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
/** Test helper: every fetch reads this tenant secret first, like a real
|
|
85
|
+
* provider reads its stored credentials; a missing secret is an auth error. */
|
|
86
|
+
export function requireCredentialSecretOnFetch(accountId: string, key: string): void {
|
|
87
|
+
stateFor(accountId).credentialSecretKey = key;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readCredentialSecret(
|
|
91
|
+
ctx: InboundMailContext,
|
|
92
|
+
account: MailAccountRecord,
|
|
93
|
+
key: string,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const secrets = requireSecretsContext(
|
|
96
|
+
{ secrets: ctx.secrets, auditUserId: ctx.auditUserId },
|
|
97
|
+
FEATURE_NAME,
|
|
98
|
+
);
|
|
99
|
+
const secret = await secrets.get(account.tenantId, key);
|
|
100
|
+
if (!secret) throw new InboundAuthError(`${FEATURE_NAME}: no credential secret ${key}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
/** Test helper: simulates the live connection terminating (the supervisor
|
|
75
104
|
* must restart with backoff). */
|
|
76
105
|
export function emitWatchError(accountId: string, err: unknown): void {
|
|
@@ -111,13 +140,16 @@ export const inMemoryInboundMailPlugin: InboundMailProviderPlugin = {
|
|
|
111
140
|
throw err;
|
|
112
141
|
}
|
|
113
142
|
},
|
|
114
|
-
fetch: async (
|
|
143
|
+
fetch: async (ctx, account, cursor, opts): Promise<InboundFetchResult> => {
|
|
115
144
|
const state = stateFor(account.id);
|
|
116
145
|
if (state.nextFetchError) {
|
|
117
146
|
const err = state.nextFetchError;
|
|
118
147
|
state.nextFetchError = null;
|
|
119
148
|
throw err;
|
|
120
149
|
}
|
|
150
|
+
if (state.credentialSecretKey) {
|
|
151
|
+
await readCredentialSecret(ctx, account, state.credentialSecretKey);
|
|
152
|
+
}
|
|
121
153
|
const offset = offsetOf(cursor);
|
|
122
154
|
const slice = state.log.slice(offset, offset + opts.maxMessages);
|
|
123
155
|
const nextOffset = offset + slice.length;
|