@bobfrankston/mailx 1.0.36 → 1.0.38

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.
package/README.md CHANGED
@@ -29,95 +29,127 @@ If you already have mailx configured on another machine with settings on OneDriv
29
29
 
30
30
  Once OneDrive syncs, mailx picks up your accounts automatically.
31
31
 
32
- ### Option B: New user -- set up from scratch
32
+ ### Option B: New user -- single machine, local settings
33
33
 
34
- **Step 1.** Create the settings directory:
34
+ **Step 1.** Create `~/.mailx/settings.jsonc` with your account(s):
35
35
 
36
+ (On Windows `~` means `%USERPROFILE%`, e.g., `C:\Users\You\.mailx\settings.jsonc`)
37
+
38
+ **Gmail -- minimal config (just your email):**
39
+ ```jsonc
40
+ {
41
+ "accounts": [
42
+ { "email": "you@gmail.com" }
43
+ ]
44
+ }
36
45
  ```
37
- mkdir ~/.mailx
38
- ```
39
46
 
40
- (On Windows: `mkdir %USERPROFILE%\.mailx`)
47
+ mailx auto-fills Gmail's IMAP/SMTP/OAuth settings. First run opens a browser for OAuth consent.
48
+
49
+ **Standard IMAP -- just host and password:**
50
+ ```jsonc
51
+ {
52
+ "accounts": [
53
+ {
54
+ "email": "you@example.com",
55
+ "password": "your-password",
56
+ "imap": { "host": "imap.example.com" },
57
+ "smtp": { "host": "smtp.example.com" }
58
+ }
59
+ ]
60
+ }
61
+ ```
41
62
 
42
- **Step 2.** Create `~/.mailx/settings.jsonc` with your account(s):
63
+ Defaults: port 993/587, TLS on, auth password, username = email address. Only provide fields that differ from defaults.
43
64
 
65
+ **Full config with all optional fields:**
44
66
  ```jsonc
45
67
  {
46
68
  "accounts": [
47
69
  {
48
- "id": "mymail", // Internal ID (no spaces, lowercase)
49
- "name": "Your Name", // Sender name in From: header
50
- "label": "Work", // Display label in UI (optional, defaults to name)
51
- "email": "you@example.com", // Your email address
70
+ "id": "mymail", // Internal ID (default: domain name)
71
+ "name": "Your Name", // From: header name (default: local part of email)
72
+ "label": "Work", // UI display label (default: name)
73
+ "email": "you@example.com",
52
74
  "imap": {
53
- "host": "imap.example.com", // IMAP server
54
- "port": 993, // Usually 993 for SSL/TLS
55
- "tls": true,
56
- "auth": "password", // "password" or "oauth2" (Gmail)
57
- "user": "you@example.com", // IMAP username
58
- "password": "your-password" // IMAP password (not needed for oauth2)
75
+ "host": "imap.example.com", // Default: imap.{domain}
76
+ "port": 993, // Default: 993
77
+ "tls": true, // Default: true
78
+ "auth": "password", // Default: "password" (or "oauth2" for Gmail/Outlook)
79
+ "user": "you@example.com", // Default: email address
80
+ "password": "your-password"
59
81
  },
60
82
  "smtp": {
61
- "host": "smtp.example.com", // SMTP server
62
- "port": 587, // Usually 587 for STARTTLS
63
- "tls": true,
83
+ "host": "smtp.example.com", // Default: smtp.{domain}
84
+ "port": 587, // Default: 587
85
+ "tls": true, // Default: true
64
86
  "auth": "password",
65
87
  "user": "you@example.com",
66
88
  "password": "your-password"
67
89
  },
68
- "enabled": true,
69
- "defaultSend": true // Use this account for sending when From doesn't match
90
+ "enabled": true, // Default: true
91
+ "defaultSend": true, // Use this account when From doesn't match
92
+ "relayDomains": [], // Domains to skip in Delivered-To chain
93
+ "deliveredToPrefix": [] // Prefixes to strip from Delivered-To alias
70
94
  }
71
95
  ],
72
96
  "sync": {
73
- "intervalMinutes": 5, // Full sync interval (minutes)
74
- "historyDays": 0 // Days of history to sync (0 = all)
97
+ "intervalMinutes": 5, // Default: 5
98
+ "historyDays": 0 // Default: 30 (0 = all)
99
+ },
100
+ "ui": {
101
+ "theme": "system" // "system" (default), "dark", or "light"
75
102
  }
76
103
  }
