@bobfrankston/mailx 1.0.221 → 1.0.223
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.js +50 -2
- package/client/components/folder-tree.js +10 -1
- package/client/components/message-viewer.js +24 -7
- package/client/compose/compose.css +30 -0
- package/client/compose/compose.html +2 -0
- package/client/compose/compose.js +64 -0
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +3 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +6 -3
- package/packages/mailx-imap/index.js +47 -18
- package/packages/mailx-imap/providers/gmail-api.d.ts +5 -1
- package/packages/mailx-imap/providers/gmail-api.js +26 -7
- package/packages/mailx-service/index.d.ts +4 -0
- package/packages/mailx-service/index.js +48 -4
- package/packages/mailx-service/jsonrpc.js +2 -0
package/client/app.js
CHANGED
|
@@ -483,6 +483,7 @@ function forwardBody(msg) {
|
|
|
483
483
|
return `<br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
|
|
484
484
|
}
|
|
485
485
|
let lastDeleted = null;
|
|
486
|
+
let lastMoved = null;
|
|
486
487
|
let undoTimeout = null;
|
|
487
488
|
async function deleteSelectedMessages() {
|
|
488
489
|
const selected = getSelectedMessages();
|
|
@@ -548,6 +549,49 @@ async function undoDelete() {
|
|
|
548
549
|
console.error(`Undo failed: ${e.message}`);
|
|
549
550
|
}
|
|
550
551
|
}
|
|
552
|
+
async function undoMove() {
|
|
553
|
+
if (!lastMoved)
|
|
554
|
+
return;
|
|
555
|
+
const { messages } = lastMoved;
|
|
556
|
+
const statusSync = document.getElementById("status-sync");
|
|
557
|
+
try {
|
|
558
|
+
// Group by (sourceAccountId, sourceFolderId) and move each group back
|
|
559
|
+
const byDest = new Map();
|
|
560
|
+
for (const m of messages) {
|
|
561
|
+
const key = `${m.accountId}:${m.sourceFolderId}`;
|
|
562
|
+
if (!byDest.has(key))
|
|
563
|
+
byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
|
|
564
|
+
byDest.get(key).uids.push(m.uid);
|
|
565
|
+
}
|
|
566
|
+
const { moveMessages, moveMessage } = await import("./lib/api-client.js");
|
|
567
|
+
for (const group of byDest.values()) {
|
|
568
|
+
if (group.uids.length === 1)
|
|
569
|
+
await moveMessage(group.accountId, group.uids[0], group.folderId);
|
|
570
|
+
else
|
|
571
|
+
await moveMessages(group.accountId, group.uids, group.folderId);
|
|
572
|
+
}
|
|
573
|
+
if (statusSync)
|
|
574
|
+
statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
|
|
575
|
+
lastMoved = null;
|
|
576
|
+
if (undoTimeout)
|
|
577
|
+
clearTimeout(undoTimeout);
|
|
578
|
+
reloadCurrentFolder();
|
|
579
|
+
}
|
|
580
|
+
catch (e) {
|
|
581
|
+
console.error(`Undo move failed: ${e.message}`);
|
|
582
|
+
if (statusSync)
|
|
583
|
+
statusSync.textContent = `Undo move failed: ${e.message}`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Listen for the "mailx-moved" custom event emitted by folder-tree's drop
|
|
587
|
+
// handler so Ctrl+Z can reverse the most recent move.
|
|
588
|
+
document.addEventListener("mailx-moved", (e) => {
|
|
589
|
+
lastMoved = e.detail;
|
|
590
|
+
lastDeleted = null; // Ctrl+Z undoes whichever came last
|
|
591
|
+
if (undoTimeout)
|
|
592
|
+
clearTimeout(undoTimeout);
|
|
593
|
+
undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
|
|
594
|
+
});
|
|
551
595
|
document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
|
|
552
596
|
document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
|
|
553
597
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
@@ -909,9 +953,13 @@ document.addEventListener("keydown", (e) => {
|
|
|
909
953
|
e.preventDefault();
|
|
910
954
|
deleteSelectedMessages();
|
|
911
955
|
}
|
|
912
|
-
// Ctrl+Z = Undo delete
|
|
956
|
+
// Ctrl+Z = Undo the most recent delete or move
|
|
913
957
|
if (e.ctrlKey && e.key === "z") {
|
|
914
|
-
if (
|
|
958
|
+
if (lastMoved) {
|
|
959
|
+
e.preventDefault();
|
|
960
|
+
undoMove();
|
|
961
|
+
}
|
|
962
|
+
else if (lastDeleted) {
|
|
915
963
|
e.preventDefault();
|
|
916
964
|
undoDelete();
|
|
917
965
|
}
|
|
@@ -325,13 +325,22 @@ function renderNode(node, container, depth) {
|
|
|
325
325
|
}
|
|
326
326
|
const moved = toMove.length;
|
|
327
327
|
if (statusEl)
|
|
328
|
-
statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name}`;
|
|
328
|
+
statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name} — Ctrl+Z to undo`;
|
|
329
329
|
// Remove from shared state — list and viewer update automatically
|
|
330
330
|
const { removeMessages } = await import("../lib/message-state.js");
|
|
331
331
|
removeMessages(toMove);
|
|
332
332
|
const treeContainer = document.getElementById("folder-tree");
|
|
333
333
|
if (treeContainer)
|
|
334
334
|
loadFolderTree(treeContainer);
|
|
335
|
+
// Notify app.ts so Ctrl+Z can undo this move. Each entry carries
|
|
336
|
+
// its ORIGINAL folderId/accountId so we know where to move back to.
|
|
337
|
+
document.dispatchEvent(new CustomEvent("mailx-moved", {
|
|
338
|
+
detail: {
|
|
339
|
+
messages: toMove.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),
|
|
340
|
+
targetAccountId: node.accountId,
|
|
341
|
+
targetFolderId: node.id,
|
|
342
|
+
},
|
|
343
|
+
}));
|
|
335
344
|
}
|
|
336
345
|
catch (err) {
|
|
337
346
|
console.error(`Move failed: ${err.message}`);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message viewer component -- displays full message in sandboxed iframe.
|
|
3
3
|
* Subscribes to message-state: clears when selected becomes null.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessage, updateFlags, allowRemoteContent, getAttachment } from "../lib/api-client.js";
|
|
5
|
+
import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
import * as state from "../lib/message-state.js";
|
|
8
8
|
/** Currently displayed message (for reply/forward) */
|
|
@@ -173,20 +173,37 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
173
173
|
toEl.textContent += ` Cc: ${msg.cc.map(formatAddr).join(", ")}`;
|
|
174
174
|
}
|
|
175
175
|
headerEl.querySelector(".mv-subject").textContent = msg.subject;
|
|
176
|
-
// Right-click on email addresses in header
|
|
177
|
-
|
|
176
|
+
// Right-click on email addresses in header: copy name, copy address,
|
|
177
|
+
// copy both, add to contacts, plus reply actions for the whole message.
|
|
178
178
|
for (const el of [fromEl, toEl]) {
|
|
179
179
|
el.addEventListener("contextmenu", (e) => {
|
|
180
180
|
e.preventDefault();
|
|
181
181
|
const me = e;
|
|
182
182
|
const items = [];
|
|
183
|
-
|
|
183
|
+
const addrs = el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])];
|
|
184
|
+
for (const addr of addrs) {
|
|
184
185
|
if (!addr?.address)
|
|
185
186
|
continue;
|
|
186
|
-
const
|
|
187
|
-
|
|
187
|
+
const name = addr.name || "";
|
|
188
|
+
const both = name ? `${name} <${addr.address}>` : addr.address;
|
|
189
|
+
if (name) {
|
|
190
|
+
items.push({ label: `Copy name: ${name}`, action: () => navigator.clipboard.writeText(name) });
|
|
191
|
+
}
|
|
192
|
+
items.push({ label: `Copy address: ${addr.address}`, action: () => navigator.clipboard.writeText(addr.address) });
|
|
193
|
+
if (name) {
|
|
194
|
+
items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });
|
|
195
|
+
}
|
|
196
|
+
items.push({
|
|
197
|
+
label: `Add to contacts: ${addr.address}`,
|
|
198
|
+
action: async () => {
|
|
199
|
+
try {
|
|
200
|
+
await addContact(name, addr.address);
|
|
201
|
+
}
|
|
202
|
+
catch { /* ignore */ }
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
items.push({ label: "", action: () => { }, separator: true });
|
|
188
206
|
}
|
|
189
|
-
items.push({ label: "", action: () => { }, separator: true });
|
|
190
207
|
items.push({ label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) });
|
|
191
208
|
items.push({ label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) });
|
|
192
209
|
items.push({ label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) });
|
|
@@ -294,6 +294,36 @@ body {
|
|
|
294
294
|
}
|
|
295
295
|
.ql-editor, .tt-content .tiptap { position: relative; }
|
|
296
296
|
|
|
297
|
+
/* Attachment chips above the editor */
|
|
298
|
+
.compose-attachments {
|
|
299
|
+
padding: var(--gap-xs) var(--gap-md);
|
|
300
|
+
display: flex;
|
|
301
|
+
flex-wrap: wrap;
|
|
302
|
+
gap: var(--gap-xs);
|
|
303
|
+
background: var(--color-bg-surface);
|
|
304
|
+
border-bottom: 1px solid var(--color-border);
|
|
305
|
+
}
|
|
306
|
+
.compose-att-chip {
|
|
307
|
+
display: inline-flex;
|
|
308
|
+
align-items: center;
|
|
309
|
+
gap: 6px;
|
|
310
|
+
padding: 3px 8px;
|
|
311
|
+
border-radius: 999px;
|
|
312
|
+
background: color-mix(in oklch, var(--color-accent) 15%, transparent);
|
|
313
|
+
color: var(--color-text);
|
|
314
|
+
font-size: var(--font-size-sm);
|
|
315
|
+
}
|
|
316
|
+
.compose-att-chip button {
|
|
317
|
+
background: none;
|
|
318
|
+
border: none;
|
|
319
|
+
color: var(--color-text-muted);
|
|
320
|
+
cursor: pointer;
|
|
321
|
+
padding: 0;
|
|
322
|
+
font-size: 1em;
|
|
323
|
+
line-height: 1;
|
|
324
|
+
}
|
|
325
|
+
.compose-att-chip button:hover { color: oklch(0.65 0.2 25); }
|
|
326
|
+
|
|
297
327
|
/* Link editor modal (Ctrl+K / toolbar link button) */
|
|
298
328
|
.mailx-modal-backdrop {
|
|
299
329
|
position: fixed;
|
|
@@ -37,9 +37,11 @@
|
|
|
37
37
|
<div class="compose-toolbar">
|
|
38
38
|
<button class="tb-btn" id="btn-send">Send</button>
|
|
39
39
|
<button class="tb-btn" id="btn-attach">Attach</button>
|
|
40
|
+
<input type="file" id="compose-file" multiple hidden>
|
|
40
41
|
<button class="tb-btn" id="btn-discard">Discard</button>
|
|
41
42
|
<span id="compose-status" class="compose-status"></span>
|
|
42
43
|
</div>
|
|
44
|
+
<div id="compose-attachments" class="compose-attachments" hidden></div>
|
|
43
45
|
<div id="compose-editor"></div>
|
|
44
46
|
</body>
|
|
45
47
|
</html>
|
|
@@ -302,6 +302,7 @@ let draftDebounceTimer = null;
|
|
|
302
302
|
let lastDraftContent = "";
|
|
303
303
|
let draftSaving = false; // prevent concurrent saves
|
|
304
304
|
let draftSaveFailed = false; // surfaced in the compose status tag
|
|
305
|
+
const attachments = [];
|
|
305
306
|
function showDraftStatus(text, isError) {
|
|
306
307
|
const status = document.getElementById("compose-status");
|
|
307
308
|
if (!status)
|
|
@@ -418,6 +419,7 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
418
419
|
subject: subjectInput.value,
|
|
419
420
|
bodyHtml: editor.getHtml(),
|
|
420
421
|
bodyText: editor.getText(),
|
|
422
|
+
attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
|
|
421
423
|
};
|
|
422
424
|
try {
|
|
423
425
|
await sendMessage(body);
|
|
@@ -495,6 +497,68 @@ async function handleCloseRequest() {
|
|
|
495
497
|
document.getElementById("btn-discard")?.addEventListener("click", () => {
|
|
496
498
|
handleCloseRequest();
|
|
497
499
|
});
|
|
500
|
+
// ── Attachments ──
|
|
501
|
+
const fileInput = document.getElementById("compose-file");
|
|
502
|
+
const attEl = document.getElementById("compose-attachments");
|
|
503
|
+
function renderAttachmentChips() {
|
|
504
|
+
attEl.innerHTML = "";
|
|
505
|
+
if (attachments.length === 0) {
|
|
506
|
+
attEl.hidden = true;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
attEl.hidden = false;
|
|
510
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
511
|
+
const a = attachments[i];
|
|
512
|
+
const chip = document.createElement("span");
|
|
513
|
+
chip.className = "compose-att-chip";
|
|
514
|
+
chip.innerHTML = `\uD83D\uDCCE ${escapeHtml(a.filename)} (${formatSize(a.size)}) `;
|
|
515
|
+
const rm = document.createElement("button");
|
|
516
|
+
rm.type = "button";
|
|
517
|
+
rm.title = "Remove attachment";
|
|
518
|
+
rm.textContent = "\u2715";
|
|
519
|
+
rm.addEventListener("click", () => {
|
|
520
|
+
attachments.splice(i, 1);
|
|
521
|
+
renderAttachmentChips();
|
|
522
|
+
});
|
|
523
|
+
chip.appendChild(rm);
|
|
524
|
+
attEl.appendChild(chip);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function escapeHtml(s) {
|
|
528
|
+
return s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
529
|
+
}
|
|
530
|
+
function formatSize(n) {
|
|
531
|
+
if (n < 1024)
|
|
532
|
+
return `${n} B`;
|
|
533
|
+
if (n < 1024 * 1024)
|
|
534
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
535
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
536
|
+
}
|
|
537
|
+
document.getElementById("btn-attach")?.addEventListener("click", () => {
|
|
538
|
+
fileInput?.click();
|
|
539
|
+
});
|
|
540
|
+
fileInput?.addEventListener("change", async () => {
|
|
541
|
+
if (!fileInput.files)
|
|
542
|
+
return;
|
|
543
|
+
for (const file of Array.from(fileInput.files)) {
|
|
544
|
+
const buf = await file.arrayBuffer();
|
|
545
|
+
// base64 the whole thing — mailx-service builds the multipart/mixed
|
|
546
|
+
let binary = "";
|
|
547
|
+
const bytes = new Uint8Array(buf);
|
|
548
|
+
for (let i = 0; i < bytes.length; i++)
|
|
549
|
+
binary += String.fromCharCode(bytes[i]);
|
|
550
|
+
const dataBase64 = btoa(binary);
|
|
551
|
+
attachments.push({
|
|
552
|
+
filename: file.name,
|
|
553
|
+
mimeType: file.type || "application/octet-stream",
|
|
554
|
+
size: file.size,
|
|
555
|
+
dataBase64,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
fileInput.value = "";
|
|
559
|
+
renderAttachmentChips();
|
|
560
|
+
scheduleDraftSave();
|
|
561
|
+
});
|
|
498
562
|
// ── Save and close (X button from parent) ──
|
|
499
563
|
window.addEventListener("compose-save-and-close", () => {
|
|
500
564
|
handleCloseRequest();
|
package/client/lib/api-client.js
CHANGED
|
@@ -146,6 +146,9 @@ export function repairAccounts() {
|
|
|
146
146
|
export function deleteDraft(accountId, draftUid, draftId) {
|
|
147
147
|
return ipc().deleteDraft?.(accountId, draftUid, draftId);
|
|
148
148
|
}
|
|
149
|
+
export function addContact(name, email) {
|
|
150
|
+
return ipc().addContact?.(name, email);
|
|
151
|
+
}
|
|
149
152
|
export function setupAccount(name, email, password) {
|
|
150
153
|
return ipc().setupAccount?.(name, email, password);
|
|
151
154
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -97,6 +97,9 @@
|
|
|
97
97
|
searchMessages: function(query, page, pageSize) {
|
|
98
98
|
return callNode("searchMessages", { query: query, page: page, pageSize: pageSize });
|
|
99
99
|
},
|
|
100
|
+
addContact: function(name, email) {
|
|
101
|
+
return callNode("addContact", { name: name, email: email });
|
|
102
|
+
},
|
|
100
103
|
searchContacts: function(query) {
|
|
101
104
|
return callNode("searchContacts", { query: query });
|
|
102
105
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.223",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
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.285",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
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.285",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -188,9 +188,12 @@ export declare class ImapManager extends EventEmitter {
|
|
|
188
188
|
private saveSendingCopy;
|
|
189
189
|
/** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
|
|
190
190
|
queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
|
|
191
|
-
/** Process local file queue —
|
|
192
|
-
*
|
|
193
|
-
*
|
|
191
|
+
/** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
|
|
192
|
+
* and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
|
|
193
|
+
* double-send bug was caused by queueOutgoing() WRITING a debug copy to
|
|
194
|
+
* sending/queued/ on every send — that write is gone now, so scanning the
|
|
195
|
+
* directory is safe again. Any legitimate files that land there (crash
|
|
196
|
+
* recovery, manual drop) will get sent. */
|
|
194
197
|
private processLocalQueue;
|
|
195
198
|
/** Send a raw RFC 2822 message via SMTP for a given account */
|
|
196
199
|
private sendRawViaSMTP;
|
|
@@ -946,20 +946,40 @@ export class ImapManager extends EventEmitter {
|
|
|
946
946
|
console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
|
|
947
947
|
this.storeApiMessages(accountId, folder.id, messages, highestUid);
|
|
948
948
|
}
|
|
949
|
-
// Reconcile deletions
|
|
949
|
+
// Reconcile deletions — messages present locally but not on the server.
|
|
950
|
+
// SAFETY: this used to silently wipe entire folders when getUids()
|
|
951
|
+
// returned a partial list (e.g. paginated fetch hit a rate limit and
|
|
952
|
+
// bailed). Multiple guards now:
|
|
953
|
+
// 1. getUids() flags partial results via _truncated — refuse to delete
|
|
954
|
+
// 2. If server list is empty but local isn't, assume a transient error
|
|
955
|
+
// 3. If reconcile would delete more than RECONCILE_DELETE_THRESHOLD of
|
|
956
|
+
// local messages, log and skip — safer to keep phantoms than to lose
|
|
957
|
+
// real messages. User can fix with `mailx -rebuild` if needed.
|
|
950
958
|
try {
|
|
951
|
-
const
|
|
959
|
+
const serverUidsArr = await api.getUids(folder.path);
|
|
960
|
+
const serverUids = new Set(serverUidsArr);
|
|
952
961
|
const localUids = this.db.getUidsForFolder(accountId, folder.id);
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
962
|
+
if (serverUidsArr._truncated) {
|
|
963
|
+
console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
|
|
964
|
+
}
|
|
965
|
+
else if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
966
|
+
console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
970
|
+
const RECONCILE_DELETE_THRESHOLD = 0.5; // refuse to delete >50% in one pass
|
|
971
|
+
if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
|
|
972
|
+
console.log(` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
for (const uid of toDelete) {
|
|
976
|
+
this.db.deleteMessage(accountId, uid);
|
|
977
|
+
this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
|
|
978
|
+
}
|
|
979
|
+
if (toDelete.length > 0)
|
|
980
|
+
console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
|
|
959
981
|
}
|
|
960
982
|
}
|
|
961
|
-
if (deleted > 0)
|
|
962
|
-
console.log(` [api] ${accountId}/${folder.path}: ${deleted} deleted`);
|
|
963
983
|
}
|
|
964
984
|
catch (e) {
|
|
965
985
|
console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
|
|
@@ -970,12 +990,15 @@ export class ImapManager extends EventEmitter {
|
|
|
970
990
|
}
|
|
971
991
|
/** Store API-fetched messages to DB */
|
|
972
992
|
storeApiMessages(accountId, folderId, msgs, highestUid) {
|
|
993
|
+
// highestUid kept for signature compatibility but no longer used to
|
|
994
|
+
// filter — Gmail message IDs aren't monotonic, so `msg.uid <= highestUid`
|
|
995
|
+
// would drop brand-new messages whose hash happens to be smaller than
|
|
996
|
+
// the previous high. upsertMessage's primary-key dedup handles it.
|
|
997
|
+
void highestUid;
|
|
973
998
|
let stored = 0;
|
|
974
999
|
this.db.beginTransaction();
|
|
975
1000
|
try {
|
|
976
1001
|
for (const msg of msgs) {
|
|
977
|
-
if (msg.uid <= highestUid)
|
|
978
|
-
continue;
|
|
979
1002
|
const flags = [];
|
|
980
1003
|
if (msg.seen)
|
|
981
1004
|
flags.push("\\Seen");
|
|
@@ -1802,15 +1825,21 @@ export class ImapManager extends EventEmitter {
|
|
|
1802
1825
|
fs.writeFileSync(path.join(localQueue, filename), rawMessage);
|
|
1803
1826
|
console.log(` [outbox] Saved locally: ${filename}`);
|
|
1804
1827
|
}
|
|
1805
|
-
/** Process local file queue —
|
|
1806
|
-
*
|
|
1807
|
-
*
|
|
1828
|
+
/** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
|
|
1829
|
+
* and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
|
|
1830
|
+
* double-send bug was caused by queueOutgoing() WRITING a debug copy to
|
|
1831
|
+
* sending/queued/ on every send — that write is gone now, so scanning the
|
|
1832
|
+
* directory is safe again. Any legitimate files that land there (crash
|
|
1833
|
+
* recovery, manual drop) will get sent. */
|
|
1808
1834
|
async processLocalQueue(accountId) {
|
|
1809
1835
|
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
1836
|
+
const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
|
|
1810
1837
|
const filesToSend = [];
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1838
|
+
for (const dir of [outboxDir, queuedDir]) {
|
|
1839
|
+
if (!fs.existsSync(dir))
|
|
1840
|
+
continue;
|
|
1841
|
+
for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
|
|
1842
|
+
filesToSend.push({ dir, file });
|
|
1814
1843
|
}
|
|
1815
1844
|
}
|
|
1816
1845
|
if (filesToSend.length === 0)
|
|
@@ -8,7 +8,11 @@ export declare class GmailApiProvider implements MailProvider {
|
|
|
8
8
|
constructor(tokenProvider: () => Promise<string>);
|
|
9
9
|
private fetch;
|
|
10
10
|
listFolders(): Promise<ProviderFolder[]>;
|
|
11
|
-
/** List message IDs matching a query, handling pagination
|
|
11
|
+
/** List message IDs matching a query, handling pagination.
|
|
12
|
+
* IMPORTANT: on any error we throw — do NOT return a partial list, because
|
|
13
|
+
* callers use this for sync reconciliation and a short list would delete
|
|
14
|
+
* real messages from the local DB. Returning [] silently caused the
|
|
15
|
+
* "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
|
|
12
16
|
private listMessageIds;
|
|
13
17
|
/** Batch-fetch message metadata or full content */
|
|
14
18
|
private batchFetch;
|
|
@@ -93,10 +93,15 @@ export class GmailApiProvider {
|
|
|
93
93
|
}
|
|
94
94
|
return folders;
|
|
95
95
|
}
|
|
96
|
-
/** List message IDs matching a query, handling pagination
|
|
96
|
+
/** List message IDs matching a query, handling pagination.
|
|
97
|
+
* IMPORTANT: on any error we throw — do NOT return a partial list, because
|
|
98
|
+
* callers use this for sync reconciliation and a short list would delete
|
|
99
|
+
* real messages from the local DB. Returning [] silently caused the
|
|
100
|
+
* "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
|
|
97
101
|
async listMessageIds(query, maxResults = 500) {
|
|
98
102
|
const ids = [];
|
|
99
103
|
let pageToken = "";
|
|
104
|
+
let truncated = false;
|
|
100
105
|
while (true) {
|
|
101
106
|
const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
|
|
102
107
|
if (pageToken)
|
|
@@ -105,10 +110,17 @@ export class GmailApiProvider {
|
|
|
105
110
|
for (const msg of data.messages || []) {
|
|
106
111
|
ids.push(msg.id);
|
|
107
112
|
}
|
|
108
|
-
if (!data.nextPageToken
|
|
113
|
+
if (!data.nextPageToken)
|
|
109
114
|
break;
|
|
115
|
+
if (ids.length >= maxResults) {
|
|
116
|
+
// Hit the caller's cap but the server has more. Flag it so
|
|
117
|
+
// reconcile-style callers can refuse to treat this as complete.
|
|
118
|
+
truncated = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
110
121
|
pageToken = data.nextPageToken;
|
|
111
122
|
}
|
|
123
|
+
ids._truncated = truncated;
|
|
112
124
|
return ids;
|
|
113
125
|
}
|
|
114
126
|
/** Batch-fetch message metadata or full content */
|
|
@@ -170,12 +182,15 @@ export class GmailApiProvider {
|
|
|
170
182
|
};
|
|
171
183
|
}
|
|
172
184
|
async fetchSince(folder, sinceUid, options = {}) {
|
|
173
|
-
// Gmail
|
|
174
|
-
//
|
|
185
|
+
// Gmail message IDs are hash-derived, NOT monotonic — filtering by
|
|
186
|
+
// `uid > sinceUid` silently drops new messages whose hash happens to
|
|
187
|
+
// fall below the high-water mark. Fetch the most recent page and let
|
|
188
|
+
// upsertMessage dedupe by (account, folder, uid). The sinceUid arg is
|
|
189
|
+
// kept for interface compatibility but no longer used for filtering.
|
|
190
|
+
void sinceUid;
|
|
175
191
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
176
192
|
const ids = await this.listMessageIds(query, 200);
|
|
177
|
-
|
|
178
|
-
return messages.filter(m => m.uid > sinceUid);
|
|
193
|
+
return this.batchFetch(ids, options);
|
|
179
194
|
}
|
|
180
195
|
async fetchByDate(folder, since, before, options = {}, onChunk) {
|
|
181
196
|
const afterDate = this.formatDate(since);
|
|
@@ -213,7 +228,11 @@ export class GmailApiProvider {
|
|
|
213
228
|
async getUids(folder) {
|
|
214
229
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
215
230
|
const ids = await this.listMessageIds(query, 10000);
|
|
216
|
-
|
|
231
|
+
const result = ids.map(idToUid);
|
|
232
|
+
// Propagate the truncation flag so reconcile can refuse to delete.
|
|
233
|
+
if (ids._truncated)
|
|
234
|
+
result._truncated = true;
|
|
235
|
+
return result;
|
|
217
236
|
}
|
|
218
237
|
async close() {
|
|
219
238
|
// No persistent connection to close
|
|
@@ -55,6 +55,10 @@ export declare class MailxService {
|
|
|
55
55
|
searchContacts(query: string): any[];
|
|
56
56
|
syncGoogleContacts(): Promise<void>;
|
|
57
57
|
seedContacts(): number;
|
|
58
|
+
/** Explicit add to address book — used by the right-click "Add to contacts"
|
|
59
|
+
* action on From/To/Cc addresses in the message viewer. Just calls the same
|
|
60
|
+
* validated upsert path as recordSentAddress. */
|
|
61
|
+
addContact(name: string, email: string): boolean;
|
|
58
62
|
getSettings(): any;
|
|
59
63
|
saveSettings(settings: any): void;
|
|
60
64
|
getStorageInfo(): {
|
|
@@ -378,16 +378,51 @@ export class MailxService {
|
|
|
378
378
|
// Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
|
|
379
379
|
const domain = account.email.split("@")[1] || "mailx.local";
|
|
380
380
|
const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
|
|
381
|
-
const
|
|
381
|
+
const hasAttachments = Array.isArray(msg.attachments) && msg.attachments.length > 0;
|
|
382
|
+
const commonHeaders = [
|
|
382
383
|
`From: ${fromHeader}`, `To: ${to}`,
|
|
383
384
|
cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
|
|
384
385
|
`Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
|
|
385
386
|
`Message-ID: ${messageId}`,
|
|
386
387
|
msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
|
|
387
388
|
msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
|
|
388
|
-
`MIME-Version: 1.0`,
|
|
389
|
-
].filter(h => h !== null)
|
|
390
|
-
|
|
389
|
+
`MIME-Version: 1.0`,
|
|
390
|
+
].filter(h => h !== null);
|
|
391
|
+
let rawMessage;
|
|
392
|
+
if (hasAttachments) {
|
|
393
|
+
// multipart/mixed with the body + one base64 attachment part per file.
|
|
394
|
+
// Each attachment chunk is wrapped at 76-char lines per RFC 2045.
|
|
395
|
+
const boundary = `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
396
|
+
const wrap76 = (s) => s.replace(/.{1,76}/g, m => m).match(/.{1,76}/g)?.join("\r\n") || s;
|
|
397
|
+
const parts = [];
|
|
398
|
+
parts.push(`--${boundary}\r\n` +
|
|
399
|
+
`Content-Type: text/html; charset=UTF-8\r\n` +
|
|
400
|
+
`Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
|
|
401
|
+
`${bodyEncoded}\r\n`);
|
|
402
|
+
for (const att of msg.attachments) {
|
|
403
|
+
const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
|
|
404
|
+
const mime = att.mimeType || "application/octet-stream";
|
|
405
|
+
const wrapped = wrap76(att.dataBase64 || "");
|
|
406
|
+
parts.push(`--${boundary}\r\n` +
|
|
407
|
+
`Content-Type: ${mime}; name="${filename}"\r\n` +
|
|
408
|
+
`Content-Disposition: attachment; filename="${filename}"\r\n` +
|
|
409
|
+
`Content-Transfer-Encoding: base64\r\n\r\n` +
|
|
410
|
+
`${wrapped}\r\n`);
|
|
411
|
+
}
|
|
412
|
+
const headers = [
|
|
413
|
+
...commonHeaders,
|
|
414
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
415
|
+
].join("\r\n");
|
|
416
|
+
rawMessage = `${headers}\r\n\r\n${parts.join("")}--${boundary}--\r\n`;
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
const headers = [
|
|
420
|
+
...commonHeaders,
|
|
421
|
+
`Content-Type: text/html; charset=UTF-8`,
|
|
422
|
+
`Content-Transfer-Encoding: quoted-printable`,
|
|
423
|
+
].join("\r\n");
|
|
424
|
+
rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
|
|
425
|
+
}
|
|
391
426
|
this.imapManager.queueOutgoingLocal(account.id, rawMessage);
|
|
392
427
|
console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
|
|
393
428
|
for (const addr of msg.to)
|
|
@@ -615,6 +650,15 @@ export class MailxService {
|
|
|
615
650
|
console.log(` Seeded ${added} contacts from message history`);
|
|
616
651
|
return added;
|
|
617
652
|
}
|
|
653
|
+
/** Explicit add to address book — used by the right-click "Add to contacts"
|
|
654
|
+
* action on From/To/Cc addresses in the message viewer. Just calls the same
|
|
655
|
+
* validated upsert path as recordSentAddress. */
|
|
656
|
+
addContact(name, email) {
|
|
657
|
+
if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
|
|
658
|
+
return false;
|
|
659
|
+
this.db.recordSentAddress(name || "", email);
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
618
662
|
// ── Settings ──
|
|
619
663
|
getSettings() {
|
|
620
664
|
return loadSettings();
|
|
@@ -94,6 +94,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
94
94
|
return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
|
|
95
95
|
case "searchContacts":
|
|
96
96
|
return svc.searchContacts(p.query);
|
|
97
|
+
case "addContact":
|
|
98
|
+
return { ok: svc.addContact(p.name, p.email) };
|
|
97
99
|
// Settings
|
|
98
100
|
case "getSettings":
|
|
99
101
|
return svc.getSettings();
|