@bobfrankston/mailx 1.0.333 → 1.0.336
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.
|
@@ -80,6 +80,45 @@ await loadEditorAssets(editorType);
|
|
|
80
80
|
const container = document.getElementById("compose-editor");
|
|
81
81
|
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
82
82
|
const editor = await createEditor(container, editorType);
|
|
83
|
+
// Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
|
|
84
|
+
// Persists per-session in localStorage so zoom survives window pop/close cycles.
|
|
85
|
+
(() => {
|
|
86
|
+
const STORAGE_KEY = "mailx.compose.zoom";
|
|
87
|
+
const MIN = 0.5, MAX = 3, STEP = 0.1;
|
|
88
|
+
let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
|
|
89
|
+
const applyZoom = () => {
|
|
90
|
+
container.style.fontSize = `${zoom}em`;
|
|
91
|
+
localStorage.setItem(STORAGE_KEY, String(zoom));
|
|
92
|
+
};
|
|
93
|
+
applyZoom();
|
|
94
|
+
container.addEventListener("wheel", (e) => {
|
|
95
|
+
if (!e.ctrlKey)
|
|
96
|
+
return;
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
const delta = e.deltaY < 0 ? STEP : -STEP;
|
|
99
|
+
zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
|
|
100
|
+
applyZoom();
|
|
101
|
+
}, { passive: false });
|
|
102
|
+
document.addEventListener("keydown", (e) => {
|
|
103
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
104
|
+
return;
|
|
105
|
+
if (e.key === "=" || e.key === "+") {
|
|
106
|
+
zoom = Math.min(MAX, zoom + STEP);
|
|
107
|
+
applyZoom();
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
}
|
|
110
|
+
else if (e.key === "-") {
|
|
111
|
+
zoom = Math.max(MIN, zoom - STEP);
|
|
112
|
+
applyZoom();
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
}
|
|
115
|
+
else if (e.key === "0") {
|
|
116
|
+
zoom = 1;
|
|
117
|
+
applyZoom();
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
})();
|
|
83
122
|
// ── Populate from init data ──
|
|
84
123
|
// From field is a free-text input with a <datalist> of known accounts. The
|
|
85
124
|
// user can pick a preset or type an arbitrary "Name <addr@domain>" — no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.336",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
40
40
|
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
41
41
|
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
42
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-sync": "^0.1.8"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/mailparser": "^3.4.6"
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
104
104
|
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
105
105
|
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
106
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
106
|
+
"@bobfrankston/mailx-sync": "^0.1.8"
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
@@ -187,7 +187,11 @@ export declare class ImapManager extends EventEmitter {
|
|
|
187
187
|
uid: number;
|
|
188
188
|
folderId: number;
|
|
189
189
|
}[]): Promise<void>;
|
|
190
|
-
/** Bulk move messages —
|
|
190
|
+
/** Bulk move messages — queues the IMAP action only. The service layer
|
|
191
|
+
* (MailxService.moveMessages) owns the local DB mutation via
|
|
192
|
+
* updateMessageFolder; this method used to ALSO deleteMessage here,
|
|
193
|
+
* which wiped the row the service just updated — the message vanished
|
|
194
|
+
* on the next reconcile and "spam folder empty" was the symptom. */
|
|
191
195
|
moveMessages(accountId: string, messages: {
|
|
192
196
|
uid: number;
|
|
193
197
|
folderId: number;
|
|
@@ -197,7 +201,8 @@ export declare class ImapManager extends EventEmitter {
|
|
|
197
201
|
private debounceSyncActions;
|
|
198
202
|
/** Move a message to Trash (delete) — local-first, queues IMAP sync */
|
|
199
203
|
trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
200
|
-
/** Move a message between folders —
|
|
204
|
+
/** Move a message between folders — queues IMAP sync only. Service
|
|
205
|
+
* layer owns the local DB update (see MailxService.moveMessage). */
|
|
201
206
|
moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void>;
|
|
202
207
|
/** Move message across accounts using iflow's moveMessageToServer */
|
|
203
208
|
moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
|
|
@@ -218,7 +223,13 @@ export declare class ImapManager extends EventEmitter {
|
|
|
218
223
|
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
219
224
|
* from earlier failed autosaves are cleaned up at the same time. */
|
|
220
225
|
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
221
|
-
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP
|
|
226
|
+
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
|
|
227
|
+
* Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
|
|
228
|
+
* kick processLocalQueue. The file IS the queue — durable across crashes,
|
|
229
|
+
* visible in the filesystem, consumed by the existing outbox worker that
|
|
230
|
+
* handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
|
|
231
|
+
* sync_actions "send" branch was removed because it duplicated the same
|
|
232
|
+
* work and risked double-send when both paths fired on the same message. */
|
|
222
233
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
223
234
|
/** Guard against concurrent processSendActions for the same account */
|
|
224
235
|
private sendingAccounts;
|
|
@@ -2052,27 +2052,18 @@ export class ImapManager extends EventEmitter {
|
|
|
2052
2052
|
// Process all queued actions in one IMAP session
|
|
2053
2053
|
this.debounceSyncActions(accountId);
|
|
2054
2054
|
}
|
|
2055
|
-
/** Bulk move messages —
|
|
2055
|
+
/** Bulk move messages — queues the IMAP action only. The service layer
|
|
2056
|
+
* (MailxService.moveMessages) owns the local DB mutation via
|
|
2057
|
+
* updateMessageFolder; this method used to ALSO deleteMessage here,
|
|
2058
|
+
* which wiped the row the service just updated — the message vanished
|
|
2059
|
+
* on the next reconcile and "spam folder empty" was the symptom. */
|
|
2056
2060
|
async moveMessages(accountId, messages, targetFolderId) {
|
|
2057
2061
|
if (messages.length === 0)
|
|
2058
2062
|
return;
|
|
2059
|
-
// Local first
|
|
2060
|
-
for (const msg of messages) {
|
|
2061
|
-
this.db.deleteMessage(accountId, msg.uid);
|
|
2062
|
-
}
|
|
2063
|
-
console.log(` Moved ${messages.length} messages locally (→ folder ${targetFolderId})`);
|
|
2064
|
-
// Queue IMAP actions
|
|
2065
2063
|
for (const msg of messages) {
|
|
2066
2064
|
this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
|
|
2067
2065
|
}
|
|
2068
|
-
|
|
2069
|
-
// badge updates immediately.
|
|
2070
|
-
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
2071
|
-
for (const fid of sourceFolderIds)
|
|
2072
|
-
this.db.recalcFolderCounts(fid);
|
|
2073
|
-
this.db.recalcFolderCounts(targetFolderId);
|
|
2074
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
2075
|
-
// Process all queued actions in one IMAP session
|
|
2066
|
+
console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) → folder ${targetFolderId}`);
|
|
2076
2067
|
this.debounceSyncActions(accountId);
|
|
2077
2068
|
}
|
|
2078
2069
|
/** Debounced sync actions — batches rapid local changes into one IMAP operation */
|
|
@@ -2092,25 +2083,25 @@ export class ImapManager extends EventEmitter {
|
|
|
2092
2083
|
// Local first — remove from DB immediately
|
|
2093
2084
|
this.db.deleteMessage(accountId, uid);
|
|
2094
2085
|
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
2095
|
-
|
|
2096
|
-
//
|
|
2086
|
+
// Queue IMAP action + log the resolution so "I deleted a message and
|
|
2087
|
+
// now it's in neither trash nor deleted" is diagnosable from the log.
|
|
2097
2088
|
if (trash && trash.id !== folderId) {
|
|
2089
|
+
const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
|
|
2098
2090
|
this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
|
|
2091
|
+
console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
|
|
2099
2092
|
}
|
|
2100
2093
|
else {
|
|
2101
2094
|
this.db.queueSyncAction(accountId, "delete", uid, folderId);
|
|
2095
|
+
console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
|
|
2102
2096
|
}
|
|
2103
2097
|
// Debounced sync — batches multiple deletes into one IMAP session
|
|
2104
2098
|
this.debounceSyncActions(accountId);
|
|
2105
2099
|
}
|
|
2106
|
-
/** Move a message between folders —
|
|
2100
|
+
/** Move a message between folders — queues IMAP sync only. Service
|
|
2101
|
+
* layer owns the local DB update (see MailxService.moveMessage). */
|
|
2107
2102
|
async moveMessage(accountId, uid, fromFolderId, toFolderId) {
|
|
2108
|
-
// Local first
|
|
2109
|
-
this.db.deleteMessage(accountId, uid);
|
|
2110
|
-
console.log(` Moved UID ${uid} locally (folder ${fromFolderId} → ${toFolderId})`);
|
|
2111
|
-
// Queue IMAP action
|
|
2112
2103
|
this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
|
|
2113
|
-
|
|
2104
|
+
console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} → ${toFolderId}`);
|
|
2114
2105
|
this.debounceSyncActions(accountId);
|
|
2115
2106
|
}
|
|
2116
2107
|
/** Move message across accounts using iflow's moveMessageToServer */
|
|
@@ -2221,13 +2212,26 @@ export class ImapManager extends EventEmitter {
|
|
|
2221
2212
|
break;
|
|
2222
2213
|
case "move": {
|
|
2223
2214
|
const target = folders.find(f => f.id === action.targetFolderId);
|
|
2224
|
-
if (target) {
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2215
|
+
if (!target) {
|
|
2216
|
+
// Target folder gone — treat as permanent failure so the
|
|
2217
|
+
// action doesn't loop forever. User must re-delete manually.
|
|
2218
|
+
console.error(` [sync] Move target folder ${action.targetFolderId} missing — dropping action UID ${action.uid}`);
|
|
2219
|
+
throw new Error(`move target folder ${action.targetFolderId} not found`);
|
|
2220
|
+
}
|
|
2221
|
+
const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
|
|
2222
|
+
if (!msg) {
|
|
2223
|
+
// Message no longer in source folder. Two real cases:
|
|
2224
|
+
// (a) another client already moved/deleted it — nothing to do,
|
|
2225
|
+
// just mark the action done.
|
|
2226
|
+
// (b) the server is lying (transient SELECT miss) — the retry
|
|
2227
|
+
// will pick it up. We can't tell these apart from one fetch,
|
|
2228
|
+
// so log loud and treat as (a) after the first failure; the
|
|
2229
|
+
// attempts counter handles (b) via the failSyncAction path.
|
|
2230
|
+
console.log(` [sync] Move UID ${action.uid} in ${folder.path}: message gone (attempt ${action.attempts + 1}); dropping action`);
|
|
2231
|
+
break;
|
|
2230
2232
|
}
|
|
2233
|
+
await client.moveMessage(msg, folder.path, target.path);
|
|
2234
|
+
console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
|
|
2231
2235
|
break;
|
|
2232
2236
|
}
|
|
2233
2237
|
case "flags":
|
|
@@ -2409,13 +2413,23 @@ export class ImapManager extends EventEmitter {
|
|
|
2409
2413
|
catch { /* ignore */ }
|
|
2410
2414
|
}
|
|
2411
2415
|
}
|
|
2412
|
-
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP
|
|
2416
|
+
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
|
|
2417
|
+
* Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
|
|
2418
|
+
* kick processLocalQueue. The file IS the queue — durable across crashes,
|
|
2419
|
+
* visible in the filesystem, consumed by the existing outbox worker that
|
|
2420
|
+
* handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
|
|
2421
|
+
* sync_actions "send" branch was removed because it duplicated the same
|
|
2422
|
+
* work and risked double-send when both paths fired on the same message. */
|
|
2413
2423
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2424
|
+
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
2425
|
+
fs.mkdirSync(outboxDir, { recursive: true });
|
|
2426
|
+
const now = new Date();
|
|
2427
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
2428
|
+
const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
|
|
2429
|
+
const filePath = path.join(outboxDir, filename);
|
|
2430
|
+
fs.writeFileSync(filePath, rawMessage);
|
|
2431
|
+
console.log(` [outbox] Queued ${filePath}`);
|
|
2432
|
+
this.processLocalQueue(accountId).catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`));
|
|
2419
2433
|
}
|
|
2420
2434
|
/** Guard against concurrent processSendActions for the same account */
|
|
2421
2435
|
sendingAccounts = new Set();
|