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.
Files changed (96) hide show
  1. package/README.md +1 -0
  2. package/dist/agent/fakeAgent.js +0 -6
  3. package/dist/agent/piAdapter.js +21 -0
  4. package/dist/agent/port.d.ts +2 -0
  5. package/dist/agent/providerAuth.d.ts +6 -0
  6. package/dist/agent/providerAuth.js +50 -14
  7. package/dist/agent/resolveAgentPort.d.ts +2 -0
  8. package/dist/agent/resolveAgentPort.js +5 -1
  9. package/dist/config/listConfig.d.ts +0 -16
  10. package/dist/config/listConfig.js +0 -3
  11. package/dist/config/loadStage.js +10 -0
  12. package/dist/config/pipelineStageKeys.js +1 -0
  13. package/dist/email/accounts.d.ts +109 -0
  14. package/dist/email/accounts.js +285 -0
  15. package/dist/email/adapter.d.ts +69 -0
  16. package/dist/email/adapter.js +720 -0
  17. package/dist/email/attachments.d.ts +21 -0
  18. package/dist/email/attachments.js +146 -0
  19. package/dist/email/events.d.ts +95 -0
  20. package/dist/email/events.js +292 -0
  21. package/dist/email/host.d.ts +15 -0
  22. package/dist/email/host.js +119 -0
  23. package/dist/email/messages.d.ts +44 -0
  24. package/dist/email/messages.js +182 -0
  25. package/dist/email/port.d.ts +161 -0
  26. package/dist/email/port.js +12 -0
  27. package/dist/email/replies.d.ts +5 -0
  28. package/dist/email/replies.js +56 -0
  29. package/dist/email/submissions.d.ts +31 -0
  30. package/dist/email/submissions.js +123 -0
  31. package/dist/email/triggers.d.ts +140 -0
  32. package/dist/email/triggers.js +563 -0
  33. package/dist/hosted/containment.d.ts +3 -0
  34. package/dist/hosted/containment.js +36 -0
  35. package/dist/hosted/errors.d.ts +21 -0
  36. package/dist/hosted/errors.js +30 -0
  37. package/dist/hosted/packageSkills.d.ts +3 -0
  38. package/dist/hosted/packageSkills.js +80 -0
  39. package/dist/hosted/runtime.d.ts +12 -0
  40. package/dist/hosted/runtime.js +596 -0
  41. package/dist/hosted/sequencer.d.ts +28 -0
  42. package/dist/hosted/sequencer.js +129 -0
  43. package/dist/hosted/translate.d.ts +3 -0
  44. package/dist/hosted/translate.js +43 -0
  45. package/dist/hosted/types.d.ts +108 -0
  46. package/dist/hosted/types.js +10 -0
  47. package/dist/index.d.ts +8 -1
  48. package/dist/index.js +5 -0
  49. package/dist/package-meta.d.ts +1 -1
  50. package/dist/package-meta.js +1 -1
  51. package/dist/projection/projectRun.d.ts +0 -1
  52. package/dist/projection/projectRun.js +0 -1
  53. package/dist/runstore/port.d.ts +4 -0
  54. package/dist/runstore/sqlite/SqliteRunStore.d.ts +1 -0
  55. package/dist/runstore/sqlite/SqliteRunStore.js +24 -5
  56. package/dist/runstore/sqlite/migrations/011-email-dispatch-key.d.ts +7 -0
  57. package/dist/runstore/sqlite/migrations/011-email-dispatch-key.js +12 -0
  58. package/dist/runstore/sqlite/migrations/index.js +2 -0
  59. package/dist/runtime/containerLimits.d.ts +0 -3
  60. package/dist/runtime/containerLimits.js +0 -5
  61. package/dist/runtime/credentialBinding.d.ts +3 -0
  62. package/dist/runtime/credentialBinding.js +9 -0
  63. package/dist/runtime/pipelineRunner.d.ts +3 -0
  64. package/dist/runtime/pipelineRunner.js +11 -0
  65. package/dist/runtime/pipelineScheduler.js +3 -0
  66. package/dist/runtime/runManager.d.ts +1 -0
  67. package/dist/runtime/runManager.js +24 -3
  68. package/dist/runtime/runRetentionSweep.d.ts +0 -2
  69. package/dist/runtime/runRetentionSweep.js +0 -8
  70. package/dist/runtime/stageAttemptBootstrap.js +14 -0
  71. package/dist/runtime/stageProcessLauncher.d.ts +4 -0
  72. package/dist/runtime/stageProcessLauncher.js +45 -1
  73. package/dist/runtime/stageRoots.js +5 -1
  74. package/dist/server/bootstrap.d.ts +7 -0
  75. package/dist/server/bootstrap.js +17 -0
  76. package/dist/server/emailRoutes.d.ts +5 -0
  77. package/dist/server/emailRoutes.js +136 -0
  78. package/dist/server/http.d.ts +2 -0
  79. package/dist/server/http.js +16 -0
  80. package/dist/tools/readEmail.d.ts +77 -0
  81. package/dist/tools/readEmail.js +31 -0
  82. package/dist/tools/replyEmail.d.ts +31 -0
  83. package/dist/tools/replyEmail.js +25 -0
  84. package/dist/tools/sendEmail.d.ts +43 -0
  85. package/dist/tools/sendEmail.js +26 -0
  86. package/dist/types/stage.d.ts +4 -0
  87. package/dist/ui/assets/index-59cjF0OA.js +133 -0
  88. package/dist/ui/index.html +1 -1
  89. package/examples/hello-email/README.md +23 -0
  90. package/examples/hello-email/hello-email-reply.pipeline.yaml +6 -0
  91. package/examples/hello-email/hello-email.pipeline.yaml +6 -0
  92. package/examples/hello-email/hello-email.task.yaml +6 -0
  93. package/examples/hello-email/reply-hello-email.yaml +63 -0
  94. package/examples/hello-email/send-hello-email.yaml +37 -0
  95. package/package.json +14 -2
  96. package/dist/ui/assets/index-p8Iou3zk.js +0 -133
