@rubytech/create-maxy-code 0.1.358 → 0.1.360

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 (79) hide show
  1. package/dist/__tests__/neo4j-pidfile-scrub.test.js +103 -0
  2. package/dist/index.js +42 -7
  3. package/dist/neo4j-pidfile-scrub.js +90 -0
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -1
  6. package/payload/platform/plugins/browser/PLUGIN.md +1 -1
  7. package/payload/platform/plugins/docs/references/admin-ui.md +2 -0
  8. package/payload/platform/plugins/email/mcp/dist/__tests__/email-status.test.d.ts +2 -0
  9. package/payload/platform/plugins/email/mcp/dist/__tests__/email-status.test.d.ts.map +1 -0
  10. package/payload/platform/plugins/email/mcp/dist/__tests__/email-status.test.js +58 -0
  11. package/payload/platform/plugins/email/mcp/dist/__tests__/email-status.test.js.map +1 -0
  12. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-probe.test.js +109 -1
  13. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-probe.test.js.map +1 -1
  14. package/payload/platform/plugins/email/mcp/dist/__tests__/source-fetch-efficiency.test.d.ts +2 -0
  15. package/payload/platform/plugins/email/mcp/dist/__tests__/source-fetch-efficiency.test.d.ts.map +1 -0
  16. package/payload/platform/plugins/email/mcp/dist/__tests__/source-fetch-efficiency.test.js +142 -0
  17. package/payload/platform/plugins/email/mcp/dist/__tests__/source-fetch-efficiency.test.js.map +1 -0
  18. package/payload/platform/plugins/email/mcp/dist/lib/credentials.d.ts +13 -0
  19. package/payload/platform/plugins/email/mcp/dist/lib/credentials.d.ts.map +1 -1
  20. package/payload/platform/plugins/email/mcp/dist/lib/credentials.js +35 -0
  21. package/payload/platform/plugins/email/mcp/dist/lib/credentials.js.map +1 -1
  22. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +18 -5
  23. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  24. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +199 -114
  25. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  26. package/payload/platform/plugins/email/mcp/dist/lib/setup-probe.d.ts +19 -0
  27. package/payload/platform/plugins/email/mcp/dist/lib/setup-probe.d.ts.map +1 -1
  28. package/payload/platform/plugins/email/mcp/dist/lib/setup-probe.js +33 -0
  29. package/payload/platform/plugins/email/mcp/dist/lib/setup-probe.js.map +1 -1
  30. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.d.ts.map +1 -1
  31. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.js +11 -1
  32. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.js.map +1 -1
  33. package/payload/platform/plugins/email/mcp/dist/tools/email-status.d.ts.map +1 -1
  34. package/payload/platform/plugins/email/mcp/dist/tools/email-status.js +24 -1
  35. package/payload/platform/plugins/email/mcp/dist/tools/email-status.js.map +1 -1
  36. package/payload/platform/plugins/email/references/email-reference.md +9 -2
  37. package/payload/platform/scripts/__tests__/vnc-cdp-liveness.test.sh +222 -0
  38. package/payload/platform/scripts/rss-sampler.sh +16 -0
  39. package/payload/platform/scripts/vnc.sh +117 -3
  40. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +11 -4
  41. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +22 -7
  43. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +0 -4
  45. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +0 -15
  47. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  48. package/payload/server/public/assets/{AdminLoginScreens-Ro_RUTDh.js → AdminLoginScreens-FufN0KIm.js} +1 -1
  49. package/payload/server/public/assets/AdminShell-CmFCbj38.js +1 -0
  50. package/payload/server/public/assets/{Checkbox-dYLaMEdh.js → Checkbox-njajXf-b.js} +1 -1
  51. package/payload/server/public/assets/OperatorConversations-B9Vx84BF.js +9 -0
  52. package/payload/server/public/assets/{OperatorConversations-2zJh7qnO.css → OperatorConversations-BqudLZH5.css} +1 -1
  53. package/payload/server/public/assets/admin-DYOYaRcc.js +1 -0
  54. package/payload/server/public/assets/{browser-DrXZ4wpy.js → browser-CUwGN38H.js} +1 -1
  55. package/payload/server/public/assets/calendar-HonT_eBL.js +1 -0
  56. package/payload/server/public/assets/chat-B-sCk0kO.js +1 -0
  57. package/payload/server/public/assets/data-vEYM-aPa.js +1 -0
  58. package/payload/server/public/assets/{graph-l_3_j3I9.js → graph-BLUtXXda.js} +3 -3
  59. package/payload/server/public/assets/{graph-labels-CJj55nHk.js → graph-labels-tUzvK8mv.js} +1 -1
  60. package/payload/server/public/assets/operator-BLEBOkrQ.js +1 -0
  61. package/payload/server/public/assets/page-CcnJY-yB.js +32 -0
  62. package/payload/server/public/assets/{public-smrhkkxD.js → public-B8GW-fZg.js} +1 -1
  63. package/payload/server/public/assets/{rotate-ccw-Dd8aAvU7.js → rotate-ccw-BA0MN5YR.js} +1 -1
  64. package/payload/server/public/browser.html +4 -4
  65. package/payload/server/public/calendar.html +4 -4
  66. package/payload/server/public/chat.html +6 -6
  67. package/payload/server/public/data.html +4 -4
  68. package/payload/server/public/graph.html +7 -7
  69. package/payload/server/public/index.html +6 -6
  70. package/payload/server/public/operator.html +8 -8
  71. package/payload/server/public/public.html +6 -6
  72. package/payload/server/public/assets/AdminShell-CmqhpAPU.js +0 -1
  73. package/payload/server/public/assets/OperatorConversations-Cei12sSK.js +0 -9
  74. package/payload/server/public/assets/admin-BldZQrJF.js +0 -1
  75. package/payload/server/public/assets/calendar-CQ4TCbO7.js +0 -1
  76. package/payload/server/public/assets/chat-D7w6_NE1.js +0 -1
  77. package/payload/server/public/assets/data-Dc5XNWgF.js +0 -1
  78. package/payload/server/public/assets/operator-CKmOgXNa.js +0 -1
  79. package/payload/server/public/assets/page-hG7xTrcO.js +0 -32
