@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.
- package/client/app.bundle.js +17 -0
- package/client/app.bundle.js.map +3 -3
- package/client/components/message-list.js +32 -0
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +29 -0
- package/client/styles/components.css +15 -0
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts +14 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +95 -4
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +83 -4
- package/packages/mailx-service/test/external-edit-sweep.test.mjs +48 -0
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-74296 → node_modules.npmglobalize-stash-63500}/.package-lock.json +0 -0
|
@@ -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
|
-
|
|
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(", ")})`);
|