@bobfrankston/mailx 1.0.186 → 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 +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 +146 -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.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);
|
|
@@ -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,7 +452,8 @@ 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 }; },
|
|
@@ -361,6 +491,11 @@ function installBridge() {
|
|
|
361
491
|
}
|
|
362
492
|
existing.push(account);
|
|
363
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
|
+
}
|
|
364
499
|
await syncManager.addAccount(account);
|
|
365
500
|
db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
366
501
|
console.log(`[android] Account added: ${email}`);
|
|
@@ -372,6 +507,16 @@ function installBridge() {
|
|
|
372
507
|
},
|
|
373
508
|
repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
|
|
374
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
|
+
},
|
|
375
520
|
restart: () => { location.reload(); },
|
|
376
521
|
onEvent: (handler) => { eventHandlers.push(handler); },
|
|
377
522
|
};
|