@rubytech/create-maxy-code 0.1.368 → 0.1.370

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 (106) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +100 -0
  2. package/dist/cron-registration.js +72 -0
  3. package/dist/index.js +50 -3
  4. package/dist/uninstall.js +31 -0
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  7. package/payload/platform/plugins/email/PLUGIN.md +8 -2
  8. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.d.ts +2 -0
  9. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.d.ts.map +1 -0
  10. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.js +151 -0
  11. package/payload/platform/plugins/email/mcp/dist/__tests__/email-delete.test.js.map +1 -0
  12. package/payload/platform/plugins/email/mcp/dist/__tests__/email-status.test.js +36 -3
  13. package/payload/platform/plugins/email/mcp/dist/__tests__/email-status.test.js.map +1 -1
  14. package/payload/platform/plugins/email/mcp/dist/__tests__/mailbox-resolve.test.d.ts +2 -0
  15. package/payload/platform/plugins/email/mcp/dist/__tests__/mailbox-resolve.test.d.ts.map +1 -0
  16. package/payload/platform/plugins/email/mcp/dist/__tests__/mailbox-resolve.test.js +101 -0
  17. package/payload/platform/plugins/email/mcp/dist/__tests__/mailbox-resolve.test.js.map +1 -0
  18. package/payload/platform/plugins/email/mcp/dist/index.js +58 -2
  19. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  20. package/payload/platform/plugins/email/mcp/dist/lib/credentials.d.ts +35 -12
  21. package/payload/platform/plugins/email/mcp/dist/lib/credentials.d.ts.map +1 -1
  22. package/payload/platform/plugins/email/mcp/dist/lib/credentials.js +90 -40
  23. package/payload/platform/plugins/email/mcp/dist/lib/credentials.js.map +1 -1
  24. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +23 -0
  25. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  26. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +66 -0
  27. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  28. package/payload/platform/plugins/email/mcp/dist/lib/ingest-batch.d.ts +8 -8
  29. package/payload/platform/plugins/email/mcp/dist/lib/ingest-batch.d.ts.map +1 -1
  30. package/payload/platform/plugins/email/mcp/dist/lib/ingest-batch.js +0 -0
  31. package/payload/platform/plugins/email/mcp/dist/lib/ingest-batch.js.map +1 -1
  32. package/payload/platform/plugins/email/mcp/dist/scripts/send-transactional.js +5 -2
  33. package/payload/platform/plugins/email/mcp/dist/scripts/send-transactional.js.map +1 -1
  34. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.d.ts +22 -0
  35. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.d.ts.map +1 -0
  36. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.js +51 -0
  37. package/payload/platform/plugins/email/mcp/dist/tools/email-delete.js.map +1 -0
  38. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-edit.d.ts +1 -0
  39. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-edit.d.ts.map +1 -1
  40. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-edit.js +1 -1
  41. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-edit.js.map +1 -1
  42. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.d.ts +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.d.ts.map +1 -1
  44. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js +1 -1
  45. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js.map +1 -1
  46. package/payload/platform/plugins/email/mcp/dist/tools/email-draft.d.ts +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/tools/email-draft.d.ts.map +1 -1
  48. package/payload/platform/plugins/email/mcp/dist/tools/email-draft.js +1 -1
  49. package/payload/platform/plugins/email/mcp/dist/tools/email-draft.js.map +1 -1
  50. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-body.d.ts +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-body.d.ts.map +1 -1
  52. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-body.js +1 -1
  53. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-body.js.map +1 -1
  54. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch.d.ts +1 -0
  55. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch.d.ts.map +1 -1
  56. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch.js +3 -3
  57. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch.js.map +1 -1
  58. package/payload/platform/plugins/email/mcp/dist/tools/email-ingest.d.ts +1 -0
  59. package/payload/platform/plugins/email/mcp/dist/tools/email-ingest.d.ts.map +1 -1
  60. package/payload/platform/plugins/email/mcp/dist/tools/email-ingest.js +14 -6
  61. package/payload/platform/plugins/email/mcp/dist/tools/email-ingest.js.map +1 -1
  62. package/payload/platform/plugins/email/mcp/dist/tools/email-otp-extract.d.ts +1 -0
  63. package/payload/platform/plugins/email/mcp/dist/tools/email-otp-extract.d.ts.map +1 -1
  64. package/payload/platform/plugins/email/mcp/dist/tools/email-otp-extract.js +1 -1
  65. package/payload/platform/plugins/email/mcp/dist/tools/email-otp-extract.js.map +1 -1
  66. package/payload/platform/plugins/email/mcp/dist/tools/email-read.d.ts +1 -0
  67. package/payload/platform/plugins/email/mcp/dist/tools/email-read.d.ts.map +1 -1
  68. package/payload/platform/plugins/email/mcp/dist/tools/email-read.js +1 -1
  69. package/payload/platform/plugins/email/mcp/dist/tools/email-read.js.map +1 -1
  70. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.d.ts +1 -0
  71. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.d.ts.map +1 -1
  72. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js +1 -1
  73. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js.map +1 -1
  74. package/payload/platform/plugins/email/mcp/dist/tools/email-search.d.ts +1 -0
  75. package/payload/platform/plugins/email/mcp/dist/tools/email-search.d.ts.map +1 -1
  76. package/payload/platform/plugins/email/mcp/dist/tools/email-search.js +1 -1
  77. package/payload/platform/plugins/email/mcp/dist/tools/email-search.js.map +1 -1
  78. package/payload/platform/plugins/email/mcp/dist/tools/email-send.d.ts +1 -0
  79. package/payload/platform/plugins/email/mcp/dist/tools/email-send.d.ts.map +1 -1
  80. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js +1 -1
  81. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js.map +1 -1
  82. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.js +3 -3
  83. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.js.map +1 -1
  84. package/payload/platform/plugins/email/mcp/dist/tools/email-status.d.ts +7 -0
  85. package/payload/platform/plugins/email/mcp/dist/tools/email-status.d.ts.map +1 -1
  86. package/payload/platform/plugins/email/mcp/dist/tools/email-status.js +38 -9
  87. package/payload/platform/plugins/email/mcp/dist/tools/email-status.js.map +1 -1
  88. package/payload/platform/plugins/email/references/email-reference.md +25 -0
  89. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +21 -4
  90. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  91. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.d.ts +5 -5
  92. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -5
  93. package/payload/platform/plugins/workflows/PLUGIN.md +2 -2
  94. package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +1 -1
  95. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  96. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  97. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  98. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  99. package/payload/platform/templates/specialists/agents/project-manager.md +1 -1
  100. package/payload/server/public/assets/{chat-BWNFeIjw.js → chat-nth30R3E.js} +1 -1
  101. package/payload/server/public/assets/{operator-B3hpbhNo.js → operator-As5Tw1vj.js} +1 -1
  102. package/payload/server/public/assets/{page-DFOoEKVK.js → page-CII4OBX3.js} +2 -2
  103. package/payload/server/public/assets/{public-Bd_0QBGJ.js → public-BxZBMrEL.js} +1 -1
  104. package/payload/server/public/chat.html +2 -2
  105. package/payload/server/public/operator.html +2 -2
  106. package/payload/server/public/public.html +2 -2
