@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.
@@ -1 +1 @@
1
- {"height":1344,"width":1586,"x":793,"y":81}
1
+ {"height":1344,"width":2151,"x":626,"y":45}
@@ -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
- searchTimeout = setTimeout(() => doSearch(false), 300);
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 finished — re-enable sync button
643
+ // Sync succeededclear 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("dragleave", () => folderEl.classList.remove("drop-target"));
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 = `/api/message/${accountId}/${uid}/attachment/${i}?folderId=${msg.folderId}`;
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
  }
@@ -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?.();
@@ -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.186",
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.236",
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
- return { version: "1.0.0-android", theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" };
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
  };