@bobfrankston/mailx 1.0.38 → 1.0.40

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.
@@ -317,7 +317,40 @@ async function loadFolderTree(container) {
317
317
  try {
318
318
  const accounts = await getAccounts();
319
319
  if (accounts.length === 0) {
320
- container.innerHTML = `<div class="folder-loading">No accounts configured.<br>Edit ~/.mailx/settings.jsonc</div>`;
320
+ container.innerHTML = `<div class="folder-loading">No accounts</div>`;
321
+ // Show setup instructions in the main content area (full width)
322
+ const mainBody = document.getElementById("ml-body");
323
+ if (mainBody) {
324
+ mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:600px">
325
+ <h2 style="margin-bottom:1rem">Welcome to mailx</h2>
326
+ <p>Create <code>~/.mailx/settings.jsonc</code> with your email accounts.</p>
327
+ <p>If you have settings on OneDrive, create <code>~/.mailx/config.jsonc</code>:</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
+ <h3 style="margin-top:1.5rem">Gmail (just your email):</h3>
330
+ <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",
331
+ "accounts": [
332
+ { "email": "you@gmail.com" }
333
+ ]
334
+ }</code>
335
+ <h3 style="margin-top:1rem">Standard IMAP:</h3>
336
+ <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",
337
+ "accounts": [
338
+ { "email": "you@example.com",
339
+ "password": "secret",
340
+ "imap": { "host": "imap.example.com" },
341
+ "smtp": { "host": "smtp.example.com" }
342
+ }
343
+ ]
344
+ }</code>
345
+ <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>
346
+ </div>`;
347
+ }
348
+ // Dismiss startup overlay
349
+ const overlay = document.getElementById("startup-overlay");
350
+ if (overlay) {
351
+ overlay.classList.add("hidden");
352
+ setTimeout(() => overlay.remove(), 400);
353
+ }
321
354
  return;
322
355
  }
323
356
  // Clear loading state now that we have data
@@ -197,7 +197,10 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
197
197
  // Save scroll position and selected UID for non-autoSelect reloads
198
198
  const savedScroll = !autoSelect ? body.scrollTop : 0;
199
199
  const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
200
- body.innerHTML = `<div class="ml-empty">Loading...</div>`;
200
+ // Only show loading indicator on fresh navigation, not reloads
201
+ if (autoSelect) {
202
+ body.innerHTML = `<div class="ml-empty">Loading...</div>`;
203
+ }
201
204
  try {
202
205
  const result = await getMessages(accountId, folderId, 1);
203
206
  totalMessages = result.total;
@@ -215,13 +218,15 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
215
218
  firstRow.click();
216
219
  }
217
220
  else {
218
- // Sync reload — restore scroll position and selection
219
- body.scrollTop = savedScroll;
220
- if (savedUid) {
221
- const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
222
- if (row)
223
- row.classList.add("selected");
224
- }
221
+ // Sync reload — restore scroll position and selection after layout
222
+ requestAnimationFrame(() => {
223
+ body.scrollTop = savedScroll;
224
+ if (savedUid) {
225
+ const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
226
+ if (row)
227
+ row.classList.add("selected");
228
+ }
229
+ });
225
230
  }
226
231
  }
227
232
  catch (e) {
@@ -30,7 +30,7 @@ async function api(path, options) {
30
30
  // Network error — server is down
31
31
  if (e.name === "AbortError")
32
32
  throw e;
33
- throw new Error("Server offline — restart with launch.ps1");
33
+ throw new Error("Server offline — run: mailx -server");
34
34
  }
35
35
  if (!res.ok) {
36
36
  const err = await res.json().catch(() => ({ error: res.statusText }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
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.9",
23
+ "@bobfrankston/iflow": "^1.0.20",
24
24
  "@bobfrankston/miscinfo": "^1.0.6",
25
25
  "@bobfrankston/oauthsupport": "^1.0.11",
26
26
  "@bobfrankston/rust-builder": "^0.1.2",
@@ -1103,7 +1103,7 @@ export class ImapManager extends EventEmitter {
1103
1103
  async syncAllContacts() {
1104
1104
  const settings = loadSettings();
1105
1105
  for (const account of settings.accounts) {
1106
- if (account.imap.auth === "oauth2" && account.enabled) {
1106
+ if (account.imap.auth === "oauth2" && (account.enabled || account.syncContacts)) {
1107
1107
  await this.syncGoogleContacts(account.id);
1108
1108
  }
1109
1109
  }
@@ -41,10 +41,10 @@ console.error = (...args) => {
41
41
  origErr(msg);
42
42
  logStream.write(msg + "\n");
43
43
  };
44
- const serverPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "package.json"), "utf-8"));
45
- const clientPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "..", "client", "package.json"), "utf-8"));
46
- const SERVER_VERSION = serverPkg.version;
47
- const CLIENT_VERSION = clientPkg.version;
44
+ // Read version from root package.json (the published version)
45
+ const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "..", "package.json"), "utf-8"));
46
+ const SERVER_VERSION = rootPkg.version;
47
+ const CLIENT_VERSION = rootPkg.version;
48
48
  // ── Initialize ──
49
49
  initLocalConfig();
50
50
  const settings = loadSettings();
@@ -185,11 +185,11 @@ async function start() {
185
185
  }, 5000);
186
186
  // Add configured accounts
187
187
  for (const account of settings.accounts) {
188
- if (!account.enabled)
188
+ if (!account.enabled && !account.syncContacts)
189
189
  continue;
190
190
  try {
191
191
  await imapManager.addAccount(account);
192
- console.log(` Account added: ${account.name} (${account.id})`);
192
+ console.log(` Account added: ${account.name} (${account.id})${!account.enabled ? " [contacts only]" : ""}`);
193
193
  }
194
194
  catch (e) {
195
195
  console.error(` Failed to add account ${account.id}: ${e.message}`);
@@ -23,6 +23,22 @@ const LOCAL_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".",
23
23
  const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.jsonc");
24
24
  const LEGACY_CONFIG_PATH = path.join(LOCAL_DIR, "config.json");
25
25
  const DEFAULT_STORE_PATH = path.join(LOCAL_DIR, "mailxstore");
26
+ /** Resolve a path from config — relative to ~/.mailx/, ~ expands to home */
27
+ function resolvePath(p) {
28
+ if (!p)
29
+ return p;
30
+ const home = process.env.USERPROFILE || process.env.HOME || ".";
31
+ // Expand ~ to home directory
32
+ if (p.startsWith("~/") || p.startsWith("~\\"))
33
+ return path.join(home, p.slice(2));
34
+ if (p === "~")
35
+ return home;
36
+ // Absolute path — use as-is
37
+ if (path.isAbsolute(p))
38
+ return p;
39
+ // Relative — resolve from config directory (~/.mailx/)
40
+ return path.resolve(LOCAL_DIR, p);
41
+ }
26
42
  function readLocalConfig() {
27
43
  // Migrate config.json → config.jsonc
28
44
  if (!fs.existsSync(LOCAL_CONFIG_PATH) && fs.existsSync(LEGACY_CONFIG_PATH)) {
@@ -35,10 +51,10 @@ function readLocalConfig() {
35
51
  function getSharedDir() {
36
52
  const config = readLocalConfig();
37
53
  if (config.sharedDir)
38
- return config.sharedDir;
54
+ return resolvePath(config.sharedDir);
39
55
  // Legacy: derive from settingsPath
40
56
  if (config.settingsPath)
41
- return path.dirname(config.settingsPath);
57
+ return path.dirname(resolvePath(config.settingsPath));
42
58
  return LOCAL_DIR;
43
59
  }
44
60
  // ── File helpers ──
@@ -55,7 +71,7 @@ function readJsonc(filePath) {
55
71
  return null;
56
72
  }
57
73
  try {
58
- return parseJsonc(fs.readFileSync(actual, "utf-8"));
74
+ return parseJsonc(fs.readFileSync(actual, "utf-8").replace(/\r/g, ""));
59
75
  }
60
76
  catch (e) {
61
77
  console.error(`Failed to read ${actual}: ${e.message}`);
@@ -160,6 +176,7 @@ function normalizeAccount(acct, globalName) {
160
176
  },
161
177
  enabled: acct.enabled ?? true,
162
178
  defaultSend: acct.defaultSend,
179
+ syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
163
180
  relayDomains: acct.relayDomains,
164
181
  deliveredToPrefix: acct.deliveredToPrefix,
165
182
  };
@@ -250,8 +267,14 @@ export function saveAllowlist(list) {
250
267
  // ── Legacy compatibility ──
251
268
  function loadLegacySettings() {
252
269
  const config = readLocalConfig();
253
- const settingsPath = config.settingsPath || path.join(LOCAL_DIR, "settings.jsonc");
254
- return readJsonc(settingsPath);
270
+ if (config.settingsPath)
271
+ return readJsonc(resolvePath(config.settingsPath));
272
+ // Try shared dir first, then local
273
+ const sharedDir = getSharedDir();
274
+ const shared = readJsonc(path.join(sharedDir, "settings.jsonc"));
275
+ if (shared)
276
+ return shared;
277
+ return readJsonc(path.join(LOCAL_DIR, "settings.jsonc"));
255
278
  }
256
279
  /** Load settings — unified view combining all files (backward compatible) */
257
280
  export function loadSettings() {
@@ -276,7 +299,7 @@ export function saveSettings(settings) {
276
299
  /** Get the local store base path */
277
300
  export function getStorePath() {
278
301
  const config = readLocalConfig();
279
- return config.storePath || DEFAULT_STORE_PATH;
302
+ return config.storePath ? resolvePath(config.storePath) : DEFAULT_STORE_PATH;
280
303
  }
281
304
  /** Get the local data directory (DB, store, etc.) */
282
305
  export function getConfigDir() {
@@ -292,10 +315,12 @@ function detectSharedDir() {
292
315
  process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx"),
293
316
  process.env.OneDriveConsumer && path.join(process.env.OneDriveConsumer, "home", ".mailx"),
294
317
  home && path.join(home, "OneDrive", "home", ".mailx"),
295
- // Linux/Mac OneDrive
296
- home && path.join(home, "OneDrive", "home", ".mailx"),
318
+ // Linux/Mac — case variations
319
+ home && path.join(home, "onedrive", "home", ".mailx"),
320
+ home && path.join(home, "Onedrive", "home", ".mailx"),
297
321
  // Dropbox
298
322
  home && path.join(home, "Dropbox", ".mailx"),
323
+ home && path.join(home, "dropbox", ".mailx"),
299
324
  // Local fallback — just use ~/.mailx itself
300
325
  ].filter(Boolean);
301
326
  for (const dir of candidates) {
@@ -30,6 +30,7 @@ export interface AccountConfig {
30
30
  };
31
31
  enabled: boolean;
32
32
  defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
33
+ syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
33
34
  relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
34
35
  deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
35
36
  }