@@ -0,0 +1,100 @@
1
+ // Acceptance gate for the restored scheduling-watcher heartbeat cron.
2
+ //
3
+ // The installer registers exactly ONE install-level cron line that ticks
4
+ // check-due-events.js once a minute. These invariants protect the two
5
+ // regressions that the original block caused (and that its narrowed
6
+ // replacement must not reintroduce), plus the marker contract the admin
7
+ // `system-status` check reads back.
8
+ //
9
+ // (a) The block carries a single heartbeat entry — no email-fetch /
10
+ // email-auto-respond line. Those dispatchers act without an operator
11
+ // booking and stay unscheduled by the installer.
12
+ // (b) The entry never `mkdir`s an account log dir. The legacy line ran
13
+ // `mkdir -p data/accounts/<id>/logs` every 60s, tripping
14
+ // setup-account/seed-neo4j's stub-account-dirs guard on reinstall.
15
+ // Logs land under <persistDir>/logs, outside the seed-guard zone.
16
+ // (c) NEO4J_URI is pinned to bolt://localhost:<brand port>. Cron inherits
17
+ // no shell env, so an unpinned URI would default to 7687 and leak
18
+ // across co-resident brand installs.
19
+ // (d) The BEGIN/END markers equal `# BEGIN <PRODUCT_UPPER> CRONS` — the
20
+ // exact strings admin `system-status` greps to report registration.
21
+ // (e) Re-running the merge is idempotent: a re-install replaces the block,
22
+ // never stacks a second copy.
23
+ import test from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { cronBlockMarkers, buildHeartbeatCronBlock, stripCronBlock, mergeHeartbeatBlock, } from "../cron-registration.js";
26
+ const OPTS = {
27
+ productName: "maxy-code",
28
+ platformRoot: "/home/me/maxy-code/platform",
29
+ neo4jPort: 17687,
30
+ nodeBin: "/usr/bin/node",
31
+ logDir: "/home/me/.maxy-code/logs",
32
+ };
33
+ test("markers are # BEGIN/END <PRODUCT_UPPER> CRONS (admin system-status contract)", () => {
34
+ const { begin, end } = cronBlockMarkers("maxy-code");
35
+ assert.equal(begin, "# BEGIN MAXY-CODE CRONS");
36
+ assert.equal(end, "# END MAXY-CODE CRONS");
37
+ });
38
+ test("block has exactly one heartbeat entry, no email lines", () => {
39
+ const block = buildHeartbeatCronBlock(OPTS);
40
+ const entries = block.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
41
+ assert.equal(entries.length, 1, "expected exactly one cron entry");
42
+ assert.match(entries[0], / # heartbeat$/, "entry must be tagged # heartbeat");
43
+ assert.doesNotMatch(block, /email-fetch/, "email-fetch must not be scheduled");
44
+ assert.doesNotMatch(block, /email-auto-respond/, "email-auto-respond must not be scheduled");
45
+ });
46
+ test("heartbeat entry never mkdirs an account log dir (Task 039 guard)", () => {
47
+ const block = buildHeartbeatCronBlock(OPTS);
48
+ assert.doesNotMatch(block, /mkdir/, "no mkdir in the cron line");
49
+ assert.doesNotMatch(block, /data\/accounts/, "must not touch the seed-guard account dir");
50
+ assert.match(block, />> \/home\/me\/\.maxy-code\/logs\/check-due-events\.log 2>&1/, "log must redirect to <persistDir>/logs");
51
+ });
52
+ test("NEO4J_URI is pinned to the brand bolt port, never defaulting 7687 (Task 571 guard)", () => {
53
+ const block = buildHeartbeatCronBlock(OPTS);
54
+ assert.match(block, /NEO4J_URI=bolt:\/\/localhost:17687 /, "NEO4J_URI must pin the brand port");
55
+ assert.doesNotMatch(block, /7687(?!.)/, "must not reference the default 7687 port");
56
+ });
57
+ test("entry runs check-due-events.js with PLATFORM_ROOT and no ACCOUNT_ID", () => {
58
+ const block = buildHeartbeatCronBlock(OPTS);
59
+ assert.match(block, /PLATFORM_ROOT=\/home\/me\/maxy-code\/platform /);
60
+ assert.match(block, /\/plugins\/scheduling\/mcp\/dist\/scripts\/check-due-events\.js/);
61
+ assert.doesNotMatch(block, /ACCOUNT_ID=/, "watcher scans all accounts itself; no per-account scoping");
62
+ });
63
+ test("entry fires every minute", () => {
64
+ const block = buildHeartbeatCronBlock(OPTS);
65
+ const entry = block.split("\n").find((l) => l.includes("check-due-events"));
66
+ assert.match(entry, /^\* \* \* \* \* /, "must be a once-a-minute schedule");
67
+ });
68
+ test("stripCronBlock removes a present block and counts its entries", () => {
69
+ const tab = `MAILTO=""\n${buildHeartbeatCronBlock(OPTS)}\n`;
70
+ const { cleaned, removedEntries, matched } = stripCronBlock(tab, "maxy-code");
71
+ assert.equal(matched, true);
72
+ assert.equal(removedEntries, 1);
73
+ assert.equal(cleaned, 'MAILTO=""');
74
+ });
75
+ test("stripCronBlock is a no-op signal when no block is present", () => {
76
+ const tab = `0 3 * * * /usr/bin/backup.sh\n`;
77
+ const { cleaned, removedEntries, matched } = stripCronBlock(tab, "maxy-code");
78
+ assert.equal(matched, false);
79
+ assert.equal(removedEntries, 0);
80
+ assert.equal(cleaned, "0 3 * * * /usr/bin/backup.sh");
81
+ });
82
+ test("stripCronBlock escapes the brand name (hyphen/dot safe)", () => {
83
+ const block = buildHeartbeatCronBlock({ ...OPTS, productName: "real.agent-code" });
84
+ const { matched, removedEntries } = stripCronBlock(block, "real.agent-code");
85
+ assert.equal(matched, true);
86
+ assert.equal(removedEntries, 1);
87
+ });
88
+ test("mergeHeartbeatBlock appends to an empty crontab", () => {
89
+ const block = buildHeartbeatCronBlock(OPTS);
90
+ const merged = mergeHeartbeatBlock("", block, "maxy-code");
91
+ assert.equal(merged, `${block}\n`);
92
+ });
93
+ test("mergeHeartbeatBlock replaces an existing block — idempotent re-install", () => {
94
+ const block = buildHeartbeatCronBlock(OPTS);
95
+ const once = mergeHeartbeatBlock(`0 3 * * * /usr/bin/backup.sh\n`, block, "maxy-code");
96
+ const twice = mergeHeartbeatBlock(once, block, "maxy-code");
97
+ assert.equal(once, twice, "re-applying the merge must not stack a second block");
98
+ const blockCount = (twice.match(/# BEGIN MAXY-CODE CRONS/g) || []).length;
99
+ assert.equal(blockCount, 1, "exactly one brand block after re-install");
100
+ });
@@ -0,0 +1,72 @@
1
+ // Scheduling-watcher heartbeat cron — pure string builders.
2
+ //
3
+ // The installer registers ONE install-level cron line that ticks
4
+ // `scheduling/mcp/dist/scripts/check-due-events.js` once a minute. The watcher
5
+ // scans every account itself (listLocalAccountIds → data/accounts/*/account.json)
6
+ // and only dispatches operator-created bookings whose time has come, so a
7
+ // per-minute tick creates no activity of its own.
8
+ //
9
+ // Narrowed from the block removed in Task 039 (commit 08e64c1f7), which also
10
+ // reintroduces neither regression that removal fixed:
11
+ // - No `mkdir` of an account log dir. Logs go to <persistDir>/logs, outside
12
+ // the seed-guard zone, so reinstall after `rm -rf ~/<brand>` never trips
13
+ // the stub-account-dirs guard.
14
+ // - NEO4J_URI pinned to the brand bolt port. Cron inherits no shell env, so
15
+ // an unpinned URI would default to 7687 and leak across co-resident brands
16
+ // (Task 571).
17
+ //
18
+ // The email dispatchers (email-fetch, email-auto-respond) act without an
19
+ // operator booking and stay unscheduled by the installer — they are NOT part
20
+ // of this block.
21
+ //
22
+ // I/O (read/write the user crontab) lives in the installer/uninstaller; this
23
+ // module is pure so the line shape and merge semantics are unit-testable.
24
+ /**
25
+ * BEGIN/END marker pair, byte-identical to the strings admin `system-status`
26
+ * greps (`# BEGIN ${productName.toUpperCase()} CRONS`).
27
+ */
28
+ export function cronBlockMarkers(productName) {
29
+ const upper = productName.toUpperCase();
30
+ return { begin: `# BEGIN ${upper} CRONS`, end: `# END ${upper} CRONS` };
31
+ }
32
+ /** The three-line cron block: BEGIN marker, the single heartbeat entry, END marker. */
33
+ export function buildHeartbeatCronBlock(opts) {
34
+ const { begin, end } = cronBlockMarkers(opts.productName);
35
+ const script = `${opts.platformRoot}/plugins/scheduling/mcp/dist/scripts/check-due-events.js`;
36
+ const log = `${opts.logDir}/check-due-events.log`;
37
+ const entry = `* * * * * PLATFORM_ROOT=${opts.platformRoot} ` +
38
+ `NEO4J_URI=bolt://localhost:${opts.neo4jPort} ` +
39
+ `${opts.nodeBin} ${script} >> ${log} 2>&1 # heartbeat`;
40
+ return [begin, entry, end].join("\n");
41
+ }
42
+ /** Escape a brand name for safe embedding in a RegExp (hyphens, dots). */
43
+ function escapeRegExp(s) {
44
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ }
46
+ /**
47
+ * Strip every brand cron block from a crontab string.
48
+ * `matched` is true when at least one block was present; `removedEntries`
49
+ * counts the non-comment, non-blank lines inside the removed block(s).
50
+ */
51
+ export function stripCronBlock(crontab, productName) {
52
+ const { begin, end } = cronBlockMarkers(productName);
53
+ const pattern = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "g");
54
+ const blocks = crontab.match(pattern) ?? [];
55
+ let removedEntries = 0;
56
+ for (const block of blocks) {
57
+ removedEntries += block
58
+ .split("\n")
59
+ .filter((line) => line.trim() && !line.trim().startsWith("#")).length;
60
+ }
61
+ const cleaned = crontab.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd();
62
+ return { cleaned, removedEntries, matched: blocks.length > 0 };
63
+ }
64
+ /**
65
+ * Produce the next crontab: strip any existing brand block, then append the
66
+ * fresh block. Re-running with the same block is idempotent — a re-install
67
+ * replaces the block, never stacks a second copy.
68
+ */
69
+ export function mergeHeartbeatBlock(existing, block, productName) {
70
+ const { cleaned } = stripCronBlock(existing, productName);
71
+ return cleaned ? `${cleaned}\n${block}\n` : `${block}\n`;
72
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./pe
6
6
  import { resolve, join, dirname } from "node:path";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { resolveInstallPortFromFs, buildMaxyUnitFile, buildClaudeSessionManagerUnitFile, buildClaudePtysSliceUnitFile, buildCloudflaredSliceUnitFile } from "./port-resolution.js";
9
+ import { buildHeartbeatCronBlock, mergeHeartbeatBlock } from "./cron-registration.js";
9
10
  import { validateTierFlag, validateEntitlementBase64, applyTierToAccountConfig, entitlementPath } from "./tier-flag.js";
10
11
  import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
11
12
  import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
@@ -2981,9 +2982,10 @@ function registerSpecialistAgents() {
2981
2982
  // written by setup-account.sh during setupAccount(). One reader, one shape, one
2982
2983
  // source of truth.
2983
2984
  //
2984
- // retired the installer's cron registration `resolveInstallAccountId`
2985
- // kept its second consumer (installService) and is referenced again at print
2986
- // time for the post-install banner.
2985
+ // `resolveInstallAccountId` has two consumers: `installService` (stamps the
2986
+ // unit env) and the post-install banner. The restored heartbeat cron
2987
+ // (`installCrons` below) is NOT a third consumer — the scheduling watcher
2988
+ // scans every account itself, so the cron line carries no ACCOUNT_ID.
2987
2989
  // ---------------------------------------------------------------------------
2988
2990
  function resolveInstallAccountId() {
2989
2991
  // Managed-service model: prefer the single `role:"house"` account; fall back
@@ -3015,6 +3017,47 @@ function resolveInstallAccountId() {
3015
3017
  catch { /* directory unreadable */ }
3016
3018
  return "";
3017
3019
  }
3020
+ // ---------------------------------------------------------------------------
3021
+ // Cron registration — scheduling-watcher heartbeat
3022
+ //
3023
+ // Registers ONE install-level cron line that ticks check-due-events.js every
3024
+ // minute (see cron-registration.ts for the line shape and the two regression
3025
+ // guards). Idempotent across re-installs via the BEGIN/END markers. Linux-only
3026
+ // — darwin runs under launchd and registers no cron. The email dispatchers are
3027
+ // deliberately NOT registered here.
3028
+ //
3029
+ // The log dir <persistDir>/logs is created earlier in installServiceLinux
3030
+ // (alongside the .env write); the cron line never mkdir's it.
3031
+ // ---------------------------------------------------------------------------
3032
+ function installCrons() {
3033
+ if (!isLinux())
3034
+ return;
3035
+ const nodeBin = spawnSync("which", ["node"], { encoding: "utf-8" }).stdout.trim() || "/usr/bin/node";
3036
+ const platformRoot = join(INSTALL_DIR, "platform");
3037
+ const logDir = join(resolve(process.env.HOME ?? "/root", BRAND.configDir), "logs");
3038
+ const block = buildHeartbeatCronBlock({
3039
+ productName: BRAND.productName,
3040
+ platformRoot,
3041
+ neo4jPort: NEO4J_PORT,
3042
+ nodeBin,
3043
+ logDir,
3044
+ });
3045
+ const existing = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
3046
+ const currentCrontab = existing.status === 0 ? existing.stdout : "";
3047
+ const newCrontab = mergeHeartbeatBlock(currentCrontab, block, BRAND.productName);
3048
+ const write = spawnSync("crontab", ["-"], {
3049
+ input: newCrontab,
3050
+ encoding: "utf-8",
3051
+ stdio: ["pipe", "pipe", "pipe"],
3052
+ });
3053
+ if (write.status === 0) {
3054
+ console.log(" Cron jobs: registered (heartbeat)");
3055
+ }
3056
+ else {
3057
+ console.error(` Cron jobs: failed to register — ${(write.stderr || "").trim()}`);
3058
+ logFile(` crontab write failed: ${write.stderr}`);
3059
+ }
3060
+ }
3018
3061
  // retired the ttyd/tmux/xterm admin terminal stack. Upgrades run
3019
3062
  // via the action runner — `systemd-run --user` transient units spawned by
3020
3063
  // POST /api/admin/actions/upgrade — whose lifetime is independent of
@@ -3846,6 +3889,10 @@ WantedBy=multi-user.target
3846
3889
  if (!webServerUp) {
3847
3890
  console.log(` Server may still be starting. Check http://${DEVICE_HOSTNAME}.local:${PORT} in a moment.`);
3848
3891
  }
3892
+ // Register the scheduling-watcher heartbeat cron — depends on the web server
3893
+ // being up (the dispatched MCP tools talk to the running platform), independent
3894
+ // of CDP. <persistDir>/logs was created above.
3895
+ installCrons();
3849
3896
  // Validate CDP: the programmatic Playwright MCP server connects to Chromium
3850
3897
  // via --cdp-endpoint http://127.0.0.1:9222. In virtual (VNC) mode, Chromium is
3851
3898
  // started by vnc.sh (ExecStartPre) before the web server — a failed probe here
package/dist/uninstall.js CHANGED
@@ -4,6 +4,7 @@ import { resolve, join, dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createInterface } from "node:readline";
6
6
  import { removeBrandStanza, hasAnyBrandStanza } from "./samba-provision.js";
7
+ import { stripCronBlock } from "./cron-registration.js";
7
8
  import { resolveDedicatedNeo4jTeardown } from "./neo4j-teardown.js";
8
9
  import { KNOWN_BRAND_HOSTNAMES } from "./known-brands.js";
9
10
  const HOME = homedir();
@@ -94,6 +95,35 @@ function commandExists(cmd) {
94
95
  }
95
96
  }
96
97
  // ---------------------------------------------------------------------------
98
+ // Cron block removal
99
+ //
100
+ // Strips the `# BEGIN <PRODUCT> CRONS` … `# END` block (the installer's
101
+ // scheduling-watcher heartbeat) from the user crontab. Idempotent: a no-op
102
+ // when the block is absent, leaving a clean crontab byte-identical. When the
103
+ // block was the only content, the crontab is dropped entirely with `crontab -r`
104
+ // so no empty tab is left behind. Linux-only — darwin registers no cron.
105
+ // ---------------------------------------------------------------------------
106
+ function removeCronBlock() {
107
+ if (!isLinux())
108
+ return;
109
+ if (!commandExists("crontab"))
110
+ return;
111
+ const existing = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
112
+ const currentCrontab = existing.status === 0 ? existing.stdout : "";
113
+ const { cleaned, removedEntries, matched } = stripCronBlock(currentCrontab, BRAND.productName);
114
+ if (!matched) {
115
+ console.log(" Cron block: none present");
116
+ return;
117
+ }
118
+ if (cleaned.trim() === "") {
119
+ spawnSync("crontab", ["-r"], { stdio: ["pipe", "pipe", "pipe"] });
120
+ }
121
+ else {
122
+ spawnSync("crontab", ["-"], { input: `${cleaned}\n`, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
123
+ }
124
+ console.log(` Cron block: removed ${removedEntries} entries`);
125
+ }
126
+ // ---------------------------------------------------------------------------
97
127
  // Detection
98
128
  // ---------------------------------------------------------------------------
99
129
  export function isMaxyInstalled() {
@@ -949,6 +979,7 @@ export async function runUninstall(options) {
949
979
  const failures = [];
950
980
  const steps = [
951
981
  { name: "Stop services", fn: stopServices },
982
+ { name: "Remove cron block", fn: removeCronBlock },
952
983
  { name: "Delete Cloudflare tunnel", fn: deleteCloudflareTunnel },
953
984
  ...(options.exportPath
954
985
  ? [{ name: "Export data", fn: () => exportData(options.exportPath) }]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.368",
3
+ "version": "0.1.370",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "email",
3
- "description": "Email — dedicated email account. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract.",
3
+ "description": "Email — one or more dedicated email mailboxes per account, each addressable by a `mailbox` selector. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract.",
4
4
  "version": "0.1.0",
5
5
  "author": {
6
6
  "name": "Rubytech LLC"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: email
3
- description: "Email — dedicated email account. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract."
3
+ description: "Email — one or more dedicated email mailboxes per account, each addressable by a `mailbox` selector. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract."
4
4
  tools:
5
5
  - name: email-provider-info
6
6
  publicAllowlist: false
@@ -32,6 +32,9 @@ tools:
32
32
  - name: email-search
33
33
  publicAllowlist: false
34
34
  adminAllowlist: false
35
+ - name: email-delete
36
+ publicAllowlist: false
37
+ adminAllowlist: false
35
38
  - name: email-graph-query
36
39
  publicAllowlist: false
37
40
  adminAllowlist: false
@@ -72,12 +75,15 @@ Manages the agent's own dedicated email account — IMAP for reading, SMTP for s
72
75
 
73
76
  **Login vs alias — the default recipient set.** A config carries two addresses. `email` is the IMAP login: the address you authenticate as, and an address this mailbox owns. `agentAddress` is an *additional* alias to surface (often a per-agent address on a shared catch-all mailbox). When they differ, `email-read`, `email-search`, `email-otp-extract`, and `email-fetch` default to surfacing mail whose **To or Cc** carries **either** address — not the alias alone — because replies commonly arrive at the login address, and a reply or multi-party thread routinely Cc's the alias rather than To'ing it (a proposal sent from `info@` is answered to `info@`, not the `maxy@` alias; a thread copies `maxy@` on Cc). When no distinct alias is configured, no recipient filter is applied. Pass an explicit `to=` to narrow to a single address. Whenever the default filter hides messages from the fetched window, `email-read`/`email-search` append a `Recipient filter: kept N, hid M (to ∈ {…}); pass to="…" to change.` line — including when every message was hidden — so "I can't see it" is never indistinguishable from "it isn't there". `email-fetch` has no tool-output filter line; its kept-vs-hidden split is recorded in the stderr `diag("fetchSinceUid", "filter applied", …)` log instead.
74
77
 
78
+ **Multiple mailboxes.** An account holds any number of mailboxes at once. Each is its own `:EmailAccount` node keyed by `(accountId, email)`, and `email-setup` adds one without touching the others (no destructive re-point to act on a second inbox). Every operational tool takes an optional `mailbox` argument naming which configured mailbox to act on. Resolution order: the named mailbox if given; the single configured mailbox when only one exists (so single-mailbox installs pass nothing); otherwise the call is refused with a line listing the stored mailbox identities, so the agent picks one rather than guessing an address. The poll high-water mark and the pending fetch/ingest batch are per-mailbox. `email-status` lists every attached mailbox.
79
+
75
80
  ## Capabilities
76
81
 
77
82
  - **Provider lookup:** `email-provider-info` — given just an email address, returns the inferred IMAP/SMTP hosts and the provider's app-password / trusted-application / IMAP-enable requirement, deterministically from the provider map. Call this BEFORE soliciting a password so the requirement is relayed first; most providers reject the normal login password. Returns "unknown" (operator supplies hosts) or "unsupported" (provider blocks IMAP/SMTP, e.g. Tuta).
78
83
  - **Setup:** `email-setup` — collect credentials in plain chat and connect IMAP/SMTP. Hosts are auto-inferred for known providers; supports alias addresses (`agentAddress`).
79
84
  - **Read inbox:** `email-read` — metadata only (UID, sender, subject, date), plus a best-effort `Archive`/`Archive-Section` hint when the IMAP message's `receivedAt` falls inside a stored `:ConversationArchive {source:'email'}` section, plus an `Attachments (N): filename (mime, size); …` footer when the message carries enclosures. Supports pagination (`before_uid`), folders (`inbox`/`sent`), filtering by sender/date/subject. Metadata is derived from IMAP `bodyStructure` — no bytes downloaded.
80
85
  - **Search inbox:** `email-search` — live IMAP search by sender, subject, body keyword, date range. Same per-archive hint and attachment footer as `email-read`.
86
+ - **Delete (move to Trash):** `email-delete` — move the messages named by `uids` (from a prior `email-read`/`email-search`, scoped to a source `folder` of `inbox` or `sent`) into the mailbox's Trash folder. **Recoverable, never an expunge** — the message leaves the inbox and permanent removal is left to the operator's own mail client or the provider's Trash auto-purge; this tool never permanently deletes. Trash is resolved per provider: the `\Trash` special-use folder if advertised, else the first existing of `Trash`, `[Gmail]/Trash`, `Deleted Items`, `Deleted Messages`. `Junk` is never treated as Trash. A mailbox with no resolvable Trash yields a typed error and moves nothing — there is no silent expunge fallback. A `uid` that is already gone is counted not-moved without failing the rest; the result reports `Moved N of M message(s) to <Trash>`. It deletes only the UIDs you name, never by search criteria.
81
87
  - **Read one whole message:** `email-fetch-body` — the COMPLETE decoded body of a single message addressed by `uid` (with optional `folder`) or `message_id` (resolved across inbox and sent). No 500-char preview cap. Downloads the full RFC822 source and decodes it with `mailparser`, so every MIME shape — flat text, single-part HTML, `multipart/alternative`, and nested `multipart/related`/`multipart/mixed` (Outlook's inline-image house style) — yields a body; the preview tools above intentionally stay preview-only. Use this when the operator wants to read or act on a message's actual content.
82
88
  - **Send:** `email-send` — new outbound email. Pass `cc`/`bcc` (each an array of email addresses) to copy or blind-copy recipients — CC appears in the message header, BCC recipients receive the message without appearing in any header. Pass `attachments` (absolute file paths under the account directory, ≤25 MB each) to attach files as SMTP attachments; an invalid path fails the send with no email dispatched. The success message names the CC recipients and reports the BCC count without naming them.
83
89
  - **Reply:** `email-reply` — threaded reply to an existing email by `messageId`. Resolves the original envelope via IMAP `SEARCH HEADER Message-ID` (INBOX then `\Sent`), so threading works for any message still present on the server even if the graph never ingested it. Supports `replyAll`, `cc`/`bcc`, and `attachments` (same path rules as `email-send`). Explicit `cc`/`bcc` are added in addition to whatever `replyAll` places in `To`, passed through as given (no overlap de-duplication against `To`).
@@ -87,7 +93,7 @@ Manages the agent's own dedicated email account — IMAP for reading, SMTP for s
87
93
  - **Ingestion — fetch:** `email-fetch` — list new IMAP messages since the stored high-water mark. Metadata + body preview only — including attachment names, mimetypes, and sizes so the operator sees what's attached before approving. No bytes downloaded, no graph writes. Stages the batch in-memory for `email-ingest`.
88
94
  - **Ingestion — apply:** `email-ingest` — apply the operator's per-message decisions to the staged batch. Each Message-ID must carry a disposition (`ingest` or `discard`). Approved messages land on `:ConversationArchive {source:'email'}` via the source-agnostic conversation-archive pipeline (one archive per thread, sessionised into `:Section` chunks). Attachment bytes are fetched, archived to `{accountDir}/archive/email/<uidValidity>-<uid>/<sha256>-<filename>` (0o600, 25 MB cap), and each attachment lands as a content-addressed `:DigitalDocument` keyed on `attachmentId = sha256(bytes)`; the `:HAS_ENCLOSURE` edge from the parent `:ConversationArchive` is wired by `memory-ingest` on its next pass via the `pendingArchiveEdges` queue. Advances the inbox high-water mark only after a successful write.
89
95
  - **Recall/history:** `email-graph-query` — search stored email archives in Neo4j by natural language, participant address, or date range. Each thread is one `:ConversationArchive {source:'email'}` with `:Section` chunks; operator participants attach via `:PARTICIPANT_IN` edges from `:Person`/`:AdminUser`. List mode surfaces a per-archive summary built from concatenated `:Section.summary` values; query mode runs vector search over the `section_embedding` index and dedups to the parent archive.
90
- - **Status:** `email-status` — connection health and configuration state.
96
+ - **Status / list:** `email-status` — connection health and configuration state for every attached mailbox (one health block each, prefixed by `Mailbox: <email>` when more than one is configured). Pass `mailbox` to report just one. This is the list surface the agent reads to see which mailboxes exist before naming one in a selector.
91
97
  - **OTP:** `email-otp-extract` — poll for verification codes during service authentication.
92
98
 
93
99
  ## Ingestion model
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=email-delete.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-delete.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/email-delete.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,151 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Mock imapflow so moveToTrash's internal createClient() yields our spy client.
3
+ // `calls` records the wire sequence (lock, search, messageMove) so the move
4
+ // target, the not-moved accounting, and the resolve-before-lock contract can be
5
+ // asserted directly. `folders` is the client.list() result; `existingUids` is
6
+ // which UIDs the source folder actually holds (the rest are already gone).
7
+ const state = {
8
+ folders: [],
9
+ existingUids: [],
10
+ calls: { locked: null, searched: null, moves: [], released: false },
11
+ };
12
+ const mockClient = {
13
+ connect: vi.fn(async () => { }),
14
+ list: vi.fn(async () => state.folders),
15
+ getMailboxLock: vi.fn(async (path) => {
16
+ state.calls.locked = path;
17
+ return { release: () => { state.calls.released = true; } };
18
+ }),
19
+ search: vi.fn(async (query) => {
20
+ state.calls.searched = query;
21
+ const requested = String(query.uid).split(",").map(Number);
22
+ return requested.filter((u) => state.existingUids.includes(u));
23
+ }),
24
+ messageMove: vi.fn(async (range, dest, opts) => {
25
+ state.calls.moves.push({ range, dest, opts });
26
+ }),
27
+ logout: vi.fn(async () => { }),
28
+ close: vi.fn(() => { }),
29
+ };
30
+ vi.mock("imapflow", () => ({
31
+ ImapFlow: function ImapFlow() {
32
+ return mockClient;
33
+ },
34
+ }));
35
+ // Mock credentials so the tool resolves an account without touching Neo4j. The
36
+ // spy records that loadCredentials was called by accountId (the by-address read
37
+ // happens inside loadCredentials → readPassword), and supplies the config/password
38
+ // moveToTrash receives — the tool itself takes no password input. vi.hoisted keeps
39
+ // the spy and its config available to the (hoisted) vi.mock factory without a TDZ.
40
+ const { loadCredentials, credConfig } = vi.hoisted(() => {
41
+ const credConfig = {
42
+ email: "owner@x.com",
43
+ agentAddress: "owner@x.com",
44
+ imapHost: "imap.x.com",
45
+ imapPort: 993,
46
+ imapSecurity: "tls",
47
+ smtpHost: "smtp.x.com",
48
+ smtpPort: 587,
49
+ smtpSecurity: "starttls",
50
+ };
51
+ const loadCredentials = vi.fn(async (_accountId) => ({
52
+ credentials: { config: credConfig, password: "resolved-pw" },
53
+ }));
54
+ return { loadCredentials, credConfig };
55
+ });
56
+ vi.mock("../lib/credentials.js", () => ({ loadCredentials }));
57
+ import { moveToTrash, resolveTrashFolder } from "../lib/imap.js";
58
+ import { emailDelete, deleteLine } from "../tools/email-delete.js";
59
+ const config = {
60
+ email: "a@x.com",
61
+ agentAddress: "a@x.com",
62
+ imapHost: "imap.x.com",
63
+ imapPort: 993,
64
+ imapSecurity: "tls",
65
+ smtpHost: "smtp.x.com",
66
+ smtpPort: 587,
67
+ smtpSecurity: "starttls",
68
+ };
69
+ function reset(folders, existingUids) {
70
+ state.folders = folders;
71
+ state.existingUids = existingUids;
72
+ state.calls = { locked: null, searched: null, moves: [], released: false };
73
+ }
74
+ beforeEach(() => {
75
+ reset([], []);
76
+ });
77
+ describe("resolveTrashFolder", () => {
78
+ it("prefers the \\Trash special-use folder over a name match", async () => {
79
+ const folders = [{ path: "INBOX" }, { path: "Trash" }, { path: "Bin", specialUse: "\\Trash" }];
80
+ await expect(resolveTrashFolder({ list: async () => folders })).resolves.toBe("Bin");
81
+ });
82
+ it("falls back to a known Deleted-folder name when no special-use exists", async () => {
83
+ const folders = [{ path: "INBOX" }, { path: "Deleted Items" }];
84
+ await expect(resolveTrashFolder({ list: async () => folders })).resolves.toBe("Deleted Items");
85
+ });
86
+ it("throws a typed no-trash-folder error and never treats Junk as Trash", async () => {
87
+ const folders = [{ path: "INBOX" }, { path: "Junk" }];
88
+ await expect(resolveTrashFolder({ list: async () => folders })).rejects.toMatchObject({ code: "no-trash-folder" });
89
+ });
90
+ });
91
+ describe("moveToTrash", () => {
92
+ it("moves every requested UID that exists, targets the resolved Trash, and reports moved=requested", async () => {
93
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], [10, 11, 12]);
94
+ const res = await moveToTrash(config, "pw", { folder: "inbox", uids: [10, 11, 12] });
95
+ expect(res).toEqual({ moved: 3, requested: 3, trash: "Trash" });
96
+ expect(state.calls.locked).toBe("INBOX");
97
+ expect(state.calls.moves).toHaveLength(1);
98
+ expect(state.calls.moves[0].range).toEqual([10, 11, 12]);
99
+ expect(state.calls.moves[0].dest).toBe("Trash");
100
+ expect(state.calls.moves[0].opts.uid).toBe(true);
101
+ expect(state.calls.released).toBe(true);
102
+ });
103
+ it("counts a vanished UID as not-moved and still moves the rest", async () => {
104
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], [10, 12]); // 11 is gone
105
+ const res = await moveToTrash(config, "pw", { folder: "inbox", uids: [10, 11, 12] });
106
+ expect(res).toEqual({ moved: 2, requested: 3, trash: "Trash" });
107
+ expect(state.calls.moves[0].range).toEqual([10, 12]);
108
+ });
109
+ it("with no Trash folder errors, moves nothing, and never locks the source", async () => {
110
+ reset([{ path: "INBOX" }, { path: "Junk" }], [10]);
111
+ await expect(moveToTrash(config, "pw", { folder: "inbox", uids: [10] })).rejects.toMatchObject({ code: "no-trash-folder" });
112
+ expect(state.calls.moves).toHaveLength(0);
113
+ expect(state.calls.locked).toBeNull();
114
+ });
115
+ it("makes no messageMove call when none of the requested UIDs still exist", async () => {
116
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], []);
117
+ const res = await moveToTrash(config, "pw", { folder: "inbox", uids: [10, 11] });
118
+ expect(res).toEqual({ moved: 0, requested: 2, trash: "Trash" });
119
+ expect(state.calls.moves).toHaveLength(0);
120
+ });
121
+ });
122
+ describe("emailDelete tool", () => {
123
+ it("resolves the credential by accountId (no password input) and moves via the resolved Trash", async () => {
124
+ reset([{ path: "INBOX" }, { path: "Trash", specialUse: "\\Trash" }], [10, 11]);
125
+ loadCredentials.mockClear();
126
+ const text = await emailDelete({ accountId: "acct-1", uids: [10, 11], folder: "inbox" });
127
+ expect(loadCredentials).toHaveBeenCalledWith("acct-1", undefined);
128
+ // The owner address came from the resolved config, not from any tool input.
129
+ expect(text).toBe("Moved 2 of 2 message(s) to Trash.");
130
+ expect(state.calls.moves[0].dest).toBe("Trash");
131
+ });
132
+ });
133
+ describe("deleteLine", () => {
134
+ it("carries the mailbox, counts, trash, and ok — never a password", () => {
135
+ const ok = deleteLine("owner@x.com", "inbox", 3, 2, "Trash", true);
136
+ expect(ok).toContain("op=delete");
137
+ expect(ok).toContain("email=owner@x.com");
138
+ expect(ok).toContain("folder=inbox");
139
+ expect(ok).toContain("requested=3");
140
+ expect(ok).toContain("moved=2");
141
+ expect(ok).toContain("trash=Trash");
142
+ expect(ok).toContain("ok=true");
143
+ expect(ok).not.toContain("resolved-pw");
144
+ });
145
+ it("records the typed reason and ok=false when no Trash resolves", () => {
146
+ const fail = deleteLine("owner@x.com", "inbox", 1, 0, null, false, "no-trash-folder");
147
+ expect(fail).toContain("ok=false");
148
+ expect(fail).toContain("reason=no-trash-folder");
149
+ });
150
+ });
151
+ //# sourceMappingURL=email-delete.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-delete.test.js","sourceRoot":"","sources":["../../src/__tests__/email-delete.test.ts"],"names":[],"mappings":"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"}
@@ -6,7 +6,7 @@ const smtp = vi.hoisted(() => ({ testConnection: vi.fn() }));
6
6
  vi.mock("../lib/imap.js", () => imap);
7
7
  vi.mock("../lib/smtp.js", () => smtp);
8
8
  const cred = vi.hoisted(() => ({
9
- readConfig: vi.fn(),
9
+ listConfigs: vi.fn(),
10
10
  loadCredentials: vi.fn(),
11
11
  credentialStorePath: vi.fn(() => "/tmp/store.json"),
12
12
  recordSuccessfulConnect: vi.fn(),
@@ -22,7 +22,7 @@ const config = {
22
22
  };
23
23
  beforeEach(() => {
24
24
  vi.clearAllMocks();
25
- cred.readConfig.mockResolvedValue(config);
25
+ cred.listConfigs.mockResolvedValue([config]);
26
26
  cred.loadCredentials.mockResolvedValue({ credentials: { config, password: "pw" } });
27
27
  });
28
28
  describe("email-status never-connected signal", () => {
@@ -48,11 +48,44 @@ describe("email-status never-connected signal", () => {
48
48
  smtp.testConnection.mockResolvedValue(null);
49
49
  imap.getInboxCount.mockResolvedValue(12);
50
50
  const out = await emailStatus({ accountId: "acct-1" });
51
- expect(cred.recordSuccessfulConnect).toHaveBeenCalledWith("acct-1");
51
+ expect(cred.recordSuccessfulConnect).toHaveBeenCalledWith("acct-1", config.email);
52
52
  expect(out).toMatch(/Last successful connect: \d{4}-\d{2}-\d{2}T/);
53
53
  expect(out).not.toMatch(/never-connected/);
54
54
  // A live success never falls back to readLastSuccessfulConnect.
55
55
  expect(cred.readLastSuccessfulConnect).not.toHaveBeenCalled();
56
56
  });
57
57
  });
58
+ describe("email-status multi-mailbox list surface", () => {
59
+ const configA = { ...config, email: "a@x.com", agentAddress: "a@x.com" };
60
+ const configB = { ...config, email: "b@x.com", agentAddress: "b@x.com" };
61
+ beforeEach(() => {
62
+ cred.listConfigs.mockResolvedValue([configA, configB]);
63
+ cred.loadCredentials.mockImplementation(async (_acct, email) => ({
64
+ credentials: { config: email === "a@x.com" ? configA : configB, password: "pw" },
65
+ }));
66
+ imap.testConnection.mockResolvedValue(null);
67
+ smtp.testConnection.mockResolvedValue(null);
68
+ imap.getInboxCount.mockResolvedValue(3);
69
+ });
70
+ it("lists every attached mailbox with a per-mailbox header and health block", async () => {
71
+ const out = await emailStatus({ accountId: "acct-1" });
72
+ expect(out).toMatch(/2 mailboxes attached/);
73
+ expect(out).toMatch(/Mailbox: a@x\.com/);
74
+ expect(out).toMatch(/Mailbox: b@x\.com/);
75
+ // Both blocks carry their own IMAP/SMTP health line.
76
+ expect((out.match(/IMAP: .* — OK/g) ?? []).length).toBe(2);
77
+ });
78
+ it("narrows to one mailbox when mailbox is named, probing only that one", async () => {
79
+ const out = await emailStatus({ accountId: "acct-1", mailbox: "b@x.com" });
80
+ expect(out).toMatch(/b@x\.com/);
81
+ expect(out).not.toMatch(/a@x\.com/);
82
+ expect(cred.recordSuccessfulConnect).toHaveBeenCalledWith("acct-1", "b@x.com");
83
+ expect(cred.recordSuccessfulConnect).not.toHaveBeenCalledWith("acct-1", "a@x.com");
84
+ });
85
+ it("refuses an unmatched selector, naming the stored identities", async () => {
86
+ const out = await emailStatus({ accountId: "acct-1", mailbox: "c@x.com" });
87
+ expect(out).toMatch(/No mailbox c@x\.com/);
88
+ expect(out).toMatch(/Stored mailbox identities: \[a@x\.com, b@x\.com\]/);
89
+ });
90
+ });
58
91
  //# sourceMappingURL=email-status.test.js.map