77
104
  ```
78
105
 
79
- **Step 3.** Run `mailx` and open `http://127.0.0.1:9333` in your browser.
106
+ **Known providers with automatic defaults:** Gmail, Google, Outlook, Hotmail, Yahoo, iCloud. For these, only `email` is required.
107
+
108
+ No `config.jsonc` needed -- when it doesn't exist, mailx reads settings directly from `~/.mailx/`.
109
+
110
+ **Step 2.** Run `mailx` and open `http://127.0.0.1:9333` in your browser.
80
111
 
81
112
  ### Option C: Multi-machine with shared settings (OneDrive/Dropbox)
82
113
 
83
- Put `settings.jsonc` on a cloud-synced folder, then create a local pointer:
114
+ To share settings across machines, put `settings.jsonc` in a cloud-synced folder and create a local pointer file.
115
+
116
+ **Step 1.** Move `settings.jsonc` to a shared location:
117
+
118
+ ```
119
+ # Example: OneDrive
120
+ mkdir "%OneDrive%\home\.mailx"
121
+ move "%USERPROFILE%\.mailx\settings.jsonc" "%OneDrive%\home\.mailx\settings.jsonc"
122
+ ```
123
+
124
+ **Step 2.** Create `~/.mailx/config.jsonc` pointing to the shared location:
84
125
 
85
- **`~/.mailx/config.jsonc`:**
86
126
  ```jsonc
87
127
  {
88
- // Point to shared settings on OneDrive (or Dropbox, network share, etc.)
128
+ // Where the shared settings live (OneDrive, Dropbox, network share, etc.)
89
129
  "sharedDir": "C:/Users/You/OneDrive/home/.mailx",
90
- // Local-only: where cached messages are stored
91
- "storePath": "C:/Users/You/.mailx/mailxstore",
92
- // Per-machine override: days of history (0 = all)
93
- "historyDays": 90
130
+
131
+ // Local-only settings (not synced):
132
+ "storePath": "C:/Users/You/.mailx/mailxstore", // cached message bodies
133
+ "historyDays": 90 // override sync depth per machine
94
134
  }
95
135
  ```
96
136
 
97
- The shared directory contains: `settings.jsonc`, `allowlist.jsonc`, `preferences.jsonc`. Each machine caches them locally for offline use.
137
+ **How it works:**
138
+ - `config.jsonc` is local to each machine -- never synced
139
+ - `sharedDir` points to the cloud folder containing `settings.jsonc`, `allowlist.jsonc`, `preferences.jsonc`
140
+ - mailx caches shared files locally for offline use
141
+ - `historyDays` in `config.jsonc` overrides the shared default per machine
142
+ - On new machines, if `config.jsonc` doesn't exist, mailx auto-detects OneDrive at `%OneDrive%/home/.mailx/`
98
143
 
99
- ### Adding a Gmail account
144
+ ### Gmail OAuth setup
100
145
 
101
- Gmail requires OAuth2 instead of a password:
146
+ Gmail accounts auto-configure -- just add `{ "email": "you@gmail.com" }` to accounts. But OAuth requires a one-time Google Cloud setup:
102
147
 
