stageflow 0.31.0 → 0.33.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/README.md +1 -0
- package/dist/agent/fakeAgent.js +0 -6
- package/dist/agent/piAdapter.js +21 -0
- package/dist/agent/port.d.ts +2 -0
- package/dist/agent/providerAuth.d.ts +6 -0
- package/dist/agent/providerAuth.js +50 -14
- package/dist/agent/resolveAgentPort.d.ts +2 -0
- package/dist/agent/resolveAgentPort.js +5 -1
- package/dist/config/listConfig.d.ts +0 -16
- package/dist/config/listConfig.js +0 -3
- 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/hosted/containment.d.ts +3 -0
- package/dist/hosted/containment.js +36 -0
- package/dist/hosted/errors.d.ts +21 -0
- package/dist/hosted/errors.js +30 -0
- package/dist/hosted/packageSkills.d.ts +3 -0
- package/dist/hosted/packageSkills.js +80 -0
- package/dist/hosted/runtime.d.ts +12 -0
- package/dist/hosted/runtime.js +596 -0
- package/dist/hosted/sequencer.d.ts +28 -0
- package/dist/hosted/sequencer.js +129 -0
- package/dist/hosted/translate.d.ts +3 -0
- package/dist/hosted/translate.js +43 -0
- package/dist/hosted/types.d.ts +108 -0
- package/dist/hosted/types.js +10 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -0
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +0 -1
- package/dist/projection/projectRun.js +0 -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/containerLimits.d.ts +0 -3
- package/dist/runtime/containerLimits.js +0 -5
- package/dist/runtime/credentialBinding.d.ts +3 -0
- package/dist/runtime/credentialBinding.js +9 -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/runRetentionSweep.d.ts +0 -2
- package/dist/runtime/runRetentionSweep.js +0 -8
- package/dist/runtime/stageAttemptBootstrap.js +14 -0
- package/dist/runtime/stageProcessLauncher.d.ts +4 -0
- package/dist/runtime/stageProcessLauncher.js +45 -1
- package/dist/runtime/stageRoots.js +5 -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 +14 -2
- package/dist/ui/assets/index-p8Iou3zk.js +0 -133
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { EmailAccount } from "./accounts.js";
|
|
3
|
+
import { type EmailArtifactContext, type SendEmailInput, type PreparedEmailAttachment, type DownloadEmailAttachmentResult } from "./port.js";
|
|
4
|
+
export declare const attachmentReferencesSchema: z.ZodArray<z.ZodObject<{
|
|
5
|
+
artifact: z.ZodString;
|
|
6
|
+
filename: z.ZodOptional<z.ZodString>;
|
|
7
|
+
}, z.core.$strict>>;
|
|
8
|
+
export declare const attachmentLimitsSchema: z.ZodObject<{
|
|
9
|
+
count: z.ZodDefault<z.ZodNumber>;
|
|
10
|
+
perFileBytes: z.ZodDefault<z.ZodNumber>;
|
|
11
|
+
totalBytes: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
downloadBytes: z.ZodDefault<z.ZodNumber>;
|
|
13
|
+
}, z.core.$strict>;
|
|
14
|
+
export declare function safeAttachmentFilename(filename: string | undefined): string;
|
|
15
|
+
export declare function prepareAttachments(account: EmailAccount, references: SendEmailInput["attachments"], context?: EmailArtifactContext): Promise<PreparedEmailAttachment[]>;
|
|
16
|
+
export declare function attachmentIdentity(attachments: PreparedEmailAttachment[]): {
|
|
17
|
+
filename: string;
|
|
18
|
+
size: number;
|
|
19
|
+
sha256: string;
|
|
20
|
+
}[];
|
|
21
|
+
export declare function saveSelectedAttachment(account: EmailAccount, source: Buffer, attachmentId: string, context: EmailArtifactContext, validateAccount: () => void): Promise<DownloadEmailAttachmentResult>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, realpath, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { simpleParser } from "mailparser";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { isInsideDir, resolveArtifactTarget } from "../runstore/workspaceLayout.js";
|
|
8
|
+
import { EmailError } from "./port.js";
|
|
9
|
+
export const attachmentReferencesSchema = z.array(z.object({ artifact: z.string().min(1).max(2048), filename: z.string().max(200).optional() }).strict()).max(100);
|
|
10
|
+
export const attachmentLimitsSchema = z.object({
|
|
11
|
+
count: z.number().int().min(1).max(100).default(10),
|
|
12
|
+
perFileBytes: z.number().int().min(1).max(16 * 1024 * 1024).default(2 * 1024 * 1024),
|
|
13
|
+
totalBytes: z.number().int().min(1).max(32 * 1024 * 1024).default(5 * 1024 * 1024),
|
|
14
|
+
downloadBytes: z.number().int().min(1).max(32 * 1024 * 1024).default(8 * 1024 * 1024),
|
|
15
|
+
}).strict();
|
|
16
|
+
export function safeAttachmentFilename(filename) {
|
|
17
|
+
const name = (filename ?? "attachment").split(/[/\\]/).at(-1).replace(/[\x00-\x1f\x7f]/g, "_").replace(/[^\p{L}\p{N} ._()-]/gu, "_").slice(0, 200).trim();
|
|
18
|
+
return !name || name === "." || name === ".." ? "attachment" : name;
|
|
19
|
+
}
|
|
20
|
+
/** Reject symlinks in every descendant, including artifact directories. */
|
|
21
|
+
async function containedPath(context, relative, createDirectories = false) {
|
|
22
|
+
if (typeof relative !== "string" || relative.includes("\\") || path.isAbsolute(relative) || relative.split("/").some(segment => !segment || segment === "." || segment === ".." || segment === ".pi-agent" || segment === "auth.json" || segment === "pi-session.jsonl"))
|
|
23
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
24
|
+
const root = await realpath(context.workspaceDir);
|
|
25
|
+
const segments = relative.split("/");
|
|
26
|
+
let current = root;
|
|
27
|
+
for (let index = 0; index < segments.length; index++) {
|
|
28
|
+
current = path.join(current, segments[index]);
|
|
29
|
+
const last = index === segments.length - 1;
|
|
30
|
+
if (createDirectories && !last)
|
|
31
|
+
await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST")
|
|
32
|
+
throw error; });
|
|
33
|
+
if (createDirectories && last)
|
|
34
|
+
break;
|
|
35
|
+
const stat = await lstat(current);
|
|
36
|
+
if (stat.isSymbolicLink() || (!last && !stat.isDirectory()) || (last && (!stat.isFile() || stat.nlink !== 1)))
|
|
37
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
38
|
+
const resolved = await realpath(current);
|
|
39
|
+
if (!isInsideDir(resolved, root))
|
|
40
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
41
|
+
}
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
44
|
+
export async function prepareAttachments(account, references, context) {
|
|
45
|
+
if (!references?.length)
|
|
46
|
+
return [];
|
|
47
|
+
if (!context)
|
|
48
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
49
|
+
if (references.length > account.attachmentLimits.count)
|
|
50
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
51
|
+
let total = 0;
|
|
52
|
+
const attachments = [];
|
|
53
|
+
for (const reference of references) {
|
|
54
|
+
// Only run artifact files are eligible; arbitrary run files are not.
|
|
55
|
+
if (!/^stages\/[^/]+\/(?:attempts\/[1-9]\d*\/)?artifacts\/.+/.test(reference.artifact))
|
|
56
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
57
|
+
let handle;
|
|
58
|
+
try {
|
|
59
|
+
const target = await containedPath(context, reference.artifact);
|
|
60
|
+
handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
61
|
+
const stat = await handle.stat();
|
|
62
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
63
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
64
|
+
await containedPath(context, reference.artifact);
|
|
65
|
+
const checked = await lstat(target);
|
|
66
|
+
if (checked.dev !== stat.dev || checked.ino !== stat.ino || !checked.isFile() || checked.nlink !== 1)
|
|
67
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
68
|
+
const limit = Math.min(account.attachmentLimits.perFileBytes, account.attachmentLimits.totalBytes - total);
|
|
69
|
+
if (stat.size > limit)
|
|
70
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
71
|
+
// Read from this descriptor once. Hash and SMTP use these same bounded bytes.
|
|
72
|
+
const content = Buffer.alloc(limit + 1);
|
|
73
|
+
let size = 0;
|
|
74
|
+
while (size <= limit) {
|
|
75
|
+
const read = await handle.read(content, size, Math.min(65536, content.length - size), null);
|
|
76
|
+
if (!read.bytesRead)
|
|
77
|
+
break;
|
|
78
|
+
size += read.bytesRead;
|
|
79
|
+
}
|
|
80
|
+
if (size > limit)
|
|
81
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
82
|
+
total += size;
|
|
83
|
+
attachments.push({ filename: safeAttachmentFilename(reference.filename ?? path.basename(reference.artifact)), content: Buffer.from(content.subarray(0, size)) });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof EmailError)
|
|
87
|
+
throw error;
|
|
88
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle?.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return attachments;
|
|
95
|
+
}
|
|
96
|
+
export function attachmentIdentity(attachments) {
|
|
97
|
+
return attachments.map(value => ({ filename: value.filename, size: value.content.length, sha256: createHash("sha256").update(value.content).digest("hex") }));
|
|
98
|
+
}
|
|
99
|
+
export async function saveSelectedAttachment(account, source, attachmentId, context, validateAccount) {
|
|
100
|
+
if (source.length > account.attachmentLimits.downloadBytes)
|
|
101
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
102
|
+
if (typeof attachmentId !== "string" || !/^(0|[1-9]\d{0,2})$/.test(attachmentId))
|
|
103
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = await simpleParser(source, { skipHtmlToText: true, skipTextToHtml: true, skipImageLinks: true, maxHtmlLengthToParse: 128 * 1024 });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
110
|
+
}
|
|
111
|
+
if (parsed.attachments.length > account.attachmentLimits.count)
|
|
112
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
113
|
+
const selected = parsed.attachments[Number(attachmentId)];
|
|
114
|
+
if (!selected)
|
|
115
|
+
throw new EmailError("EMAIL_MESSAGE_NOT_FOUND");
|
|
116
|
+
if (selected.size > account.attachmentLimits.perFileBytes || selected.content.length > account.attachmentLimits.perFileBytes || selected.content.length > account.attachmentLimits.totalBytes)
|
|
117
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
118
|
+
const filename = safeAttachmentFilename(selected.filename);
|
|
119
|
+
const artifact = resolveArtifactTarget(context.workspaceDir, context.stageId, context.attempt, `email-${randomUUID()}.bin`).runRelativePath;
|
|
120
|
+
let target;
|
|
121
|
+
let created = false;
|
|
122
|
+
try {
|
|
123
|
+
validateAccount();
|
|
124
|
+
target = await containedPath(context, artifact, true);
|
|
125
|
+
validateAccount();
|
|
126
|
+
const handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
127
|
+
created = true;
|
|
128
|
+
try {
|
|
129
|
+
await handle.writeFile(selected.content);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await handle.close();
|
|
133
|
+
}
|
|
134
|
+
// Recheck directory containment after the write; never return an escaped result.
|
|
135
|
+
await containedPath(context, artifact);
|
|
136
|
+
validateAccount();
|
|
137
|
+
return { id: attachmentId, filename, contentType: selected.contentType.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200), size: selected.content.length, artifact };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (created && target)
|
|
141
|
+
await unlink(target).catch(() => { });
|
|
142
|
+
if (error instanceof EmailError)
|
|
143
|
+
throw error;
|
|
144
|
+
throw new EmailError("EMAIL_UNAUTHORIZED");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { EmailAccounts, type EmailAccount } from "./accounts.js";
|
|
2
|
+
import { type EmailErrorCode, type EmailMessageSummary, type EmailReceivedEvent } from "./port.js";
|
|
3
|
+
/** Adapter-owned identity shared by live delivery and explicit historical retrieval. */
|
|
4
|
+
export declare function receivedEventId(scope: string, accountId: string, folder: string, generation: string, uid: number): string;
|
|
5
|
+
/** Internal provider seam. Each bounded metadata read isolates normalization faults by UID. */
|
|
6
|
+
export type ReceiveOutcome = {
|
|
7
|
+
uid: number;
|
|
8
|
+
} & ({
|
|
9
|
+
message: EmailMessageSummary;
|
|
10
|
+
fault?: never;
|
|
11
|
+
} | {
|
|
12
|
+
fault: "EMAIL_INVALID_INPUT" | "EMAIL_RESOURCE_LIMIT";
|
|
13
|
+
message?: never;
|
|
14
|
+
});
|
|
15
|
+
export interface ReceiveMailbox {
|
|
16
|
+
snapshot(): Promise<{
|
|
17
|
+
generation: string;
|
|
18
|
+
high: number;
|
|
19
|
+
}>;
|
|
20
|
+
read(lower: number, upper: number): Promise<ReceiveOutcome[]>;
|
|
21
|
+
next(lower: number, upper: number): Promise<number | undefined>;
|
|
22
|
+
watch?(): void;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
type Progress = {
|
|
26
|
+
generation: string;
|
|
27
|
+
uid: number;
|
|
28
|
+
};
|
|
29
|
+
type SafeError = {
|
|
30
|
+
code: EmailErrorCode;
|
|
31
|
+
retryable: boolean;
|
|
32
|
+
};
|
|
33
|
+
export type ReceivedRecord = {
|
|
34
|
+
event: EmailReceivedEvent;
|
|
35
|
+
state: "pending" | "accepted" | "faulted";
|
|
36
|
+
acceptedAt?: string;
|
|
37
|
+
error?: SafeError;
|
|
38
|
+
};
|
|
39
|
+
export type MessageFault = {
|
|
40
|
+
accountId: string;
|
|
41
|
+
folder: string;
|
|
42
|
+
generation: string;
|
|
43
|
+
uid: number;
|
|
44
|
+
code: EmailErrorCode;
|
|
45
|
+
detectedAt: string;
|
|
46
|
+
};
|
|
47
|
+
export type WatcherHealth = {
|
|
48
|
+
accountId: string;
|
|
49
|
+
folder: string;
|
|
50
|
+
state: "starting" | "watching" | "recovering" | "failed";
|
|
51
|
+
error?: SafeError;
|
|
52
|
+
storageError?: SafeError;
|
|
53
|
+
retryAt?: string;
|
|
54
|
+
lastAccepted?: Progress;
|
|
55
|
+
reset?: {
|
|
56
|
+
previousGeneration: string;
|
|
57
|
+
generation: string;
|
|
58
|
+
baseline: number;
|
|
59
|
+
detectedAt: string;
|
|
60
|
+
};
|
|
61
|
+
faults?: MessageFault[];
|
|
62
|
+
};
|
|
63
|
+
/** Internal fault-injection seam. Production uses real timers and atomic file writes. */
|
|
64
|
+
type RecoveryDependencies = {
|
|
65
|
+
random?: () => number;
|
|
66
|
+
beforePersist?: () => void;
|
|
67
|
+
afterPersist?: () => void;
|
|
68
|
+
};
|
|
69
|
+
/** Single host writer. A pending event blocks folder progress until its consumer accepts it. */
|
|
70
|
+
export declare class EmailEvents {
|
|
71
|
+
private readonly accounts;
|
|
72
|
+
private readonly open;
|
|
73
|
+
private readonly dependencies;
|
|
74
|
+
private readonly file;
|
|
75
|
+
private saved;
|
|
76
|
+
private durable;
|
|
77
|
+
private readonly watchers;
|
|
78
|
+
private emit?;
|
|
79
|
+
private unsubscribe?;
|
|
80
|
+
private changes;
|
|
81
|
+
constructor(accounts: EmailAccounts, open: (account: EmailAccount, folder: string, signal: () => void) => Promise<ReceiveMailbox>, dependencies?: RecoveryDependencies);
|
|
82
|
+
list(): ReceivedRecord[];
|
|
83
|
+
health(): WatcherHealth[];
|
|
84
|
+
start(emit: (event: EmailReceivedEvent) => Promise<void>): Promise<void>;
|
|
85
|
+
stop(): Promise<void>;
|
|
86
|
+
private cancel;
|
|
87
|
+
private replace;
|
|
88
|
+
private signal;
|
|
89
|
+
private fail;
|
|
90
|
+
private reconcile;
|
|
91
|
+
private folderRecords;
|
|
92
|
+
private deliver;
|
|
93
|
+
private persist;
|
|
94
|
+
}
|
|
95
|
+
export {};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { decodeRef } from "./messages.js";
|
|
5
|
+
import { EmailError } from "./port.js";
|
|
6
|
+
/** Adapter-owned identity shared by live delivery and explicit historical retrieval. */
|
|
7
|
+
export function receivedEventId(scope, accountId, folder, generation, uid) {
|
|
8
|
+
return createHash("sha256").update(JSON.stringify([scope, accountId, folder, generation, uid])).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
/** Single host writer. A pending event blocks folder progress until its consumer accepts it. */
|
|
11
|
+
export class EmailEvents {
|
|
12
|
+
accounts;
|
|
13
|
+
open;
|
|
14
|
+
dependencies;
|
|
15
|
+
file;
|
|
16
|
+
saved;
|
|
17
|
+
durable;
|
|
18
|
+
watchers = new Map();
|
|
19
|
+
emit;
|
|
20
|
+
unsubscribe;
|
|
21
|
+
changes = Promise.resolve();
|
|
22
|
+
constructor(accounts, open, dependencies = {}) {
|
|
23
|
+
this.accounts = accounts;
|
|
24
|
+
this.open = open;
|
|
25
|
+
this.dependencies = dependencies;
|
|
26
|
+
this.file = path.join(accounts.scope, ".stageflow", "email-events.json");
|
|
27
|
+
this.saved = existsSync(this.file) ? JSON.parse(readFileSync(this.file, "utf8")) : { version: 1, progress: {}, records: [] };
|
|
28
|
+
if (this.saved.version !== 1 || !this.saved.progress || !Array.isArray(this.saved.records))
|
|
29
|
+
throw new EmailError("EMAIL_INVALID_INPUT");
|
|
30
|
+
this.durable = structuredClone(this.saved);
|
|
31
|
+
}
|
|
32
|
+
list() { return structuredClone(this.saved.records); }
|
|
33
|
+
health() {
|
|
34
|
+
return [...this.watchers.entries()].map(([key, watcher]) => structuredClone({
|
|
35
|
+
...watcher.health,
|
|
36
|
+
...(this.saved.lastAccepted?.[key] ? { lastAccepted: this.saved.lastAccepted[key] } : {}),
|
|
37
|
+
...(this.saved.resets?.[key] ? { reset: this.saved.resets[key] } : {}),
|
|
38
|
+
...(this.saved.messageFaults?.some(value => value.accountId === watcher.account.accountId && value.folder === watcher.folder)
|
|
39
|
+
? { faults: this.saved.messageFaults.filter(value => value.accountId === watcher.account.accountId && value.folder === watcher.folder) } : {}),
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
async start(emit) {
|
|
43
|
+
if (this.emit) {
|
|
44
|
+
await this.changes;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.emit = emit;
|
|
48
|
+
this.unsubscribe = this.accounts.onChange(accountId => {
|
|
49
|
+
for (const watcher of this.watchers.values())
|
|
50
|
+
if (watcher.account.accountId === accountId)
|
|
51
|
+
this.cancel(watcher);
|
|
52
|
+
this.changes = this.changes.then(() => this.replace(accountId));
|
|
53
|
+
});
|
|
54
|
+
this.changes = Promise.all(this.accounts.list().map(account => this.replace(account.accountId))).then(() => { });
|
|
55
|
+
await this.changes;
|
|
56
|
+
}
|
|
57
|
+
async stop() {
|
|
58
|
+
this.unsubscribe?.();
|
|
59
|
+
this.unsubscribe = undefined;
|
|
60
|
+
const stopping = [...this.watchers.values()];
|
|
61
|
+
for (const watcher of stopping)
|
|
62
|
+
this.cancel(watcher);
|
|
63
|
+
this.emit = undefined;
|
|
64
|
+
await this.changes;
|
|
65
|
+
await Promise.all(stopping.map(value => value.work));
|
|
66
|
+
this.watchers.clear();
|
|
67
|
+
}
|
|
68
|
+
cancel(watcher) {
|
|
69
|
+
watcher.cancelled = true;
|
|
70
|
+
clearTimeout(watcher.timer);
|
|
71
|
+
clearTimeout(watcher.retry);
|
|
72
|
+
watcher.connection?.close();
|
|
73
|
+
}
|
|
74
|
+
async replace(accountId) {
|
|
75
|
+
const old = [...this.watchers.entries()].filter(([, watcher]) => watcher.account.accountId === accountId);
|
|
76
|
+
for (const [, watcher] of old)
|
|
77
|
+
this.cancel(watcher);
|
|
78
|
+
await Promise.all(old.map(([, watcher]) => watcher.work));
|
|
79
|
+
for (const [key] of old)
|
|
80
|
+
this.watchers.delete(key);
|
|
81
|
+
const account = this.accounts.list().find(value => value.accountId === accountId && value.enabled);
|
|
82
|
+
if (!account || !this.emit)
|
|
83
|
+
return;
|
|
84
|
+
await Promise.all([...new Set(account.folders)].map(async (folder) => {
|
|
85
|
+
const key = JSON.stringify([accountId, folder]);
|
|
86
|
+
const watcher = { account, folder, cancelled: false, dirty: false, scheduled: false, attempts: 0, work: Promise.resolve(), health: { accountId, folder, state: "starting" } };
|
|
87
|
+
this.watchers.set(key, watcher);
|
|
88
|
+
this.signal(key, watcher);
|
|
89
|
+
await watcher.work;
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
signal(key, watcher) {
|
|
93
|
+
if (watcher.cancelled || watcher.retry || watcher.health.state === "failed")
|
|
94
|
+
return;
|
|
95
|
+
watcher.dirty = true;
|
|
96
|
+
if (watcher.scheduled)
|
|
97
|
+
return;
|
|
98
|
+
watcher.scheduled = true;
|
|
99
|
+
watcher.work = watcher.work.then(async () => {
|
|
100
|
+
try {
|
|
101
|
+
while (watcher.dirty && !watcher.cancelled && !watcher.retry && watcher.health.state !== "failed") {
|
|
102
|
+
watcher.dirty = false;
|
|
103
|
+
try {
|
|
104
|
+
watcher.connection ??= await this.open(watcher.account, watcher.folder, () => this.signal(key, watcher));
|
|
105
|
+
if (watcher.cancelled || !this.emit) {
|
|
106
|
+
watcher.connection.close();
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
await this.reconcile(key, watcher);
|
|
110
|
+
if (watcher.cancelled)
|
|
111
|
+
break;
|
|
112
|
+
watcher.connection.watch?.();
|
|
113
|
+
watcher.attempts = 0;
|
|
114
|
+
watcher.health = { accountId: watcher.account.accountId, folder: watcher.folder, state: "watching" };
|
|
115
|
+
if (this.saved.faults?.[key]) {
|
|
116
|
+
delete this.saved.faults[key];
|
|
117
|
+
this.persist();
|
|
118
|
+
}
|
|
119
|
+
clearTimeout(watcher.timer);
|
|
120
|
+
watcher.timer = setTimeout(() => this.signal(key, watcher), watcher.account.pollingIntervalMs);
|
|
121
|
+
watcher.timer.unref();
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
this.fail(key, watcher, error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
watcher.scheduled = false;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
fail(key, watcher, error) {
|
|
134
|
+
if (watcher.cancelled)
|
|
135
|
+
return;
|
|
136
|
+
const fault = error instanceof EmailError ? error : new EmailError("EMAIL_CONNECTION_FAILED", true);
|
|
137
|
+
const retryable = fault.retryable && !["EMAIL_AUTH_FAILED", "EMAIL_TOKEN_EXPIRED"].includes(fault.code);
|
|
138
|
+
watcher.health = { accountId: watcher.account.accountId, folder: watcher.folder, state: retryable ? "recovering" : "failed", error: { code: fault.code, retryable } };
|
|
139
|
+
clearTimeout(watcher.timer);
|
|
140
|
+
if (retryable) {
|
|
141
|
+
const ceiling = Math.min(watcher.account.reconnectMaxDelayMs, 1000 * 2 ** Math.min(watcher.attempts++, 22));
|
|
142
|
+
const random = Math.max(0, Math.min(1, (this.dependencies.random ?? Math.random)()));
|
|
143
|
+
const delay = Math.floor(ceiling * (0.5 + random * 0.5));
|
|
144
|
+
watcher.health.retryAt = new Date(Date.now() + delay).toISOString();
|
|
145
|
+
watcher.retry = setTimeout(() => { watcher.retry = undefined; this.signal(key, watcher); }, delay);
|
|
146
|
+
watcher.retry.unref();
|
|
147
|
+
}
|
|
148
|
+
// Set the recovery state before close: synchronous close signals cannot queue a retry.
|
|
149
|
+
watcher.connection?.close();
|
|
150
|
+
watcher.connection = undefined;
|
|
151
|
+
this.saved.faults ??= {};
|
|
152
|
+
this.saved.faults[key] = structuredClone(watcher.health);
|
|
153
|
+
try {
|
|
154
|
+
this.persist();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
watcher.health.storageError = { code: "EMAIL_STORAGE_FAILED", retryable: true };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async reconcile(key, watcher) {
|
|
161
|
+
const connection = watcher.connection;
|
|
162
|
+
const snapshot = await connection.snapshot();
|
|
163
|
+
if (!/^\d{1,20}$/.test(snapshot.generation) || !Number.isSafeInteger(snapshot.high) || snapshot.high < 0 || snapshot.high > 4294967295)
|
|
164
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
165
|
+
if (watcher.cancelled)
|
|
166
|
+
return;
|
|
167
|
+
const progress = this.saved.progress[key];
|
|
168
|
+
if (!progress || progress.generation !== snapshot.generation) {
|
|
169
|
+
if (progress) {
|
|
170
|
+
this.saved.resets ??= {};
|
|
171
|
+
this.saved.resets[key] = { previousGeneration: progress.generation, generation: snapshot.generation, baseline: snapshot.high, detectedAt: new Date().toISOString() };
|
|
172
|
+
for (const record of this.folderRecords(watcher)) {
|
|
173
|
+
if (record.state !== "pending")
|
|
174
|
+
continue;
|
|
175
|
+
record.state = "faulted";
|
|
176
|
+
record.error = { code: "EMAIL_STALE_REFERENCE", retryable: false };
|
|
177
|
+
record.acceptedAt = new Date().toISOString();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this.saved.progress[key] = { generation: snapshot.generation, uid: snapshot.high };
|
|
181
|
+
this.persist();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const pending = this.folderRecords(watcher).filter(value => value.state === "pending")
|
|
185
|
+
.sort((a, b) => decodeRef(watcher.account, a.event.message.ref).uid - decodeRef(watcher.account, b.event.message.ref).uid);
|
|
186
|
+
for (const record of pending) {
|
|
187
|
+
if (watcher.cancelled || !this.emit)
|
|
188
|
+
return;
|
|
189
|
+
const reference = decodeRef(watcher.account, record.event.message.ref);
|
|
190
|
+
if (reference.generation !== progress.generation) {
|
|
191
|
+
record.state = "faulted";
|
|
192
|
+
record.error = { code: "EMAIL_STALE_REFERENCE", retryable: false };
|
|
193
|
+
record.acceptedAt = new Date().toISOString();
|
|
194
|
+
this.persist();
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
await this.deliver(key, watcher, record, reference.uid);
|
|
198
|
+
progress.uid = Math.max(progress.uid, reference.uid);
|
|
199
|
+
this.persist();
|
|
200
|
+
}
|
|
201
|
+
while (progress.uid < snapshot.high) {
|
|
202
|
+
if (watcher.cancelled)
|
|
203
|
+
return;
|
|
204
|
+
const first = await connection.next(progress.uid + 1, snapshot.high);
|
|
205
|
+
if (watcher.cancelled)
|
|
206
|
+
return;
|
|
207
|
+
if (first === undefined) {
|
|
208
|
+
progress.uid = snapshot.high;
|
|
209
|
+
this.persist();
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (!Number.isSafeInteger(first) || first <= progress.uid || first > snapshot.high)
|
|
213
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
214
|
+
const upper = Math.min(snapshot.high, first + 99);
|
|
215
|
+
const messages = await connection.read(first, upper);
|
|
216
|
+
if (messages.length > 100 || new Set(messages.map(value => value.uid)).size !== messages.length || messages.some(value => !Number.isSafeInteger(value.uid) || value.uid < first || value.uid > upper))
|
|
217
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
218
|
+
messages.sort((a, b) => a.uid - b.uid);
|
|
219
|
+
for (const outcome of messages) {
|
|
220
|
+
if (watcher.cancelled || !this.emit)
|
|
221
|
+
return;
|
|
222
|
+
const { uid } = outcome;
|
|
223
|
+
if (outcome.fault) {
|
|
224
|
+
this.saved.messageFaults ??= [];
|
|
225
|
+
if (!this.saved.messageFaults.some(value => value.accountId === watcher.account.accountId && value.folder === watcher.folder && value.generation === snapshot.generation && value.uid === uid)) {
|
|
226
|
+
this.saved.messageFaults.push({ accountId: watcher.account.accountId, folder: watcher.folder, generation: snapshot.generation, uid, code: outcome.fault, detectedAt: new Date().toISOString() });
|
|
227
|
+
this.persist();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const eventId = receivedEventId(this.accounts.scope, watcher.account.accountId, watcher.folder, snapshot.generation, uid);
|
|
232
|
+
let record = this.saved.records.find(value => value.event.eventId === eventId);
|
|
233
|
+
if (!record) {
|
|
234
|
+
if (this.saved.records.filter(value => value.state === "pending").length >= 10000)
|
|
235
|
+
throw new EmailError("EMAIL_RESOURCE_LIMIT");
|
|
236
|
+
const message = outcome.message;
|
|
237
|
+
record = { state: "pending", event: { type: "email.received", version: 1, eventId, accountId: watcher.account.accountId, message, receivedAt: message.receivedAt, detectedAt: new Date().toISOString() } };
|
|
238
|
+
this.saved.records.push(record);
|
|
239
|
+
this.persist();
|
|
240
|
+
}
|
|
241
|
+
if (record.state === "pending")
|
|
242
|
+
await this.deliver(key, watcher, record, uid);
|
|
243
|
+
}
|
|
244
|
+
progress.uid = uid;
|
|
245
|
+
this.persist();
|
|
246
|
+
}
|
|
247
|
+
if (watcher.cancelled)
|
|
248
|
+
return;
|
|
249
|
+
progress.uid = upper;
|
|
250
|
+
this.persist();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
folderRecords(watcher) {
|
|
254
|
+
return this.saved.records.filter(value => value.event.accountId === watcher.account.accountId && value.event.message.ref.mailbox === watcher.folder);
|
|
255
|
+
}
|
|
256
|
+
async deliver(key, watcher, record, uid) {
|
|
257
|
+
this.persist();
|
|
258
|
+
try {
|
|
259
|
+
await this.emit(structuredClone(record.event));
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
throw new EmailError("EMAIL_EVENT_ACCEPTANCE_FAILED", true);
|
|
263
|
+
}
|
|
264
|
+
record.state = "accepted";
|
|
265
|
+
record.acceptedAt = new Date().toISOString();
|
|
266
|
+
this.saved.lastAccepted ??= {};
|
|
267
|
+
this.saved.lastAccepted[key] = { generation: decodeRef(watcher.account, record.event.message.ref).generation, uid };
|
|
268
|
+
// Persist acceptance before checkpoint advancement. A restart uses the stable event ID.
|
|
269
|
+
this.saved.records = [...this.saved.records.filter(value => value !== record), record];
|
|
270
|
+
this.persist();
|
|
271
|
+
}
|
|
272
|
+
persist() {
|
|
273
|
+
const cutoff = Date.now() - 30 * 86400000;
|
|
274
|
+
const completed = this.saved.records.filter(value => value.state !== "pending" && Date.parse(value.acceptedAt) >= cutoff)
|
|
275
|
+
.sort((a, b) => Date.parse(a.acceptedAt) - Date.parse(b.acceptedAt)).slice(-1000);
|
|
276
|
+
this.saved.records = [...this.saved.records.filter(value => value.state === "pending"), ...completed];
|
|
277
|
+
this.saved.messageFaults = this.saved.messageFaults?.filter(value => Date.parse(value.detectedAt) >= cutoff).slice(-1000);
|
|
278
|
+
try {
|
|
279
|
+
this.dependencies.beforePersist?.();
|
|
280
|
+
mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
281
|
+
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
282
|
+
writeFileSync(temporary, JSON.stringify(this.saved), { mode: 0o600 });
|
|
283
|
+
renameSync(temporary, this.file);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
this.saved = structuredClone(this.durable);
|
|
287
|
+
throw new EmailError("EMAIL_STORAGE_FAILED", true);
|
|
288
|
+
}
|
|
289
|
+
this.durable = structuredClone(this.saved);
|
|
290
|
+
this.dependencies.afterPersist?.();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { EmailAccounts } from "./accounts.js";
|
|
2
|
+
import { LocalEmailAdapter } from "./adapter.js";
|
|
3
|
+
import { type EmailMailbox, type EmailArtifactContext } from "./port.js";
|
|
4
|
+
import type { StageConfig } from "../types/stage.js";
|
|
5
|
+
export declare function emailHostFor(cwd: string): {
|
|
6
|
+
accounts: EmailAccounts;
|
|
7
|
+
mailbox: LocalEmailAdapter;
|
|
8
|
+
};
|
|
9
|
+
export declare function releaseEmailHost(cwd: string): Promise<void>;
|
|
10
|
+
export type StageEmail = Pick<EmailMailbox, "send" | "reply" | "search" | "getMessage" | "downloadAttachment">;
|
|
11
|
+
export declare function validateStageEmailAccounts(accounts: EmailAccounts, stage: StageConfig): void;
|
|
12
|
+
export declare function stageEmail(mailbox: EmailMailbox, stage: StageConfig, runId: string, context?: Omit<EmailArtifactContext, "stageId">): StageEmail;
|
|
13
|
+
export declare function emailWorkerEnvironment(env: NodeJS.ProcessEnv, accounts: EmailAccounts): NodeJS.ProcessEnv;
|
|
14
|
+
/** The inherited IPC channel identifies the child; no caller-provided run or stage identity is accepted. */
|
|
15
|
+
export declare function workerStageEmail(): StageEmail;
|