@bobfrankston/mailx 1.0.184 → 1.0.186

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":1586,"x":793,"y":81}
@@ -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>
package/client/app.js CHANGED
@@ -268,16 +268,15 @@ async function openCompose(mode) {
268
268
  // Auto-detect reply From: if the message was delivered to an identity domain,
269
269
  // reply from that address instead of the default account address.
270
270
  // Identity domains configured per-account in accounts.jsonc (identityDomains array).
271
- // Default identity domains for bob.ma account:
272
- const identityDomains = ["bob.ma", "frankston.com"];
271
+ const account = accounts.find((a) => a.id === accountId);
272
+ const identityDomains = account?.identityDomains || [];
273
273
  function detectReplyFrom() {
274
- if (!msg)
274
+ if (!msg || identityDomains.length === 0)
275
275
  return undefined;
276
- // Check deliveredTo first (most reliable), then To addresses
277
276
  const candidates = [msg.deliveredTo, ...(msg.to || []).map((a) => a.address)].filter(Boolean);
278
277
  for (const addr of candidates) {
279
278
  const domain = addr.split("@")[1]?.toLowerCase();
280
- if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
279
+ if (domain && identityDomains.some((d) => domain === d.toLowerCase())) {
281
280
  return addr;
282
281
  }
283
282
  }
@@ -306,14 +305,10 @@ async function openCompose(mode) {
306
305
  }
307
306
  // Store init data for compose window to pick up
308
307
  sessionStorage.setItem("composeInit", JSON.stringify(init));
309
- // IPC mode: navigate in same window (popups don't have custom protocol)
310
- // HTTP mode: open as popup window
311
- if (typeof mailxapi !== "undefined") {
312
- window.location.href = "compose/compose.html";
313
- }
314
- else {
315
- window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
316
- }
308
+ // Open compose always as popup via window.open().
309
+ // In IPC mode, msger's new_window_req_handler allows msger.localhost popups.
310
+ // DO NOT use window.location.href — msger exits on navigation.
311
+ window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
317
312
  }
318
313
  function quoteBody(msg) {
319
314
  const date = new Date(msg.date).toLocaleString();
@@ -585,10 +580,16 @@ onWsEvent((event) => {
585
580
  break;
586
581
  }
587
582
  case "folderCountsChanged": {
588
- // Update folder badges only never reload the message list or touch the viewer.
589
- // The list refreshes when the user clicks a folder or presses Sync.
583
+ // Update folder badges + silently refresh message list (preserves selection and viewer)
590
584
  updateFolderCounts();
591
585
  updateNewMessageCount();
586
+ // Debounced silent reload — preserves scroll position, selection, and viewer
587
+ if (reloadDebounceTimer)
588
+ clearTimeout(reloadDebounceTimer);
589
+ reloadDebounceTimer = setTimeout(() => {
590
+ reloadDebounceTimer = null;
591
+ reloadCurrentFolder();
592
+ }, 2000);
592
593
  // Sync finished — re-enable sync button
593
594
  const syncBtn = document.getElementById("btn-sync");
594
595
  if (syncBtn) {
@@ -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;
@@ -357,8 +357,9 @@ async function loadFolderTree(container) {
357
357
  if (mainBody) {
358
358
  mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:500px">
359
359
  <h2 style="margin-bottom:1rem">Welcome to mailx</h2>
360
+ <div id="setup-device-accounts"></div>
360
361
  <div id="setup-cloud-status"></div>
361
- <p>Add your email account to get started.</p>
362
+ <p id="setup-form-intro">Add your email account to get started.</p>
362
363
  <form id="setup-form" style="margin-top:1rem">
363
364
  <label style="display:block;margin-bottom:0.5rem">
364
365
  Your name
@@ -462,6 +463,41 @@ async function loadFolderTree(container) {
462
463
  </div>`;
463
464
  }
464
465
  }).catch(() => { });
466
+ // On Android, check for device Google accounts and show picker
467
+ getDeviceAccounts().then(async (deviceAccounts) => {
468
+ const pickerEl = document.getElementById("setup-device-accounts");
469
+ if (!pickerEl || deviceAccounts.length === 0)
470
+ return;
471
+ const formEl = document.getElementById("setup-form");
472
+ const introEl = document.getElementById("setup-form-intro");
473
+ if (introEl)
474
+ introEl.textContent = "Select an account:";
475
+ if (formEl)
476
+ formEl.style.display = "none";
477
+ 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>`;
478
+ pickerEl.querySelectorAll(".device-account-btn").forEach((btn) => {
479
+ btn.addEventListener("click", async () => {
480
+ const email = btn.dataset.email || "";
481
+ const name = btn.dataset.name || "";
482
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-text-muted)">Setting up ${email}...</div>`;
483
+ const result = await setupAccount(name, email, "");
484
+ if (result?.ok) {
485
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-accent)">${result.message || "Account added!"}</div>`;
486
+ setTimeout(() => location.reload(), 2000);
487
+ }
488
+ else {
489
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:#f55">${result?.error || "Setup failed"}</div>`;
490
+ }
491
+ });
492
+ });
493
+ document.getElementById("setup-show-form")?.addEventListener("click", () => {
494
+ pickerEl.style.display = "none";
495
+ if (formEl)
496
+ formEl.style.display = "block";
497
+ if (introEl)
498
+ introEl.textContent = "Add your email account to get started.";
499
+ });
500
+ }).catch(() => { });
465
501
  }
466
502
  // Dismiss startup overlay
467
503
  const overlay = document.getElementById("startup-overlay");
@@ -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 {
@@ -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) {
@@ -258,14 +253,24 @@ function parseAddrs(s) {
258
253
  });
259
254
  }
260
255
  function applyInit(init) {
261
- // Populate From dropdown
262
- populateFromSelect(init.accounts, init.accountId);
263
- // Auto-detect reply From: if fromAddress is set (identity domain match),
264
- // use it as the From address via the "Other..." custom field
265
- if (init.fromAddress) {
266
- fromSelect.value = "__custom__";
267
- fromCustom.hidden = false;
268
- fromCustom.value = init.fromAddress;
256
+ // If identity domain matched, add as first option in dropdown
257
+ const replyAddr = init.fromAddress;
258
+ const account = init.accounts.find(a => a.id === init.accountId);
259
+ const displayName = account?.name || "";
260
+ if (replyAddr) {
261
+ // Insert identity address as first option, selected
262
+ const idOpt = document.createElement("option");
263
+ idOpt.value = init.accountId;
264
+ idOpt.textContent = `${displayName} <${replyAddr}>`;
265
+ idOpt.dataset.email = replyAddr;
266
+ idOpt.dataset.name = displayName;
267
+ idOpt.selected = true;
268
+ // Populate rest of dropdown, then prepend identity option
269
+ populateFromSelect(init.accounts, "");
270
+ fromSelect.prepend(idOpt);
271
+ }
272
+ else {
273
+ populateFromSelect(init.accounts, init.accountId);
269
274
  }
270
275
  toInput.value = formatAddrs(init.to);
271
276
  ccInput.value = formatAddrs(init.cc);
@@ -309,6 +309,11 @@ export function setupAccount(name, email, password) {
309
309
  return getIpc().setupAccount?.(name, email, password);
310
310
  return api("/setup", { method: "POST", body: JSON.stringify({ name, email, password }) });
311
311
  }
312
+ export async function getDeviceAccounts() {
313
+ if (hasIPC())
314
+ return getIpc().getDeviceAccounts?.() ?? [];
315
+ return [];
316
+ }
312
317
  // Legacy exports for backward compatibility
313
318
  export const connectWebSocket = connectEvents;
314
319
  export const onWsEvent = onEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.184",
3
+ "version": "1.0.186",
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.234",
27
+ "@bobfrankston/msger": "^0.1.236",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -12,8 +12,10 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { simpleParser } from "mailparser";
14
14
  import { GmailApiProvider } from "./providers/gmail-api.js";
15
- import { createTransport } from "nodemailer";
16
15
  import * as os from "node:os";
16
+ // Well-known ports — no magic numbers
17
+ const SMTP_PORT_STARTTLS = 587;
18
+ const SMTP_PORT_IMPLICIT_TLS = 465;
17
19
  /** Extract full error detail with provenance */
18
20
  function imapError(err) {
19
21
  const msg = err.message || err.reason || err.code || (typeof err === "string" ? err : "");
@@ -1734,21 +1736,27 @@ export class ImapManager extends EventEmitter {
1734
1736
  const account = settings.accounts.find(a => a.id === accountId);
1735
1737
  if (!account?.smtp)
1736
1738
  throw new Error(`No SMTP config for ${accountId}`);
1739
+ // SMTP auth: use explicit SMTP credentials, fall back to IMAP credentials
1737
1740
  let smtpAuth;
1738
- if (account.smtp.auth === "password") {
1739
- smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
1741
+ const smtpAuthType = account.smtp.auth || (account.imap?.password ? "password" : undefined);
1742
+ if (smtpAuthType === "password") {
1743
+ smtpAuth = {
1744
+ user: account.smtp.user || account.imap?.user || account.email,
1745
+ pass: account.smtp.password || account.imap?.password,
1746
+ };
1740
1747
  }
1741
- else if (account.smtp.auth === "oauth2") {
1748
+ else if (smtpAuthType === "oauth2") {
1742
1749
  const accessToken = await this.getOAuthToken(accountId);
1743
1750
  if (!accessToken)
1744
1751
  throw new Error("OAuth token not available");
1745
- smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
1752
+ smtpAuth = { type: "OAuth2", user: account.smtp.user || account.imap?.user || account.email, accessToken };
1746
1753
  }
1747
1754
  const { createTransport } = await import("nodemailer");
1755
+ const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
1748
1756
  const transport = createTransport({
1749
- host: account.smtp.host,
1750
- port: account.smtp.port,
1751
- secure: account.smtp.port === 465,
1757
+ host: account.smtp.host || account.imap?.host,
1758
+ port: smtpPort,
1759
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS, // 465 = implicit TLS, 587 = STARTTLS
1752
1760
  auth: smtpAuth,
1753
1761
  tls: { rejectUnauthorized: false },
1754
1762
  });
@@ -1824,52 +1832,10 @@ export class ImapManager extends EventEmitter {
1824
1832
  await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
1825
1833
  continue;
1826
1834
  }
1827
- // Send via SMTP
1835
+ // Send via shared SMTP method
1828
1836
  try {
1829
- let smtpAuth;
1830
- if (account.smtp.auth === "password") {
1831
- smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
1832
- }
1833
- else if (account.smtp.auth === "oauth2") {
1834
- const accessToken = await this.getOAuthToken(accountId);
1835
- if (!accessToken)
1836
- throw new Error("OAuth token not available — re-authenticate");
1837
- smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
1838
- }
1839
- const transport = createTransport({
1840
- host: account.smtp.host,
1841
- port: account.smtp.port,
1842
- secure: account.smtp.port === 465,
1843
- auth: smtpAuth,
1844
- tls: { rejectUnauthorized: false },
1845
- });
1846
- // Parse recipients from raw message headers for SMTP envelope
1847
- const toMatch = msg.source.match(/^To:\s*(.+)$/mi);
1848
- const ccMatch = msg.source.match(/^Cc:\s*(.+)$/mi);
1849
- const bccMatch = msg.source.match(/^Bcc:\s*(.+)$/mi);
1850
- const fromMatch = msg.source.match(/^From:\s*(.+)$/mi);
1851
- const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
1852
- const recipients = [
1853
- ...(toMatch ? parseAddrs(toMatch[1]) : []),
1854
- ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
1855
- ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
1856
- ];
1857
- const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
1858
- if (recipients.length === 0) {
1859
- console.error(` [outbox] No recipients in UID ${uid} — permanent failure`);
1860
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
1861
- await client.addFlags(outboxFolder.path, uid, ["$PermanentFailure"]);
1862
- continue;
1863
- }
1864
- // Strip Bcc header from raw message before sending
1865
- const rawToSend = msg.source.replace(/^Bcc:.*\r?\n/mi, "");
1866
- // Save debug copy before sending
1867
- this.saveSendingCopy(accountId, rawToSend, `sent-${uid}`);
1868
- await transport.sendMail({
1869
- raw: rawToSend,
1870
- envelope: { from: sender, to: recipients },
1871
- });
1872
- console.log(` [outbox] Sent UID ${uid} → ${recipients.join(", ")}`);
1837
+ await this.sendRawViaSMTP(accountId, msg.source);
1838
+ console.log(` [outbox] Sent UID ${uid}`);
1873
1839
  // Delete from Outbox FIRST to prevent double-send if move-to-Sent fails.
1874
1840
  // The message is already sent via SMTP — worst case we lose the Sent copy,
1875
1841
  // which is better than sending the message twice.
@@ -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;
@@ -327,7 +327,49 @@ function installBridge() {
327
327
  },
328
328
  getAutocompleteSettings: () => service.getAutocompleteSettings(),
329
329
  saveAutocompleteSettings: async (settings) => { await service.saveAutocompleteSettings(settings); return { ok: true }; },
330
- setupAccount: async () => ({ ok: false, error: "Use desktop for initial setup" }),
330
+ getDeviceAccounts: async () => {
331
+ const bridge = window._nativeBridge;
332
+ if (bridge?.app?.getDeviceAccounts) {
333
+ return bridge.app.getDeviceAccounts();
334
+ }
335
+ return [];
336
+ },
337
+ setupAccount: async (name, email, _password) => {
338
+ try {
339
+ if (!email || !email.includes("@")) {
340
+ return { ok: false, error: "Email address required" };
341
+ }
342
+ const domain = email.split("@")[1].toLowerCase();
343
+ const id = domain.split(".")[0] || "account";
344
+ const account = {
345
+ id,
346
+ name: name || email.split("@")[0],
347
+ email,
348
+ enabled: true,
349
+ imap: { host: `imap.${domain}`, port: 993, tls: true, auth: "oauth2", user: email },
350
+ smtp: { host: `smtp.${domain}`, port: 587, tls: true, auth: "oauth2", user: email },
351
+ };
352
+ // Apply known provider defaults
353
+ if (domain === "gmail.com" || domain === "googlemail.com") {
354
+ account.label = "Gmail";
355
+ account.imap = { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2", user: email };
356
+ account.smtp = { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2", user: email };
357
+ }
358
+ const existing = await loadAccounts();
359
+ if (existing.some(a => a.email === email)) {
360
+ return { ok: true, message: "Account already exists" };
361
+ }
362
+ existing.push(account);
363
+ await saveAccounts(existing);
364
+ await syncManager.addAccount(account);
365
+ db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
366
+ console.log(`[android] Account added: ${email}`);
367
+ return { ok: true, message: `Added ${email}. Syncing...` };
368
+ }
369
+ catch (e) {
370
+ return { ok: false, error: e.message };
371
+ }
372
+ },
331
373
  repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
332
374
  resetStore: () => resetStore(),
333
375
  restart: () => { location.reload(); },
@@ -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;