@@ -1,6 +1,6 @@
1
1
  import { ImapFlow } from "imapflow";
2
2
  import { simpleParser } from "mailparser";
3
- import { trackProbe, releaseProbe, newProbeToken } from "./setup-probe.js";
3
+ import { trackProbe, releaseProbe, newProbeToken, containProbeSocket } from "./setup-probe.js";
4
4
  const CONNECT_TIMEOUT = 10_000;
5
5
  const COMMAND_TIMEOUT = 30_000;
6
6
  /** Diagnostic logging to stderr (captured by claude-agent-stderr log on Pi) */
@@ -299,42 +299,100 @@ export async function fetchAttachmentBytesForMessages(config, password, requests
299
299
  }
300
300
  return results;
301
301
  }
302
+ /**
303
+ * Classify an IMAP probe error by its structured fields, falling back to message
304
+ * strings only where no structured field carries the signal.
305
+ *
306
+ * The auth case is why this exists: ImapFlow reports an authentication rejection
307
+ * as a generic `Error("Command failed")` and carries the real signal in
308
+ * `authenticationFailed` / `serverResponseCode` / `responseText` (set by
309
+ * imapflow's login.js / authenticate.js), none of which appear in `err.message`.
310
+ * The transport cases (refused/not-found/timeout) carry `err.code` from Node's
311
+ * socket layer; TLS errors carry a cert/TLS/SSL marker in code or message.
312
+ */
313
+ export function classifyImapError(err) {
314
+ const e = (err ?? {});
315
+ const message = typeof e.message === "string" ? e.message : String(err);
316
+ const code = typeof e.code === "string" ? e.code : "";
317
+ const serverCode = typeof e.serverResponseCode === "string" ? e.serverResponseCode : "";
318
+ const responseText = typeof e.responseText === "string" ? e.responseText : "";
319
+ // Order: structured/code signals beat fuzzy message strings. Auth's structured
320
+ // fields are unambiguous and come first. The code-bearing transport cases are
321
+ // checked before the message-only TLS and auth fallbacks, so a timeout/refusal
322
+ // whose OS message embeds a hostname containing "ssl"/"tls"/"authentication"
323
+ // is not stolen by a later substring match.
324
+ if (e.authenticationFailed === true || serverCode === "AUTHENTICATIONFAILED")
325
+ return "auth";
326
+ if (code === "ECONNREFUSED" || message.includes("ECONNREFUSED"))
327
+ return "refused";
328
+ if (code === "ENOTFOUND" || message.includes("ENOTFOUND"))
329
+ return "not-found";
330
+ if (code === "ETIMEDOUT" || code === "CONNECT_TIMEOUT" || code === "GREETING_TIMEOUT")
331
+ return "timeout";
332
+ // TLS errors carry a cert/TLS/SSL marker in the code (e.g. CERT_HAS_EXPIRED,
333
+ // ERR_TLS_*) or the message; checked on code first, message second.
334
+ if (/cert|TLS|SSL/i.test(`${code} ${message}`))
335
+ return "tls";
336
+ // Message-only fallbacks for errors that arrived without a structured field —
337
+ // last resort, after every code-based case above.
338
+ if (/AUTHENTICATIONFAILED|Invalid credentials|LOGIN failed|Authentication failed/i.test(`${message} ${responseText}`))
339
+ return "auth";
340
+ if (/ETIMEDOUT|timed?\s?out/i.test(message))
341
+ return "timeout";
342
+ return "unknown";
343
+ }
344
+ /** Map a classified failure to the operator-actionable message. */
345
+ function imapFailureMessage(kind, config, err) {
346
+ switch (kind) {
347
+ case "auth":
348
+ return `IMAP authentication failed for ${config.email}. Check the password — most providers require an app-specific password when 2FA is enabled.`;
349
+ case "refused":
350
+ return `IMAP connection refused at ${config.imapHost}:${config.imapPort}. Check that IMAP is enabled in your email provider settings.`;
351
+ case "not-found":
352
+ return `IMAP host ${config.imapHost} not found. Check the hostname.`;
353
+ case "tls":
354
+ return `IMAP TLS error connecting to ${config.imapHost}:${config.imapPort}. Try port 993 (TLS) or 143 (STARTTLS).`;
355
+ case "timeout":
356
+ return `IMAP connection timed out to ${config.imapHost}:${config.imapPort}. Check the host and port.`;
357
+ default:
358
+ return `IMAP connection failed: ${err instanceof Error ? err.message : String(err)}`;
359
+ }
360
+ }
302
361
  /**
303
362
  * Test IMAP connectivity and authentication.
304
363
  * Returns null on success, error message on failure.
364
+ *
365
+ * Emits one structured attempt line per probe (host/port/security/outcome/reason,
366
+ * no secret) so a failing setup is visible server-side without opening the session
367
+ * transcript.
305
368
  */
