@bobfrankston/mailx 1.0.185 → 1.0.187
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/.msger-window.json +1 -1
- package/client/android.html +2 -1
- package/client/app.js +79 -11
- package/client/components/folder-tree.js +62 -3
- package/client/components/message-list.js +66 -0
- package/client/components/message-viewer.js +17 -9
- package/client/compose/compose.js +2 -7
- package/client/lib/api-client.js +21 -0
- package/client/lib/mailxapi.js +5 -0
- package/package.json +2 -2
- package/packages/mailx-service/jsonrpc.js +5 -0
- package/packages/mailx-store-web/android-bootstrap.js +190 -3
- package/packages/mailx-store-web/sql-wasm-esm.js +10 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":
|
|
1
|
+
{"height":1344,"width":2151,"x":626,"y":45}
|
package/client/android.html
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"@bobfrankston/mailx-store-web": "../packages/mailx-store-web/index.js",
|
|
16
16
|
"@bobfrankston/mailx-store-web/": "../packages/mailx-store-web/",
|
|
17
17
|
"@bobfrankston/mailx-types": "../packages/mailx-types/index.js",
|
|
18
|
-
"sql.js": "../
|
|
18
|
+
"sql.js": "../packages/mailx-store-web/sql-wasm-esm.js"
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
</script>
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
<div class="tb-menu-dropdown" id="restart-dropdown" hidden>
|
|
91
91
|
<button class="tb-menu-item" id="btn-restart-quick" title="Reload the page">Reload</button>
|
|
92
92
|
<button class="tb-menu-item" id="btn-rebuild" title="Wipe local cache and re-sync">Reset local store</button>
|
|
93
|
+
<button class="tb-menu-item" id="btn-factory-reset" title="Delete everything — accounts, settings, cache. Back to first-run.">Factory reset</button>
|
|
93
94
|
</div>
|
|
94
95
|
</div>
|
|
95
96
|
</div>
|
package/client/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
|
|
8
|
-
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags } from "./lib/api-client.js";
|
|
9
9
|
import * as messageState from "./lib/message-state.js";
|
|
10
10
|
// ── New message badge (favicon + title) ──
|
|
11
11
|
let baseTitle = "mailx";
|
|
@@ -146,6 +146,22 @@ initMessageList((accountId, uid, folderId) => {
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
initViewer();
|
|
149
|
+
// Status bar: show selected message UID/folder for debugging
|
|
150
|
+
messageState.subscribe((change) => {
|
|
151
|
+
if (change === "selected" || change === "removed") {
|
|
152
|
+
const acctEl = document.getElementById("status-accounts");
|
|
153
|
+
if (!acctEl)
|
|
154
|
+
return;
|
|
155
|
+
const sel = messageState.getSelected();
|
|
156
|
+
if (sel) {
|
|
157
|
+
acctEl.textContent = `${sel.accountId}/uid:${sel.uid} folder:${sel.folderId}`;
|
|
158
|
+
acctEl.style.color = "";
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
acctEl.textContent = "";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
149
165
|
// ── Auto two-line when message list is narrow ──
|
|
150
166
|
const messageList = document.getElementById("message-list");
|
|
151
167
|
if (messageList) {
|
|
@@ -247,6 +263,26 @@ document.getElementById("btn-rebuild")?.addEventListener("click", async () => {
|
|
|
247
263
|
}
|
|
248
264
|
catch { /* server is shutting down */ }
|
|
249
265
|
});
|
|
266
|
+
document.getElementById("btn-factory-reset")?.addEventListener("click", async () => {
|
|
267
|
+
if (restartDropdown)
|
|
268
|
+
restartDropdown.hidden = true;
|
|
269
|
+
if (!confirm("Factory reset?\n\nThis deletes ALL data — accounts, settings, messages, cache.\nYou will need to set up your account again."))
|
|
270
|
+
return;
|
|
271
|
+
const ipc = window.mailxapi;
|
|
272
|
+
if (ipc?.resetAll) {
|
|
273
|
+
await ipc.resetAll();
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Fallback: clear IndexedDB + localStorage manually
|
|
277
|
+
const dbs = await indexedDB.databases();
|
|
278
|
+
for (const db of dbs) {
|
|
279
|
+
if (db.name)
|
|
280
|
+
indexedDB.deleteDatabase(db.name);
|
|
281
|
+
}
|
|
282
|
+
localStorage.clear();
|
|
283
|
+
location.reload();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
250
286
|
async function openCompose(mode) {
|
|
251
287
|
const current = getCurrentMessage();
|
|
252
288
|
const accounts = await getAccounts();
|
|
@@ -305,14 +341,10 @@ async function openCompose(mode) {
|
|
|
305
341
|
}
|
|
306
342
|
// Store init data for compose window to pick up
|
|
307
343
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
315
|
-
}
|
|
344
|
+
// Open compose — always as popup via window.open().
|
|
345
|
+
// In IPC mode, msger's new_window_req_handler allows msger.localhost popups.
|
|
346
|
+
// DO NOT use window.location.href — msger exits on navigation.
|
|
347
|
+
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
316
348
|
}
|
|
317
349
|
function quoteBody(msg) {
|
|
318
350
|
const date = new Date(msg.date).toLocaleString();
|
|
@@ -398,6 +430,11 @@ document.getElementById("btn-compose")?.addEventListener("click", () => openComp
|
|
|
398
430
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
399
431
|
document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
|
|
400
432
|
document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
|
|
433
|
+
// Context menu events from message-list right-click
|
|
434
|
+
document.addEventListener("mailx-compose", ((e) => {
|
|
435
|
+
openCompose(e.detail.mode);
|
|
436
|
+
}));
|
|
437
|
+
document.addEventListener("mailx-delete", () => deleteSelectedMessages());
|
|
401
438
|
// ── Search ──
|
|
402
439
|
let searchTimeout;
|
|
403
440
|
const searchInput = document.getElementById("search-input");
|
|
@@ -433,7 +470,16 @@ let currentFolderId = 0;
|
|
|
433
470
|
let reloadDebounceTimer = null;
|
|
434
471
|
searchInput?.addEventListener("input", () => {
|
|
435
472
|
clearTimeout(searchTimeout);
|
|
436
|
-
|
|
473
|
+
if (searchInput.value.trim() === "") {
|
|
474
|
+
// Cleared — reset immediately, no debounce
|
|
475
|
+
const body = document.getElementById("ml-body");
|
|
476
|
+
if (body)
|
|
477
|
+
body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
|
|
478
|
+
reloadCurrentFolder();
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
searchTimeout = setTimeout(() => doSearch(false), 300);
|
|
482
|
+
}
|
|
437
483
|
});
|
|
438
484
|
searchInput?.addEventListener("keydown", (e) => {
|
|
439
485
|
if (e.key === "Enter") {
|
|
@@ -594,7 +640,8 @@ onWsEvent((event) => {
|
|
|
594
640
|
reloadDebounceTimer = null;
|
|
595
641
|
reloadCurrentFolder();
|
|
596
642
|
}, 2000);
|
|
597
|
-
// Sync
|
|
643
|
+
// Sync succeeded — clear any transient error banner and re-enable sync button
|
|
644
|
+
hideAlert();
|
|
598
645
|
const syncBtn = document.getElementById("btn-sync");
|
|
599
646
|
if (syncBtn) {
|
|
600
647
|
syncBtn.disabled = false;
|
|
@@ -735,6 +782,27 @@ document.addEventListener("keydown", (e) => {
|
|
|
735
782
|
e.preventDefault();
|
|
736
783
|
document.getElementById("btn-sync")?.click();
|
|
737
784
|
}
|
|
785
|
+
// R = Toggle read/unread
|
|
786
|
+
if (e.key === "r" && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
787
|
+
const active = document.activeElement;
|
|
788
|
+
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
|
|
789
|
+
return;
|
|
790
|
+
const sel = messageState.getSelected();
|
|
791
|
+
if (!sel)
|
|
792
|
+
return;
|
|
793
|
+
e.preventDefault();
|
|
794
|
+
const isSeen = sel.flags.includes("\\Seen");
|
|
795
|
+
const newFlags = isSeen
|
|
796
|
+
? sel.flags.filter((f) => f !== "\\Seen")
|
|
797
|
+
: [...sel.flags, "\\Seen"];
|
|
798
|
+
updateFlags(sel.accountId, sel.uid, newFlags).then(() => {
|
|
799
|
+
sel.flags = newFlags;
|
|
800
|
+
messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
|
|
801
|
+
const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`);
|
|
802
|
+
if (row)
|
|
803
|
+
row.classList.toggle("unread", !newFlags.includes("\\Seen"));
|
|
804
|
+
}).catch(() => { });
|
|
805
|
+
}
|
|
738
806
|
});
|
|
739
807
|
// ── View menu ──
|
|
740
808
|
const viewBtn = document.getElementById("btn-view");
|
|
@@ -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, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder, setupAccount, getVersion } from "../lib/api-client.js";
|
|
5
|
+
import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder, setupAccount, getDeviceAccounts, getVersion } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
let onFolderSelect;
|
|
8
8
|
let onUnifiedInbox = null;
|
|
@@ -263,15 +263,38 @@ function renderNode(node, container, depth) {
|
|
|
263
263
|
});
|
|
264
264
|
// ── Drop target for message drag-and-drop ──
|
|
265
265
|
if (node.id !== -1) {
|
|
266
|
+
let dragExpandTimer = null;
|
|
266
267
|
folderEl.addEventListener("dragover", (e) => {
|
|
267
268
|
e.preventDefault();
|
|
268
269
|
e.dataTransfer.dropEffect = e.ctrlKey ? "copy" : "move";
|
|
269
270
|
folderEl.classList.add("drop-target");
|
|
270
271
|
});
|
|
271
|
-
folderEl.addEventListener("
|
|
272
|
+
folderEl.addEventListener("dragenter", () => {
|
|
273
|
+
if (hasChildren && !isExpanded && !dragExpandTimer) {
|
|
274
|
+
dragExpandTimer = setTimeout(() => {
|
|
275
|
+
dragExpandTimer = null;
|
|
276
|
+
expandState[expandKey] = true;
|
|
277
|
+
saveExpandState();
|
|
278
|
+
const treeContainer = document.getElementById("folder-tree");
|
|
279
|
+
if (treeContainer)
|
|
280
|
+
loadFolderTree(treeContainer);
|
|
281
|
+
}, 500);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
folderEl.addEventListener("dragleave", () => {
|
|
285
|
+
folderEl.classList.remove("drop-target");
|
|
286
|
+
if (dragExpandTimer) {
|
|
287
|
+
clearTimeout(dragExpandTimer);
|
|
288
|
+
dragExpandTimer = null;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
272
291
|
folderEl.addEventListener("drop", async (e) => {
|
|
273
292
|
e.preventDefault();
|
|
274
293
|
folderEl.classList.remove("drop-target");
|
|
294
|
+
if (dragExpandTimer) {
|
|
295
|
+
clearTimeout(dragExpandTimer);
|
|
296
|
+
dragExpandTimer = null;
|
|
297
|
+
}
|
|
275
298
|
// Multi-message or single-message drop
|
|
276
299
|
const multiData = e.dataTransfer.getData("application/x-mailx-messages");
|
|
277
300
|
const singleData = e.dataTransfer.getData("application/x-mailx-message");
|
|
@@ -357,8 +380,9 @@ async function loadFolderTree(container) {
|
|
|
357
380
|
if (mainBody) {
|
|
358
381
|
mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:500px">
|
|
359
382
|
<h2 style="margin-bottom:1rem">Welcome to mailx</h2>
|
|
383
|
+
<div id="setup-device-accounts"></div>
|
|
360
384
|
<div id="setup-cloud-status"></div>
|
|
361
|
-
<p>Add your email account to get started.</p>
|
|
385
|
+
<p id="setup-form-intro">Add your email account to get started.</p>
|
|
362
386
|
<form id="setup-form" style="margin-top:1rem">
|
|
363
387
|
<label style="display:block;margin-bottom:0.5rem">
|
|
364
388
|
Your name
|
|
@@ -462,6 +486,41 @@ async function loadFolderTree(container) {
|
|
|
462
486
|
</div>`;
|
|
463
487
|
}
|
|
464
488
|
}).catch(() => { });
|
|
489
|
+
// On Android, check for device Google accounts and show picker
|
|
490
|
+
getDeviceAccounts().then(async (deviceAccounts) => {
|
|
491
|
+
const pickerEl = document.getElementById("setup-device-accounts");
|
|
492
|
+
if (!pickerEl || deviceAccounts.length === 0)
|
|
493
|
+
return;
|
|
494
|
+
const formEl = document.getElementById("setup-form");
|
|
495
|
+
const introEl = document.getElementById("setup-form-intro");
|
|
496
|
+
if (introEl)
|
|
497
|
+
introEl.textContent = "Select an account:";
|
|
498
|
+
if (formEl)
|
|
499
|
+
formEl.style.display = "none";
|
|
500
|
+
pickerEl.innerHTML = deviceAccounts.map((a) => `<button class="device-account-btn" data-email="${a.email}" data-name="${a.name}" style="display:block;width:100%;padding:0.75rem 1rem;margin-bottom:0.5rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:6px;cursor:pointer;font-size:1rem;text-align:left">${a.email}</button>`).join("") + `<button id="setup-show-form" style="margin-top:0.5rem;padding:0.5rem 1rem;background:none;color:var(--color-text-muted);border:none;cursor:pointer;font-size:0.9rem">Use a different account...</button>`;
|
|
501
|
+
pickerEl.querySelectorAll(".device-account-btn").forEach((btn) => {
|
|
502
|
+
btn.addEventListener("click", async () => {
|
|
503
|
+
const email = btn.dataset.email || "";
|
|
504
|
+
const name = btn.dataset.name || "";
|
|
505
|
+
pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-text-muted)">Setting up ${email}...</div>`;
|
|
506
|
+
const result = await setupAccount(name, email, "");
|
|
507
|
+
if (result?.ok) {
|
|
508
|
+
pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-accent)">${result.message || "Account added!"}</div>`;
|
|
509
|
+
setTimeout(() => location.reload(), 2000);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
pickerEl.innerHTML = `<div style="padding:0.5rem;color:#f55">${result?.error || "Setup failed"}</div>`;
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
document.getElementById("setup-show-form")?.addEventListener("click", () => {
|
|
517
|
+
pickerEl.style.display = "none";
|
|
518
|
+
if (formEl)
|
|
519
|
+
formEl.style.display = "block";
|
|
520
|
+
if (introEl)
|
|
521
|
+
introEl.textContent = "Add your email account to get started.";
|
|
522
|
+
});
|
|
523
|
+
}).catch(() => { });
|
|
465
524
|
}
|
|
466
525
|
// Dismiss startup overlay
|
|
467
526
|
const overlay = document.getElementById("startup-overlay");
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags } from "../lib/api-client.js";
|
|
6
6
|
import * as state from "../lib/message-state.js";
|
|
7
|
+
import { showContextMenu } from "./context-menu.js";
|
|
7
8
|
let onMessageSelect;
|
|
8
9
|
let currentAccountId;
|
|
9
10
|
let currentFolderId;
|
|
@@ -407,6 +408,71 @@ function appendMessages(body, accountId, items) {
|
|
|
407
408
|
}
|
|
408
409
|
});
|
|
409
410
|
row.addEventListener("dragend", () => row.classList.remove("dragging"));
|
|
411
|
+
// ── Right-click context menu ──
|
|
412
|
+
row.addEventListener("contextmenu", (e) => {
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
// Select row if not already selected
|
|
415
|
+
if (!row.classList.contains("selected")) {
|
|
416
|
+
clearSelection();
|
|
417
|
+
row.classList.add("selected");
|
|
418
|
+
lastClickedRow = row;
|
|
419
|
+
state.select(msg);
|
|
420
|
+
onMessageSelect(msgAccountId, msg.uid, msg.folderId);
|
|
421
|
+
}
|
|
422
|
+
const isSeen = msg.flags.includes("\\Seen");
|
|
423
|
+
const isFlagged = msg.flags.includes("\\Flagged");
|
|
424
|
+
const items = [
|
|
425
|
+
{
|
|
426
|
+
label: isSeen ? "Mark unread" : "Mark read",
|
|
427
|
+
action: async () => {
|
|
428
|
+
const newFlags = isSeen
|
|
429
|
+
? msg.flags.filter((f) => f !== "\\Seen")
|
|
430
|
+
: [...msg.flags, "\\Seen"];
|
|
431
|
+
try {
|
|
432
|
+
await updateFlags(msgAccountId, msg.uid, newFlags);
|
|
433
|
+
msg.flags = newFlags;
|
|
434
|
+
state.updateMessageFlags(msgAccountId, msg.uid, newFlags);
|
|
435
|
+
row.classList.toggle("unread", !newFlags.includes("\\Seen"));
|
|
436
|
+
}
|
|
437
|
+
catch { /* ignore */ }
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
label: isFlagged ? "Unflag" : "Flag",
|
|
442
|
+
action: async () => {
|
|
443
|
+
const newFlags = isFlagged
|
|
444
|
+
? msg.flags.filter((f) => f !== "\\Flagged")
|
|
445
|
+
: [...msg.flags, "\\Flagged"];
|
|
446
|
+
try {
|
|
447
|
+
await updateFlags(msgAccountId, msg.uid, newFlags);
|
|
448
|
+
msg.flags = newFlags;
|
|
449
|
+
row.classList.toggle("flagged");
|
|
450
|
+
flag.textContent = row.classList.contains("flagged") ? "\u2605" : "\u2606";
|
|
451
|
+
}
|
|
452
|
+
catch { /* ignore */ }
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{ label: "", action: () => { }, separator: true },
|
|
456
|
+
{
|
|
457
|
+
label: "Reply",
|
|
458
|
+
action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })),
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
label: "Reply All",
|
|
462
|
+
action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })),
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
label: "Forward",
|
|
466
|
+
action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })),
|
|
467
|
+
},
|
|
468
|
+
{ label: "", action: () => { }, separator: true },
|
|
469
|
+
{
|
|
470
|
+
label: "Delete",
|
|
471
|
+
action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
|
|
472
|
+
},
|
|
473
|
+
];
|
|
474
|
+
showContextMenu(e.clientX, e.clientY, items);
|
|
475
|
+
});
|
|
410
476
|
body.appendChild(row);
|
|
411
477
|
}
|
|
412
478
|
}
|
|
@@ -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 } from "../lib/api-client.js";
|
|
5
|
+
import { getMessage, updateFlags, allowRemoteContent, getAttachment } from "../lib/api-client.js";
|
|
6
6
|
import * as state from "../lib/message-state.js";
|
|
7
7
|
/** Currently displayed message (for reply/forward) */
|
|
8
8
|
let currentMessage = null;
|
|
@@ -144,12 +144,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
144
144
|
draftFolderId: msg.folderId,
|
|
145
145
|
};
|
|
146
146
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
147
|
-
|
|
148
|
-
window.location.href = "compose/compose.html";
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
152
|
-
}
|
|
147
|
+
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
153
148
|
};
|
|
154
149
|
}
|
|
155
150
|
else {
|
|
@@ -280,9 +275,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
280
275
|
const chip = document.createElement("a");
|
|
281
276
|
chip.className = "mv-att-chip";
|
|
282
277
|
chip.textContent = `\uD83D\uDCCE ${att.filename} (${formatSize(att.size)})`;
|
|
283
|
-
chip.href =
|
|
284
|
-
chip.target = "_blank";
|
|
278
|
+
chip.href = "#";
|
|
285
279
|
chip.title = `${att.filename} (${att.mimeType})`;
|
|
280
|
+
chip.addEventListener("click", async (e) => {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
try {
|
|
283
|
+
const data = await getAttachment(accountId, uid, i, msg.folderId);
|
|
284
|
+
// Create blob URL and open
|
|
285
|
+
const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
|
|
286
|
+
const blob = new Blob([bytes], { type: data.contentType });
|
|
287
|
+
const url = URL.createObjectURL(blob);
|
|
288
|
+
window.open(url, "_blank");
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
console.error(`Attachment download failed: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
286
294
|
attEl.appendChild(chip);
|
|
287
295
|
}
|
|
288
296
|
}
|
|
@@ -5,14 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createEditor } from "./editor.js";
|
|
7
7
|
import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
|
|
8
|
-
/** Close compose
|
|
8
|
+
/** Close compose window */
|
|
9
9
|
function closeCompose() {
|
|
10
|
-
|
|
11
|
-
window.location.href = "../index.html";
|
|
12
|
-
}
|
|
13
|
-
else {
|
|
14
|
-
closeCompose();
|
|
15
|
-
}
|
|
10
|
+
window.close();
|
|
16
11
|
}
|
|
17
12
|
// ── Load editor scripts dynamically ──
|
|
18
13
|
function loadScript(src) {
|
package/client/lib/api-client.js
CHANGED
|
@@ -294,6 +294,22 @@ export function saveSettings(settings) {
|
|
|
294
294
|
return getIpc().saveSettingsData?.(settings);
|
|
295
295
|
return api("/settings", { method: "PUT", body: JSON.stringify(settings) });
|
|
296
296
|
}
|
|
297
|
+
export async function getAttachment(accountId, uid, attachmentId, folderId) {
|
|
298
|
+
if (hasIPC())
|
|
299
|
+
return getIpc().getAttachment(accountId, uid, attachmentId, folderId);
|
|
300
|
+
// HTTP mode: fetch as blob
|
|
301
|
+
const params = folderId != null ? `?folderId=${folderId}` : "";
|
|
302
|
+
const res = await fetch(`/api/message/${accountId}/${uid}/attachment/${attachmentId}${params}`);
|
|
303
|
+
if (!res.ok)
|
|
304
|
+
throw new Error("Attachment not found");
|
|
305
|
+
const blob = await res.blob();
|
|
306
|
+
const buf = await blob.arrayBuffer();
|
|
307
|
+
return {
|
|
308
|
+
content: btoa(String.fromCharCode(...new Uint8Array(buf))),
|
|
309
|
+
contentType: res.headers.get("content-type") || "application/octet-stream",
|
|
310
|
+
filename: `attachment-${attachmentId}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
297
313
|
export function repairAccounts() {
|
|
298
314
|
if (hasIPC())
|
|
299
315
|
return getIpc().repairAccounts?.();
|
|
@@ -309,6 +325,11 @@ export function setupAccount(name, email, password) {
|
|
|
309
325
|
return getIpc().setupAccount?.(name, email, password);
|
|
310
326
|
return api("/setup", { method: "POST", body: JSON.stringify({ name, email, password }) });
|
|
311
327
|
}
|
|
328
|
+
export async function getDeviceAccounts() {
|
|
329
|
+
if (hasIPC())
|
|
330
|
+
return getIpc().getDeviceAccounts?.() ?? [];
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
312
333
|
// Legacy exports for backward compatibility
|
|
313
334
|
export const connectWebSocket = connectEvents;
|
|
314
335
|
export const onWsEvent = onEvent;
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -143,6 +143,11 @@
|
|
|
143
143
|
getAutocompleteSettings: function() { return callNode("getAutocompleteSettings"); },
|
|
144
144
|
saveAutocompleteSettings: function(settings) { return callNode("saveAutocompleteSettings", settings); },
|
|
145
145
|
|
|
146
|
+
// Attachments
|
|
147
|
+
getAttachment: function(accountId, uid, attachmentId, folderId) {
|
|
148
|
+
return callNode("getAttachment", { accountId: accountId, uid: uid, attachmentId: attachmentId, folderId: folderId });
|
|
149
|
+
},
|
|
150
|
+
|
|
146
151
|
// Setup & Repair
|
|
147
152
|
setupAccount: function(name, email, password) {
|
|
148
153
|
return callNode("setupAccount", { name: name, email: email, password: password });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.187",
|
|
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.21",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.237",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -122,6 +122,11 @@ async function dispatchAction(svc, action, p) {
|
|
|
122
122
|
case "saveAutocompleteSettings":
|
|
123
123
|
svc.saveAutocompleteSettings(p);
|
|
124
124
|
return { ok: true };
|
|
125
|
+
// Attachments
|
|
126
|
+
case "getAttachment": {
|
|
127
|
+
const att = await svc.getAttachment(p.accountId, p.uid, p.attachmentId, p.folderId);
|
|
128
|
+
return { content: att.content.toString("base64"), contentType: att.contentType, filename: att.filename };
|
|
129
|
+
}
|
|
125
130
|
// Setup & Repair
|
|
126
131
|
case "setupAccount":
|
|
127
132
|
return svc.setupAccount(p.name, p.email, p.password);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { WebMailxDB } from "./db.js";
|
|
15
15
|
import { WebMessageStore } from "./web-message-store.js";
|
|
16
16
|
import { WebMailxService } from "./web-service.js";
|
|
17
|
-
import { loadAccounts, clearSettings } from "./web-settings.js";
|
|
17
|
+
import { loadAccounts, saveAccounts, clearSettings } from "./web-settings.js";
|
|
18
18
|
import { GmailApiWebProvider } from "./gmail-api-web.js";
|
|
19
19
|
// ── State ──
|
|
20
20
|
let db;
|
|
@@ -237,6 +237,130 @@ class AndroidSyncManager {
|
|
|
237
237
|
async searchOnServer() { return []; }
|
|
238
238
|
async syncAllContacts() { }
|
|
239
239
|
}
|
|
240
|
+
// ── OAuth credentials (same "installed" client as desktop) ──
|
|
241
|
+
const OAUTH_CLIENT = {
|
|
242
|
+
clientId: "1067464114116-ndd19nqat8lucb0mbb0mvl7i3tbhnc43.apps.googleusercontent.com",
|
|
243
|
+
clientSecret: "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",
|
|
244
|
+
authUri: "https://accounts.google.com/o/oauth2/auth",
|
|
245
|
+
tokenUri: "https://oauth2.googleapis.com/token",
|
|
246
|
+
redirectUri: "http://localhost",
|
|
247
|
+
};
|
|
248
|
+
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive.file";
|
|
249
|
+
// ── Token cache (IndexedDB) ──
|
|
250
|
+
async function getCachedToken(email) {
|
|
251
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
252
|
+
const raw = localStorage.getItem(key);
|
|
253
|
+
if (!raw)
|
|
254
|
+
return null;
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(raw);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function setCachedToken(email, token) {
|
|
263
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
264
|
+
localStorage.setItem(key, JSON.stringify(token));
|
|
265
|
+
}
|
|
266
|
+
async function clearCachedToken(email) {
|
|
267
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
268
|
+
localStorage.removeItem(key);
|
|
269
|
+
}
|
|
270
|
+
// ── Token exchange ──
|
|
271
|
+
async function exchangeCodeForTokens(code) {
|
|
272
|
+
const body = new URLSearchParams({
|
|
273
|
+
code,
|
|
274
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
275
|
+
client_secret: OAUTH_CLIENT.clientSecret,
|
|
276
|
+
redirect_uri: OAUTH_CLIENT.redirectUri,
|
|
277
|
+
grant_type: "authorization_code",
|
|
278
|
+
});
|
|
279
|
+
const res = await fetch(OAUTH_CLIENT.tokenUri, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
282
|
+
body: body.toString(),
|
|
283
|
+
});
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
const text = await res.text();
|
|
286
|
+
throw new Error(`Token exchange failed: ${res.status} ${text}`);
|
|
287
|
+
}
|
|
288
|
+
return res.json();
|
|
289
|
+
}
|
|
290
|
+
async function refreshAccessToken(refreshToken) {
|
|
291
|
+
const body = new URLSearchParams({
|
|
292
|
+
refresh_token: refreshToken,
|
|
293
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
294
|
+
client_secret: OAUTH_CLIENT.clientSecret,
|
|
295
|
+
grant_type: "refresh_token",
|
|
296
|
+
});
|
|
297
|
+
const res = await fetch(OAUTH_CLIENT.tokenUri, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
300
|
+
body: body.toString(),
|
|
301
|
+
});
|
|
302
|
+
if (!res.ok) {
|
|
303
|
+
const text = await res.text();
|
|
304
|
+
throw new Error(`Token refresh failed: ${res.status} ${text}`);
|
|
305
|
+
}
|
|
306
|
+
return res.json();
|
|
307
|
+
}
|
|
308
|
+
// ── Token provider (browser OAuth, same as desktop) ──
|
|
309
|
+
function createNativeTokenProvider(email) {
|
|
310
|
+
return async () => {
|
|
311
|
+
// Check cached token first
|
|
312
|
+
const cached = await getCachedToken(email);
|
|
313
|
+
if (cached?.access_token) {
|
|
314
|
+
const expiresAt = cached.expires_at || 0;
|
|
315
|
+
const bufferMs = 5 * 60 * 1000; // 5 min buffer
|
|
316
|
+
if (Date.now() < expiresAt - bufferMs) {
|
|
317
|
+
return cached.access_token;
|
|
318
|
+
}
|
|
319
|
+
// Try refresh
|
|
320
|
+
if (cached.refresh_token) {
|
|
321
|
+
try {
|
|
322
|
+
console.log(`[oauth] Refreshing token for ${email}`);
|
|
323
|
+
const refreshed = await refreshAccessToken(cached.refresh_token);
|
|
324
|
+
const token = {
|
|
325
|
+
access_token: refreshed.access_token,
|
|
326
|
+
refresh_token: cached.refresh_token,
|
|
327
|
+
expires_at: Date.now() + refreshed.expires_in * 1000,
|
|
328
|
+
};
|
|
329
|
+
await setCachedToken(email, token);
|
|
330
|
+
return token.access_token;
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// No valid token — start browser OAuth flow
|
|
338
|
+
const bridge = window._nativeBridge;
|
|
339
|
+
if (!bridge?.app?.startOAuth) {
|
|
340
|
+
throw new Error("No native OAuth bridge");
|
|
341
|
+
}
|
|
342
|
+
const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
|
|
343
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
344
|
+
redirect_uri: OAUTH_CLIENT.redirectUri,
|
|
345
|
+
response_type: "code",
|
|
346
|
+
scope: OAUTH_SCOPES,
|
|
347
|
+
access_type: "offline",
|
|
348
|
+
prompt: "consent",
|
|
349
|
+
login_hint: email,
|
|
350
|
+
}).toString();
|
|
351
|
+
console.log(`[oauth] Starting browser consent for ${email}`);
|
|
352
|
+
const code = await bridge.app.startOAuth(authUrl);
|
|
353
|
+
const tokens = await exchangeCodeForTokens(code);
|
|
354
|
+
const token = {
|
|
355
|
+
access_token: tokens.access_token,
|
|
356
|
+
refresh_token: tokens.refresh_token,
|
|
357
|
+
expires_at: Date.now() + tokens.expires_in * 1000,
|
|
358
|
+
};
|
|
359
|
+
await setCachedToken(email, token);
|
|
360
|
+
console.log(`[oauth] Token obtained for ${email}`);
|
|
361
|
+
return token.access_token;
|
|
362
|
+
};
|
|
363
|
+
}
|
|
240
364
|
// ── Initialization ──
|
|
241
365
|
export async function initAndroid() {
|
|
242
366
|
console.log("[android] Initializing mailx...");
|
|
@@ -250,6 +374,11 @@ export async function initAndroid() {
|
|
|
250
374
|
for (const account of accounts) {
|
|
251
375
|
if (!account.enabled)
|
|
252
376
|
continue;
|
|
377
|
+
// Set up OAuth token provider for Gmail accounts via Android AccountManager
|
|
378
|
+
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
379
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
380
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
|
|
381
|
+
}
|
|
253
382
|
await syncManager.addAccount(account);
|
|
254
383
|
}
|
|
255
384
|
installBridge();
|
|
@@ -323,13 +452,71 @@ function installBridge() {
|
|
|
323
452
|
saveSettingsData: async (data) => { await service.saveSettingsData(data); return { ok: true }; },
|
|
324
453
|
getVersion: async () => {
|
|
325
454
|
const settings = await service.getSettings();
|
|
326
|
-
|
|
455
|
+
const nativeVersion = window._nativeBridge?.info?.version || "?";
|
|
456
|
+
return { version: nativeVersion, theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" };
|
|
327
457
|
},
|
|
328
458
|
getAutocompleteSettings: () => service.getAutocompleteSettings(),
|
|
329
459
|
saveAutocompleteSettings: async (settings) => { await service.saveAutocompleteSettings(settings); return { ok: true }; },
|
|
330
|
-
|
|
460
|
+
getDeviceAccounts: async () => {
|
|
461
|
+
const bridge = window._nativeBridge;
|
|
462
|
+
if (bridge?.app?.getDeviceAccounts) {
|
|
463
|
+
return bridge.app.getDeviceAccounts();
|
|
464
|
+
}
|
|
465
|
+
return [];
|
|
466
|
+
},
|
|
467
|
+
setupAccount: async (name, email, _password) => {
|
|
468
|
+
try {
|
|
469
|
+
if (!email || !email.includes("@")) {
|
|
470
|
+
return { ok: false, error: "Email address required" };
|
|
471
|
+
}
|
|
472
|
+
const domain = email.split("@")[1].toLowerCase();
|
|
473
|
+
const id = domain.split(".")[0] || "account";
|
|
474
|
+
const account = {
|
|
475
|
+
id,
|
|
476
|
+
name: name || email.split("@")[0],
|
|
477
|
+
email,
|
|
478
|
+
enabled: true,
|
|
479
|
+
imap: { host: `imap.${domain}`, port: 993, tls: true, auth: "oauth2", user: email },
|
|
480
|
+
smtp: { host: `smtp.${domain}`, port: 587, tls: true, auth: "oauth2", user: email },
|
|
481
|
+
};
|
|
482
|
+
// Apply known provider defaults
|
|
483
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
484
|
+
account.label = "Gmail";
|
|
485
|
+
account.imap = { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2", user: email };
|
|
486
|
+
account.smtp = { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2", user: email };
|
|
487
|
+
}
|
|
488
|
+
const existing = await loadAccounts();
|
|
489
|
+
if (existing.some(a => a.email === email)) {
|
|
490
|
+
return { ok: true, message: "Account already exists" };
|
|
491
|
+
}
|
|
492
|
+
existing.push(account);
|
|
493
|
+
await saveAccounts(existing);
|
|
494
|
+
// Set up token provider before adding account
|
|
495
|
+
const setupDomain = email.split("@")[1].toLowerCase();
|
|
496
|
+
if (setupDomain === "gmail.com" || setupDomain === "googlemail.com") {
|
|
497
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(email));
|
|
498
|
+
}
|
|
499
|
+
await syncManager.addAccount(account);
|
|
500
|
+
db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
501
|
+
console.log(`[android] Account added: ${email}`);
|
|
502
|
+
return { ok: true, message: `Added ${email}. Syncing...` };
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
return { ok: false, error: e.message };
|
|
506
|
+
}
|
|
507
|
+
},
|
|
331
508
|
repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
|
|
332
509
|
resetStore: () => resetStore(),
|
|
510
|
+
resetAll: async () => {
|
|
511
|
+
const bridge = window._nativeBridge;
|
|
512
|
+
if (bridge?.app?.resetAll) {
|
|
513
|
+
await bridge.app.resetAll();
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
await resetStore();
|
|
517
|
+
location.reload();
|
|
518
|
+
}
|
|
519
|
+
},
|
|
333
520
|
restart: () => { location.reload(); },
|
|
334
521
|
onEvent: (handler) => { eventHandlers.push(handler); },
|
|
335
522
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// ESM wrapper for sql.js (UMD) — loads as classic script, re-exports the global
|
|
2
|
+
await new Promise((resolve, reject) => {
|
|
3
|
+
const s = document.createElement("script");
|
|
4
|
+
s.src = new URL("../../node_modules/sql.js/dist/sql-wasm.js", import.meta.url).href;
|
|
5
|
+
s.onload = resolve;
|
|
6
|
+
s.onerror = reject;
|
|
7
|
+
document.head.appendChild(s);
|
|
8
|
+
});
|
|
9
|
+
const initSqlJs = globalThis.initSqlJs;
|
|
10
|
+
export default initSqlJs;
|