@bobfrankston/mailx 1.0.222 → 1.0.224
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 +5 -5
- package/packages/mailx-imap/index.d.ts +6 -3
- package/packages/mailx-imap/index.js +27 -10
- 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.224",
|
|
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.10",
|
|
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.286",
|
|
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.10",
|
|
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.286",
|
|
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;
|
|
@@ -371,24 +371,35 @@ export class ImapManager extends EventEmitter {
|
|
|
371
371
|
}
|
|
372
372
|
const tokenDir = path.join(getConfigDir(), "tokens", account.imap.user.replace(/[@.]/g, "_"));
|
|
373
373
|
tokenProvider = async () => {
|
|
374
|
-
|
|
374
|
+
// Wrap authenticateOAuth with a 30s wall-clock timeout. Without this,
|
|
375
|
+
// a hung OAuth server could block the entire sync thread indefinitely.
|
|
376
|
+
const TOKEN_FETCH_TIMEOUT_MS = 30000;
|
|
377
|
+
const authPromise = authenticateOAuth(credPath, {
|
|
375
378
|
scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/calendar",
|
|
376
379
|
tokenDirectory: tokenDir,
|
|
377
380
|
credentialsKey: "installed",
|
|
378
381
|
loginHint: account.imap.user,
|
|
379
382
|
});
|
|
383
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`OAuth token fetch timeout (${TOKEN_FETCH_TIMEOUT_MS / 1000}s)`)), TOKEN_FETCH_TIMEOUT_MS));
|
|
384
|
+
const result = await Promise.race([authPromise, timeoutPromise]);
|
|
380
385
|
return result?.access_token || "";
|
|
381
386
|
};
|
|
382
387
|
}
|
|
388
|
+
// Non-Gmail accounts (typically Dovecot / generic IMAP) get a smaller
|
|
389
|
+
// fetch chunk size (10 vs 25) and longer inactivity timeout (300s) so
|
|
390
|
+
// multi-body FETCH batches don't trip the connection-dead detector on
|
|
391
|
+
// slow servers. Gmail stays at the defaults since it's fast and has
|
|
392
|
+
// its own rate limits to respect.
|
|
393
|
+
const isGmail = account.imap.host?.includes("gmail") || account.email.endsWith("@gmail.com");
|
|
383
394
|
const config = createAutoImapConfig({
|
|
384
395
|
server: account.imap.host,
|
|
385
396
|
port: account.imap.port,
|
|
386
397
|
username: account.imap.user,
|
|
387
398
|
password: account.imap.password,
|
|
388
399
|
tokenProvider,
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
400
|
+
inactivityTimeout: isGmail ? 60000 : 300000,
|
|
401
|
+
fetchChunkSize: isGmail ? 25 : 10,
|
|
402
|
+
fetchChunkSizeMax: isGmail ? 500 : 100,
|
|
392
403
|
});
|
|
393
404
|
this.configs.set(account.id, config);
|
|
394
405
|
// Register account in DB
|
|
@@ -1825,15 +1836,21 @@ export class ImapManager extends EventEmitter {
|
|
|
1825
1836
|
fs.writeFileSync(path.join(localQueue, filename), rawMessage);
|
|
1826
1837
|
console.log(` [outbox] Saved locally: ${filename}`);
|
|
1827
1838
|
}
|
|
1828
|
-
/** Process local file queue —
|
|
1829
|
-
*
|
|
1830
|
-
*
|
|
1839
|
+
/** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
|
|
1840
|
+
* and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
|
|
1841
|
+
* double-send bug was caused by queueOutgoing() WRITING a debug copy to
|
|
1842
|
+
* sending/queued/ on every send — that write is gone now, so scanning the
|
|
1843
|
+
* directory is safe again. Any legitimate files that land there (crash
|
|
1844
|
+
* recovery, manual drop) will get sent. */
|
|
1831
1845
|
async processLocalQueue(accountId) {
|
|
1832
1846
|
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
1847
|
+
const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
|
|
1833
1848
|
const filesToSend = [];
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1849
|
+
for (const dir of [outboxDir, queuedDir]) {
|
|
1850
|
+
if (!fs.existsSync(dir))
|
|
1851
|
+
continue;
|
|
1852
|
+
for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
|
|
1853
|
+
filesToSend.push({ dir, file });
|
|
1837
1854
|
}
|
|
1838
1855
|
}
|
|
1839
1856
|
if (filesToSend.length === 0)
|
|
@@ -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();
|