@bobfrankston/rmfmail 1.2.246 → 1.2.248

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.
@@ -1208,9 +1208,64 @@ export class MailxService implements MailxApi {
1208
1208
  }
1209
1209
  }
1210
1210
 
1211
+ /** Age out the external-edit scratch directory.
1212
+ *
1213
+ * Every Edit-in-Word writes a fresh timestamped .docx (the filename is
1214
+ * unique per open so a still-locked previous file cannot cause EBUSY),
1215
+ * plus Word's ~$ lock files and the autosave sidecar's .ps1. Nothing
1216
+ * ever deleted them, so the directory accumulated every document the
1217
+ * user had ever edited — copies of real letters, sitting in the clear
1218
+ * indefinitely (Bob 2026-08-10: "clean up external edits more than a
1219
+ * week old"). A week is comfortably longer than any live editing session
1220
+ * and short enough that the folder stops being an archive.
1221
+ *
1222
+ * Files belonging to a LIVE session are skipped regardless of age — a
1223
+ * document open in Word for eight days is still in use. */
1224
+ private sweepExternalEdits(dir: string, maxAgeMs = 7 * 24 * 60 * 60 * 1000): void {
1225
+ // Protect every file a live session owns: the document itself, Word's
1226
+ // "~$" owner-lock (which drops the first TWO characters of the name,
1227
+ // so a substring test never matches it), and the sidecar script.
1228
+ const live = new Set<string>();
1229
+ for (const e of this.wordEdits.values()) {
1230
+ const base = path.basename(e.path).replace(/\.(docx|html?)$/i, "");
1231
+ live.add(`${base}.docx`);
1232
+ live.add(`${base}.html`);
1233
+ live.add(`${base}.htm`);
1234
+ live.add(`~$${base.slice(2)}.docx`);
1235
+ live.add(`${base}-autosave.ps1`);
1236
+ }
1237
+ let removed = 0, bytes = 0;
1238
+ const cutoff = Date.now() - maxAgeMs;
1239
+ let names: string[] = [];
1240
+ try { names = fs.readdirSync(dir); } catch { return; }
1241
+ for (const name of names) {
1242
+ if (live.has(name)) continue;
1243
+ const full = path.join(dir, name);
1244
+ try {
1245
+ const st = fs.statSync(full);
1246
+ if (st.mtimeMs > cutoff) continue;
1247
+ bytes += st.size;
1248
+ fs.rmSync(full, { recursive: true, force: true });
1249
+ removed++;
1250
+ } catch (e: any) {
1251
+ // In use, or permissions — leave it and say so. Silently
1252
+ // skipping is how a directory quietly keeps growing.
1253
+ console.log(` [word-edit] could not remove stale ${name}: ${e?.message || e}`);
1254
+ }
1255
+ }
1256
+ if (removed > 0) {
1257
+ console.log(` [word-edit] swept ${removed} external-edit file(s) older than 7 days (${(bytes / 1024).toFixed(0)} KB)`);
1258
+ }
1259
+ }
1260
+
1211
1261
  private async openInWordUncached(editId: string, html: string, popoutId = ""): Promise<{ ok: boolean; path: string; opener: string }> {
1212
1262
  const dir = path.join(getConfigDir(), "external-edit");
1213
1263
  fs.mkdirSync(dir, { recursive: true });
1264
+ // Housekeeping on the way in: cheap (one readdir), and it runs exactly
1265
+ // when the directory is about to be used, so there is no timer to own.
1266
+ try { this.sweepExternalEdits(dir); } catch (e: any) {
1267
+ console.log(` [word-edit] sweep failed: ${e?.message || e}`);
1268
+ }
1214
1269
  // Pre-convert to .docx (Word's native format) so Save in Word writes
1215
1270
  // back to the same file naturally — no Save-As-Web-Page gymnastics.
1216
1271
  // The .html-as-input flow had a 50% rate of "user saved but rmfmail
@@ -1400,10 +1455,7 @@ export class MailxService implements MailxApi {
1400
1455
  // is no longer there.
1401
1456
  const entry = this.wordEdits.get(editId);
1402
1457
  if (entry) entry.closed = true;
1403
- // popoutId names the window to raise; empty means the
1404
- // compose lives in the main window, which the daemon
1405
- // raises through its own service channel.
1406
- this.imapManager.emit("wordEditClosed", { editId, fromPopout: !!popoutId, popoutId });
1458
+ announceClosed("autosave sidecar");
1407
1459
  }
1408
1460
  });
1409
1461
  console.log(` [word-edit] autosave sidecar armed — closing Word is the only step`);