103
148
  1. Go to [Google Cloud Console](https://console.cloud.google.com/) and create a project
104
149
  2. Enable the **Gmail API** (and **People API** for contacts)
105
150
  3. Create **OAuth 2.0 credentials** (Desktop app type)
106
151
  4. Download `credentials.json` to the iflow package directory
107
- 5. Add the Gmail account to `settings.jsonc` with `"auth": "oauth2"`
108
- 6. First connection opens a browser for OAuth consent. Tokens are cached and refresh automatically.
109
-
110
- ```jsonc
111
- {
112
- "id": "gmail",
113
- "name": "Your Name",
114
- "label": "Gmail",
115
- "email": "you@gmail.com",
116
- "imap": { "host": "imap.gmail.com", "port": 993, "tls": true, "auth": "oauth2", "user": "you@gmail.com" },
117
- "smtp": { "host": "smtp.gmail.com", "port": 587, "tls": true, "auth": "oauth2", "user": "you@gmail.com" },
118
- "enabled": true
119
- }
120
- ```
152
+ 5. First connection opens a browser for OAuth consent. Tokens are cached and refresh automatically.
121
153
 
122
154
  ## Usage
123
155
 
package/bin/mailx.js CHANGED
@@ -172,16 +172,34 @@ async function main() {
172
172
 
173
173
  let launcherPath = launcherPaths.find(p => fs.existsSync(p));
174
174
 
175
+ // On Linux, skip native launcher if no display server available
176
+ if (launcherPath && process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
177
+ log("No display server (DISPLAY/WAYLAND_DISPLAY not set) — skipping native launcher");
178
+ launcherPath = undefined;
179
+ }
180
+
175
181
  if (launcherPath) {
176
182
  console.log("Starting mailx...");
177
183
  log(`Launching: ${launcherPath}`);
178
184
  const { spawn } = await import("node:child_process");
179
- const child = spawn(launcherPath, args, { detached: true, stdio: "ignore" });
180
- child.unref();
181
- console.log("mailx launched");
185
+ try {
186
+ const child = spawn(launcherPath, args, { detached: true, stdio: "ignore" });
187
+ child.on("error", () => {
188
+ console.log("Native launcher failed, starting in browser mode...");
189
+ process.argv.push("--server");
190
+ main();
191
+ });
192
+ child.unref();
193
+ console.log("mailx launched");
194
+ } catch (e) {
195
+ console.log(`Native launcher failed: ${e.message}`);
196
+ console.log("Starting in browser mode...");
197
+ process.argv.push("--server");
198
+ await main();
199
+ }
182
200
  } else {
183
- console.log("Native launcher not found, starting in browser mode...");
184
- log("Falling back to --server mode");
201
+ console.log("Starting in browser mode...");
202
+ log("No native launcher — falling back to --server mode");
185
203
  process.argv.push("--server");
186
204
  await main(); // recurse with --server
187
205
  }
package/client/app.js CHANGED
@@ -639,6 +639,14 @@ setInterval(async () => {
639
639
  }, 5000);
640
640
  console.log("mailx client initialized, location:", location.href);
641
641
  updateNewMessageCount();
642
+ // ── Apply theme from settings ──
643
+ fetch("/api/version").then(r => r.json()).then(d => {
644
+ if (d.theme === "dark")
645
+ document.documentElement.classList.add("theme-dark");
646
+ else if (d.theme === "light")
647
+ document.documentElement.classList.add("theme-light");
648
+ // "system" or missing = no class, CSS media query handles it
649
+ }).catch(() => { });
642
650
  // Diagnostic: test API connectivity (helps debug WebView2 blank screen)
643
651
  fetch("/api/version").then(r => r.json()).then(d => {
644
652
  console.log("API reachable:", d);
@@ -387,7 +387,16 @@ async function loadFolderTree(container) {
387
387
  // Re-select previous folder on refresh, or auto-select Inbox on first load
388
388
  const allFolderEls = container.querySelectorAll('.ft-folder');
389
389
  let target = null;
390
- if (selectedAccountId && selectedFolderId !== null && selectedFolderId >= 0) {
390
+ if (selectedFolderId === -1) {
391
+ // Unified inbox was selected — just re-highlight it, don't click
392
+ const unified = container.querySelector('.ft-unified');
393
+ if (unified) {
394
+ unified.classList.add("selected");
395
+ selectedElement = unified;
396
+ target = unified;
397
+ }
398
+ }
399
+ else if (selectedAccountId && selectedFolderId !== null && selectedFolderId >= 0) {
391
400
  for (const f of allFolderEls) {
392
401
  const el = f;
393
402
  if (el.dataset.accountId === selectedAccountId && el.dataset.folderId === String(selectedFolderId)) {
@@ -1,4 +1,5 @@
1
1
  /* mailx design tokens -- all theming via custom properties */
2
+ /* Theme controlled by class on <html>: theme-dark, theme-light, or absent (system) */
2
3
 
3
4
  :root {
4
5
  /* Layout */
@@ -8,19 +9,19 @@
8
9
  --splitter-size: 5px;
9
10
  --list-viewer-split: 40%;
10
11
 
11
- /* Colors in oklch */
12
- --color-brand: oklch(0.88 0.04 240); /* mailx light blue — toolbar, highlights */
13
- --color-brand-dark: oklch(0.45 0.10 240); /* darker brand for text on light bg */
12
+ /* Colors dark by default */
13
+ --color-brand: oklch(0.88 0.04 240);
14
+ --color-brand-dark: oklch(0.45 0.10 240);
14
15
  --color-bg: oklch(0.20 0.02 270);
15
16
  --color-bg-surface: oklch(0.25 0.03 270);
16
17
  --color-bg-hover: color-mix(in oklch, var(--color-bg-surface) 80%, white);
17
18
  --color-bg-selected: var(--color-brand);
18
19
  --color-bg-toolbar: oklch(0.22 0.02 270);
19
- --color-text: oklch(0.90 0.01 270);
20
- --color-text-muted: oklch(0.55 0.02 270);
20
+ --color-text: oklch(0.92 0.01 270);
21
+ --color-text-muted: oklch(0.68 0.02 270);
21
22
  --color-accent: var(--color-brand-dark);
22
23
  --color-unread: oklch(0.95 0.02 270);
23
- --color-border: oklch(0.35 0.02 270);
24
+ --color-border: oklch(0.40 0.02 270);
24
25
  --color-danger: oklch(0.65 0.15 15);
25
26
  --color-success: oklch(0.65 0.15 145);
26
27
  --color-badge: oklch(0.60 0.18 250);
@@ -46,9 +47,26 @@
46
47
  color-scheme: dark light;
47
48
  }
48
49
 
49
- /* Light theme -- follows system preference */
50
+ /* Light overrides */
51
+ .theme-light {
52
+ --color-bg: oklch(0.97 0.005 270);
53
+ --color-bg-surface: oklch(1.0 0 0);
54
+ --color-bg-hover: oklch(0.95 0.005 270);
55
+ --color-brand: oklch(0.88 0.04 240);
56
+ --color-brand-dark: oklch(0.35 0.12 240);
57
+ --color-bg-selected: var(--color-brand);
58
+ --color-bg-toolbar: oklch(0.95 0.005 270);
59
+ --color-text: oklch(0.20 0.02 270);
60
+ --color-text-muted: oklch(0.45 0.02 270);
61
+ --color-accent: var(--color-brand-dark);
62
+ --color-unread: oklch(0.10 0.02 270);
63
+ --color-border: oklch(0.85 0.01 270);
64
+ color-scheme: light;
65
+ }
66
+
67
+ /* System preference: light — apply when no explicit theme class */
50
68
  @media (prefers-color-scheme: light) {
51
- :root {
69
+ :root:not(.theme-dark):not(.theme-light) {
52
70
  --color-bg: oklch(0.97 0.005 270);
53
71
  --color-bg-surface: oklch(1.0 0 0);
54
72
  --color-bg-hover: oklch(0.95 0.005 270);
@@ -61,5 +79,6 @@
61
79
  --color-accent: var(--color-brand-dark);
62
80
  --color-unread: oklch(0.10 0.02 270);
63
81
  --color-border: oklch(0.85 0.01 270);
82
+ color-scheme: light;
64
83
  }
65
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
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.7",
23
+ "@bobfrankston/iflow": "^1.0.9",
24
24
  "@bobfrankston/miscinfo": "^1.0.6",
25
25
  "@bobfrankston/oauthsupport": "^1.0.11",
26
26
  "@bobfrankston/rust-builder": "^0.1.2",
@@ -81,7 +81,7 @@ app.use("/node_modules", express.static(path.join(rootDir, "node_modules"), { et
81
81
  // Mount API
82
82
  const apiRouter = createApiRouter(db, imapManager);
83
83
  app.use("/api", apiRouter);
84
- app.get("/api/version", (req, res) => res.json({ server: SERVER_VERSION, client: CLIENT_VERSION }));
84
+ app.get("/api/version", (req, res) => res.json({ server: SERVER_VERSION, client: CLIENT_VERSION, theme: settings.ui?.theme || "system" }));
85
85
  app.get("/status", (req, res) => {
86
86
  const accounts = db.getAccounts();
87
87
  const pendingSync = db.getTotalPendingSyncCount();
@@ -20,7 +20,7 @@ declare const LOCAL_DIR: string;
20
20
  declare function getSharedDir(): string;
21
21
  declare const DEFAULT_PREFERENCES: {
22
22
  ui: {
23
- theme: "dark" | "light";
23
+ theme: "system" | "dark" | "light";
24
24
  folderWidth: number;
25
25
  listViewerSplit: number;
26
26
  fontSize: number;
@@ -39,7 +39,7 @@ declare const DEFAULT_ALLOWLIST: {
39
39
  export declare function loadAccounts(): AccountConfig[];
40
40
  /** Save account configs */
41
41
  export declare function saveAccounts(accounts: AccountConfig[]): void;
42
- /** Load preferences (shared + local overrides) */
42
+ /** Load preferences (shared + local overrides, with legacy fallback) */
43
43
  export declare function loadPreferences(): typeof DEFAULT_PREFERENCES;
44
44
  /** Save preferences */
45
45
  export declare function savePreferences(prefs: typeof DEFAULT_PREFERENCES): void;
@@ -105,11 +105,70 @@ function saveFile(filename, data) {
105
105
  catch { /* ignore */ }
106
106
  }
107
107
  }
108
+ const PROVIDERS = {
109
+ "gmail.com": {
110
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
111
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
112
+ },
113
+ "googlemail.com": {
114
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
115
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
116
+ },
117
+ "outlook.com": {
118
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
119
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
120
+ },
121
+ "hotmail.com": {
122
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
123
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
124
+ },
125
+ "yahoo.com": {
126
+ imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
127
+ smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
128
+ },
129
+ "icloud.com": {
130
+ imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
131
+ smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
132
+ },
133
+ };
134
+ /** Fill in provider defaults for an account based on email domain */
135
+ function normalizeAccount(acct, globalName) {
136
+ const email = acct.email || "";
137
+ const domain = email.split("@")[1]?.toLowerCase() || "";
138
+ const provider = PROVIDERS[domain];
139
+ const user = acct.imap?.user || acct.user || email;
140
+ return {
141
+ id: acct.id || domain.split(".")[0] || "account",
142
+ name: acct.name || globalName || email.split("@")[0],
143
+ label: acct.label,
144
+ email,
145
+ imap: {
146
+ host: acct.imap?.host || provider?.imap.host || `imap.${domain}`,
147
+ port: acct.imap?.port || provider?.imap.port || 993,
148
+ tls: acct.imap?.tls ?? provider?.imap.tls ?? true,
149
+ auth: acct.imap?.auth || provider?.imap.auth || "password",
150
+ user: acct.imap?.user || user,
151
+ password: acct.imap?.password || acct.password,
152
+ },
153
+ smtp: {
154
+ host: acct.smtp?.host || provider?.smtp.host || `smtp.${domain}`,
155
+ port: acct.smtp?.port || provider?.smtp.port || 587,
156
+ tls: acct.smtp?.tls ?? provider?.smtp.tls ?? true,
157
+ auth: acct.smtp?.auth || provider?.smtp.auth || "password",
158
+ user: acct.smtp?.user || user,
159
+ password: acct.smtp?.password || acct.password,
160
+ },
161
+ enabled: acct.enabled ?? true,
162
+ defaultSend: acct.defaultSend,
163
+ relayDomains: acct.relayDomains,
164
+ deliveredToPrefix: acct.deliveredToPrefix,
165
+ };
166
+ }
108
167
  // ── Defaults ──
109
168
  const DEFAULT_ACCOUNTS = [];
110
169
  const DEFAULT_PREFERENCES = {
111
170
  ui: {
112
- theme: "dark",
171
+ theme: "system",
113
172
  folderWidth: 220,
114
173
  listViewerSplit: 40,
115
174
  fontSize: 15,
@@ -143,21 +202,29 @@ export function loadAccounts() {
143
202
  }
144
203
  catch { /* ignore */ }
145
204
  }
146
- return accounts.accounts || accounts;
205
+ const raw = accounts.accounts || accounts;
206
+ const globalName = accounts.name || "";
207
+ return raw.map((a) => normalizeAccount(a, globalName));
147
208
  }
148
209
  // Legacy: read from settings.jsonc
149
210
  const legacy = loadLegacySettings();
150
211
  if (legacy?.accounts)
151
- return legacy.accounts;
212
+ return legacy.accounts.map((a) => normalizeAccount(a, legacy.name));
152
213
  return DEFAULT_ACCOUNTS;
153
214
  }
154
215
  /** Save account configs */
155
216
  export function saveAccounts(accounts) {
156
217
  saveFile("accounts.jsonc", { accounts });
157
218
  }
158
- /** Load preferences (shared + local overrides) */
219
+ /** Load preferences (shared + local overrides, with legacy fallback) */
159
220
  export function loadPreferences() {
160
- const shared = loadFile("preferences.jsonc", DEFAULT_PREFERENCES);
221
+ let shared = loadFile("preferences.jsonc", DEFAULT_PREFERENCES);
222
+ // Legacy fallback: read ui/sync from settings.jsonc if preferences.jsonc had only defaults
223
+ const legacy = loadLegacySettings();
224
+ if (legacy?.ui)
225
+ shared = { ...shared, ui: { ...shared.ui, ...legacy.ui } };
226
+ if (legacy?.sync)
227
+ shared = { ...shared, sync: { ...shared.sync, ...legacy.sync } };
161
228
  const localConfig = readLocalConfig();
162
229
  // Local overrides
163
230
  if (localConfig.historyDays !== undefined) {
@@ -175,7 +175,7 @@ export type WsEvent = {
175
175
  export interface MailxSettings {
176
176
  accounts: AccountConfig[];
177
177
  ui: {
178
- theme: "dark" | "light";
178
+ theme: "system" | "dark" | "light";
179
179
  folderWidth: number;
180
180
  listViewerSplit: number; /** Percentage for message list height */
181
181
  fontSize: number;