@bobfrankston/mailx 1.0.44 → 1.0.46
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/bin/mailx.js +142 -5
- package/client/app.js +5 -20
- package/client/components/folder-tree.js +8 -26
- package/client/components/message-list.js +2 -6
- package/client/components/message-viewer.js +4 -16
- package/client/compose/compose.js +6 -7
- package/client/lib/api-client.js +75 -0
- package/package.json +2 -2
package/bin/mailx.js
CHANGED
|
@@ -27,9 +27,10 @@ const verbose = hasFlag("verbose");
|
|
|
27
27
|
|
|
28
28
|
const setupMode = hasFlag("setup");
|
|
29
29
|
const addMode = hasFlag("add");
|
|
30
|
+
const testMode = hasFlag("test");
|
|
30
31
|
|
|
31
32
|
// Validate arguments
|
|
32
|
-
const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add"];
|
|
33
|
+
const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test"];
|
|
33
34
|
for (const arg of args) {
|
|
34
35
|
const flag = arg.replace(/^--?/, "");
|
|
35
36
|
if (arg.startsWith("-") && !knownFlags.includes(flag)) {
|
|
@@ -291,21 +292,60 @@ async function runSetup() {
|
|
|
291
292
|
sync: { intervalMinutes: 5, historyDays: 0 },
|
|
292
293
|
};
|
|
293
294
|
|
|
294
|
-
|
|
295
|
+
const domain = account.email.split("@")[1]?.toLowerCase() || "";
|
|
296
|
+
const isGmail = domain === "gmail.com" || domain === "googlemail.com";
|
|
297
|
+
|
|
298
|
+
// Ask where to store settings
|
|
295
299
|
const mountedDrive = findMountedDrive();
|
|
300
|
+
let storageChoice = "local";
|
|
301
|
+
|
|
296
302
|
if (mountedDrive) {
|
|
303
|
+
const useCloud = await prompt(`Store settings on ${mountedDrive.provider} (syncs across machines)? [Y/n]: `);
|
|
304
|
+
if (!useCloud || useCloud.toLowerCase() !== "n") storageChoice = mountedDrive.provider;
|
|
305
|
+
} else if (isGmail) {
|
|
306
|
+
const useGDrive = await prompt("Store settings on Google Drive (syncs across machines, editable via drive.google.com)? [Y/n]: ");
|
|
307
|
+
if (!useGDrive || useGDrive.toLowerCase() !== "n") storageChoice = "gdrive-api";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
311
|
+
|
|
312
|
+
if (storageChoice === "gdrive-api") {
|
|
313
|
+
// Save to Google Drive via API (not mounted)
|
|
314
|
+
console.log("\nSaving settings to Google Drive via API...");
|
|
315
|
+
try {
|
|
316
|
+
const { getCloudProvider } = await import("../packages/mailx-settings/cloud.js");
|
|
317
|
+
const gdrive = getCloudProvider("gdrive");
|
|
318
|
+
if (gdrive) {
|
|
319
|
+
const content = JSON.stringify(settings, null, 2);
|
|
320
|
+
const ok = await gdrive.write("home/.mailx/settings.jsonc", content);
|
|
321
|
+
if (ok) {
|
|
322
|
+
console.log("Settings saved to Google Drive: home/.mailx/settings.jsonc");
|
|
323
|
+
const config = { sharedDir: { provider: "gdrive", path: "home/.mailx" } };
|
|
324
|
+
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
325
|
+
console.log("Local config created pointing to Google Drive.");
|
|
326
|
+
} else {
|
|
327
|
+
console.log("Google Drive write failed — saving locally instead.");
|
|
328
|
+
fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
console.log("Google Drive API not available — saving locally.");
|
|
332
|
+
fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
|
|
333
|
+
}
|
|
334
|
+
} catch (e) {
|
|
335
|
+
console.log(`Google Drive error: ${e.message} — saving locally.`);
|
|
336
|
+
fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
|
|
337
|
+
}
|
|
338
|
+
} else if (storageChoice !== "local" && mountedDrive) {
|
|
339
|
+
// Save to mounted cloud drive
|
|
297
340
|
console.log(`\nSaving settings to ${mountedDrive.provider} at ${mountedDrive.dir}...`);
|
|
298
341
|
fs.mkdirSync(mountedDrive.dir, { recursive: true });
|
|
299
342
|
fs.writeFileSync(path.join(mountedDrive.dir, "settings.jsonc"), JSON.stringify(settings, null, 2));
|
|
300
|
-
// Create local config pointing to cloud
|
|
301
|
-
fs.mkdirSync(mailxDir, { recursive: true });
|
|
302
343
|
const config = { sharedDir: { provider: mountedDrive.provider, path: "home/.mailx" } };
|
|
303
344
|
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
304
345
|
console.log("Settings saved to cloud drive + local config created.");
|
|
305
346
|
} else {
|
|
306
347
|
// Save locally
|
|
307
348
|
console.log(`\nSaving settings to ${mailxDir}...`);
|
|
308
|
-
fs.mkdirSync(mailxDir, { recursive: true });
|
|
309
349
|
fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
|
|
310
350
|
}
|
|
311
351
|
|
|
@@ -330,11 +370,108 @@ function findMountedDrive() {
|
|
|
330
370
|
return null;
|
|
331
371
|
}
|
|
332
372
|
|
|
373
|
+
/** Test account connectivity — IMAP connect, SMTP send, sync round-trip */
|
|
374
|
+
async function runTest() {
|
|
375
|
+
console.log("\nmailx — connection test\n");
|
|
376
|
+
|
|
377
|
+
// Start server in-process to access settings
|
|
378
|
+
console.log("Loading settings...");
|
|
379
|
+
const { loadSettings, getSharedDir } = await import("../packages/mailx-settings/index.js");
|
|
380
|
+
const { initLocalConfig } = await import("../packages/mailx-settings/index.js");
|
|
381
|
+
initLocalConfig();
|
|
382
|
+
const settings = loadSettings();
|
|
383
|
+
|
|
384
|
+
if (settings.accounts.length === 0) {
|
|
385
|
+
console.log("No accounts configured. Run: mailx -setup");
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
console.log(`Shared dir: ${getSharedDir()}`);
|
|
390
|
+
console.log(`Accounts: ${settings.accounts.map(a => `${a.label || a.name} <${a.email}>`).join(", ")}\n`);
|
|
391
|
+
|
|
392
|
+
for (const account of settings.accounts) {
|
|
393
|
+
if (!account.enabled) { console.log(` ${account.label || account.id}: SKIPPED (disabled)\n`); continue; }
|
|
394
|
+
|
|
395
|
+
console.log(`Testing ${account.label || account.id} (${account.email}):`);
|
|
396
|
+
|
|
397
|
+
// Test IMAP
|
|
398
|
+
try {
|
|
399
|
+
const { ImapClient, createAutoImapConfig } = await import("@bobfrankston/iflow");
|
|
400
|
+
const config = createAutoImapConfig({
|
|
401
|
+
server: account.imap.host,
|
|
402
|
+
port: account.imap.port,
|
|
403
|
+
username: account.imap.user,
|
|
404
|
+
password: account.imap.password
|
|
405
|
+
});
|
|
406
|
+
const client = new ImapClient(config);
|
|
407
|
+
const folders = await client.getFolderList();
|
|
408
|
+
await client.logout();
|
|
409
|
+
console.log(` IMAP: OK (${folders.length} folders)`);
|
|
410
|
+
} catch (e) {
|
|
411
|
+
console.log(` IMAP: FAILED — ${e.message}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Test SMTP
|
|
415
|
+
try {
|
|
416
|
+
const { createTransport } = await import("nodemailer");
|
|
417
|
+
let smtpAuth;
|
|
418
|
+
if (account.smtp.auth === "password") {
|
|
419
|
+
smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
|
|
420
|
+
} else if (account.smtp.auth === "oauth2") {
|
|
421
|
+
// Try to get OAuth token
|
|
422
|
+
const { ImapClient, createAutoImapConfig } = await import("@bobfrankston/iflow");
|
|
423
|
+
const config = createAutoImapConfig({
|
|
424
|
+
server: account.imap.host,
|
|
425
|
+
port: account.imap.port,
|
|
426
|
+
username: account.imap.user,
|
|
427
|
+
});
|
|
428
|
+
if (config.tokenProvider) {
|
|
429
|
+
const accessToken = await config.tokenProvider();
|
|
430
|
+
smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const transport = createTransport({
|
|
434
|
+
host: account.smtp.host,
|
|
435
|
+
port: account.smtp.port,
|
|
436
|
+
secure: account.smtp.port === 465,
|
|
437
|
+
auth: smtpAuth,
|
|
438
|
+
tls: { rejectUnauthorized: false },
|
|
439
|
+
});
|
|
440
|
+
await transport.verify();
|
|
441
|
+
console.log(` SMTP: OK`);
|
|
442
|
+
|
|
443
|
+
// Send test message to self
|
|
444
|
+
const testSubject = `mailx test — ${new Date().toLocaleString()}`;
|
|
445
|
+
await transport.sendMail({
|
|
446
|
+
from: `${account.name} <${account.email}>`,
|
|
447
|
+
to: account.email,
|
|
448
|
+
subject: testSubject,
|
|
449
|
+
text: `This is a test message from mailx -test.\nSent: ${new Date().toISOString()}\nAccount: ${account.id}`,
|
|
450
|
+
});
|
|
451
|
+
console.log(` SEND: OK — test message sent to ${account.email}`);
|
|
452
|
+
console.log(` Subject: "${testSubject}"`);
|
|
453
|
+
} catch (e) {
|
|
454
|
+
console.log(` SMTP: FAILED — ${e.message}`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
console.log();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
console.log("Test complete. Check your inbox for the test message(s).");
|
|
461
|
+
process.exit(0);
|
|
462
|
+
}
|
|
463
|
+
|
|
333
464
|
async function main() {
|
|
334
465
|
log(`Platform: ${process.platform} ${process.arch}`);
|
|
335
466
|
log(`Node: ${process.version}`);
|
|
336
467
|
log(`Mode: ${serverMode ? "server" : setupMode ? "setup" : "auto-detect"}`);
|
|
337
468
|
|
|
469
|
+
// Test connectivity
|
|
470
|
+
if (testMode) {
|
|
471
|
+
await runTest();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
338
475
|
// Add account to existing config
|
|
339
476
|
if (addMode) {
|
|
340
477
|
const account = await promptForAccount();
|
package/client/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
|
|
8
|
-
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, getSyncPending } from "./lib/api-client.js";
|
|
9
9
|
// ── New message badge (favicon + title) ──
|
|
10
10
|
let baseTitle = "mailx";
|
|
11
11
|
let lastSeenCount = 0;
|
|
@@ -180,7 +180,7 @@ document.getElementById("btn-restart")?.addEventListener("click", async () => {
|
|
|
180
180
|
if (statusSync)
|
|
181
181
|
statusSync.textContent = "Restarting...";
|
|
182
182
|
try {
|
|
183
|
-
await
|
|
183
|
+
await restartServer();
|
|
184
184
|
}
|
|
185
185
|
catch { /* server is shutting down */ }
|
|
186
186
|
// Server broadcasts reload event; if missed, WebSocket reconnect will trigger page reload
|
|
@@ -247,11 +247,7 @@ async function deleteCurrentMessage() {
|
|
|
247
247
|
return;
|
|
248
248
|
const { accountId, message } = current;
|
|
249
249
|
try {
|
|
250
|
-
|
|
251
|
-
if (!res.ok) {
|
|
252
|
-
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
253
|
-
throw new Error(err.error || res.statusText);
|
|
254
|
-
}
|
|
250
|
+
await deleteMessage(accountId, message.uid);
|
|
255
251
|
lastDeleted = { accountId, uid: message.uid, folderId: message.folderId, subject: message.subject };
|
|
256
252
|
// Show undo notification in status bar
|
|
257
253
|
const statusSync = document.getElementById("status-sync");
|
|
@@ -296,15 +292,7 @@ async function undoDelete() {
|
|
|
296
292
|
return;
|
|
297
293
|
const { accountId, uid, folderId } = lastDeleted;
|
|
298
294
|
try {
|
|
299
|
-
|
|
300
|
-
method: "POST",
|
|
301
|
-
headers: { "Content-Type": "application/json" },
|
|
302
|
-
body: JSON.stringify({ folderId }),
|
|
303
|
-
});
|
|
304
|
-
if (!res.ok) {
|
|
305
|
-
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
306
|
-
throw new Error(err.error || res.statusText);
|
|
307
|
-
}
|
|
295
|
+
await undeleteMessage(accountId, uid, folderId);
|
|
308
296
|
const statusSync = document.getElementById("status-sync");
|
|
309
297
|
if (statusSync)
|
|
310
298
|
statusSync.textContent = "Message restored";
|
|
@@ -638,10 +626,7 @@ fetch("/api/version").then(r => r.json()).then(d => {
|
|
|
638
626
|
let serverDown = false;
|
|
639
627
|
setInterval(async () => {
|
|
640
628
|
try {
|
|
641
|
-
const
|
|
642
|
-
if (!res.ok)
|
|
643
|
-
return;
|
|
644
|
-
const data = await res.json();
|
|
629
|
+
const data = await getSyncPending();
|
|
645
630
|
const el = document.getElementById("status-pending");
|
|
646
631
|
if (el) {
|
|
647
632
|
el.textContent = data.pending > 0 ? `↻ ${data.pending} pending` : "";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Folder tree component -- renders account folders with hierarchy,
|
|
3
3
|
* expand/collapse, and optional unified inbox.
|
|
4
4
|
*/
|
|
5
|
-
import { getAccounts, getFolders } from "../lib/api-client.js";
|
|
5
|
+
import { getAccounts, getFolders, moveMessage, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
let onFolderSelect;
|
|
8
8
|
let onUnifiedInbox = null;
|
|
@@ -166,7 +166,7 @@ function renderNode(node, container, depth) {
|
|
|
166
166
|
const items = [
|
|
167
167
|
{ label: "Mark all read", action: async () => {
|
|
168
168
|
try {
|
|
169
|
-
await
|
|
169
|
+
await markFolderRead(node.accountId, node.id);
|
|
170
170
|
const treeContainer = document.getElementById("folder-tree");
|
|
171
171
|
if (treeContainer)
|
|
172
172
|
loadFolderTree(treeContainer);
|
|
@@ -179,11 +179,7 @@ function renderNode(node, container, depth) {
|
|
|
179
179
|
if (!name)
|
|
180
180
|
return;
|
|
181
181
|
try {
|
|
182
|
-
await
|
|
183
|
-
method: "POST",
|
|
184
|
-
headers: { "Content-Type": "application/json" },
|
|
185
|
-
body: JSON.stringify({ parentPath: node.path, name }),
|
|
186
|
-
});
|
|
182
|
+
await createFolder(node.accountId, node.path, name);
|
|
187
183
|
const treeContainer = document.getElementById("folder-tree");
|
|
188
184
|
if (treeContainer)
|
|
189
185
|
loadFolderTree(treeContainer);
|
|
@@ -197,11 +193,7 @@ function renderNode(node, container, depth) {
|
|
|
197
193
|
if (!newName || newName === node.name)
|
|
198
194
|
return;
|
|
199
195
|
try {
|
|
200
|
-
await
|
|
201
|
-
method: "POST",
|
|
202
|
-
headers: { "Content-Type": "application/json" },
|
|
203
|
-
body: JSON.stringify({ newName }),
|
|
204
|
-
});
|
|
196
|
+
await renameFolder(node.accountId, node.id, newName);
|
|
205
197
|
const treeContainer = document.getElementById("folder-tree");
|
|
206
198
|
if (treeContainer)
|
|
207
199
|
loadFolderTree(treeContainer);
|
|
@@ -214,7 +206,7 @@ function renderNode(node, container, depth) {
|
|
|
214
206
|
if (!confirm(`Delete folder "${node.name}"? Messages will be moved to Trash.`))
|
|
215
207
|
return;
|
|
216
208
|
try {
|
|
217
|
-
await
|
|
209
|
+
await deleteFolder(node.accountId, node.id);
|
|
218
210
|
const treeContainer = document.getElementById("folder-tree");
|
|
219
211
|
if (treeContainer)
|
|
220
212
|
loadFolderTree(treeContainer);
|
|
@@ -230,7 +222,7 @@ function renderNode(node, container, depth) {
|
|
|
230
222
|
if (!confirm(`Permanently delete all messages in "${node.name}"?`))
|
|
231
223
|
return;
|
|
232
224
|
try {
|
|
233
|
-
await
|
|
225
|
+
await emptyFolder(node.accountId, node.id);
|
|
234
226
|
const treeContainer = document.getElementById("folder-tree");
|
|
235
227
|
if (treeContainer)
|
|
236
228
|
loadFolderTree(treeContainer);
|
|
@@ -267,18 +259,8 @@ function renderNode(node, container, depth) {
|
|
|
267
259
|
try {
|
|
268
260
|
let moved = 0;
|
|
269
261
|
for (const msg of toMove) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
body.targetAccountId = node.accountId;
|
|
273
|
-
const res = await fetch(`/api/message/${msg.accountId}/${msg.uid}/move`, {
|
|
274
|
-
method: "POST",
|
|
275
|
-
headers: { "Content-Type": "application/json" },
|
|
276
|
-
body: JSON.stringify(body),
|
|
277
|
-
});
|
|
278
|
-
if (!res.ok) {
|
|
279
|
-
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
280
|
-
throw new Error(err.error);
|
|
281
|
-
}
|
|
262
|
+
const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;
|
|
263
|
+
await moveMessage(msg.accountId, msg.uid, node.id, targetAccountId);
|
|
282
264
|
moved++;
|
|
283
265
|
}
|
|
284
266
|
if (statusEl)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message list component -- renders paginated message rows.
|
|
3
3
|
* Loads more messages on scroll.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessages, getUnifiedInbox, searchMessages } from "../lib/api-client.js";
|
|
5
|
+
import { getMessages, getUnifiedInbox, searchMessages, updateFlags } from "../lib/api-client.js";
|
|
6
6
|
/** Clear the message viewer when no message is selected */
|
|
7
7
|
function clearViewer() {
|
|
8
8
|
const bodyEl = document.getElementById("mv-body");
|
|
@@ -302,11 +302,7 @@ function appendMessages(body, accountId, items) {
|
|
|
302
302
|
? currentFlags.filter((f) => f !== "\\Flagged")
|
|
303
303
|
: [...currentFlags, "\\Flagged"];
|
|
304
304
|
try {
|
|
305
|
-
await
|
|
306
|
-
method: "PATCH",
|
|
307
|
-
headers: { "Content-Type": "application/json" },
|
|
308
|
-
body: JSON.stringify({ flags: newFlags }),
|
|
309
|
-
});
|
|
305
|
+
await updateFlags(msgAccountId, msg.uid, newFlags);
|
|
310
306
|
msg.flags = newFlags;
|
|
311
307
|
row.classList.toggle("flagged");
|
|
312
308
|
flag.textContent = row.classList.contains("flagged") ? "\u2605" : "\u2606";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Message viewer component -- displays full message in sandboxed iframe.
|
|
3
3
|
*/
|
|
4
|
-
import { getMessage, updateFlags } from "../lib/api-client.js";
|
|
4
|
+
import { getMessage, updateFlags, allowRemoteContent } from "../lib/api-client.js";
|
|
5
5
|
/** Currently displayed message (for reply/forward) */
|
|
6
6
|
let currentMessage = null;
|
|
7
7
|
let currentAccountId = "";
|
|
@@ -189,30 +189,18 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
189
189
|
};
|
|
190
190
|
banner.querySelector("#btn-load-remote").addEventListener("click", loadRemote);
|
|
191
191
|
banner.querySelector("#btn-allow-sender")?.addEventListener("click", async () => {
|
|
192
|
-
await
|
|
193
|
-
method: "POST",
|
|
194
|
-
headers: { "Content-Type": "application/json" },
|
|
195
|
-
body: JSON.stringify({ type: "sender", value: senderAddr }),
|
|
196
|
-
});
|
|
192
|
+
await allowRemoteContent("sender", senderAddr);
|
|
197
193
|
loadRemote();
|
|
198
194
|
});
|
|
199
195
|
banner.querySelector("#btn-allow-domain")?.addEventListener("click", async () => {
|
|
200
|
-
await
|
|
201
|
-
method: "POST",
|
|
202
|
-
headers: { "Content-Type": "application/json" },
|
|
203
|
-
body: JSON.stringify({ type: "domain", value: senderDomain }),
|
|
204
|
-
});
|
|
196
|
+
await allowRemoteContent("domain", senderDomain);
|
|
205
197
|
loadRemote();
|
|
206
198
|
});
|
|
207
199
|
banner.querySelector("#btn-allow-to")?.addEventListener("click", async () => {
|
|
208
200
|
const addr = deliveredTo || toAddr;
|
|
209
201
|
if (!addr)
|
|
210
202
|
return;
|
|
211
|
-
await
|
|
212
|
-
method: "POST",
|
|
213
|
-
headers: { "Content-Type": "application/json" },
|
|
214
|
-
body: JSON.stringify({ type: "recipient", value: addr }),
|
|
215
|
-
});
|
|
203
|
+
await allowRemoteContent("recipient", addr);
|
|
216
204
|
loadRemote();
|
|
217
205
|
});
|
|
218
206
|
}
|
|
@@ -17,6 +17,8 @@ const editor = new Quill("#compose-editor", {
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
});
|
|
20
|
+
// Make toolbar buttons non-tabbable so Tab goes straight to editor body
|
|
21
|
+
document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
|
|
20
22
|
// ── Populate from init data ──
|
|
21
23
|
const fromSelect = document.getElementById("compose-from-select");
|
|
22
24
|
const fromCustom = document.getElementById("compose-from-custom");
|
|
@@ -259,7 +261,7 @@ async function saveDraft() {
|
|
|
259
261
|
return; // empty
|
|
260
262
|
lastDraftContent = content;
|
|
261
263
|
try {
|
|
262
|
-
const
|
|
264
|
+
const data = await fetch("/api/draft", {
|
|
263
265
|
method: "POST",
|
|
264
266
|
headers: { "Content-Type": "application/json" },
|
|
265
267
|
body: JSON.stringify({
|
|
@@ -271,12 +273,9 @@ async function saveDraft() {
|
|
|
271
273
|
cc: ccInput.value,
|
|
272
274
|
previousDraftUid: draftUid,
|
|
273
275
|
}),
|
|
274
|
-
});
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
if (data.draftUid)
|
|
278
|
-
draftUid = data.draftUid;
|
|
279
|
-
}
|
|
276
|
+
}).then(r => r.ok ? r.json() : null);
|
|
277
|
+
if (data?.draftUid)
|
|
278
|
+
draftUid = data.draftUid;
|
|
280
279
|
}
|
|
281
280
|
catch { /* ignore draft save errors */ }
|
|
282
281
|
}
|
package/client/lib/api-client.js
CHANGED
|
@@ -122,6 +122,81 @@ export function allowRemoteContent(type, value) {
|
|
|
122
122
|
body: JSON.stringify({ type, value })
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
|
+
// ── Message actions ──
|
|
126
|
+
// IMPORTANT: All server operations MUST go through these centralized methods
|
|
127
|
+
// so IPC mode works. Never use fetch("/api/...") directly in components.
|
|
128
|
+
export function deleteMessage(accountId, uid) {
|
|
129
|
+
if (hasIPC)
|
|
130
|
+
return mailxapi.deleteMessage(accountId, uid);
|
|
131
|
+
return api(`/message/${accountId}/${uid}`, { method: "DELETE" });
|
|
132
|
+
}
|
|
133
|
+
export function undeleteMessage(accountId, uid, folderId) {
|
|
134
|
+
if (hasIPC)
|
|
135
|
+
return mailxapi.undeleteMessage(accountId, uid, folderId);
|
|
136
|
+
return api(`/message/${accountId}/${uid}/undelete`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: JSON.stringify({ folderId })
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export function moveMessage(accountId, uid, targetFolderId, targetAccountId) {
|
|
142
|
+
if (hasIPC)
|
|
143
|
+
return mailxapi.moveMessage(accountId, uid, targetFolderId, targetAccountId);
|
|
144
|
+
const body = { targetFolderId };
|
|
145
|
+
if (targetAccountId)
|
|
146
|
+
body.targetAccountId = targetAccountId;
|
|
147
|
+
return api(`/message/${accountId}/${uid}/move`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
body: JSON.stringify(body)
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function restartServer() {
|
|
153
|
+
if (hasIPC)
|
|
154
|
+
return mailxapi.restart?.();
|
|
155
|
+
return api("/restart", { method: "POST" }).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
// ── Folder management ──
|
|
158
|
+
export function markFolderRead(accountId, folderId) {
|
|
159
|
+
if (hasIPC)
|
|
160
|
+
return mailxapi.markFolderRead?.(accountId, folderId);
|
|
161
|
+
return api(`/folder/${accountId}/${folderId}/mark-read`, { method: "POST" });
|
|
162
|
+
}
|
|
163
|
+
export function createFolder(accountId, parentPath, name) {
|
|
164
|
+
if (hasIPC)
|
|
165
|
+
return mailxapi.createFolder?.(accountId, parentPath, name);
|
|
166
|
+
return api(`/folder/${accountId}`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body: JSON.stringify({ parentPath, name })
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export function renameFolder(accountId, folderId, newName) {
|
|
172
|
+
if (hasIPC)
|
|
173
|
+
return mailxapi.renameFolder?.(accountId, folderId, newName);
|
|
174
|
+
return api(`/folder/${accountId}/${folderId}/rename`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body: JSON.stringify({ newName })
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
export function deleteFolder(accountId, folderId) {
|
|
180
|
+
if (hasIPC)
|
|
181
|
+
return mailxapi.deleteFolder?.(accountId, folderId);
|
|
182
|
+
return api(`/folder/${accountId}/${folderId}`, { method: "DELETE" });
|
|
183
|
+
}
|
|
184
|
+
export function emptyFolder(accountId, folderId) {
|
|
185
|
+
if (hasIPC)
|
|
186
|
+
return mailxapi.emptyFolder?.(accountId, folderId);
|
|
187
|
+
return api(`/folder/${accountId}/${folderId}/empty`, { method: "POST" });
|
|
188
|
+
}
|
|
189
|
+
// ── Compose ──
|
|
190
|
+
export function sendMessage(body) {
|
|
191
|
+
if (hasIPC)
|
|
192
|
+
return mailxapi.sendMessage?.(body);
|
|
193
|
+
return api("/send", { method: "POST", body: JSON.stringify(body) });
|
|
194
|
+
}
|
|
195
|
+
export function saveDraft(body) {
|
|
196
|
+
if (hasIPC)
|
|
197
|
+
return mailxapi.saveDraft?.(body);
|
|
198
|
+
return api("/draft", { method: "POST", body: JSON.stringify(body) });
|
|
199
|
+
}
|
|
125
200
|
const eventHandlers = [];
|
|
126
201
|
export function onEvent(handler) {
|
|
127
202
|
eventHandlers.push(handler);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.46",
|
|
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,7 +20,7 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.26",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.11",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.2",
|