@@ -0,0 +1,720 @@
1
+ import { ImapFlow } from "imapflow";
2
+ import { EmailEvents, receivedEventId } from "./events.js";
3
+ import { randomBytes } from "node:crypto";
4
+ import nodemailer from "nodemailer";
5
+ import MailComposer from "nodemailer/lib/mail-composer/index.js";
6
+ import { connect as connectTcp } from "node:net";
7
+ import { connect as connectTls } from "node:tls";
8
+ import { EmailSubmissions, validateSend } from "./submissions.js";
9
+ import { replyMessage, validateReply } from "./replies.js";
10
+ import { attachmentIdentity, prepareAttachments, saveSelectedAttachment } from "./attachments.js";
11
+ import { decodeRef, decodeCursor, EMAIL_SOURCE_LIMIT, EMAIL_SEARCH_FIELDS, envelopeSummary, matchesSearch, parseMessage, searchCursor, searchQuery, summarize } from "./messages.js";
12
+ import { resolveEmailSecret } from "./accounts.js";
13
+ import { EmailError, } from "./port.js";
14
+ const automaticMessageHeaders = { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" };
15
+ function normalizedConnectionError(error) {
16
+ if (error instanceof EmailError)
17
+ return error;
18
+ const fault = error;
19
+ if (fault.authenticationFailed || fault.code === "EAUTH" || fault.responseCode === 535) {
20
+ return new EmailError("EMAIL_AUTH_FAILED");
21
+ }
22
+ if (["ETIMEDOUT", "ETIMEOUT"].includes(fault.code ?? ""))
23
+ return new EmailError("EMAIL_TIMEOUT", true);
24
+ return new EmailError("EMAIL_CONNECTION_FAILED", true);
25
+ }
26
+ function connectionStatus(error) {
27
+ if (!error)
28
+ return { state: "ok" };
29
+ const normalized = normalizedConnectionError(error);
30
+ return { state: "failed", error: { code: normalized.code, retryable: normalized.retryable } };
31
+ }
32
+ async function receiveOutcome(uid, normalize) {
33
+ try {
34
+ return { uid, message: await normalize() };
35
+ }
36
+ catch (error) {
37
+ if (error instanceof EmailError && (error.code === "EMAIL_INVALID_INPUT" || error.code === "EMAIL_RESOURCE_LIMIT"))
38
+ return { uid, fault: error.code };
39
+ if (!(error instanceof EmailError))
40
+ return { uid, fault: "EMAIL_INVALID_INPUT" };
41
+ throw error;
42
+ }
43
+ }
44
+ async function submittedMime(account, input, operationId, attachments) {
45
+ const message = new MailComposer({ from: input.from, to: input.to, cc: input.cc, subject: input.subject,
46
+ text: input.text, html: input.html, inReplyTo: input.inReplyTo, references: input.references,
47
+ attachments, headers: automaticMessageHeaders, messageId: `<${operationId}@stageflow>`, date: new Date(), disableFileAccess: true, disableUrlAccess: true }).compile();
48
+ const stream = message.createReadStream();
49
+ const chunks = [];
50
+ let size = 0;
51
+ for await (const chunk of stream) {
52
+ const bytes = Buffer.from(chunk);
53
+ size += bytes.length;
54
+ if (size > account.sentCopyMaxBytes) {
55
+ stream.destroy();
56
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
57
+ }
58
+ chunks.push(bytes);
59
+ }
60
+ return Buffer.concat(chunks, size);
61
+ }
62
+ /** Both adapters validate account scope before any operation, including unsupported operations. */
63
+ export class AccountEmailAdapter {
64
+ accounts;
65
+ submissions;
66
+ events;
67
+ cursorKey = randomBytes(32);
68
+ stopped = false;
69
+ constructor(accounts) {
70
+ this.accounts = accounts;
71
+ this.submissions = new EmailSubmissions(accounts.scope);
72
+ this.events = new EmailEvents(accounts, (account, folder, signal) => this.openReceiver(account, folder, signal));
73
+ }
74
+ async getReceivedEvent(ref) {
75
+ const account = this.account(ref?.accountId);
76
+ // getMessage checks the current provider generation and message existence.
77
+ const message = summarize(await this.getMessage(ref));
78
+ if (JSON.stringify(account) !== JSON.stringify(this.account(account.accountId)))
79
+ throw new EmailError("EMAIL_OPERATION_CONFLICT");
80
+ const identity = decodeRef(account, message.ref);
81
+ const eventId = receivedEventId(this.accounts.scope, account.accountId, identity.mailbox, identity.generation, identity.uid);
82
+ delete message.preview;
83
+ return { type: "email.received", version: 1, eventId, accountId: account.accountId, message,
84
+ receivedAt: message.receivedAt, detectedAt: new Date().toISOString() };
85
+ }
86
+ account(accountId) {
87
+ if (this.stopped)
88
+ throw new EmailError("EMAIL_CONNECTION_FAILED", true);
89
+ return this.accounts.get(accountId);
90
+ }
91
+ async send(input, context) {
92
+ const account = this.account(input?.accountId);
93
+ const validated = validateSend(input, account);
94
+ return this.sendValidated(account, validated, context);
95
+ }
96
+ async sendValidated(account, validated, context, identity) {
97
+ const attachments = await prepareAttachments(account, validated.attachments, context);
98
+ return this.submissions.send(validated, async (operationId, accepted) => {
99
+ if (JSON.stringify(this.accounts.get(account.accountId)) !== JSON.stringify(account))
100
+ throw new EmailError("EMAIL_CONNECTION_FAILED", true);
101
+ const mime = account.sentCopyPolicy === "imap-append" ? await submittedMime(account, validated, operationId, attachments) : undefined;
102
+ if (JSON.stringify(this.account(account.accountId)) !== JSON.stringify(account))
103
+ throw new EmailError("EMAIL_CONNECTION_FAILED", true);
104
+ const receipt = await this.submit(account, validated, operationId, attachments, mime);
105
+ if (!mime)
106
+ return receipt;
107
+ receipt.sentCopy = { state: "pending" };
108
+ accepted(receipt);
109
+ try {
110
+ if (JSON.stringify(this.account(account.accountId)) !== JSON.stringify(account))
111
+ throw new EmailError("EMAIL_CONNECTION_FAILED");
112
+ await this.appendSent(account, mime);
113
+ receipt.sentCopy = { state: "completed" };
114
+ }
115
+ catch (error) {
116
+ const fault = normalizedConnectionError(error);
117
+ const known = ["EMAIL_INVALID_INPUT", "EMAIL_AUTH_FAILED", "EMAIL_TOKEN_EXPIRED"].includes(fault.code);
118
+ receipt.sentCopy = { state: known ? "failed" : "unknown", error: fault.code };
119
+ receipt.warnings = [known ? "EMAIL_SENT_COPY_FAILED" : "EMAIL_SENT_COPY_OUTCOME_UNKNOWN"];
120
+ }
121
+ return receipt;
122
+ }, attachments.length ? { operation: identity, attachments: attachmentIdentity(attachments) } : identity);
123
+ }
124
+ async reply(input, context) {
125
+ const account = this.account(input?.ref?.accountId);
126
+ const validated = validateReply(input);
127
+ const original = await this.getMessage(validated.ref);
128
+ const message = validateSend(replyMessage(account, original, validated), account);
129
+ return this.sendValidated(account, message, context, { operation: "reply", ref: original.ref, replyAll: validated.replyAll });
130
+ }
131
+ async downloadAttachment(input, context) {
132
+ const account = this.account(input?.ref?.accountId);
133
+ if (!context)
134
+ throw new EmailError("EMAIL_UNAUTHORIZED");
135
+ if (!input || Object.keys(input).some(key => !["ref", "attachmentId"].includes(key)) || typeof input.attachmentId !== "string" || !/^(0|[1-9]\d{0,2})$/.test(input.attachmentId))
136
+ throw new EmailError("EMAIL_INVALID_INPUT");
137
+ const source = await this.attachmentSource(account, input.ref);
138
+ const validateAccount = () => {
139
+ if (JSON.stringify(this.accounts.get(account.accountId)) !== JSON.stringify(account))
140
+ throw new EmailError("EMAIL_CONNECTION_FAILED", true);
141
+ };
142
+ validateAccount();
143
+ return saveSelectedAttachment(account, source, input.attachmentId, context, validateAccount);
144
+ }
145
+ async start(emit) { this.stopped = false; this.submissions.setAccepting(true); await this.events.start(emit); }
146
+ async stop() { this.stopped = true; this.submissions.setAccepting(false); await this.events.stop(); await this.submissions.drain(); }
147
+ searchPosition(uid, high, last, query) {
148
+ return uid <= high && (last === undefined || (query.sort === "newest" ? uid < last : uid > last));
149
+ }
150
+ status(accountId) {
151
+ return {
152
+ accountId, checkedAt: new Date().toISOString(),
153
+ capabilities: { operations: ["testAccount", "send", "reply", "search", "getMessage", "downloadAttachment"], searchFields: [...EMAIL_SEARCH_FIELDS], idle: false },
154
+ };
155
+ }
156
+ }
157
+ export class InMemoryEmailAdapter extends AccountEmailAdapter {
158
+ signals = new Map();
159
+ mailboxes = new Map();
160
+ /** Provider fixture data. Content is held only by this test adapter. */
161
+ seedMailbox(accountId, messages, mailbox = "INBOX", generation = "1") {
162
+ this.accounts.get(accountId, false);
163
+ this.mailboxes.set(`${accountId}:${mailbox}`, { generation, messages: messages.map(value => ({ ...value, source: Buffer.from(value.source), flags: new Set(value.flags), receivedAt: new Date(value.receivedAt) })) });
164
+ for (const signal of this.signals.get(`${accountId}:${mailbox}`) ?? [])
165
+ signal();
166
+ }
167
+ async openReceiver(account, folder, signal) {
168
+ const key = `${account.accountId}:${folder}`;
169
+ const signals = this.signals.get(key) ?? new Set();
170
+ this.signals.set(key, signals);
171
+ signals.add(signal);
172
+ return {
173
+ snapshot: async () => { const stored = this.mailboxes.get(key); return { generation: stored?.generation ?? "1", high: Math.max(0, ...stored?.messages.map(value => value.uid) ?? []) }; },
174
+ next: async (lower, upper) => {
175
+ let next;
176
+ for (const record of this.mailboxes.get(key)?.messages ?? [])
177
+ if (record.uid >= lower && record.uid <= upper && (next === undefined || record.uid < next))
178
+ next = record.uid;
179
+ return next;
180
+ },
181
+ read: async (lower, upper) => {
182
+ const stored = this.mailboxes.get(key);
183
+ const messages = [];
184
+ for (const record of stored?.messages ?? []) {
185
+ if (record.uid < lower || record.uid > upper)
186
+ continue;
187
+ const end = record.source.indexOf("\r\n\r\n");
188
+ const headers = record.source.subarray(0, Math.min(end < 0 ? record.source.length : end + 4, 65536));
189
+ messages.push(await receiveOutcome(record.uid, async () => {
190
+ const message = summarize(await parseMessage(account, folder, stored.generation, { ...record, source: headers }));
191
+ delete message.preview;
192
+ delete message.hasAttachments;
193
+ return message;
194
+ }));
195
+ }
196
+ return messages;
197
+ },
198
+ close: () => { signals.delete(signal); },
199
+ };
200
+ }
201
+ async search(input) {
202
+ const account = this.account(input?.accountId);
203
+ const query = searchQuery(input);
204
+ const { mailbox } = query;
205
+ const stored = this.mailboxes.get(`${account.accountId}:${mailbox}`);
206
+ if (!stored && mailbox !== "INBOX")
207
+ throw new EmailError("EMAIL_INVALID_INPUT");
208
+ const generation = stored?.generation ?? "1";
209
+ const cursor = decodeCursor(account, query, generation, input.cursor, this.cursorKey);
210
+ const high = cursor?.high ?? Math.max(0, ...stored?.messages.map(value => value.uid) ?? []);
211
+ const records = [...stored?.messages ?? []].sort((a, b) => query.sort === "newest" ? b.uid - a.uid : a.uid - b.uid);
212
+ let inspected = 0;
213
+ const messages = [];
214
+ let last = 0;
215
+ for (const record of records) {
216
+ if (!this.searchPosition(record.uid, high, cursor?.last, query))
217
+ continue;
218
+ if (++inspected > account.searchWorkLimit)
219
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
220
+ const end = record.source.indexOf("\r\n\r\n");
221
+ const headers = record.source.subarray(0, end < 0 ? Math.min(record.source.length, 65536) : Math.min(end + 4, 65536));
222
+ const summary = summarize(await parseMessage(account, mailbox, generation, { ...record, source: headers }));
223
+ delete summary.hasAttachments;
224
+ delete summary.preview;
225
+ if (!matchesSearch(summary, query))
226
+ continue;
227
+ if (messages.length === query.limit)
228
+ return { messages, nextCursor: searchCursor(account, query, generation, high, last, this.cursorKey) };
229
+ messages.push(summary);
230
+ last = record.uid;
231
+ }
232
+ return { messages };
233
+ }
234
+ async getMessage(ref) {
235
+ const account = this.account(ref?.accountId);
236
+ const decoded = decodeRef(account, ref);
237
+ const stored = this.mailboxes.get(`${account.accountId}:${decoded.mailbox}`);
238
+ if (!stored || stored.generation !== decoded.generation)
239
+ throw new EmailError("EMAIL_STALE_REFERENCE");
240
+ const record = stored.messages.find(value => value.uid === decoded.uid);
241
+ if (!record)
242
+ throw new EmailError("EMAIL_MESSAGE_NOT_FOUND");
243
+ return parseMessage(account, decoded.mailbox, stored.generation, record);
244
+ }
245
+ sent = [];
246
+ sentAttachments = [];
247
+ async attachmentSource(account, ref) {
248
+ const decoded = decodeRef(account, ref);
249
+ const stored = this.mailboxes.get(`${account.accountId}:${decoded.mailbox}`);
250
+ if (!stored || stored.generation !== decoded.generation)
251
+ throw new EmailError("EMAIL_STALE_REFERENCE");
252
+ const record = stored.messages.find(value => value.uid === decoded.uid);
253
+ if (!record)
254
+ throw new EmailError("EMAIL_MESSAGE_NOT_FOUND");
255
+ if (record.source.length > account.attachmentLimits.downloadBytes)
256
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
257
+ return Buffer.from(record.source);
258
+ }
259
+ async appendSent(account, mime) {
260
+ const mailbox = this.mailboxes.get(`${account.accountId}:${account.sentFolder}`);
261
+ if (!mailbox)
262
+ throw new EmailError("EMAIL_INVALID_INPUT");
263
+ mailbox.messages.push({ uid: Math.max(0, ...mailbox.messages.map(value => value.uid)) + 1, source: Buffer.from(mime), flags: new Set(["\\Seen"]), receivedAt: new Date() });
264
+ }
265
+ async submit(_account, input, operationId, attachments, _mime) {
266
+ this.sent.push(structuredClone(input));
267
+ this.sentAttachments.push(attachments.map(value => ({ filename: value.filename, content: Buffer.from(value.content) })));
268
+ return { operationId, messageId: `<${operationId}@stageflow>`, accepted: [...input.to, ...input.cc ?? [], ...input.bcc ?? []].map(value => value.address),
269
+ rejected: [], submittedAt: new Date().toISOString() };
270
+ }
271
+ async testAccount(accountId, protocol = "both") {
272
+ if (!["imap", "smtp", "both"].includes(protocol))
273
+ throw new EmailError("EMAIL_INVALID_INPUT");
274
+ this.account(accountId);
275
+ const status = this.status(accountId);
276
+ if (protocol !== "smtp")
277
+ status.imap = { state: "ok" };
278
+ if (protocol !== "imap")
279
+ status.smtp = { state: "ok" };
280
+ this.accounts.recordHealth(status);
281
+ return status;
282
+ }
283
+ }
284
+ export class LocalEmailAdapter extends AccountEmailAdapter {
285
+ env;
286
+ active = new Map();
287
+ unsubscribe;
288
+ constructor(accounts, env = process.env) {
289
+ super(accounts);
290
+ this.env = env;
291
+ this.unsubscribe = accounts.onChange(accountId => this.cancel(accountId));
292
+ }
293
+ async start(emit) {
294
+ this.unsubscribe ??= this.accounts.onChange(accountId => this.cancel(accountId));
295
+ await super.start(emit);
296
+ }
297
+ async openReceiver(account, folder, signal) {
298
+ const config = account.imap;
299
+ const secret = resolveEmailSecret(config, this.env);
300
+ const client = new ImapFlow({ host: config.host, port: config.port, secure: config.tls === "implicit", doSTARTTLS: config.tls === "starttls", logger: false,
301
+ auth: { user: config.username, ...(config.auth.type === "oauth2" ? { accessToken: secret } : { pass: secret }) },
302
+ connectionTimeout: account.connectionTimeoutMs, greetingTimeout: account.connectionTimeoutMs, socketTimeout: 0,
303
+ tls: { rejectUnauthorized: true }, maxIdleTime: account.pollingIntervalMs, disableAutoIdle: true });
304
+ client.on("error", signal);
305
+ client.on("close", signal);
306
+ client.on("exists", signal);
307
+ try {
308
+ const command = async (work) => {
309
+ resolveEmailSecret(config, this.env);
310
+ try {
311
+ return await this.receiveWork(account, () => client.close(), work);
312
+ }
313
+ catch (error) {
314
+ throw normalizedConnectionError(error);
315
+ }
316
+ };
317
+ await command(async () => { await client.connect(); await client.mailboxOpen(folder, { readOnly: true }); });
318
+ return {
319
+ next: (lower, upper) => command(() => this.nextSearchUid(client, lower, upper, "oldest")),
320
+ snapshot: () => command(async () => {
321
+ await client.noop();
322
+ if (!client.mailbox)
323
+ throw new EmailError("EMAIL_CONNECTION_FAILED", true);
324
+ const last = client.mailbox.exists ? await client.fetchOne("*", { uid: true }) : undefined;
325
+ return { generation: String(client.mailbox.uidValidity), high: Math.max(client.mailbox.uidNext - 1, last ? last.uid : 0) };
326
+ }),
327
+ read: (lower, upper) => command(async () => {
328
+ const found = await client.search({ uid: `${lower}:${upper}` }, { uid: true });
329
+ if (!Array.isArray(found) || found.length > 100 || new Set(found).size !== found.length || found.some(uid => uid < lower || uid > upper))
330
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
331
+ const messages = [];
332
+ for (const uid of found) {
333
+ const result = await client.fetchOne(String(uid), { uid: true, flags: true, internalDate: true, envelope: true }, { uid: true });
334
+ if (result)
335
+ messages.push(await receiveOutcome(uid, async () => envelopeSummary(account, folder, String(client.mailbox && client.mailbox.uidValidity), result)));
336
+ }
337
+ return messages;
338
+ }),
339
+ watch: () => { if (client.capabilities.has("IDLE"))
340
+ void client.idle().catch(signal); },
341
+ close: () => { client.off("exists", signal); client.off("error", signal); client.off("close", signal); client.on("error", () => { }); client.close(); },
342
+ };
343
+ }
344
+ catch (error) {
345
+ client.close();
346
+ throw normalizedConnectionError(error);
347
+ }
348
+ }
349
+ async readMailbox(account, mailbox, read) {
350
+ const config = account.imap;
351
+ const secret = resolveEmailSecret(config, this.env);
352
+ const client = new ImapFlow({ host: config.host, port: config.port, secure: config.tls === "implicit", doSTARTTLS: config.tls === "starttls", logger: false,
353
+ auth: { user: config.username, ...(config.auth.type === "oauth2" ? { accessToken: secret } : { pass: secret }) },
354
+ connectionTimeout: account.connectionTimeoutMs, greetingTimeout: account.connectionTimeoutMs, socketTimeout: account.connectionTimeoutMs,
355
+ tls: { rejectUnauthorized: true }, disableAutoIdle: true });
356
+ client.on("error", () => { });
357
+ try {
358
+ return await this.bounded(account, () => client.close(), async () => {
359
+ await client.connect();
360
+ let opened;
361
+ try {
362
+ opened = await client.mailboxOpen(mailbox, { readOnly: true });
363
+ }
364
+ catch (error) {
365
+ if (error.responseStatus === "NO")
366
+ throw new EmailError("EMAIL_INVALID_INPUT");
367
+ throw error;
368
+ }
369
+ return read(client, String(opened.uidValidity), opened.exists);
370
+ });
371
+ }
372
+ catch (error) {
373
+ throw normalizedConnectionError(error);
374
+ }
375
+ }
376
+ async readMessage(client, account, mailbox, generation, id, uid) {
377
+ // BODY.PEEK[]<0.limit> bounds bytes at the provider before a source buffer is allocated.
378
+ const result = await client.fetchOne(String(id), { uid: true, flags: true, internalDate: true, size: true, source: { start: 0, maxLength: EMAIL_SOURCE_LIMIT + 1 } }, { uid });
379
+ if (!result)
380
+ throw new EmailError("EMAIL_MESSAGE_NOT_FOUND");
381
+ if ((result.size ?? 0) > EMAIL_SOURCE_LIMIT || !result.source || result.source.length > EMAIL_SOURCE_LIMIT)
382
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
383
+ return parseMessage(account, mailbox, generation, { uid: result.uid, source: result.source, flags: result.flags ?? new Set(), receivedAt: result.internalDate instanceof Date ? result.internalDate : new Date(0) });
384
+ }
385
+ async nextSearchUid(client, lower, upper, sort) {
386
+ if (lower > upper)
387
+ return;
388
+ let left = 1;
389
+ let right = client.mailbox ? client.mailbox.exists : 0;
390
+ let candidate;
391
+ // Provider UIDs are monotone in sequence order. Binary lookup skips large UID gaps
392
+ // without an unbounded SEARCH result or a scan of earlier pages.
393
+ for (let probe = 0; left <= right; probe++) {
394
+ if (probe >= 32)
395
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
396
+ const middle = Math.floor((left + right) / 2);
397
+ const result = await client.fetchOne(String(middle), { uid: true });
398
+ if (!result)
399
+ throw new EmailError("EMAIL_CONNECTION_FAILED", true);
400
+ if (sort === "oldest") {
401
+ if (result.uid >= lower) {
402
+ candidate = result.uid;
403
+ right = middle - 1;
404
+ }
405
+ else
406
+ left = middle + 1;
407
+ }
408
+ else {
409
+ if (result.uid <= upper) {
410
+ candidate = result.uid;
411
+ left = middle + 1;
412
+ }
413
+ else
414
+ right = middle - 1;
415
+ }
416
+ }
417
+ if (candidate !== undefined && candidate >= lower && candidate <= upper)
418
+ return candidate;
419
+ }
420
+ async search(input) {
421
+ const account = this.account(input?.accountId);
422
+ const query = searchQuery(input);
423
+ const { mailbox } = query;
424
+ return this.readMailbox(account, mailbox, async (client, generation) => {
425
+ const cursor = decodeCursor(account, query, generation, input.cursor, this.cursorKey);
426
+ const high = cursor?.high ?? (client.mailbox ? client.mailbox.uidNext - 1 : 0);
427
+ const messages = [];
428
+ let last = 0;
429
+ let inspected = 0;
430
+ let lower = query.sort === "oldest" && cursor ? cursor.last + 1 : 1;
431
+ let upper = query.sort === "newest" && cursor ? cursor.last - 1 : high;
432
+ while (lower <= upper) {
433
+ const first = await this.nextSearchUid(client, lower, upper, query.sort);
434
+ if (first === undefined)
435
+ break;
436
+ const windowLower = query.sort === "oldest" ? first : Math.max(lower, first - 99);
437
+ const windowUpper = query.sort === "oldest" ? Math.min(upper, first + 99) : first;
438
+ const found = await client.search({ uid: `${windowLower}:${windowUpper}` }, { uid: true });
439
+ if (!Array.isArray(found) || found.length > 100 || new Set(found).size !== found.length || found.some(uid => !Number.isSafeInteger(uid) || uid < windowLower || uid > windowUpper))
440
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
441
+ found.sort((a, b) => query.sort === "newest" ? b - a : a - b);
442
+ for (const uid of found) {
443
+ if (++inspected > account.searchWorkLimit)
444
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
445
+ const result = await client.fetchOne(String(uid), { uid: true, flags: true, internalDate: true, envelope: true }, { uid: true });
446
+ if (!result)
447
+ continue;
448
+ const summary = envelopeSummary(account, mailbox, generation, result);
449
+ if (!matchesSearch(summary, query))
450
+ continue;
451
+ if (messages.length === query.limit)
452
+ return { messages, nextCursor: searchCursor(account, query, generation, high, last, this.cursorKey) };
453
+ messages.push(summary);
454
+ last = result.uid;
455
+ }
456
+ if (query.sort === "oldest")
457
+ lower = windowUpper + 1;
458
+ else
459
+ upper = windowLower - 1;
460
+ }
461
+ return { messages };
462
+ });
463
+ }
464
+ async getMessage(ref) {
465
+ const account = this.account(ref?.accountId);
466
+ const decoded = decodeRef(account, ref);
467
+ return this.readMailbox(account, decoded.mailbox, async (client, generation) => {
468
+ if (generation !== decoded.generation)
469
+ throw new EmailError("EMAIL_STALE_REFERENCE");
470
+ return this.readMessage(client, account, decoded.mailbox, generation, decoded.uid, true);
471
+ });
472
+ }
473
+ async attachmentSource(account, ref) {
474
+ const decoded = decodeRef(account, ref);
475
+ return this.readMailbox(account, decoded.mailbox, async (client, generation) => {
476
+ if (generation !== decoded.generation)
477
+ throw new EmailError("EMAIL_STALE_REFERENCE");
478
+ const metadata = await client.fetchOne(String(decoded.uid), { size: true }, { uid: true });
479
+ if (!metadata)
480
+ throw new EmailError("EMAIL_MESSAGE_NOT_FOUND");
481
+ const limit = account.attachmentLimits.downloadBytes;
482
+ if ((metadata.size ?? 0) > limit)
483
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
484
+ const fetched = await client.fetchOne(String(decoded.uid), { size: true, source: { start: 0, maxLength: limit + 1 } }, { uid: true });
485
+ if (!fetched)
486
+ throw new EmailError("EMAIL_MESSAGE_NOT_FOUND");
487
+ if (!fetched.source || (fetched.size ?? 0) > limit || fetched.source.length > limit)
488
+ throw new EmailError("EMAIL_RESOURCE_LIMIT");
489
+ return fetched.source;
490
+ });
491
+ }
492
+ async appendSent(account, mime) {
493
+ const config = account.imap;
494
+ const secret = resolveEmailSecret(config, this.env);
495
+ const client = new ImapFlow({ host: config.host, port: config.port, secure: config.tls === "implicit", doSTARTTLS: config.tls === "starttls", logger: false,
496
+ auth: { user: config.username, ...(config.auth.type === "oauth2" ? { accessToken: secret } : { pass: secret }) },
497
+ connectionTimeout: account.connectionTimeoutMs, greetingTimeout: account.connectionTimeoutMs, socketTimeout: account.connectionTimeoutMs,
498
+ tls: { rejectUnauthorized: true }, disableAutoIdle: true });
499
+ client.on("error", () => { });
500
+ await this.bounded(account, () => client.close(), async () => {
501
+ await client.connect();
502
+ resolveEmailSecret(config, this.env);
503
+ try {
504
+ if (!await client.append(account.sentFolder, mime, ["\\Seen"]))
505
+ throw new EmailError("EMAIL_CONNECTION_FAILED");
506
+ }
507
+ catch (error) {
508
+ if (error.responseStatus === "NO")
509
+ throw new EmailError("EMAIL_INVALID_INPUT");
510
+ throw error;
511
+ }
512
+ });
513
+ }
514
+ async submit(account, input, operationId, attachments, mime) {
515
+ const config = account.smtp;
516
+ const secret = resolveEmailSecret(config, this.env);
517
+ let socket;
518
+ const transport = nodemailer.createTransport({
519
+ host: config.host, port: config.port, secure: config.tls === "implicit",
520
+ requireTLS: config.tls === "starttls", ignoreTLS: config.tls === "none",
521
+ connectionTimeout: account.connectionTimeoutMs, greetingTimeout: account.connectionTimeoutMs,
522
+ socketTimeout: account.connectionTimeoutMs, logger: false, debug: false,
523
+ tls: { rejectUnauthorized: true }, disableFileAccess: true, disableUrlAccess: true,
524
+ getSocket: (_options, callback) => {
525
+ let settled = false;
526
+ const connected = () => { if (settled)
527
+ return; settled = true; callback(null, { connection: socket, secured: config.tls === "implicit" }); };
528
+ socket = config.tls === "implicit"
529
+ ? connectTls({ host: config.host, port: config.port, rejectUnauthorized: true, servername: config.host }, connected)
530
+ : connectTcp({ host: config.host, port: config.port }, connected);
531
+ socket.once("error", error => { if (!settled) {
532
+ settled = true;
533
+ callback(error);
534
+ } });
535
+ },
536
+ auth: config.auth.type === "oauth2" ? { type: "OAuth2", user: config.username, accessToken: secret }
537
+ : { user: config.username, pass: secret },
538
+ });
539
+ try {
540
+ const receipt = await this.bounded(account, () => { socket?.destroy(); transport.close(); }, () => transport.sendMail(mime
541
+ ? { raw: mime, messageId: `<${operationId}@stageflow>`, envelope: { from: input.from, to: [...input.to, ...input.cc ?? [], ...input.bcc ?? []].map(value => value.address) } }
542
+ : { from: input.from, to: input.to, cc: input.cc, bcc: input.bcc, headers: automaticMessageHeaders,
543
+ subject: input.subject, text: input.text, html: input.html, inReplyTo: input.inReplyTo, references: input.references, attachments, messageId: `<${operationId}@stageflow>` }));
544
+ return { operationId, messageId: receipt.messageId, accepted: receipt.accepted, rejected: receipt.rejected,
545
+ submittedAt: new Date().toISOString() };
546
+ }
547
+ catch (error) {
548
+ const fault = error;
549
+ if (fault.code === "EAUTH")
550
+ throw new EmailError("EMAIL_AUTH_FAILED");
551
+ if (fault.code === "EENVELOPE")
552
+ throw new EmailError("EMAIL_RECIPIENTS_REJECTED");
553
+ if (fault.responseCode && fault.responseCode >= 400)
554
+ throw new EmailError("EMAIL_CONNECTION_FAILED", fault.responseCode < 500);
555
+ if (["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"].includes(fault.code ?? ""))
556
+ throw normalizedConnectionError(error);
557
+ throw new EmailError("EMAIL_SEND_OUTCOME_UNKNOWN");
558
+ }
559
+ finally {
560
+ socket?.destroy();
561
+ transport.close();
562
+ }
563
+ }
564
+ async testAccount(accountId, protocol = "both") {
565
+ if (!["imap", "smtp", "both"].includes(protocol))
566
+ throw new EmailError("EMAIL_INVALID_INPUT");
567
+ const account = this.account(accountId);
568
+ const status = this.status(accountId);
569
+ const tests = [];
570
+ if (protocol !== "smtp")
571
+ tests.push(this.testImap(account).then(result => {
572
+ status.imap = result.status;
573
+ status.capabilities.idle = result.idle;
574
+ }));
575
+ if (protocol !== "imap")
576
+ tests.push(this.testSmtp(account).then(result => { status.smtp = result; }));
577
+ await Promise.all(tests);
578
+ // Updates cancel tests. Never publish an old result for a new account configuration.
579
+ if (JSON.stringify(this.account(accountId)) !== JSON.stringify(account)) {
580
+ throw new EmailError("EMAIL_CONNECTION_FAILED", true);
581
+ }
582
+ this.accounts.recordHealth(status);
583
+ return status;
584
+ }
585
+ async stop() {
586
+ for (const accountId of this.active.keys())
587
+ this.cancel(accountId);
588
+ await super.stop();
589
+ this.unsubscribe?.();
590
+ this.unsubscribe = undefined;
591
+ await this.submissions.drain();
592
+ }
593
+ cancel(accountId) {
594
+ for (const cancel of this.active.get(accountId) ?? [])
595
+ cancel();
596
+ }
597
+ async receiveWork(account, close, work) {
598
+ let cancel;
599
+ let timer;
600
+ const cancelled = new Promise((_resolve, reject) => {
601
+ cancel = () => { close(); reject(new EmailError("EMAIL_CONNECTION_FAILED", true)); };
602
+ timer = setTimeout(() => { close(); reject(new EmailError("EMAIL_TIMEOUT", true)); }, account.connectionTimeoutMs);
603
+ });
604
+ const active = this.active.get(account.accountId) ?? new Set();
605
+ this.active.set(account.accountId, active);
606
+ active.add(cancel);
607
+ try {
608
+ return await Promise.race([work(), cancelled]);
609
+ }
610
+ finally {
611
+ clearTimeout(timer);
612
+ active.delete(cancel);
613
+ if (!active.size)
614
+ this.active.delete(account.accountId);
615
+ }
616
+ }
617
+ async bounded(account, close, work) {
618
+ let timer;
619
+ let cancel = () => { };
620
+ const cancelled = new Promise((_resolve, reject) => {
621
+ cancel = () => {
622
+ close();
623
+ reject(new EmailError("EMAIL_CONNECTION_FAILED", true));
624
+ };
625
+ timer = setTimeout(() => {
626
+ close();
627
+ reject(new EmailError("EMAIL_TIMEOUT", true));
628
+ }, account.connectionTimeoutMs);
629
+ });
630
+ const active = this.active.get(account.accountId) ?? new Set();
631
+ this.active.set(account.accountId, active);
632
+ active.add(cancel);
633
+ try {
634
+ return await Promise.race([work(), cancelled]);
635
+ }
636
+ finally {
637
+ clearTimeout(timer);
638
+ close();
639
+ active.delete(cancel);
640
+ if (active.size === 0)
641
+ this.active.delete(account.accountId);
642
+ }
643
+ }
644
+ async testImap(account) {
645
+ let client;
646
+ try {
647
+ const config = account.imap;
648
+ const secret = resolveEmailSecret(config, this.env);
649
+ client = new ImapFlow({
650
+ host: config.host, port: config.port, secure: config.tls === "implicit",
651
+ doSTARTTLS: config.tls === "starttls", logger: false,
652
+ auth: { user: config.username, ...(config.auth.type === "oauth2" ? { accessToken: secret } : { pass: secret }) },
653
+ connectionTimeout: account.connectionTimeoutMs, greetingTimeout: account.connectionTimeoutMs,
654
+ socketTimeout: account.connectionTimeoutMs, tls: { rejectUnauthorized: true },
655
+ });
656
+ client.on("error", () => { });
657
+ const connection = client;
658
+ await this.bounded(account, () => connection.close(), () => connection.connect());
659
+ return { status: connectionStatus(), idle: client.capabilities.has("IDLE") };
660
+ }
661
+ catch (error) {
662
+ return { status: connectionStatus(error), idle: false };
663
+ }
664
+ }
665
+ async testSmtp(account) {
666
+ let transport;
667
+ let socket;
668
+ try {
669
+ const config = account.smtp;
670
+ const secret = resolveEmailSecret(config, this.env);
671
+ const options = {
672
+ host: config.host, port: config.port, forceAuth: true,
673
+ secure: config.tls === "implicit", requireTLS: config.tls === "starttls",
674
+ ignoreTLS: config.tls === "none", logger: false, debug: false,
675
+ connectionTimeout: account.connectionTimeoutMs, greetingTimeout: account.connectionTimeoutMs,
676
+ socketTimeout: account.connectionTimeoutMs, tls: { rejectUnauthorized: true },
677
+ auth: config.auth.type === "oauth2"
678
+ ? { type: "OAuth2", user: config.username, accessToken: secret }
679
+ : { user: config.username, pass: secret },
680
+ getSocket: (_options, callback) => {
681
+ let settled = false;
682
+ const failed = (error) => {
683
+ if (settled)
684
+ return;
685
+ settled = true;
686
+ callback(error);
687
+ };
688
+ const connected = () => {
689
+ if (settled)
690
+ return;
691
+ settled = true;
692
+ callback(null, { connection: socket, secured: config.tls === "implicit" });
693
+ };
694
+ if (config.tls === "implicit") {
695
+ socket = connectTls({ host: config.host, port: config.port, rejectUnauthorized: true,
696
+ servername: config.host }, connected);
697
+ }
698
+ else {
699
+ socket = connectTcp({ host: config.host, port: config.port }, connected);
700
+ }
701
+ socket.once("error", failed);
702
+ },
703
+ };
704
+ transport = nodemailer.createTransport(options);
705
+ const connection = transport;
706
+ await this.bounded(account, () => {
707
+ socket?.destroy();
708
+ connection.close();
709
+ }, () => connection.verify());
710
+ return connectionStatus();
711
+ }
712
+ catch (error) {
713
+ return connectionStatus(error);
714
+ }
715
+ finally {
716
+ socket?.destroy();
717
+ transport?.close();
718
+ }
719
+ }
720
+ }