306
369
  export async function testConnection(config, password, token = newProbeToken()) {
307
370
  const client = createClient(config, password);
308
371
  trackProbe("imap", client, token);
372
+ const attempt = (outcome, reason) => {
373
+ process.stderr.write(`[email] imap-attempt host=${config.imapHost} port=${config.imapPort} security=${config.imapSecurity} outcome=${outcome} reason=${reason}\n`);
374
+ };
309
375
  try {
310
376
  await client.connect();
377
+ // Socket is live once connect resolves — contain a late socket 'error' that
378
+ // could fire during logout/teardown.
379
+ containProbeSocket(client, "imap", token);
311
380
  await safeLogout(client);
381
+ attempt("ok", "none");
312
382
  return null;
313
383
  }
314
384
  catch (err) {
315
- // Never abandon the client: an unlistened half-established TLS socket
316
- // emitting a late 'error' kills the process.
385
+ // On the auth-reject path ImapFlow leaves the socket live; contain it before
386
+ // close() removes ImapFlow's own forwarder, so a late socket 'error' cannot
387
+ // escape as an uncaughtException once the census has returned to baseline.
388
+ containProbeSocket(client, "imap", token);
317
389
  try {
318
390
  client.close();
319
391
  }
320
392
  catch { /* contained */ }
321
- const msg = err instanceof Error ? err.message : String(err);
322
- if (msg.includes("AUTHENTICATIONFAILED") || msg.includes("Invalid credentials") || msg.includes("LOGIN failed")) {
323
- return `IMAP authentication failed for ${config.email}. Check the password — most providers require an app-specific password when 2FA is enabled.`;
324
- }
325
- if (msg.includes("ECONNREFUSED")) {
326
- return `IMAP connection refused at ${config.imapHost}:${config.imapPort}. Check that IMAP is enabled in your email provider settings.`;
327
- }
328
- if (msg.includes("ENOTFOUND")) {
329
- return `IMAP host ${config.imapHost} not found. Check the hostname.`;
330
- }
331
- if (msg.includes("certificate") || msg.includes("TLS") || msg.includes("SSL")) {
332
- return `IMAP TLS error connecting to ${config.imapHost}:${config.imapPort}. Try port 993 (TLS) or 143 (STARTTLS).`;
333
- }
334
- if (msg.includes("ETIMEDOUT") || msg.includes("timeout")) {
335
- return `IMAP connection timed out to ${config.imapHost}:${config.imapPort}. Check the host and port.`;
336
- }
337
- return `IMAP connection failed: ${msg}`;
393
+ const kind = classifyImapError(err);
394
+ attempt("fail", kind);
395
+ return imapFailureMessage(kind, config, err);
338
396
  }
339
397
  finally {
340
398
  releaseProbe("imap", token);
@@ -506,15 +564,15 @@ export async function fetchRecent(config, password, options = {}) {
506
564
  let envelopeCount = 0;
507
565
  let recipientKept = 0;
508
566
  let recipientHidden = 0;
509
- // Body sources captured during streaming and parsed after the fetch
510
- // iterator closes running simpleParser inside the open fetch loop can
511
- // stall the IMAP socket. Aligned by index with `messages`.
512
- const bodySources = [];
567
+ // Pass 1: envelopes + structure only (no source) across the whole
568
+ // over-fetched window, so recipient kept/hidden accounting covers every
569
+ // UID while costing only envelope bytes. structureType is captured per
570
+ // displayed message (aligned by index with `messages`) for the diag.
571
+ const displayStructureTypes = [];
513
572
  for await (const msg of client.fetch(uidList, {
514
573
  envelope: true,
515
574
  uid: true,
516
575
  bodyStructure: true,
517
- source: true,
518
576
  }, { uid: true })) {
519
577
  envelopeCount++;
520
578
  diag("fetchRecent", `envelope #${envelopeCount}`, `uid=${msg.uid} from=${msg.envelope?.from?.[0]?.address ?? "?"} at ${Date.now() - t2}ms`);
@@ -535,14 +593,10 @@ export async function fetchRecent(config, password, options = {}) {
535
593
  continue;
536
594
  // Collect up to `limit` messages for display, but keep iterating past
537
595
  // that point so the recipient kept/hidden accounting covers the whole
538
- // fetched window. Body parsing is deferred until the iterator closes
539
- // (see bodySources) and only runs for the displayed messages.
596
+ // fetched window.
540
597
  if (messages.length < limit) {
541
598
  const attachments = walkAttachmentMetadata(msg.bodyStructure);
542
- bodySources.push({
543
- source: msg.source ?? Buffer.alloc(0),
544
- structureType: msg.bodyStructure?.type ?? "?",
545
- });
599
+ displayStructureTypes.push(msg.bodyStructure?.type ?? "?");
546
600
  messages.push({
547
601
  uid: msg.uid,
548
602
  messageId,
@@ -555,11 +609,20 @@ export async function fetchRecent(config, password, options = {}) {
555
609
  });
556
610
  }
557
611
  }
558
- // Parse the captured sources now that the fetch stream is closed.
559
- for (let i = 0; i < messages.length; i++) {
560
- const { body, kind } = await extractBody(bodySources[i].source);
561
- messages[i].bodyPreview = previewBody(body);
562
- diag("fetchRecent", `body #${i + 1}`, `uid=${messages[i].uid} preview=${messages[i].bodyPreview.length}chars type=${bodySources[i].structureType} bodySource=${kind}`);
612
+ // Pass 2: download full source ONLY for the messages we display, keyed by
613
+ // UID, then decode previews now that the stream is closed. A heavily
614
+ // filtered over-fetch no longer pulls source for the UIDs it dropped.
615
+ if (messages.length > 0) {
616
+ const displayUids = messages.map((m) => m.uid);
617
+ const sourceByUid = new Map();
618
+ for await (const msg of client.fetch(displayUids.map(String).join(","), { uid: true, source: true }, { uid: true })) {
619
+ sourceByUid.set(msg.uid, msg.source ?? Buffer.alloc(0));
620
+ }
621
+ for (let i = 0; i < messages.length; i++) {
622
+ const { body, kind } = await extractBody(sourceByUid.get(messages[i].uid) ?? Buffer.alloc(0));
623
+ messages[i].bodyPreview = previewBody(body);
624
+ diag("fetchRecent", `body #${i + 1}`, `uid=${messages[i].uid} preview=${messages[i].bodyPreview.length}chars type=${displayStructureTypes[i]} bodySource=${kind}`);
625
+ }
563
626
  }
564
627
  diag("fetchRecent", "fetch done", `${envelopeCount} envelopes, ${messages.length} messages in ${Date.now() - t2}ms (total ${Date.now() - t0}ms)`);
565
628
  if (clientSideRecipients) {
@@ -635,14 +698,14 @@ export async function searchMessages(config, password, query) {
635
698
  let envelopeCount = 0;
636
699
  let recipientKept = 0;
637
700
  let recipientHidden = 0;
638
- // Body sources captured during streaming and parsed after the fetch
639
- // iterator closes (see fetchRecent). Aligned by index with `messages`.
640
- const bodySources = [];
701
+ // Pass 1: envelopes + structure only (no source) across the whole
702
+ // over-fetched window (see fetchRecent). structureType is captured per
703
+ // displayed message, aligned by index with `messages`.
704
+ const displayStructureTypes = [];
641
705
  for await (const msg of client.fetch(recentUids.map(String).join(","), {
642
706
  envelope: true,
643
707
  uid: true,
644
708
  bodyStructure: true,
645
- source: true,
646
709
  }, { uid: true })) {
647
710
  envelopeCount++;
648
711
  // Client-side recipient filter (replaces IMAP SEARCH TO), with kept-vs-hidden accounting
@@ -660,13 +723,10 @@ export async function searchMessages(config, password, query) {
660
723
  const inReplyTo = msg.envelope?.inReplyTo ?? null;
661
724
  // Collect up to `limit` messages for display, but keep iterating past
662
725
  // that point so the recipient kept/hidden accounting covers the whole
663
- // fetched window. Body parsing is deferred until the iterator closes.
726
+ // fetched window.
664
727
  if (messages.length < limit) {
665
728
  const attachments = walkAttachmentMetadata(msg.bodyStructure);
666
- bodySources.push({
667
- source: msg.source ?? Buffer.alloc(0),
668
- structureType: msg.bodyStructure?.type ?? "?",
669
- });
729
+ displayStructureTypes.push(msg.bodyStructure?.type ?? "?");
670
730
  messages.push({
671
731
  uid: msg.uid,
672
732
  messageId,
@@ -679,11 +739,19 @@ export async function searchMessages(config, password, query) {
679
739
  });
680
740
  }
681
741
  }
682
- // Parse the captured sources now that the fetch stream is closed.
683
- for (let i = 0; i < messages.length; i++) {
684
- const { body, kind } = await extractBody(bodySources[i].source);
685
- messages[i].bodyPreview = previewBody(body);
686
- diag("searchMessages", `body #${i + 1}`, `uid=${messages[i].uid} preview=${messages[i].bodyPreview.length}chars type=${bodySources[i].structureType} bodySource=${kind}`);
742
+ // Pass 2: download full source ONLY for the displayed messages, keyed by
743
+ // UID, then decode previews (see fetchRecent).
744
+ if (messages.length > 0) {
745
+ const displayUids = messages.map((m) => m.uid);
746
+ const sourceByUid = new Map();
747
+ for await (const msg of client.fetch(displayUids.map(String).join(","), { uid: true, source: true }, { uid: true })) {
748
+ sourceByUid.set(msg.uid, msg.source ?? Buffer.alloc(0));
749
+ }
750
+ for (let i = 0; i < messages.length; i++) {
751
+ const { body, kind } = await extractBody(sourceByUid.get(messages[i].uid) ?? Buffer.alloc(0));
752
+ messages[i].bodyPreview = previewBody(body);
753
+ diag("searchMessages", `body #${i + 1}`, `uid=${messages[i].uid} preview=${messages[i].bodyPreview.length}chars type=${displayStructureTypes[i]} bodySource=${kind}`);
754
+ }
687
755
  }
688
756
  diag("searchMessages", "fetch done", `${envelopeCount} envelopes, ${messages.length} messages in ${Date.now() - t2}ms (total ${Date.now() - t0}ms)`);
689
757
  if (clientSideRecipients) {
@@ -823,67 +891,81 @@ export async function fetchSinceUid(config, password, sinceUid, recipients) {
823
891
  }
824
892
  }
825
893
  /**
826
- * Resolve an envelope by Message-ID via IMAP. Searches INBOX first, then
827
- * the \Sent special-use folder. Returns null when no folder yields a hit.
894
+ * Locate a message by Message-ID on an ALREADY-CONNECTED client. Searches
895
+ * INBOX first, then the \Sent special-use folder, returning the first hit
896
+ * whose envelope carries a From address. The caller owns connect/logout — this
897
+ * helper neither opens nor closes the connection, so one login can both resolve
898
+ * the message and fetch its source (see fetchFullBody's message_id path).
828
899
  *
829
- * Uses `header: { "message-id": ... }` IMAP SEARCH — sufficient on every
830
- * server that honours RFC 3501 header search. The Message-ID is sent with
831
- * angle brackets restored because some servers (notably Gmail) only match
832
- * the raw header value, not the bare identifier.
900
+ * Uses `header: { "message-id": ... }` IMAP SEARCH — sufficient on every server
901
+ * that honours RFC 3501 header search. The Message-ID is sent with angle
902
+ * brackets restored because some servers (notably Gmail) only match the raw
903
+ * header value, not the bare identifier.
833
904
  */
834
- export async function fetchEnvelopeByMessageId(config, password, messageId) {
905
+ async function locateByMessageId(client, messageId) {
835
906
  const cleanId = messageId.replace(/^<|>$/g, "");
836
907
  const needle = `<${cleanId}>`;
908
+ const folders = ["INBOX"];
909
+ try {
910
+ const sentPath = await resolveFolderName(client, "sent");
911
+ folders.push(sentPath);
912
+ }
913
+ catch {
914
+ // No \Sent folder advertised — only search INBOX.
915
+ }
916
+ for (const folderPath of folders) {
917
+ const lock = await client.getMailboxLock(folderPath);
918
+ try {
919
+ const uids = await client.search({ header: { "message-id": needle } }, { uid: true });
920
+ if (!uids || uids.length === 0)
921
+ continue;
922
+ const uid = uids[0];
923
+ for await (const msg of client.fetch(String(uid), { envelope: true, uid: true }, { uid: true })) {
924
+ const env = msg.envelope;
925
+ if (!env?.from?.[0]?.address)
926
+ continue;
927
+ return { uid: msg.uid, mailbox: folderPath, envelope: env };
928
+ }
929
+ }
930
+ finally {
931
+ lock.release();
932
+ }
933
+ }
934
+ return null;
935
+ }
936
+ /**
937
+ * Resolve an envelope by Message-ID via IMAP. Searches INBOX first, then
938
+ * the \Sent special-use folder. Returns null when no folder yields a hit.
939
+ */
940
+ export async function fetchEnvelopeByMessageId(config, password, messageId) {
837
941
  const client = createClient(config, password);
838
942
  const t0 = Date.now();
839
- diag("fetchEnvelopeByMessageId", "start", `messageId=${cleanId}`);
943
+ diag("fetchEnvelopeByMessageId", "start", `messageId=${messageId.replace(/^<|>$/g, "")}`);
840
944
  try {
841
945
  await client.connect();
842
946
  diag("fetchEnvelopeByMessageId", "connected", `${Date.now() - t0}ms`);
843
- const folders = ["INBOX"];
844
- try {
845
- const sentPath = await resolveFolderName(client, "sent");
846
- folders.push(sentPath);
847
- }
848
- catch {
849
- // No \Sent folder advertised — only search INBOX.
850
- }
851
- for (const folderPath of folders) {
852
- const lock = await client.getMailboxLock(folderPath);
853
- try {
854
- const uids = await client.search({ header: { "message-id": needle } }, { uid: true });
855
- if (!uids || uids.length === 0)
856
- continue;
857
- const uid = uids[0];
858
- for await (const msg of client.fetch(String(uid), { envelope: true, uid: true }, { uid: true })) {
859
- const env = msg.envelope ?? {};
860
- const fromAddr = env.from?.[0]?.address ?? "";
861
- if (!fromAddr)
862
- continue;
863
- diag("fetchEnvelopeByMessageId", "match", `folder=${folderPath} uid=${msg.uid} in ${Date.now() - t0}ms`);
864
- return {
865
- uid: msg.uid,
866
- mailbox: folderPath,
867
- messageId: env.messageId ?? messageId,
868
- fromAddress: fromAddr,
869
- fromName: env.from?.[0]?.name ?? null,
870
- toAddresses: (env.to ?? [])
871
- .map((a) => a.address)
872
- .filter((a) => !!a),
873
- ccAddresses: (env.cc ?? [])
874
- .map((a) => a.address)
875
- .filter((a) => !!a),
876
- subject: env.subject ?? "",
877
- date: env.date?.toISOString() ?? "",
878
- };
879
- }
880
- }
881
- finally {
882
- lock.release();
883
- }
947
+ const loc = await locateByMessageId(client, messageId);
948
+ if (!loc) {
949
+ diag("fetchEnvelopeByMessageId", "no-match", `in ${Date.now() - t0}ms`);
950
+ return null;
884
951
  }
885
- diag("fetchEnvelopeByMessageId", "no-match", `searched=${folders.join(",")} in ${Date.now() - t0}ms`);
886
- return null;
952
+ const env = loc.envelope;
953
+ diag("fetchEnvelopeByMessageId", "match", `folder=${loc.mailbox} uid=${loc.uid} in ${Date.now() - t0}ms`);
954
+ return {
955
+ uid: loc.uid,
956
+ mailbox: loc.mailbox,
957
+ messageId: env.messageId ?? messageId,
958
+ fromAddress: env.from?.[0]?.address ?? "",
959
+ fromName: env.from?.[0]?.name ?? null,
960
+ toAddresses: (env.to ?? [])
961
+ .map((a) => a.address)
962
+ .filter((a) => !!a),
963
+ ccAddresses: (env.cc ?? [])
964
+ .map((a) => a.address)
965
+ .filter((a) => !!a),
966
+ subject: env.subject ?? "",
967
+ date: env.date?.toISOString() ?? "",
968
+ };
887
969
  }
888
970
  catch (err) {
889
971
  diag("fetchEnvelopeByMessageId", "FAILED", `${err instanceof Error ? err.message : String(err)} after ${Date.now() - t0}ms`);
@@ -900,18 +982,6 @@ export async function fetchEnvelopeByMessageId(config, password, messageId) {
900
982
  * MIME-shape blind spot. Returns null when the message cannot be located.
901
983
  */
902
984
  export async function fetchFullBody(config, password, target) {
903
- let mailbox = "";
904
- let uid;
905
- if ("messageId" in target) {
906
- const env = await fetchEnvelopeByMessageId(config, password, target.messageId);
907
- if (!env)
908
- return null;
909
- mailbox = env.mailbox;
910
- uid = env.uid;
911
- }
912
- else {
913
- uid = target.uid;
914
- }
915
985
  const client = createClient(config, password);
916
986
  const t0 = Date.now();
917
987
  const label = "messageId" in target
@@ -920,8 +990,22 @@ export async function fetchFullBody(config, password, target) {
920
990
  diag("fetchFullBody", "start", label);
921
991
  try {
922
992
  await client.connect();
923
- if (!("messageId" in target)) {
993
+ // Resolve the message location on THIS connection — a message_id read no
994
+ // longer opens a second login just to learn its uid/mailbox.
995
+ let mailbox;
996
+ let uid;
997
+ if ("messageId" in target) {
998
+ const loc = await locateByMessageId(client, target.messageId);
999
+ if (!loc) {
1000
+ diag("fetchFullBody", "not-found", `messageId=${target.messageId} in ${Date.now() - t0}ms`);
1001
+ return null;
1002
+ }
1003
+ mailbox = loc.mailbox;
1004
+ uid = loc.uid;
1005
+ }
1006
+ else {
924
1007
  mailbox = await resolveFolderName(client, target.folder ?? "inbox");
1008
+ uid = target.uid;
925
1009
  }
926
1010
  const lock = await client.getMailboxLock(mailbox);
927
1011
  try {
@@ -965,6 +1049,7 @@ export async function getInboxCount(config, password, token = newProbeToken()) {
965
1049
  trackProbe("imap", client, token);
966
1050
  try {
967
1051
  await client.connect();
1052
+ containProbeSocket(client, "imap", token);
968
1053
  const mailbox = await client.mailboxOpen("INBOX");
969
1054
  return mailbox.exists;
970
1055
  }