@bobfrankston/mailx 1.0.104 → 1.0.105
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
|
|
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:
|
|
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>
|
|
309
|
-
<
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.105",
|
|
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.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.40",
|
|
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-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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();
|