@@ -1429,7 +1481,34 @@ export class MailxService implements MailxApi {
1429
1481
  // a web-page save doesn't vanish into an unwatched file (Bob
1430
1482
  // 2026-07-18, .html-fallback variant of the same report).
1431
1483
  const watchNames = new Set([`${fileBase}.docx`, `${fileBase}.htm`, `${fileBase}.html`]);
1484
+ // Word's owner-lock file: "~$" + the filename with its first TWO
1485
+ // characters dropped. Word creates it while the document is open and
1486
+ // DELETES it on close — a filesystem fact the watcher already sees.
1487
+ //
1488
+ // This is now the primary "document closed" signal. The COM autosave
1489
+ // sidecar was the only one and it is fragile: it attaches through
1490
+ // GetActiveObject and matches an exact FullName, so when Word hands
1491
+ // the document to an already-running instance (or normalises the
1492
+ // path) it never attaches, never exits, and never reports the close.
1493
+ // Observed doing exactly that on 2026-08-10 — Word had zero documents
1494
+ // open while the sidecar was still looping, so nothing raised the
1495
+ // compose window ("so there is still the z-plane problem"). Whichever
1496
+ // signal arrives first wins; announceClosed collapses the duplicate.
1497
+ const lockName = "~$" + fileBase.slice(2) + ".docx";
1498
+ let closeAnnounced = false;
1499
+ const announceClosed = (how: string): void => {
1500
+ if (closeAnnounced) return;
1501
+ closeAnnounced = true;
1502
+ console.log(` [word-edit] document closed (${how}) — raising the compose window`);
1503
+ this.imapManager.emit("wordEditClosed", { editId, fromPopout: !!popoutId, popoutId });
1504
+ };
1432
1505
  const watcher = fs.watch(dir, (eventType, name) => {
1506
+ if (name === lockName) {
1507
+ // Removal = Word let go of the document. Creation fires here
1508
+ // too, so only ABSENCE counts as closed.
1509
+ if (!fs.existsSync(path.join(dir, lockName))) announceClosed("lock file removed");
1510
+ return;
1511
+ }
1433
1512
  if (!name || !watchNames.has(name)) return;
1434
1513
  const savedPath = path.join(dir, name);
1435
1514
  const isDocx = name.endsWith(".docx");
@@ -0,0 +1,48 @@
1
+ /** Exercise the real sweepExternalEdits over a scratch directory: old files go,
2
+ * recent ones stay, and anything belonging to a LIVE edit is untouchable. */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import assert from "node:assert";
7
+
8
+ const { MailxService } = await import("file:///Y:/dev/email/mailx/app/packages/mailx-service/index.js");
9
+
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "extedit-"));
11
+ const DAY = 24 * 60 * 60 * 1000;
12
+ const mk = (name, ageDays) => {
13
+ const p = path.join(dir, name);
14
+ fs.writeFileSync(p, "x".repeat(100));
15
+ const t = new Date(Date.now() - ageDays * DAY);
16
+ fs.utimesSync(p, t, t);
17
+ return p;
18
+ };
19
+
20
+ const oldDoc = mk("compose-old-1.docx", 30);
21
+ const oldLock = mk("~$mpose-old-1.docx", 30);
22
+ const oldPs1 = mk("compose-old-1-autosave.ps1", 30);
23
+ const freshDoc = mk("compose-fresh-2.docx", 1);
24
+ const liveDoc = mk("compose-live-3.docx", 30); // ancient BUT open in Word
25
+ const liveLock = mk("~$mpose-live-3.docx", 30);
26
+ const edgeDoc = mk("compose-edge-4.docx", 6.9); // just inside a week
27
+
28
+ const svc = Object.create(MailxService.prototype);
29
+ svc.wordEdits = new Map([["live", { path: liveDoc, stop: () => {}, opener: "word", html: "" }]]);
30
+
31
+ MailxService.prototype.sweepExternalEdits.call(svc, dir);
32
+
33
+ const left = fs.readdirSync(dir).sort();
34
+ assert.ok(!left.includes(path.basename(oldDoc)), "a 30-day-old document should be swept");
35
+ assert.ok(!left.includes(path.basename(oldLock)), "its stale lock file should go too");
36
+ assert.ok(!left.includes(path.basename(oldPs1)), "its stale sidecar script should go too");
37
+ assert.ok(left.includes(path.basename(freshDoc)), "yesterday's document must stay");
38
+ assert.ok(left.includes(path.basename(edgeDoc)), "6.9 days old is inside the window and must stay");
39
+ assert.ok(left.includes(path.basename(liveDoc)), "a document open in Word must survive regardless of age");
40
+ assert.ok(left.includes(path.basename(liveLock)), "the live document's lock file must survive too");
41
+
42
+ // idempotent: running again removes nothing further
43
+ const before = fs.readdirSync(dir).length;
44
+ MailxService.prototype.sweepExternalEdits.call(svc, dir);
45
+ assert.strictEqual(fs.readdirSync(dir).length, before, "a second sweep must be a no-op");
46
+
47
+ fs.rmSync(dir, { recursive: true, force: true });
48
+ console.log(`external-edit sweep: 8 checks passed (kept ${left.join(", ")})`);