stageflow 0.31.0 → 0.32.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/agent/piAdapter.js +21 -0
- package/dist/agent/port.d.ts +2 -0
- package/dist/config/loadStage.js +10 -0
- package/dist/config/pipelineStageKeys.js +1 -0
- package/dist/email/accounts.d.ts +109 -0
- package/dist/email/accounts.js +285 -0
- package/dist/email/adapter.d.ts +69 -0
- package/dist/email/adapter.js +720 -0
- package/dist/email/attachments.d.ts +21 -0
- package/dist/email/attachments.js +146 -0
- package/dist/email/events.d.ts +95 -0
- package/dist/email/events.js +292 -0
- package/dist/email/host.d.ts +15 -0
- package/dist/email/host.js +119 -0
- package/dist/email/messages.d.ts +44 -0
- package/dist/email/messages.js +182 -0
- package/dist/email/port.d.ts +161 -0
- package/dist/email/port.js +12 -0
- package/dist/email/replies.d.ts +5 -0
- package/dist/email/replies.js +56 -0
- package/dist/email/submissions.d.ts +31 -0
- package/dist/email/submissions.js +123 -0
- package/dist/email/triggers.d.ts +140 -0
- package/dist/email/triggers.js +563 -0
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/runstore/port.d.ts +4 -0
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +1 -0
- package/dist/runstore/sqlite/SqliteRunStore.js +24 -5
- package/dist/runstore/sqlite/migrations/011-email-dispatch-key.d.ts +7 -0
- package/dist/runstore/sqlite/migrations/011-email-dispatch-key.js +12 -0
- package/dist/runstore/sqlite/migrations/index.js +2 -0
- package/dist/runtime/pipelineRunner.d.ts +3 -0
- package/dist/runtime/pipelineRunner.js +11 -0
- package/dist/runtime/pipelineScheduler.js +3 -0
- package/dist/runtime/runManager.d.ts +1 -0
- package/dist/runtime/runManager.js +24 -3
- package/dist/runtime/stageAttemptBootstrap.js +14 -0
- package/dist/runtime/stageProcessLauncher.d.ts +4 -0
- package/dist/runtime/stageProcessLauncher.js +45 -1
- package/dist/server/bootstrap.d.ts +7 -0
- package/dist/server/bootstrap.js +17 -0
- package/dist/server/emailRoutes.d.ts +5 -0
- package/dist/server/emailRoutes.js +136 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +16 -0
- package/dist/tools/readEmail.d.ts +77 -0
- package/dist/tools/readEmail.js +31 -0
- package/dist/tools/replyEmail.d.ts +31 -0
- package/dist/tools/replyEmail.js +25 -0
- package/dist/tools/sendEmail.d.ts +43 -0
- package/dist/tools/sendEmail.js +26 -0
- package/dist/types/stage.d.ts +4 -0
- package/dist/ui/assets/index-59cjF0OA.js +133 -0
- package/dist/ui/index.html +1 -1
- package/examples/hello-email/README.md +23 -0
- package/examples/hello-email/hello-email-reply.pipeline.yaml +6 -0
- package/examples/hello-email/hello-email.pipeline.yaml +6 -0
- package/examples/hello-email/hello-email.task.yaml +6 -0
- package/examples/hello-email/reply-hello-email.yaml +63 -0
- package/examples/hello-email/send-hello-email.yaml +37 -0
- package/package.json +6 -2
- package/dist/ui/assets/index-p8Iou3zk.js +0 -133
package/dist/agent/piAdapter.js
CHANGED
|
@@ -44,6 +44,9 @@ import { attemptContext, noAttemptContext, } from "../runtime/stageAttemptContex
|
|
|
44
44
|
import { createEmitStageEnvelopeTool, } from "../tools/emitStageEnvelope.js";
|
|
45
45
|
import { createAskOperatorTool, } from "../tools/askOperator.js";
|
|
46
46
|
import { createWriteStageArtifactTool } from "../tools/writeStageArtifact.js";
|
|
47
|
+
import { createSendEmailTool } from "../tools/sendEmail.js";
|
|
48
|
+
import { createReplyEmailTool } from "../tools/replyEmail.js";
|
|
49
|
+
import { createGetEmailTool, createSearchEmailTool, createDownloadEmailAttachmentTool } from "../tools/readEmail.js";
|
|
47
50
|
import "./cursorProvider.js";
|
|
48
51
|
import { findProviderSupport } from "./providerSupport.js";
|
|
49
52
|
import { mapSessionEventToActivity, readActivityVerbose } from "./activity.js";
|
|
@@ -479,6 +482,9 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
|
|
|
479
482
|
].join("\n")
|
|
480
483
|
: `Create factory stage artifacts under ${attemptArtifactsPath} relative to the run folder.`;
|
|
481
484
|
return [
|
|
485
|
+
...(input.stage.email?.length ? [
|
|
486
|
+
`Stageflow email permissions: ${JSON.stringify(input.stage.email)}. Use only declared accounts and operations. Use a stable operationKey for each intended message. Provider acceptance does not prove delivery. Never resend an unknown submission automatically.`,
|
|
487
|
+
] : []),
|
|
482
488
|
`Task id: ${input.task.id}`,
|
|
483
489
|
`Stage id: ${runtimeStageId(input)}`,
|
|
484
490
|
`Goal: ${input.task.goal}`,
|
|
@@ -908,6 +914,21 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
908
914
|
if (attached.extensionFactories !== undefined) {
|
|
909
915
|
tools.push(...collectMcpExtensionToolNames(loader));
|
|
910
916
|
}
|
|
917
|
+
if (input.email) {
|
|
918
|
+
const permitted = new Set(input.stage.email?.flatMap(permission => permission.operations));
|
|
919
|
+
const definitions = [
|
|
920
|
+
permitted.has("send") ? createSendEmailTool(input.email) : undefined,
|
|
921
|
+
permitted.has("reply") ? createReplyEmailTool(input.email) : undefined,
|
|
922
|
+
permitted.has("search") ? createSearchEmailTool(input.email) : undefined,
|
|
923
|
+
permitted.has("getMessage") ? createGetEmailTool(input.email) : undefined,
|
|
924
|
+
permitted.has("downloadAttachment") ? createDownloadEmailAttachmentTool(input.email) : undefined,
|
|
925
|
+
];
|
|
926
|
+
for (const definition of definitions)
|
|
927
|
+
if (definition) {
|
|
928
|
+
customTools.push(defineTool(definition));
|
|
929
|
+
tools.push(definition.name);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
911
932
|
return {
|
|
912
933
|
sessionManager,
|
|
913
934
|
modelRuntime,
|
package/dist/agent/port.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { TaskFile } from "../types/task.js";
|
|
|
7
7
|
import type { StageActivityEvent } from "./activity.js";
|
|
8
8
|
import type { ResolvedMcpServers } from "../config/resolveStageMcpServers.js";
|
|
9
9
|
import type { StageRoots } from "../runtime/stageRoots.js";
|
|
10
|
+
import type { StageEmail } from "../email/host.js";
|
|
10
11
|
import type { QaExchange } from "../hitl/qaTrail.js";
|
|
11
12
|
import type { StageUsage } from "../types/usage.js";
|
|
12
13
|
/** Opaque to runtime; adapters interpret. */
|
|
@@ -63,6 +64,7 @@ export type StageRunInput = {
|
|
|
63
64
|
/** Optional raw assistant-text-delta sink, fired alongside onActivity, not instead of it. */
|
|
64
65
|
onAssistantTextDelta?: (delta: string) => void;
|
|
65
66
|
skillFilePath?: string;
|
|
67
|
+
email?: StageEmail;
|
|
66
68
|
/** Bundled browser skill; set by the runtime when the stage has `browser`. */
|
|
67
69
|
browserSkillFilePath?: string;
|
|
68
70
|
forkEmitContext?: ForkEmitContext;
|
package/dist/config/loadStage.js
CHANGED
|
@@ -7,6 +7,7 @@ import { parseModelField } from "./modelField.js";
|
|
|
7
7
|
import { allowLegacyYamlAuthoring, dialectWarningForDocument, legacyAuthoringRejected, presentLegacyKeys, } from "./legacyYaml.js";
|
|
8
8
|
import { parsePreEmitChecks } from "./parsePreEmitChecks.js";
|
|
9
9
|
import { readYamlObject } from "./readYamlObject.js";
|
|
10
|
+
import { z } from "zod";
|
|
10
11
|
import { parseStageSecrets } from "../runtime/stageSecretDecl.js";
|
|
11
12
|
import { parseToolRequires } from "./toolRequires.js";
|
|
12
13
|
import { parseStageBrowser } from "./stageBrowser.js";
|
|
@@ -140,6 +141,15 @@ function parseStageFields(raw, label, entryId, deferSchemaRefs, requireIo, compi
|
|
|
140
141
|
id: entryId,
|
|
141
142
|
system_prompt: raw.system_prompt,
|
|
142
143
|
};
|
|
144
|
+
if (raw.email !== undefined) {
|
|
145
|
+
const permissions = z.array(z.object({ accountId: z.string().min(1).max(200),
|
|
146
|
+
operations: z.array(z.enum(["send", "reply", "getMessage", "search", "downloadAttachment"])).min(1).max(5),
|
|
147
|
+
}).strict()).max(20).safeParse(raw.email);
|
|
148
|
+
if (!permissions.success || new Set(permissions.data.map(permission => permission.accountId)).size !== permissions.data.length) {
|
|
149
|
+
return loadFailure([{ code: "stage.invalid_shape", message: `Invalid stage ${label}: invalid email permissions`, category: "stage", stageId: entryId }]);
|
|
150
|
+
}
|
|
151
|
+
stage.email = permissions.data;
|
|
152
|
+
}
|
|
143
153
|
if (raw.clone_actions !== undefined) {
|
|
144
154
|
return loadFailure([
|
|
145
155
|
{
|
|
@@ -28,6 +28,7 @@ export const WIRING_KEYS = new Set([
|
|
|
28
28
|
]);
|
|
29
29
|
/** Stage body keys. Target: `io` / `verify`. IR/legacy: `payload_schema`, `pre_emit_checks`, `clone_input_schema`. */
|
|
30
30
|
export const BODY_KEYS = new Set([
|
|
31
|
+
"email",
|
|
31
32
|
"system_prompt",
|
|
32
33
|
"model",
|
|
33
34
|
"payload_schema",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type EmailAccountStatus } from "./port.js";
|
|
3
|
+
declare const connectionSchema: z.ZodObject<{
|
|
4
|
+
host: z.ZodString;
|
|
5
|
+
port: z.ZodNumber;
|
|
6
|
+
username: z.ZodString;
|
|
7
|
+
tls: z.ZodDefault<z.ZodEnum<{
|
|
8
|
+
implicit: "implicit";
|
|
9
|
+
none: "none";
|
|
10
|
+
starttls: "starttls";
|
|
11
|
+
}>>;
|
|
12
|
+
auth: z.ZodObject<{
|
|
13
|
+
type: z.ZodEnum<{
|
|
14
|
+
oauth2: "oauth2";
|
|
15
|
+
password: "password";
|
|
16
|
+
}>;
|
|
17
|
+
secretRef: z.ZodString;
|
|
18
|
+
expiresAt: z.ZodOptional<z.ZodISODateTime>;
|
|
19
|
+
}, z.core.$strict>;
|
|
20
|
+
}, z.core.$strict>;
|
|
21
|
+
declare const accountSchema: z.ZodObject<{
|
|
22
|
+
displayName: z.ZodString;
|
|
23
|
+
address: z.ZodEmail;
|
|
24
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
25
|
+
adapter: z.ZodDefault<z.ZodLiteral<"local">>;
|
|
26
|
+
folders: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
27
|
+
senderAliases: z.ZodDefault<z.ZodArray<z.ZodEmail>>;
|
|
28
|
+
imap: z.ZodObject<{
|
|
29
|
+
host: z.ZodString;
|
|
30
|
+
port: z.ZodNumber;
|
|
31
|
+
username: z.ZodString;
|
|
32
|
+
tls: z.ZodDefault<z.ZodEnum<{
|
|
33
|
+
implicit: "implicit";
|
|
34
|
+
none: "none";
|
|
35
|
+
starttls: "starttls";
|
|
36
|
+
}>>;
|
|
37
|
+
auth: z.ZodObject<{
|
|
38
|
+
type: z.ZodEnum<{
|
|
39
|
+
oauth2: "oauth2";
|
|
40
|
+
password: "password";
|
|
41
|
+
}>;
|
|
42
|
+
secretRef: z.ZodString;
|
|
43
|
+
expiresAt: z.ZodOptional<z.ZodISODateTime>;
|
|
44
|
+
}, z.core.$strict>;
|
|
45
|
+
}, z.core.$strict>;
|
|
46
|
+
smtp: z.ZodObject<{
|
|
47
|
+
host: z.ZodString;
|
|
48
|
+
port: z.ZodNumber;
|
|
49
|
+
username: z.ZodString;
|
|
50
|
+
tls: z.ZodDefault<z.ZodEnum<{
|
|
51
|
+
implicit: "implicit";
|
|
52
|
+
none: "none";
|
|
53
|
+
starttls: "starttls";
|
|
54
|
+
}>>;
|
|
55
|
+
auth: z.ZodObject<{
|
|
56
|
+
type: z.ZodEnum<{
|
|
57
|
+
oauth2: "oauth2";
|
|
58
|
+
password: "password";
|
|
59
|
+
}>;
|
|
60
|
+
secretRef: z.ZodString;
|
|
61
|
+
expiresAt: z.ZodOptional<z.ZodISODateTime>;
|
|
62
|
+
}, z.core.$strict>;
|
|
63
|
+
}, z.core.$strict>;
|
|
64
|
+
allowInsecureLocalDevelopment: z.ZodDefault<z.ZodBoolean>;
|
|
65
|
+
connectionTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
66
|
+
pollingIntervalMs: z.ZodDefault<z.ZodNumber>;
|
|
67
|
+
reconnectMaxDelayMs: z.ZodDefault<z.ZodNumber>;
|
|
68
|
+
searchWorkLimit: z.ZodDefault<z.ZodNumber>;
|
|
69
|
+
attachmentLimits: z.ZodDefault<z.ZodObject<{
|
|
70
|
+
count: z.ZodDefault<z.ZodNumber>;
|
|
71
|
+
perFileBytes: z.ZodDefault<z.ZodNumber>;
|
|
72
|
+
totalBytes: z.ZodDefault<z.ZodNumber>;
|
|
73
|
+
downloadBytes: z.ZodDefault<z.ZodNumber>;
|
|
74
|
+
}, z.core.$strict>>;
|
|
75
|
+
sentFolder: z.ZodOptional<z.ZodString>;
|
|
76
|
+
sentCopyPolicy: z.ZodDefault<z.ZodEnum<{
|
|
77
|
+
"imap-append": "imap-append";
|
|
78
|
+
"provider-managed": "provider-managed";
|
|
79
|
+
}>>;
|
|
80
|
+
sentCopyMaxBytes: z.ZodDefault<z.ZodNumber>;
|
|
81
|
+
}, z.core.$strict>;
|
|
82
|
+
export type EmailAccountInput = z.input<typeof accountSchema>;
|
|
83
|
+
export type EmailConnection = z.output<typeof connectionSchema>;
|
|
84
|
+
export type EmailAccount = z.output<typeof accountSchema> & {
|
|
85
|
+
accountId: string;
|
|
86
|
+
scope: string;
|
|
87
|
+
};
|
|
88
|
+
/** Host-owned configuration. Reads return copies; mutations replace the file atomically. */
|
|
89
|
+
export declare class EmailAccounts {
|
|
90
|
+
readonly scope: string;
|
|
91
|
+
private readonly file;
|
|
92
|
+
private state;
|
|
93
|
+
private readonly listeners;
|
|
94
|
+
constructor(cwd: string);
|
|
95
|
+
list(): EmailAccount[];
|
|
96
|
+
get(accountId: string, requireEnabled?: boolean): EmailAccount;
|
|
97
|
+
create(input: unknown): EmailAccount;
|
|
98
|
+
update(accountId: string, patch: unknown): EmailAccount;
|
|
99
|
+
remove(accountId: string): void;
|
|
100
|
+
health(accountId: string): EmailAccountStatus | undefined;
|
|
101
|
+
recordHealth(status: EmailAccountStatus): void;
|
|
102
|
+
onChange(listener: (accountId: string) => void): () => void;
|
|
103
|
+
private notify;
|
|
104
|
+
private requireHttpAccount;
|
|
105
|
+
private syncStartupAccounts;
|
|
106
|
+
private persist;
|
|
107
|
+
}
|
|
108
|
+
export declare function resolveEmailSecret(connection: EmailConnection, env?: NodeJS.ProcessEnv): string;
|
|
109
|
+
export {};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseDocument } from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { storeRootFor } from "../runstore/paths.js";
|
|
7
|
+
import { EmailError } from "./port.js";
|
|
8
|
+
import { attachmentLimitsSchema } from "./attachments.js";
|
|
9
|
+
const localSecrets = new Map();
|
|
10
|
+
const authSchema = z.object({
|
|
11
|
+
type: z.enum(["password", "oauth2"]),
|
|
12
|
+
secretRef: z.string().regex(/^(env:[A-Za-z_][A-Za-z0-9_]*|local:[a-f0-9]{64})$/),
|
|
13
|
+
expiresAt: z.iso.datetime().optional(),
|
|
14
|
+
}).strict().refine(auth => auth.type !== "oauth2" || auth.expiresAt !== undefined);
|
|
15
|
+
const connectionSchema = z.object({
|
|
16
|
+
host: z.string().min(1).max(253).regex(/^[A-Za-z0-9.:-]+$/),
|
|
17
|
+
port: z.number().int().min(1).max(65535),
|
|
18
|
+
username: z.string().min(1).max(320),
|
|
19
|
+
tls: z.enum(["implicit", "starttls", "none"]).default("implicit"),
|
|
20
|
+
auth: authSchema,
|
|
21
|
+
}).strict();
|
|
22
|
+
const accountSchema = z.object({
|
|
23
|
+
displayName: z.string().min(1).max(200), address: z.email(), enabled: z.boolean().default(true),
|
|
24
|
+
adapter: z.literal("local").default("local"),
|
|
25
|
+
folders: z.array(z.string().min(1).max(200)).min(1).max(20).default(["INBOX"]),
|
|
26
|
+
senderAliases: z.array(z.email()).max(20).default([]),
|
|
27
|
+
imap: connectionSchema, smtp: connectionSchema,
|
|
28
|
+
allowInsecureLocalDevelopment: z.boolean().default(false),
|
|
29
|
+
connectionTimeoutMs: z.number().int().min(100).max(60000).default(10000),
|
|
30
|
+
pollingIntervalMs: z.number().int().min(1000).max(3600000).default(60000),
|
|
31
|
+
reconnectMaxDelayMs: z.number().int().min(1000).max(3600000).default(60000),
|
|
32
|
+
searchWorkLimit: z.number().int().min(1).max(10000).default(1000),
|
|
33
|
+
attachmentLimits: attachmentLimitsSchema.default({ count: 10, perFileBytes: 2097152, totalBytes: 5242880, downloadBytes: 8388608 }),
|
|
34
|
+
sentFolder: z.string().min(1).max(200).optional(),
|
|
35
|
+
sentCopyPolicy: z.enum(["provider-managed", "imap-append"]).default("provider-managed"),
|
|
36
|
+
sentCopyMaxBytes: z.number().int().min(1).max(50331648).default(8388608),
|
|
37
|
+
}).strict();
|
|
38
|
+
const yamlFileLimit = 262144;
|
|
39
|
+
const yamlFileSchema = z.object({
|
|
40
|
+
version: z.literal(1),
|
|
41
|
+
accounts: z.array(z.object({ accountId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/) }).passthrough()).max(100),
|
|
42
|
+
}).strict();
|
|
43
|
+
function parseAccount(input) {
|
|
44
|
+
const parsed = accountSchema.safeParse(input);
|
|
45
|
+
if (!parsed.success)
|
|
46
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
47
|
+
if (parsed.data.sentCopyPolicy === "imap-append" && !parsed.data.sentFolder)
|
|
48
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
49
|
+
for (const connection of [parsed.data.imap, parsed.data.smtp]) {
|
|
50
|
+
if (connection.tls === "none" && (!parsed.data.allowInsecureLocalDevelopment ||
|
|
51
|
+
!["127.0.0.1", "::1", "localhost"].includes(connection.host.toLowerCase()))) {
|
|
52
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return parsed.data;
|
|
56
|
+
}
|
|
57
|
+
function startupAccounts(file, scope) {
|
|
58
|
+
let descriptor;
|
|
59
|
+
try {
|
|
60
|
+
descriptor = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === "ENOENT")
|
|
64
|
+
return [];
|
|
65
|
+
throw new EmailError("EMAIL_STORAGE_FAILED");
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const stat = fstatSync(descriptor);
|
|
69
|
+
if (!stat.isFile())
|
|
70
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
71
|
+
if (stat.size > yamlFileLimit)
|
|
72
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
73
|
+
const bytes = Buffer.alloc(yamlFileLimit + 1);
|
|
74
|
+
let length = 0;
|
|
75
|
+
while (length < bytes.length) {
|
|
76
|
+
const count = readSync(descriptor, bytes, length, bytes.length - length, null);
|
|
77
|
+
if (!count)
|
|
78
|
+
break;
|
|
79
|
+
length += count;
|
|
80
|
+
}
|
|
81
|
+
if (length > yamlFileLimit)
|
|
82
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
83
|
+
const document = parseDocument(bytes.toString("utf8", 0, length), { uniqueKeys: true, strict: true });
|
|
84
|
+
if (document.errors.length || document.warnings.length)
|
|
85
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
86
|
+
const parsed = yamlFileSchema.safeParse(document.toJS({ maxAliasCount: 0 }));
|
|
87
|
+
if (!parsed.success)
|
|
88
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
89
|
+
const ids = new Set();
|
|
90
|
+
const pendingSecrets = new Map();
|
|
91
|
+
const configured = parsed.data.accounts.map(({ accountId, ...input }) => {
|
|
92
|
+
if (ids.has(accountId))
|
|
93
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
94
|
+
ids.add(accountId);
|
|
95
|
+
const normalized = structuredClone(input);
|
|
96
|
+
for (const protocol of ["imap", "smtp"]) {
|
|
97
|
+
const connection = normalized[protocol];
|
|
98
|
+
const auth = connection?.auth;
|
|
99
|
+
if (auth?.type !== "password")
|
|
100
|
+
continue;
|
|
101
|
+
let password = auth.password;
|
|
102
|
+
if (password === undefined && typeof auth.secretRef === "string"
|
|
103
|
+
&& !auth.secretRef.startsWith("env:") && !auth.secretRef.startsWith("local:")) {
|
|
104
|
+
password = auth.secretRef;
|
|
105
|
+
}
|
|
106
|
+
if (password === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
if (typeof password !== "string" || !password.length || (auth.password !== undefined && auth.secretRef !== undefined))
|
|
109
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
110
|
+
const reference = `local:${createHash("sha256").update(JSON.stringify([scope, accountId, protocol])).digest("hex")}`;
|
|
111
|
+
const gmailAppPassword = connection?.host?.endsWith(".gmail.com") && /^[a-z]{4}( [a-z]{4}){3}$/.test(password);
|
|
112
|
+
pendingSecrets.set(reference, gmailAppPassword ? password.replaceAll(" ", "") : password);
|
|
113
|
+
delete auth.password;
|
|
114
|
+
auth.secretRef = reference;
|
|
115
|
+
}
|
|
116
|
+
return { ...parseAccount(normalized), accountId, scope };
|
|
117
|
+
});
|
|
118
|
+
for (const [reference, secret] of pendingSecrets)
|
|
119
|
+
localSecrets.set(reference, secret);
|
|
120
|
+
return configured;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof EmailError)
|
|
124
|
+
throw error;
|
|
125
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
closeSync(descriptor);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Host-owned configuration. Reads return copies; mutations replace the file atomically. */
|
|
132
|
+
export class EmailAccounts {
|
|
133
|
+
scope;
|
|
134
|
+
file;
|
|
135
|
+
state;
|
|
136
|
+
listeners = new Set();
|
|
137
|
+
constructor(cwd) {
|
|
138
|
+
this.scope = path.resolve(cwd);
|
|
139
|
+
this.file = path.join(storeRootFor(cwd), "email-accounts.json");
|
|
140
|
+
this.state = { version: 1, scope: this.scope, accounts: [], health: {} };
|
|
141
|
+
if (existsSync(this.file)) {
|
|
142
|
+
try {
|
|
143
|
+
const saved = JSON.parse(readFileSync(this.file, "utf8"));
|
|
144
|
+
if (saved.version !== 1 || saved.scope !== this.scope || !Array.isArray(saved.accounts)) {
|
|
145
|
+
throw new Error();
|
|
146
|
+
}
|
|
147
|
+
this.state = { ...saved, accounts: saved.accounts.map(({ accountId, scope, ...input }) => {
|
|
148
|
+
if (typeof accountId !== "string" || scope !== this.scope)
|
|
149
|
+
throw new Error();
|
|
150
|
+
return { ...parseAccount(input), accountId, scope };
|
|
151
|
+
}) };
|
|
152
|
+
const ids = this.state.accounts.map(account => account.accountId);
|
|
153
|
+
const managed = saved.yamlManagedAccountIds ?? [];
|
|
154
|
+
if (new Set(ids).size !== ids.length || !Array.isArray(managed) || new Set(managed).size !== managed.length
|
|
155
|
+
|| managed.some(id => typeof id !== "string" || !ids.includes(id)))
|
|
156
|
+
throw new Error();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.syncStartupAccounts(startupAccounts(path.join(this.scope, "email.yaml"), this.scope));
|
|
163
|
+
}
|
|
164
|
+
list() {
|
|
165
|
+
return structuredClone(this.state.accounts);
|
|
166
|
+
}
|
|
167
|
+
get(accountId, requireEnabled = true) {
|
|
168
|
+
const account = this.state.accounts.find(a => a.accountId === accountId && a.scope === this.scope);
|
|
169
|
+
if (!account)
|
|
170
|
+
throw new EmailError("EMAIL_ACCOUNT_NOT_FOUND");
|
|
171
|
+
if (requireEnabled && !account.enabled)
|
|
172
|
+
throw new EmailError("EMAIL_ACCOUNT_DISABLED");
|
|
173
|
+
return structuredClone(account);
|
|
174
|
+
}
|
|
175
|
+
create(input) {
|
|
176
|
+
const account = { ...parseAccount(input), accountId: randomUUID(), scope: this.scope };
|
|
177
|
+
this.state.accounts.push(account);
|
|
178
|
+
this.persist();
|
|
179
|
+
this.notify(account.accountId);
|
|
180
|
+
return structuredClone(account);
|
|
181
|
+
}
|
|
182
|
+
update(accountId, patch) {
|
|
183
|
+
this.requireHttpAccount(accountId);
|
|
184
|
+
const { accountId: _id, scope: _scope, ...current } = this.get(accountId, false);
|
|
185
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch))
|
|
186
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
187
|
+
const account = { ...parseAccount({ ...current, ...patch }), accountId, scope: this.scope };
|
|
188
|
+
this.state.accounts = this.state.accounts.map(a => a.accountId === accountId ? account : a);
|
|
189
|
+
delete this.state.health[accountId];
|
|
190
|
+
this.persist();
|
|
191
|
+
this.notify(accountId);
|
|
192
|
+
return structuredClone(account);
|
|
193
|
+
}
|
|
194
|
+
remove(accountId) {
|
|
195
|
+
this.requireHttpAccount(accountId);
|
|
196
|
+
this.get(accountId, false);
|
|
197
|
+
this.state.accounts = this.state.accounts.filter(a => a.accountId !== accountId);
|
|
198
|
+
delete this.state.health[accountId];
|
|
199
|
+
this.persist();
|
|
200
|
+
this.notify(accountId);
|
|
201
|
+
}
|
|
202
|
+
health(accountId) {
|
|
203
|
+
this.get(accountId, false);
|
|
204
|
+
return structuredClone(this.state.health[accountId]);
|
|
205
|
+
}
|
|
206
|
+
recordHealth(status) {
|
|
207
|
+
this.get(status.accountId);
|
|
208
|
+
this.state.health[status.accountId] = structuredClone(status);
|
|
209
|
+
this.persist();
|
|
210
|
+
}
|
|
211
|
+
onChange(listener) {
|
|
212
|
+
this.listeners.add(listener);
|
|
213
|
+
return () => this.listeners.delete(listener);
|
|
214
|
+
}
|
|
215
|
+
notify(accountId) {
|
|
216
|
+
for (const listener of this.listeners)
|
|
217
|
+
listener(accountId);
|
|
218
|
+
}
|
|
219
|
+
requireHttpAccount(accountId) {
|
|
220
|
+
if (this.state.yamlManagedAccountIds?.includes(accountId))
|
|
221
|
+
throw new EmailError("EMAIL_OPERATION_CONFLICT");
|
|
222
|
+
}
|
|
223
|
+
syncStartupAccounts(configured) {
|
|
224
|
+
const managed = new Set(this.state.yamlManagedAccountIds ?? []);
|
|
225
|
+
for (const account of configured) {
|
|
226
|
+
if (!managed.has(account.accountId) && this.state.accounts.some(saved => saved.accountId === account.accountId)) {
|
|
227
|
+
throw new EmailError("EMAIL_OPERATION_CONFLICT");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const declared = new Map(configured.map(account => [account.accountId, account]));
|
|
231
|
+
const changed = new Set();
|
|
232
|
+
const accounts = this.state.accounts.map(saved => {
|
|
233
|
+
const replacement = declared.get(saved.accountId);
|
|
234
|
+
declared.delete(saved.accountId);
|
|
235
|
+
const account = replacement ?? (managed.has(saved.accountId) ? { ...saved, enabled: false } : saved);
|
|
236
|
+
if (JSON.stringify(account) !== JSON.stringify(saved))
|
|
237
|
+
changed.add(saved.accountId);
|
|
238
|
+
return account;
|
|
239
|
+
});
|
|
240
|
+
for (const account of declared.values()) {
|
|
241
|
+
accounts.push(account);
|
|
242
|
+
changed.add(account.accountId);
|
|
243
|
+
}
|
|
244
|
+
for (const account of configured)
|
|
245
|
+
managed.add(account.accountId);
|
|
246
|
+
if (!changed.size)
|
|
247
|
+
return;
|
|
248
|
+
const next = { ...this.state, accounts, health: { ...this.state.health }, yamlManagedAccountIds: [...managed] };
|
|
249
|
+
for (const accountId of changed)
|
|
250
|
+
delete next.health[accountId];
|
|
251
|
+
try {
|
|
252
|
+
this.persist(next);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
throw new EmailError("EMAIL_STORAGE_FAILED");
|
|
256
|
+
}
|
|
257
|
+
this.state = next;
|
|
258
|
+
}
|
|
259
|
+
persist(state = this.state) {
|
|
260
|
+
mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
261
|
+
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
262
|
+
try {
|
|
263
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
264
|
+
renameSync(temporary, this.file);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
try {
|
|
268
|
+
unlinkSync(temporary);
|
|
269
|
+
}
|
|
270
|
+
catch { /* A failed write might not create a temporary file. */ }
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export function resolveEmailSecret(connection, env = process.env) {
|
|
276
|
+
if (connection.auth.type === "oauth2" && connection.auth.expiresAt &&
|
|
277
|
+
Date.parse(connection.auth.expiresAt) <= Date.now())
|
|
278
|
+
throw new EmailError("EMAIL_TOKEN_EXPIRED");
|
|
279
|
+
const secret = connection.auth.secretRef.startsWith("local:")
|
|
280
|
+
? localSecrets.get(connection.auth.secretRef)
|
|
281
|
+
: env[connection.auth.secretRef.slice(4)];
|
|
282
|
+
if (!secret)
|
|
283
|
+
throw new EmailError("EMAIL_AUTH_FAILED");
|
|
284
|
+
return secret;
|
|
285
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { EmailEvents, type ReceiveMailbox } from "./events.js";
|
|
2
|
+
import { EmailSubmissions } from "./submissions.js";
|
|
3
|
+
import { type MailRecord, type MailQuery } from "./messages.js";
|
|
4
|
+
import { EmailAccounts, type EmailAccount } from "./accounts.js";
|
|
5
|
+
import { type EmailMailbox, type EmailEventSource, type EmailAccountStatus, type EmailMessageRef, type EmailMessage, type SendEmailInput, type SendEmailResult, type ReplyToEmailInput, type SearchEmailsInput, type SearchEmailsResult, type EmailReceivedEvent, type EmailArtifactContext, type PreparedEmailAttachment, type DownloadEmailAttachmentInput, type DownloadEmailAttachmentResult } from "./port.js";
|
|
6
|
+
/** Both adapters validate account scope before any operation, including unsupported operations. */
|
|
7
|
+
export declare abstract class AccountEmailAdapter implements EmailMailbox, EmailEventSource {
|
|
8
|
+
protected readonly accounts: EmailAccounts;
|
|
9
|
+
readonly submissions: EmailSubmissions;
|
|
10
|
+
readonly events: EmailEvents;
|
|
11
|
+
protected readonly cursorKey: NonSharedBuffer;
|
|
12
|
+
private stopped;
|
|
13
|
+
constructor(accounts: EmailAccounts);
|
|
14
|
+
abstract testAccount(accountId: string, protocol?: "imap" | "smtp" | "both"): Promise<EmailAccountStatus>;
|
|
15
|
+
getReceivedEvent(ref: EmailMessageRef): Promise<EmailReceivedEvent>;
|
|
16
|
+
protected account(accountId: string): EmailAccount;
|
|
17
|
+
send(input: SendEmailInput, context?: EmailArtifactContext): Promise<SendEmailResult>;
|
|
18
|
+
private sendValidated;
|
|
19
|
+
protected abstract submit(account: EmailAccount, input: SendEmailInput, operationId: string, attachments: PreparedEmailAttachment[], mime?: Buffer): Promise<SendEmailResult>;
|
|
20
|
+
protected abstract appendSent(account: EmailAccount, mime: Buffer): Promise<void>;
|
|
21
|
+
reply(input: ReplyToEmailInput, context?: EmailArtifactContext): Promise<SendEmailResult>;
|
|
22
|
+
downloadAttachment(input: DownloadEmailAttachmentInput, context?: EmailArtifactContext): Promise<DownloadEmailAttachmentResult>;
|
|
23
|
+
protected abstract attachmentSource(account: EmailAccount, ref: EmailMessageRef): Promise<Buffer>;
|
|
24
|
+
abstract getMessage(ref: EmailMessageRef): Promise<EmailMessage>;
|
|
25
|
+
abstract search(input: SearchEmailsInput): Promise<SearchEmailsResult>;
|
|
26
|
+
protected abstract openReceiver(account: EmailAccount, folder: string, signal: () => void): Promise<ReceiveMailbox>;
|
|
27
|
+
start(emit: (event: EmailReceivedEvent) => Promise<void>): Promise<void>;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
protected searchPosition(uid: number, high: number, last: number | undefined, query: MailQuery): boolean;
|
|
30
|
+
protected status(accountId: string): EmailAccountStatus;
|
|
31
|
+
}
|
|
32
|
+
export declare class InMemoryEmailAdapter extends AccountEmailAdapter {
|
|
33
|
+
private readonly signals;
|
|
34
|
+
private readonly mailboxes;
|
|
35
|
+
/** Provider fixture data. Content is held only by this test adapter. */
|
|
36
|
+
seedMailbox(accountId: string, messages: MailRecord[], mailbox?: string, generation?: string): void;
|
|
37
|
+
protected openReceiver(account: EmailAccount, folder: string, signal: () => void): Promise<ReceiveMailbox>;
|
|
38
|
+
search(input: SearchEmailsInput): Promise<SearchEmailsResult>;
|
|
39
|
+
getMessage(ref: EmailMessageRef): Promise<EmailMessage>;
|
|
40
|
+
readonly sent: SendEmailInput[];
|
|
41
|
+
readonly sentAttachments: PreparedEmailAttachment[][];
|
|
42
|
+
protected attachmentSource(account: EmailAccount, ref: EmailMessageRef): Promise<Buffer>;
|
|
43
|
+
protected appendSent(account: EmailAccount, mime: Buffer): Promise<void>;
|
|
44
|
+
protected submit(_account: EmailAccount, input: SendEmailInput, operationId: string, attachments: PreparedEmailAttachment[], _mime?: Buffer): Promise<SendEmailResult>;
|
|
45
|
+
testAccount(accountId: string, protocol?: "imap" | "smtp" | "both"): Promise<EmailAccountStatus>;
|
|
46
|
+
}
|
|
47
|
+
export declare class LocalEmailAdapter extends AccountEmailAdapter {
|
|
48
|
+
private readonly env;
|
|
49
|
+
private readonly active;
|
|
50
|
+
private unsubscribe?;
|
|
51
|
+
constructor(accounts: EmailAccounts, env?: NodeJS.ProcessEnv);
|
|
52
|
+
start(emit: (event: EmailReceivedEvent) => Promise<void>): Promise<void>;
|
|
53
|
+
protected openReceiver(account: EmailAccount, folder: string, signal: () => void): Promise<ReceiveMailbox>;
|
|
54
|
+
private readMailbox;
|
|
55
|
+
private readMessage;
|
|
56
|
+
private nextSearchUid;
|
|
57
|
+
search(input: SearchEmailsInput): Promise<SearchEmailsResult>;
|
|
58
|
+
getMessage(ref: EmailMessageRef): Promise<EmailMessage>;
|
|
59
|
+
protected attachmentSource(account: EmailAccount, ref: EmailMessageRef): Promise<Buffer>;
|
|
60
|
+
protected appendSent(account: EmailAccount, mime: Buffer): Promise<void>;
|
|
61
|
+
protected submit(account: EmailAccount, input: SendEmailInput, operationId: string, attachments: PreparedEmailAttachment[], mime?: Buffer): Promise<SendEmailResult>;
|
|
62
|
+
testAccount(accountId: string, protocol?: "imap" | "smtp" | "both"): Promise<EmailAccountStatus>;
|
|
63
|
+
stop(): Promise<void>;
|
|
64
|
+
private cancel;
|
|
65
|
+
private receiveWork;
|
|
66
|
+
private bounded;
|
|
67
|
+
private testImap;
|
|
68
|
+
private testSmtp;
|
|
69
|
+
}
|