@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.
@@ -1 +1 @@
1
- {"height":1344,"width":1946,"x":172,"y":85}
1
+ {"height":1344,"width":2151,"x":626,"y":45}
@@ -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": "../node_modules/sql.js/dist/sql-wasm.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
- // IPC mode: navigate in same window (popups don't have custom protocol)
309
- // HTTP mode: open as popup window
310
- if (typeof mailxapi !== "undefined") {
311
- window.location.href = "compose/compose.html";
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
- 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
+ }
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 finished — re-enable sync button
643
+ // Sync succeededclear 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("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");
@@ -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
- if (typeof window.mailxapi !== "undefined") {
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 = `/api/message/${accountId}/${uid}/attachment/${i}?folderId=${msg.folderId}`;
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 — navigate back in IPC mode, window.close() in HTTP mode */
8
+ /** Close compose window */
9
9
  function closeCompose() {
10
- if (typeof window.mailxapi !== "undefined") {
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) {
@@ -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;
@@ -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.185",
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);
@@ -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
- 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 }; },
330
- setupAccount: async () => ({ ok: false, error: "Use desktop for initial setup" }),
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;