@bobfrankston/mailx 1.0.104 → 1.0.106

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.
@@ -300,32 +300,73 @@ async function loadFolderTree(container) {
300
300
  const accounts = await getAccounts();
301
301
  if (accounts.length === 0) {
302
302
  container.innerHTML = `<div class="folder-loading">No accounts</div>`;
303
- // Show setup instructions in the main content area (full width)
303
+ // Show simple setup form in the main content area
304
304
  const mainBody = document.getElementById("ml-body");
305
305
  if (mainBody) {
306
- mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:600px">
306
+ mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:500px">
307
307
  <h2 style="margin-bottom:1rem">Welcome to mailx</h2>
308
- <p>Create <code>~/.mailx/settings.jsonc</code> with your email accounts.</p>
309
- <p>If you have settings on OneDrive, create <code>~/.mailx/config.jsonc</code>:</p>
310
- <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": "~/OneDrive/home/.mailx" }</code>
311
- <h3 style="margin-top:1.5rem">Gmail (just your email):</h3>
312
- <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "name": "Your Name",
313
- "accounts": [
314
- { "email": "you@gmail.com" }
315
- ]
316
- }</code>
317
- <h3 style="margin-top:1rem">Standard IMAP:</h3>
318
- <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "name": "Your Name",
319
- "accounts": [
320
- { "email": "you@example.com",
321
- "password": "secret",
322
- "imap": { "host": "imap.example.com" },
323
- "smtp": { "host": "smtp.example.com" }
324
- }
325
- ]
326
- }</code>
327
- <p style="margin-top:1rem;color:var(--color-text-muted)">Known providers (Gmail, Outlook, Yahoo, iCloud) auto-configure from just the email address. For other providers, specify IMAP/SMTP hosts. Restart after editing settings.</p>
308
+ <p>Add your email account to get started.</p>
309
+ <form id="setup-form" style="margin-top:1rem">
310
+ <label style="display:block;margin-bottom:0.5rem">
311
+ Your name
312
+ <input id="setup-name" type="text" placeholder="Your Name" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
313
+ </label>
314
+ <label style="display:block;margin-bottom:0.5rem">
315
+ Email address
316
+ <input id="setup-email" type="email" placeholder="you@gmail.com" required style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
317
+ </label>
318
+ <label id="setup-password-row" style="display:block;margin-bottom:0.5rem">
319
+ Password <span style="color:var(--color-text-muted);font-size:0.85rem">(not needed for Gmail/Outlook)</span>
320
+ <input id="setup-password" type="password" placeholder="password" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
321
+ </label>
322
+ <button type="submit" style="margin-top:1rem;padding:0.5rem 2rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:1rem">Add Account</button>
323
+ <div id="setup-status" style="margin-top:1rem;color:var(--color-text-muted)"></div>
324
+ </form>
325
+ <details style="margin-top:2rem;color:var(--color-text-muted)">
326
+ <summary>Manual setup (advanced)</summary>
327
+ <p style="margin-top:0.5rem">Create <code>~/.mailx/settings.jsonc</code> or point to shared settings:</p>
328
+ <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": "~/OneDrive/home/.mailx" }</code>
329
+ </details>
328
330
  </div>`;
331
+ // Wire up the setup form
332
+ const form = document.getElementById("setup-form");
333
+ const emailInput = document.getElementById("setup-email");
334
+ const statusEl = document.getElementById("setup-status");
335
+ // Hide password for known OAuth providers
336
+ emailInput?.addEventListener("input", () => {
337
+ const domain = emailInput.value.split("@")[1]?.toLowerCase() || "";
338
+ const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"].includes(domain);
339
+ const pwRow = document.getElementById("setup-password-row");
340
+ if (pwRow)
341
+ pwRow.style.display = isOAuth ? "none" : "block";
342
+ });
343
+ form?.addEventListener("submit", async (e) => {
344
+ e.preventDefault();
345
+ const name = document.getElementById("setup-name").value.trim();
346
+ const email = emailInput.value.trim();
347
+ const password = document.getElementById("setup-password").value;
348
+ if (!email)
349
+ return;
350
+ statusEl.textContent = "Setting up account...";
351
+ try {
352
+ const res = await fetch("/api/setup", {
353
+ method: "POST",
354
+ headers: { "Content-Type": "application/json" },
355
+ body: JSON.stringify({ name, email, password }),
356
+ });
357
+ const data = await res.json();
358
+ if (data.ok) {
359
+ statusEl.textContent = "Account added! Restarting...";
360
+ setTimeout(() => location.reload(), 2000);
361
+ }
362
+ else {
363
+ statusEl.textContent = `Error: ${data.error || "Setup failed"}`;
364
+ }
365
+ }
366
+ catch (err) {
367
+ statusEl.textContent = `Error: ${err.message}`;
368
+ }
369
+ });
329
370
  }
330
371
  // Dismiss startup overlay
331
372
  const overlay = document.getElementById("startup-overlay");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.104",
3
+ "version": "1.0.106",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,7 +20,7 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.39",
23
+ "@bobfrankston/iflow": "^1.0.41",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
26
  "@bobfrankston/rust-builder": "^0.1.3",
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Router } from "express";
6
6
  import { MailxService } from "@bobfrankston/mailx-service";
7
+ import { loadAccounts, saveAccounts, initLocalConfig } from "@bobfrankston/mailx-settings";
7
8
  export function createApiRouter(db, imapManager) {
8
9
  const svc = new MailxService(db, imapManager);
9
10
  const router = Router();
@@ -105,6 +106,37 @@ export function createApiRouter(db, imapManager) {
105
106
  res.status(500).json({ error: e.message });
106
107
  }
107
108
  });
109
+ // ── First-time setup ──
110
+ router.post("/setup", async (req, res) => {
111
+ try {
112
+ const { name, email, password } = req.body;
113
+ if (!email) {
114
+ res.status(400).json({ error: "Email address required" });
115
+ return;
116
+ }
117
+ // Ensure ~/.mailx exists
118
+ initLocalConfig();
119
+ // Build account config (normalizeAccount handles provider detection)
120
+ const account = { email, name: name || email.split("@")[0] };
121
+ if (password)
122
+ account.password = password;
123
+ // Load existing accounts, add new one, save
124
+ const accounts = loadAccounts();
125
+ const domain = email.split("@")[1]?.toLowerCase() || "";
126
+ const id = domain.split(".")[0] || "account";
127
+ if (accounts.some((a) => a.email === email)) {
128
+ res.json({ ok: false, error: "Account already exists" });
129
+ return;
130
+ }
131
+ account.id = id;
132
+ accounts.push(account);
133
+ saveAccounts(accounts);
134
+ res.json({ ok: true, message: "Account added. Restart to begin syncing." });
135
+ }
136
+ catch (e) {
137
+ res.status(500).json({ error: e.message });
138
+ }
139
+ });
108
140
  // ── Send ──
109
141
  router.post("/send", async (req, res) => {
110
142
  try {
@@ -39,6 +39,8 @@ export declare class ImapManager extends EventEmitter {
39
39
  getOAuthToken(accountId: string): Promise<string | null>;
40
40
  /** Accounts currently re-authenticating — all operations skip these */
41
41
  private reauthenticating;
42
+ /** Last reauth attempt timestamp per account — prevents reauth loops (5 min cooldown) */
43
+ private lastReauthAttempt;
42
44
  /** Force re-authentication for an OAuth account — deletes cached IMAP token, triggers browser consent */
43
45
  reauthenticate(accountId: string): Promise<boolean>;
44
46
  /** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
@@ -101,6 +101,8 @@ export class ImapManager extends EventEmitter {
101
101
  }
102
102
  /** Accounts currently re-authenticating — all operations skip these */
103
103
  reauthenticating = new Set();
104
+ /** Last reauth attempt timestamp per account — prevents reauth loops (5 min cooldown) */
105
+ lastReauthAttempt = new Map();
104
106
  /** Force re-authentication for an OAuth account — deletes cached IMAP token, triggers browser consent */
105
107
  async reauthenticate(accountId) {
106
108
  if (this.reauthenticating.has(accountId))
@@ -515,19 +517,28 @@ export class ImapManager extends EventEmitter {
515
517
  console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
516
518
  }
517
519
  else if (isAuth && isOAuth) {
518
- // OAuth auth error: auto-attempt re-auth
519
- console.log(` [auth] ${accountId}: attempting automatic re-authentication...`);
520
- this.reauthenticate(accountId).then(ok => {
521
- if (!ok && !this.accountErrorShown.has(accountId)) {
522
- this.accountErrorShown.add(accountId);
523
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
524
- }
525
- }).catch(() => {
526
- if (!this.accountErrorShown.has(accountId)) {
527
- this.accountErrorShown.add(accountId);
528
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
529
- }
530
- });
520
+ // OAuth auth error: auto-reauth ONCE, then show banner
521
+ const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
522
+ const cooldown = Date.now() - lastReauth > 300000; // 5 min cooldown
523
+ if (cooldown && !this.reauthenticating.has(accountId)) {
524
+ this.lastReauthAttempt.set(accountId, Date.now());
525
+ console.log(` [auth] ${accountId}: attempting automatic re-authentication...`);
526
+ this.reauthenticate(accountId).then(ok => {
527
+ if (!ok && !this.accountErrorShown.has(accountId)) {
528
+ this.accountErrorShown.add(accountId);
529
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
530
+ }
531
+ }).catch(() => {
532
+ if (!this.accountErrorShown.has(accountId)) {
533
+ this.accountErrorShown.add(accountId);
534
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
535
+ }
536
+ });
537
+ }
538
+ else if (!this.accountErrorShown.has(accountId)) {
539
+ this.accountErrorShown.add(accountId);
540
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
541
+ }
531
542
  }
532
543
  else if (!this.accountErrorShown.has(accountId)) {
533
544
  // Non-transient, non-OAuth: show error banner
@@ -1097,6 +1108,11 @@ export class ImapManager extends EventEmitter {
1097
1108
  try {
1098
1109
  await client.appendMessage(outboxPath, rawMessage, ["\\Seen"]);
1099
1110
  console.log(` [outbox] Queued message in ${outboxPath}`);
1111
+ // Sync outbox folder so message appears in UI immediately
1112
+ const outboxFolder = this.findFolder(accountId, "outbox");
1113
+ if (outboxFolder) {
1114
+ this.syncFolder(accountId, outboxFolder.id).catch(() => { });
1115
+ }
1100
1116
  return;
1101
1117
  }
1102
1118
  finally {
@@ -1236,19 +1252,29 @@ export class ImapManager extends EventEmitter {
1236
1252
  envelope: { from: sender, to: recipients },
1237
1253
  });
1238
1254
  console.log(` [outbox] Sent UID ${uid} → ${recipients.join(", ")}`);
1239
- // Move to Sent
1255
+ // Move to Sent and sync both folders so UI updates
1240
1256
  const sentFolder = this.findFolder(accountId, "sent");
1241
1257
  if (sentFolder) {
1242
1258
  await client.moveMessage(msg, outboxFolder.path, sentFolder.path);
1259
+ this.syncFolder(accountId, sentFolder.id).catch(() => { });
1260
+ this.syncFolder(accountId, outboxFolder.id).catch(() => { });
1243
1261
  }
1244
1262
  else {
1245
1263
  await client.deleteMessageByUid(outboxFolder.path, uid);
1246
1264
  }
1247
1265
  }
1248
1266
  catch (e) {
1249
- console.error(` [outbox] Send failed UID ${uid}: ${e.message}`);
1267
+ const errMsg = e.message || String(e);
1268
+ console.error(` [outbox] Send failed UID ${uid}: ${errMsg}`);
1250
1269
  await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
1251
1270
  await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
1271
+ // Notify UI about the send failure
1272
+ this.emit("accountError", accountId, `Send failed: ${errMsg}`, "Message kept in Outbox", false);
1273
+ // Auth failures should not retry — back off this account
1274
+ if (/auth|login|credential|invalid/i.test(errMsg)) {
1275
+ this.outboxBackoff.set(accountId, Date.now() + 3600000); // 1 hour
1276
+ console.error(` [outbox] Auth failure for ${accountId} — outbox paused for 1 hour`);
1277
+ }
1252
1278
  }
1253
1279
  }
1254
1280
  }
@@ -57,14 +57,13 @@ if (settings.accounts.length === 0) {
57
57
  const dbDir = getConfigDir();
58
58
  const db = new MailxDB(dbDir);
59
59
  const imapManager = new ImapManager(db);
60
- // Native IMAP client has connection leak logout() doesn't reliably close sockets.
61
- // Use legacy imapflow by default until fixed. --native-imap to test.
62
- if (process.argv.includes("--native-imap") || process.argv.includes("-native-imap") || process.env.MAILX_NATIVE_IMAP) {
63
- imapManager.useNativeClient = true;
64
- console.log(" Using native IMAP client (transport-agnostic)");
60
+ // Native IMAP client is default. Use --legacy-imap to fall back to imapflow.
61
+ if (process.argv.includes("--legacy-imap") || process.argv.includes("-legacy-imap") || process.env.MAILX_LEGACY_IMAP) {
62
+ console.log(" Using legacy IMAP client (imapflow)");
65
63
  }
66
64
  else {
67
- console.log(" Using legacy IMAP client (imapflow)");
65
+ imapManager.useNativeClient = true;
66
+ console.log(" Using native IMAP client (transport-agnostic)");
68
67
  }
69
68
  // ── Express App ──
70
69
  const app = express();
@@ -94,7 +93,7 @@ app.get("/api/version", (req, res) => {
94
93
  res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage });
95
94
  });
96
95
  app.all("/info", (req, res) => {
97
- res.json({ version: SERVER_VERSION, uptime: Math.round(process.uptime()), port: PORT });
96
+ res.json({ version: SERVER_VERSION, uptime: Math.round(process.uptime()), port: PORT, imap: imapManager.useNativeClient ? "native" : "imapflow" });
98
97
  });
99
98
  app.get("/status", (req, res) => {
100
99
  const accounts = db.getAccounts();
package/rebuild.cmd CHANGED
@@ -1,7 +1,4 @@
1
- setlocal
2
- set MAILX_NATIVE_IMAP=1
3
1
  cld
4
2
  call npmglobalize
5
3
  call killmail.cmd
6
4
  launch.ps1
7
- endlocal