@bobfrankston/mailx 1.0.218 → 1.0.220
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/.msger-window.json +1 -1
- package/client/app.js +44 -7
- package/client/compose/compose.js +67 -17
- package/package.json +5 -5
- package/packages/mailx-imap/index.js +23 -1
- package/packages/mailx-service/index.js +23 -0
- package/packages/mailx-store/db.js +8 -0
- package/packages/mailx-store/file-store.d.ts +2 -0
- package/packages/mailx-store/file-store.js +4 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":1060,"y":329}
|
package/client/app.js
CHANGED
|
@@ -192,6 +192,24 @@ document.getElementById("folder-tree")?.addEventListener("click", (e) => {
|
|
|
192
192
|
document.querySelector(".folder-panel")?.classList.remove("open");
|
|
193
193
|
}
|
|
194
194
|
});
|
|
195
|
+
// Close folder overlay when user clicks outside it (narrow mode OR
|
|
196
|
+
// medium-width mode where the folder panel slides in as an overlay).
|
|
197
|
+
// Uses capture phase so it beats any child handler that might stopPropagation.
|
|
198
|
+
document.addEventListener("pointerdown", (e) => {
|
|
199
|
+
const panel = document.querySelector(".folder-panel");
|
|
200
|
+
if (!panel || !panel.classList.contains("open"))
|
|
201
|
+
return;
|
|
202
|
+
const target = e.target;
|
|
203
|
+
// Ignore clicks inside the panel itself and on the hamburger toggle
|
|
204
|
+
if (target.closest(".folder-panel") || target.closest("#btn-menu"))
|
|
205
|
+
return;
|
|
206
|
+
// Only auto-dismiss when we're in overlay mode (small or medium screens).
|
|
207
|
+
// On wide screens the panel is a permanent column and the "open" class
|
|
208
|
+
// is irrelevant.
|
|
209
|
+
if (window.innerWidth <= 1100 || window.innerHeight <= 600) {
|
|
210
|
+
panel.classList.remove("open");
|
|
211
|
+
}
|
|
212
|
+
}, true);
|
|
195
213
|
// ── Toolbar actions ──
|
|
196
214
|
document.getElementById("btn-sync")?.addEventListener("click", async () => {
|
|
197
215
|
const btn = document.getElementById("btn-sync");
|
|
@@ -371,30 +389,49 @@ function showComposeOverlay() {
|
|
|
371
389
|
closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
|
|
372
390
|
closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
|
|
373
391
|
closeBtn.addEventListener("click", () => {
|
|
392
|
+
// compose.ts handles the prompt (Save/Discard/Cancel) and then calls
|
|
393
|
+
// window.close() which is redirected to wrapper.remove() at line below.
|
|
394
|
+
// If the user cancels the prompt, closeCompose() is never called and
|
|
395
|
+
// the wrapper stays. Don't force-remove on a timer — that defeats Cancel.
|
|
374
396
|
try {
|
|
375
397
|
const win = frame.contentWindow;
|
|
376
398
|
if (win)
|
|
377
399
|
win.dispatchEvent(new Event("compose-save-and-close"));
|
|
378
400
|
}
|
|
379
401
|
catch { /* */ }
|
|
380
|
-
setTimeout(() => wrapper.remove(), 500);
|
|
381
402
|
});
|
|
382
403
|
titleBar.appendChild(closeBtn);
|
|
383
|
-
// Drag to move
|
|
404
|
+
// Drag to move. While dragging we set pointer-events:none on the iframe
|
|
405
|
+
// so mouse events don't get swallowed by the inner document the moment
|
|
406
|
+
// the cursor crosses into the iframe region. Without that, drag only
|
|
407
|
+
// worked if you stayed on the title bar pixels, which is why it felt
|
|
408
|
+
// broken except at the lower-right (resize grip) corner.
|
|
384
409
|
let dragX = 0, dragY = 0;
|
|
385
410
|
titleBar.addEventListener("mousedown", (e) => {
|
|
386
411
|
if (e.target === closeBtn)
|
|
387
412
|
return;
|
|
388
413
|
e.preventDefault();
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
414
|
+
const rect = wrapper.getBoundingClientRect();
|
|
415
|
+
dragX = e.clientX - rect.left;
|
|
416
|
+
dragY = e.clientY - rect.top;
|
|
417
|
+
// Clamp movement to the viewport so the title bar stays grabbable.
|
|
418
|
+
const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
|
|
419
|
+
frame.style.pointerEvents = "none";
|
|
420
|
+
document.body.style.userSelect = "none";
|
|
421
|
+
const onMove = (ev) => {
|
|
422
|
+
ev.preventDefault();
|
|
423
|
+
const w = wrapper.offsetWidth;
|
|
424
|
+
const h = wrapper.offsetHeight;
|
|
425
|
+
const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
|
|
426
|
+
const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
|
|
427
|
+
wrapper.style.left = `${left}px`;
|
|
428
|
+
wrapper.style.top = `${top}px`;
|
|
394
429
|
wrapper.style.bottom = "auto";
|
|
395
430
|
wrapper.style.right = "auto";
|
|
396
431
|
};
|
|
397
432
|
const onUp = () => {
|
|
433
|
+
frame.style.pointerEvents = "";
|
|
434
|
+
document.body.style.userSelect = "";
|
|
398
435
|
document.removeEventListener("mousemove", onMove);
|
|
399
436
|
document.removeEventListener("mouseup", onUp);
|
|
400
437
|
};
|
|
@@ -439,18 +439,65 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
439
439
|
alert(`Send failed: ${e.message}`);
|
|
440
440
|
}
|
|
441
441
|
});
|
|
442
|
-
// ──
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
442
|
+
// ── Close handling ──
|
|
443
|
+
/** True if the compose has anything worth asking about. */
|
|
444
|
+
function composeHasContent() {
|
|
445
|
+
return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
|
|
446
|
+
}
|
|
447
|
+
/** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel". */
|
|
448
|
+
function promptSaveOrDiscard() {
|
|
449
|
+
// Three-way prompt built from two native dialogs: OK(save) / Cancel → then
|
|
450
|
+
// for Cancel, ask "Really discard?" which becomes Discard / Cancel.
|
|
451
|
+
const saveIt = confirm("Save this message as a draft?\n\nOK = Save as draft\nCancel = continue...");
|
|
452
|
+
if (saveIt)
|
|
453
|
+
return "save";
|
|
454
|
+
const discard = confirm("Discard the message without saving?\n\nOK = Discard\nCancel = Keep editing");
|
|
455
|
+
if (discard)
|
|
456
|
+
return "discard";
|
|
457
|
+
return "cancel";
|
|
458
|
+
}
|
|
459
|
+
/** Handle any "close the compose" action (Discard button, Escape, X, window close). */
|
|
460
|
+
async function handleCloseRequest() {
|
|
461
|
+
if (!composeHasContent()) {
|
|
462
|
+
closeCompose();
|
|
463
|
+
return true;
|
|
447
464
|
}
|
|
448
|
-
|
|
465
|
+
const choice = promptSaveOrDiscard();
|
|
466
|
+
if (choice === "cancel")
|
|
467
|
+
return false;
|
|
468
|
+
// Stop auto-save so it can't race with our explicit save/discard.
|
|
469
|
+
if (draftDebounceTimer) {
|
|
470
|
+
clearTimeout(draftDebounceTimer);
|
|
471
|
+
draftDebounceTimer = null;
|
|
472
|
+
}
|
|
473
|
+
if (draftTimer) {
|
|
474
|
+
clearInterval(draftTimer);
|
|
475
|
+
draftTimer = null;
|
|
476
|
+
}
|
|
477
|
+
if (choice === "save") {
|
|
478
|
+
try {
|
|
479
|
+
await saveDraft();
|
|
480
|
+
}
|
|
481
|
+
catch { /* already logged */ }
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
// Discard: if we have a tracked draft, delete it so the orphan doesn't stick around.
|
|
485
|
+
if (draftUid || draftId) {
|
|
486
|
+
try {
|
|
487
|
+
await deleteDraft(getFromAccountId(), draftUid || 0, draftId || "");
|
|
488
|
+
}
|
|
489
|
+
catch { /* ignore */ }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
closeCompose();
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
document.getElementById("btn-discard")?.addEventListener("click", () => {
|
|
496
|
+
handleCloseRequest();
|
|
449
497
|
});
|
|
450
498
|
// ── Save and close (X button from parent) ──
|
|
451
|
-
window.addEventListener("compose-save-and-close",
|
|
452
|
-
|
|
453
|
-
closeCompose();
|
|
499
|
+
window.addEventListener("compose-save-and-close", () => {
|
|
500
|
+
handleCloseRequest();
|
|
454
501
|
});
|
|
455
502
|
// ── Keyboard shortcuts ──
|
|
456
503
|
document.addEventListener("keydown", (e) => {
|
|
@@ -458,17 +505,20 @@ document.addEventListener("keydown", (e) => {
|
|
|
458
505
|
document.getElementById("btn-send")?.click();
|
|
459
506
|
}
|
|
460
507
|
if (e.key === "Escape") {
|
|
461
|
-
document.getElementById("btn-discard")?.click();
|
|
462
|
-
}
|
|
463
|
-
// Ctrl+K = trigger address completion
|
|
464
|
-
if (e.ctrlKey && e.key === "k") {
|
|
465
508
|
e.preventDefault();
|
|
509
|
+
handleCloseRequest();
|
|
510
|
+
}
|
|
511
|
+
// Ctrl+K in an address field = trigger address completion.
|
|
512
|
+
// NOTE: Ctrl+K is ALSO the editor's "insert link" shortcut. Scope this handler
|
|
513
|
+
// strictly to the to/cc/bcc inputs so it doesn't shadow the editor binding when
|
|
514
|
+
// focus is in the body.
|
|
515
|
+
if (e.ctrlKey && (e.key === "k" || e.key === "K")) {
|
|
466
516
|
const active = document.activeElement;
|
|
467
517
|
const addressFields = [toInput, ccInput, bccInput];
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
518
|
+
if (addressFields.includes(active)) {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
active.dispatchEvent(new Event("input"));
|
|
521
|
+
}
|
|
472
522
|
}
|
|
473
523
|
});
|
|
474
524
|
//# sourceMappingURL=compose.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.220",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.9",
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.282",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -74,11 +74,11 @@
|
|
|
74
74
|
},
|
|
75
75
|
".transformedSnapshot": {
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
77
|
+
"@bobfrankston/iflow-direct": "^0.1.9",
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.282",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -1250,8 +1250,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1250
1250
|
/** Fetch a single message body on demand, caching in the store.
|
|
1251
1251
|
* Uses its own fresh connection — never blocked by background prefetch. */
|
|
1252
1252
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
1253
|
-
// Already cached?
|
|
1253
|
+
// Already cached? If the file is on disk but body_path wasn't written to
|
|
1254
|
+
// the DB (e.g. from an interrupted earlier run), the prefetch loop would
|
|
1255
|
+
// otherwise keep returning the same missing rows forever — once saw
|
|
1256
|
+
// "gmail: 17266796 bodies cached" in the logs, which is the counter
|
|
1257
|
+
// spinning on the same 100 rows.
|
|
1254
1258
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
1259
|
+
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1260
|
+
if (existingPath)
|
|
1261
|
+
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1255
1262
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
1256
1263
|
}
|
|
1257
1264
|
if (!this.configs.has(accountId))
|
|
@@ -1359,6 +1366,14 @@ export class ImapManager extends EventEmitter {
|
|
|
1359
1366
|
this.db.queueSyncAction(accountId, "delete", msg.uid, msg.folderId);
|
|
1360
1367
|
}
|
|
1361
1368
|
}
|
|
1369
|
+
// Recalc folder counts so the tree badge updates immediately instead
|
|
1370
|
+
// of showing stale numbers until the next full sync.
|
|
1371
|
+
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
1372
|
+
for (const fid of sourceFolderIds)
|
|
1373
|
+
this.db.recalcFolderCounts(fid);
|
|
1374
|
+
if (trash)
|
|
1375
|
+
this.db.recalcFolderCounts(trash.id);
|
|
1376
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
1362
1377
|
// Process all queued actions in one IMAP session
|
|
1363
1378
|
this.debounceSyncActions(accountId);
|
|
1364
1379
|
}
|
|
@@ -1375,6 +1390,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1375
1390
|
for (const msg of messages) {
|
|
1376
1391
|
this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
|
|
1377
1392
|
}
|
|
1393
|
+
// Recalc folder counts (source folders + destination) so the tree
|
|
1394
|
+
// badge updates immediately.
|
|
1395
|
+
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
1396
|
+
for (const fid of sourceFolderIds)
|
|
1397
|
+
this.db.recalcFolderCounts(fid);
|
|
1398
|
+
this.db.recalcFolderCounts(targetFolderId);
|
|
1399
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
1378
1400
|
// Process all queued actions in one IMAP session
|
|
1379
1401
|
this.debounceSyncActions(accountId);
|
|
1380
1402
|
}
|
|
@@ -341,11 +341,34 @@ export class MailxService {
|
|
|
341
341
|
const account = settings.accounts.find(a => a.id === msg.from);
|
|
342
342
|
if (!account)
|
|
343
343
|
throw new Error(`Unknown account: ${msg.from}`);
|
|
344
|
+
// Vet every recipient address — refuse to send if any field contains a
|
|
345
|
+
// non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
|
|
346
|
+
// autocomplete). This catches garbage BEFORE it hits SMTP, where the
|
|
347
|
+
// server would either accept-and-bounce or reject the whole envelope.
|
|
348
|
+
const emailRe = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
|
|
349
|
+
const validateList = (label, list) => {
|
|
350
|
+
if (!list)
|
|
351
|
+
return;
|
|
352
|
+
for (const a of list) {
|
|
353
|
+
const addr = (a?.address || "").trim();
|
|
354
|
+
if (!addr)
|
|
355
|
+
throw new Error(`${label} has an empty address`);
|
|
356
|
+
if (!emailRe.test(addr))
|
|
357
|
+
throw new Error(`${label} has an invalid address: "${addr}"${a?.name ? ` (displayed as "${a.name}")` : ""}`);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
validateList("To", msg.to);
|
|
361
|
+
validateList("Cc", msg.cc);
|
|
362
|
+
validateList("Bcc", msg.bcc);
|
|
363
|
+
if (!msg.to?.length)
|
|
364
|
+
throw new Error("No To recipients");
|
|
344
365
|
// Extract bare email from fromAddress (may be "Name <addr>" or just "addr")
|
|
345
366
|
let fromAddr = msg.fromAddress || account.email;
|
|
346
367
|
const angleMatch = fromAddr.match(/<([^>]+)>/);
|
|
347
368
|
if (angleMatch)
|
|
348
369
|
fromAddr = angleMatch[1];
|
|
370
|
+
if (!emailRe.test(fromAddr))
|
|
371
|
+
throw new Error(`From address is not a valid email: "${fromAddr}"`);
|
|
349
372
|
const fromHeader = `${account.name} <${fromAddr}>`;
|
|
350
373
|
const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
351
374
|
const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
@@ -369,6 +369,11 @@ export class MailxDB {
|
|
|
369
369
|
// ── Contacts ──
|
|
370
370
|
/** Record an address used in sent mail */
|
|
371
371
|
recordSentAddress(name, email) {
|
|
372
|
+
// Don't pollute the contacts table with non-addresses. Anything without
|
|
373
|
+
// an @ or without a TLD-ish tail would show up in autocomplete and end
|
|
374
|
+
// up back in To/Cc headers as "Name <not an email>".
|
|
375
|
+
if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
|
|
376
|
+
return;
|
|
372
377
|
const now = Date.now();
|
|
373
378
|
const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
|
|
374
379
|
if (existing) {
|
|
@@ -387,6 +392,9 @@ export class MailxDB {
|
|
|
387
392
|
GROUP BY from_address`).all();
|
|
388
393
|
let added = 0;
|
|
389
394
|
for (const r of rows) {
|
|
395
|
+
// Skip invalid addresses so contact autocomplete never proposes non-emails
|
|
396
|
+
if (!r.from_address || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(r.from_address))
|
|
397
|
+
continue;
|
|
390
398
|
const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(r.from_address);
|
|
391
399
|
if (!existing) {
|
|
392
400
|
this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)").run(r.from_name || "", r.from_address, r.last, r.cnt, now);
|
|
@@ -8,6 +8,8 @@ export declare class FileMessageStore implements MessageStore {
|
|
|
8
8
|
private basePath;
|
|
9
9
|
constructor(basePath: string);
|
|
10
10
|
private messagePath;
|
|
11
|
+
/** Public lookup of the on-disk path without touching the file. */
|
|
12
|
+
getMessagePath(accountId: string, folderId: number, uid: number): string;
|
|
11
13
|
putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
|
|
12
14
|
getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;
|
|
13
15
|
deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
@@ -14,6 +14,10 @@ export class FileMessageStore {
|
|
|
14
14
|
messagePath(accountId, folderId, uid) {
|
|
15
15
|
return path.join(this.basePath, accountId, String(folderId), `${uid}.eml`);
|
|
16
16
|
}
|
|
17
|
+
/** Public lookup of the on-disk path without touching the file. */
|
|
18
|
+
getMessagePath(accountId, folderId, uid) {
|
|
19
|
+
return this.messagePath(accountId, folderId, uid);
|
|
20
|
+
}
|
|
17
21
|
async putMessage(accountId, folderId, uid, raw) {
|
|
18
22
|
const filePath = this.messagePath(accountId, folderId, uid);
|
|
19
23
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|