@bobfrankston/mailx 1.0.186 → 1.0.188
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 +1 -0
- package/client/app.js +75 -3
- package/client/components/folder-tree.js +24 -1
- package/client/components/message-list.js +66 -0
- package/client/components/message-viewer.js +16 -3
- package/client/lib/api-client.js +16 -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 +168 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":
|
|
1
|
+
{"height":1344,"width":2151,"x":626,"y":45}
|
package/client/android.html
CHANGED
|
@@ -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();
|
|
@@ -394,6 +430,11 @@ document.getElementById("btn-compose")?.addEventListener("click", () => openComp
|
|
|
394
430
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
395
431
|
document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
|
|
396
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());
|
|
397
438
|
// ── Search ──
|
|
398
439
|
let searchTimeout;
|
|
399
440
|
const searchInput = document.getElementById("search-input");
|
|
@@ -429,7 +470,16 @@ let currentFolderId = 0;
|
|
|
429
470
|
let reloadDebounceTimer = null;
|
|
430
471
|
searchInput?.addEventListener("input", () => {
|
|
431
472
|
clearTimeout(searchTimeout);
|
|
432
|
-
|
|
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
|
+
}
|
|
433
483
|
});
|
|
434
484
|
searchInput?.addEventListener("keydown", (e) => {
|
|
435
485
|
if (e.key === "Enter") {
|
|
@@ -590,7 +640,8 @@ onWsEvent((event) => {
|
|
|
590
640
|
reloadDebounceTimer = null;
|
|
591
641
|
reloadCurrentFolder();
|
|
592
642
|
}, 2000);
|
|
593
|
-
// Sync
|
|
643
|
+
// Sync succeeded — clear any transient error banner and re-enable sync button
|
|
644
|
+
hideAlert();
|
|
594
645
|
const syncBtn = document.getElementById("btn-sync");
|
|
595
646
|
if (syncBtn) {
|
|
596
647
|
syncBtn.disabled = false;
|
|
@@ -731,6 +782,27 @@ document.addEventListener("keydown", (e) => {
|
|
|
731
782
|
e.preventDefault();
|
|
732
783
|
document.getElementById("btn-sync")?.click();
|
|
733
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
|
+
}
|
|
734
806
|
});
|
|
735
807
|
// ── View menu ──
|
|
736
808
|
const viewBtn = document.getElementById("btn-view");
|
|
@@ -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");
|
|
@@ -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;
|
|
@@ -275,9 +275,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
275
275
|
const chip = document.createElement("a");
|
|
276
276
|
chip.className = "mv-att-chip";
|
|
277
277
|
chip.textContent = `\uD83D\uDCCE ${att.filename} (${formatSize(att.size)})`;
|
|
278
|
-
chip.href =
|
|
279
|
-
chip.target = "_blank";
|
|
278
|
+
chip.href = "#";
|
|
280
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
|
+
});
|
|
281
294
|
attEl.appendChild(chip);
|
|
282
295
|
}
|
|
283
296
|
}
|
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?.();
|
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.188",
|
|
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.238",
|
|
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);
|
|
@@ -237,9 +237,155 @@ 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
|
+
// Reverse client ID scheme — auto-allowed for Google "installed" apps
|
|
247
|
+
redirectUri: "com.googleusercontent.apps.1067464114116-ndd19nqat8lucb0mbb0mvl7i3tbhnc43:/oauth2callback",
|
|
248
|
+
};
|
|
249
|
+
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive.file";
|
|
250
|
+
// ── Token cache (IndexedDB) ──
|
|
251
|
+
async function getCachedToken(email) {
|
|
252
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
253
|
+
const raw = localStorage.getItem(key);
|
|
254
|
+
if (!raw)
|
|
255
|
+
return null;
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(raw);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function setCachedToken(email, token) {
|
|
264
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
265
|
+
localStorage.setItem(key, JSON.stringify(token));
|
|
266
|
+
}
|
|
267
|
+
async function clearCachedToken(email) {
|
|
268
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
269
|
+
localStorage.removeItem(key);
|
|
270
|
+
}
|
|
271
|
+
// ── Token exchange ──
|
|
272
|
+
async function exchangeCodeForTokens(code) {
|
|
273
|
+
const body = new URLSearchParams({
|
|
274
|
+
code,
|
|
275
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
276
|
+
client_secret: OAUTH_CLIENT.clientSecret,
|
|
277
|
+
redirect_uri: OAUTH_CLIENT.redirectUri,
|
|
278
|
+
grant_type: "authorization_code",
|
|
279
|
+
});
|
|
280
|
+
const res = await fetch(OAUTH_CLIENT.tokenUri, {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
283
|
+
body: body.toString(),
|
|
284
|
+
});
|
|
285
|
+
if (!res.ok) {
|
|
286
|
+
const text = await res.text();
|
|
287
|
+
throw new Error(`Token exchange failed: ${res.status} ${text}`);
|
|
288
|
+
}
|
|
289
|
+
return res.json();
|
|
290
|
+
}
|
|
291
|
+
async function refreshAccessToken(refreshToken) {
|
|
292
|
+
const body = new URLSearchParams({
|
|
293
|
+
refresh_token: refreshToken,
|
|
294
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
295
|
+
client_secret: OAUTH_CLIENT.clientSecret,
|
|
296
|
+
grant_type: "refresh_token",
|
|
297
|
+
});
|
|
298
|
+
const res = await fetch(OAUTH_CLIENT.tokenUri, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
301
|
+
body: body.toString(),
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok) {
|
|
304
|
+
const text = await res.text();
|
|
305
|
+
throw new Error(`Token refresh failed: ${res.status} ${text}`);
|
|
306
|
+
}
|
|
307
|
+
return res.json();
|
|
308
|
+
}
|
|
309
|
+
// ── Token provider (browser OAuth, same as desktop) ──
|
|
310
|
+
function createNativeTokenProvider(email) {
|
|
311
|
+
return async () => {
|
|
312
|
+
// Check cached token first
|
|
313
|
+
const cached = await getCachedToken(email);
|
|
314
|
+
if (cached?.access_token) {
|
|
315
|
+
const expiresAt = cached.expires_at || 0;
|
|
316
|
+
const bufferMs = 5 * 60 * 1000; // 5 min buffer
|
|
317
|
+
if (Date.now() < expiresAt - bufferMs) {
|
|
318
|
+
return cached.access_token;
|
|
319
|
+
}
|
|
320
|
+
// Try refresh
|
|
321
|
+
if (cached.refresh_token) {
|
|
322
|
+
try {
|
|
323
|
+
console.log(`[oauth] Refreshing token for ${email}`);
|
|
324
|
+
const refreshed = await refreshAccessToken(cached.refresh_token);
|
|
325
|
+
const token = {
|
|
326
|
+
access_token: refreshed.access_token,
|
|
327
|
+
refresh_token: cached.refresh_token,
|
|
328
|
+
expires_at: Date.now() + refreshed.expires_in * 1000,
|
|
329
|
+
};
|
|
330
|
+
await setCachedToken(email, token);
|
|
331
|
+
return token.access_token;
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// No valid token — start browser OAuth flow
|
|
339
|
+
const bridge = window._nativeBridge;
|
|
340
|
+
if (!bridge?.app?.startOAuth) {
|
|
341
|
+
throw new Error("No native OAuth bridge");
|
|
342
|
+
}
|
|
343
|
+
const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
|
|
344
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
345
|
+
redirect_uri: OAUTH_CLIENT.redirectUri,
|
|
346
|
+
response_type: "code",
|
|
347
|
+
scope: OAUTH_SCOPES,
|
|
348
|
+
access_type: "offline",
|
|
349
|
+
prompt: "consent",
|
|
350
|
+
login_hint: email,
|
|
351
|
+
}).toString();
|
|
352
|
+
console.log(`[oauth] Starting browser consent for ${email}`);
|
|
353
|
+
const code = await bridge.app.startOAuth(authUrl);
|
|
354
|
+
const tokens = await exchangeCodeForTokens(code);
|
|
355
|
+
const token = {
|
|
356
|
+
access_token: tokens.access_token,
|
|
357
|
+
refresh_token: tokens.refresh_token,
|
|
358
|
+
expires_at: Date.now() + tokens.expires_in * 1000,
|
|
359
|
+
};
|
|
360
|
+
await setCachedToken(email, token);
|
|
361
|
+
console.log(`[oauth] Token obtained for ${email}`);
|
|
362
|
+
return token.access_token;
|
|
363
|
+
};
|
|
364
|
+
}
|
|
240
365
|
// ── Initialization ──
|
|
366
|
+
async function waitForNativeBridge(timeoutMs = 5000) {
|
|
367
|
+
if (window._nativeBridge)
|
|
368
|
+
return;
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
const start = Date.now();
|
|
371
|
+
const check = () => {
|
|
372
|
+
if (window._nativeBridge || Date.now() - start > timeoutMs) {
|
|
373
|
+
resolve();
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
setTimeout(check, 50);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
// Also listen for the event C# dispatches after bridge injection
|
|
380
|
+
window.addEventListener("nativebridgeready", () => resolve(), { once: true });
|
|
381
|
+
check();
|
|
382
|
+
});
|
|
383
|
+
}
|
|
241
384
|
export async function initAndroid() {
|
|
242
385
|
console.log("[android] Initializing mailx...");
|
|
386
|
+
// Wait for C# to inject the native bridge (TCP/FS/HTTP + OAuth)
|
|
387
|
+
await waitForNativeBridge();
|
|
388
|
+
console.log(`[android] Native bridge: ${window._nativeBridge ? "ready" : "timeout"}`);
|
|
243
389
|
db = new WebMailxDB("mailx");
|
|
244
390
|
await db.waitReady();
|
|
245
391
|
bodyStore = new WebMessageStore();
|
|
@@ -250,6 +396,11 @@ export async function initAndroid() {
|
|
|
250
396
|
for (const account of accounts) {
|
|
251
397
|
if (!account.enabled)
|
|
252
398
|
continue;
|
|
399
|
+
// Set up OAuth token provider for Gmail accounts via Android AccountManager
|
|
400
|
+
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
401
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
402
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
|
|
403
|
+
}
|
|
253
404
|
await syncManager.addAccount(account);
|
|
254
405
|
}
|
|
255
406
|
installBridge();
|
|
@@ -323,7 +474,8 @@ function installBridge() {
|
|
|
323
474
|
saveSettingsData: async (data) => { await service.saveSettingsData(data); return { ok: true }; },
|
|
324
475
|
getVersion: async () => {
|
|
325
476
|
const settings = await service.getSettings();
|
|
326
|
-
|
|
477
|
+
const nativeVersion = window._nativeBridge?.info?.version || "?";
|
|
478
|
+
return { version: nativeVersion, theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" };
|
|
327
479
|
},
|
|
328
480
|
getAutocompleteSettings: () => service.getAutocompleteSettings(),
|
|
329
481
|
saveAutocompleteSettings: async (settings) => { await service.saveAutocompleteSettings(settings); return { ok: true }; },
|
|
@@ -361,6 +513,11 @@ function installBridge() {
|
|
|
361
513
|
}
|
|
362
514
|
existing.push(account);
|
|
363
515
|
await saveAccounts(existing);
|
|
516
|
+
// Set up token provider before adding account
|
|
517
|
+
const setupDomain = email.split("@")[1].toLowerCase();
|
|
518
|
+
if (setupDomain === "gmail.com" || setupDomain === "googlemail.com") {
|
|
519
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(email));
|
|
520
|
+
}
|
|
364
521
|
await syncManager.addAccount(account);
|
|
365
522
|
db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
366
523
|
console.log(`[android] Account added: ${email}`);
|
|
@@ -372,6 +529,16 @@ function installBridge() {
|
|
|
372
529
|
},
|
|
373
530
|
repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
|
|
374
531
|
resetStore: () => resetStore(),
|
|
532
|
+
resetAll: async () => {
|
|
533
|
+
const bridge = window._nativeBridge;
|
|
534
|
+
if (bridge?.app?.resetAll) {
|
|
535
|
+
await bridge.app.resetAll();
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
await resetStore();
|
|
539
|
+
location.reload();
|
|
540
|
+
}
|
|
541
|
+
},
|
|
375
542
|
restart: () => { location.reload(); },
|
|
376
543
|
onEvent: (handler) => { eventHandlers.push(handler); },
|
|
377
544
|
};
|