@bobfrankston/mailx 1.0.207 → 1.0.209
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/bin/mailx.js +17 -9
- package/client/app.js +8 -1
- package/client/components/message-list.js +2 -0
- package/client/styles/components.css +11 -0
- package/client/styles/layout.css +4 -0
- package/package.json +2 -2
- package/packages/mailx-store/db.js +4 -2
- package/packages/mailx-store-web/android-bootstrap.js +6 -0
- package/packages/mailx-store-web/web-settings.js +68 -12
package/bin/mailx.js
CHANGED
|
@@ -31,14 +31,22 @@ const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process
|
|
|
31
31
|
// Auto-detach: re-spawn as background process so terminal returns immediately
|
|
32
32
|
// Skip for: --verbose (want console), --daemon (already detached),
|
|
33
33
|
// and any command flags (setup, kill, test, etc.)
|
|
34
|
-
if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a)
|
|
35
|
-
const { spawn } = await import("node:child_process");
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
|
|
35
|
+
const { execSync, spawn } = await import("node:child_process");
|
|
36
|
+
if (process.platform === "win32") {
|
|
37
|
+
// Use wscript to launch without any visible console window
|
|
38
|
+
const args = [...process.argv.slice(1), "--daemon"].map(a => `"${a}"`).join(" ");
|
|
39
|
+
const vbs = `CreateObject("Wscript.Shell").Run """${process.execPath}"" ${args}", 0, False`;
|
|
40
|
+
const tmpVbs = path.join(os.tmpdir(), "mailx-launch.vbs");
|
|
41
|
+
fs.writeFileSync(tmpVbs, vbs);
|
|
42
|
+
execSync(`wscript "${tmpVbs}"`, { stdio: "ignore" });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
|
|
46
|
+
detached: true, stdio: "ignore",
|
|
47
|
+
});
|
|
48
|
+
child.unref();
|
|
49
|
+
}
|
|
42
50
|
process.exit(0);
|
|
43
51
|
}
|
|
44
52
|
const setupMode = hasFlag("setup");
|
|
@@ -607,7 +615,7 @@ async function registerClient(settings) {
|
|
|
607
615
|
}
|
|
608
616
|
}
|
|
609
617
|
catch { /* ignore */ }
|
|
610
|
-
// Read existing clients.jsonc from cloud
|
|
618
|
+
// Read existing clients.jsonc from cloud (may not exist yet — that's fine)
|
|
611
619
|
let clients = {};
|
|
612
620
|
try {
|
|
613
621
|
const content = await cloudRead("clients.jsonc");
|
package/client/app.js
CHANGED
|
@@ -352,7 +352,14 @@ async function openCompose(mode) {
|
|
|
352
352
|
function showComposeOverlay() {
|
|
353
353
|
const wrapper = document.createElement("div");
|
|
354
354
|
wrapper.className = "compose-overlay";
|
|
355
|
-
|
|
355
|
+
// Full-screen on small/short screens, floating on larger
|
|
356
|
+
const isSmall = window.innerWidth <= 768 || window.innerHeight <= 600;
|
|
357
|
+
if (isSmall) {
|
|
358
|
+
wrapper.style.cssText = "position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:#fff;";
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
wrapper.style.cssText = "position:fixed;bottom:0;right:16px;width:min(900px,55vw);height:min(700px,70vh);z-index:1000;border-radius:8px 8px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;";
|
|
362
|
+
}
|
|
356
363
|
// Title bar — drag to move, close button
|
|
357
364
|
const titleBar = document.createElement("div");
|
|
358
365
|
titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;";
|
|
@@ -311,6 +311,8 @@ function appendMessages(body, accountId, items) {
|
|
|
311
311
|
row.classList.add("unread");
|
|
312
312
|
if (msg.flags.includes("\\Flagged"))
|
|
313
313
|
row.classList.add("flagged");
|
|
314
|
+
if (!msg.bodyPath)
|
|
315
|
+
row.classList.add("not-downloaded");
|
|
314
316
|
row.dataset.uid = String(msg.uid);
|
|
315
317
|
row.dataset.accountId = msgAccountId;
|
|
316
318
|
row.dataset.folderId = String(msg.folderId);
|
|
@@ -398,6 +398,17 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
398
398
|
}
|
|
399
399
|
.no-snippets .ml-preview { display: none; }
|
|
400
400
|
.ml-date { white-space: nowrap; text-align: right; color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--font-size-sm); }
|
|
401
|
+
/* Not-downloaded indicator: small dot before the date */
|
|
402
|
+
.ml-row.not-downloaded .ml-date::before {
|
|
403
|
+
content: "○ ";
|
|
404
|
+
color: var(--color-text-muted);
|
|
405
|
+
opacity: 0.7;
|
|
406
|
+
}
|
|
407
|
+
.ml-row:not(.not-downloaded) .ml-date::before {
|
|
408
|
+
content: "● ";
|
|
409
|
+
color: #4a9;
|
|
410
|
+
opacity: 0.5;
|
|
411
|
+
}
|
|
401
412
|
|
|
402
413
|
.ml-empty {
|
|
403
414
|
grid-column: 1 / -1;
|
package/client/styles/layout.css
CHANGED
|
@@ -88,6 +88,10 @@ body {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/* Responsive: narrow OR short viewport — single panel navigation */
|
|
91
|
+
@media (max-width: 768px), (max-height: 600px) {
|
|
92
|
+
/* Hide preview snippet under message subject — save space */
|
|
93
|
+
.ml-preview { display: none; }
|
|
94
|
+
}
|
|
91
95
|
@media (max-width: 768px), (max-height: 600px) {
|
|
92
96
|
body {
|
|
93
97
|
grid-template-columns: 1fr;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.209",
|
|
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.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.270",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -248,7 +248,8 @@ export class MailxDB {
|
|
|
248
248
|
flags: JSON.parse(r.flags_json),
|
|
249
249
|
size: r.size,
|
|
250
250
|
hasAttachments: !!r.has_attachments,
|
|
251
|
-
preview: r.preview
|
|
251
|
+
preview: r.preview,
|
|
252
|
+
bodyPath: r.body_path || ""
|
|
252
253
|
}));
|
|
253
254
|
return { items, total, page, pageSize };
|
|
254
255
|
}
|
|
@@ -279,7 +280,8 @@ export class MailxDB {
|
|
|
279
280
|
flags: JSON.parse(r.flags_json),
|
|
280
281
|
size: r.size,
|
|
281
282
|
hasAttachments: !!r.has_attachments,
|
|
282
|
-
preview: r.preview
|
|
283
|
+
preview: r.preview,
|
|
284
|
+
bodyPath: r.body_path || ""
|
|
283
285
|
}));
|
|
284
286
|
return { items, total, page, pageSize };
|
|
285
287
|
}
|
|
@@ -420,6 +420,12 @@ async function registerDeviceInGDrive(tokenProvider, folderId, accountIds) {
|
|
|
420
420
|
catch { /* */ }
|
|
421
421
|
}
|
|
422
422
|
}
|
|
423
|
+
// Remove stale android-* entries (from old random-UUID approach) — keep only this device
|
|
424
|
+
for (const key of Object.keys(clients)) {
|
|
425
|
+
if (key.startsWith("android-") && key !== deviceId) {
|
|
426
|
+
delete clients[key];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
423
429
|
clients[deviceId] = {
|
|
424
430
|
hostname: deviceId,
|
|
425
431
|
platform: "android",
|
|
@@ -232,6 +232,56 @@ const DEFAULT_ALLOWLIST = {
|
|
|
232
232
|
domains: [],
|
|
233
233
|
recipients: [],
|
|
234
234
|
};
|
|
235
|
+
// ── JSONC parser (strips comments and trailing commas) ──
|
|
236
|
+
function parseJsonc(text) {
|
|
237
|
+
// Strip /* block comments */ and // line comments, but preserve content inside strings
|
|
238
|
+
let stripped = "";
|
|
239
|
+
let i = 0;
|
|
240
|
+
let inString = false;
|
|
241
|
+
let stringChar = "";
|
|
242
|
+
while (i < text.length) {
|
|
243
|
+
const c = text[i];
|
|
244
|
+
const next = text[i + 1];
|
|
245
|
+
if (inString) {
|
|
246
|
+
stripped += c;
|
|
247
|
+
if (c === "\\" && i + 1 < text.length) {
|
|
248
|
+
stripped += text[i + 1];
|
|
249
|
+
i += 2;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (c === stringChar)
|
|
253
|
+
inString = false;
|
|
254
|
+
i++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (c === '"' || c === "'") {
|
|
258
|
+
inString = true;
|
|
259
|
+
stringChar = c;
|
|
260
|
+
stripped += c;
|
|
261
|
+
i++;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (c === "/" && next === "/") {
|
|
265
|
+
// Line comment — skip to end of line
|
|
266
|
+
while (i < text.length && text[i] !== "\n")
|
|
267
|
+
i++;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (c === "/" && next === "*") {
|
|
271
|
+
// Block comment — skip to */
|
|
272
|
+
i += 2;
|
|
273
|
+
while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/"))
|
|
274
|
+
i++;
|
|
275
|
+
i += 2;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
stripped += c;
|
|
279
|
+
i++;
|
|
280
|
+
}
|
|
281
|
+
// Strip trailing commas before } or ]
|
|
282
|
+
stripped = stripped.replace(/,(\s*[}\]])/g, "$1");
|
|
283
|
+
return JSON.parse(stripped);
|
|
284
|
+
}
|
|
235
285
|
// ── Public API ──
|
|
236
286
|
/** Load accounts — first from IndexedDB cache, then GDrive */
|
|
237
287
|
export async function loadAccounts() {
|
|
@@ -239,24 +289,28 @@ export async function loadAccounts() {
|
|
|
239
289
|
const cached = await idbRead("accounts.jsonc");
|
|
240
290
|
if (cached) {
|
|
241
291
|
try {
|
|
242
|
-
const data =
|
|
292
|
+
const data = parseJsonc(cached);
|
|
243
293
|
const raw = data.accounts || (Array.isArray(data) ? data : []);
|
|
244
294
|
if (raw.length > 0) {
|
|
245
295
|
return raw.map((a) => normalizeAccount(a, data.name));
|
|
246
296
|
}
|
|
247
297
|
}
|
|
248
|
-
catch {
|
|
298
|
+
catch (e) {
|
|
299
|
+
console.warn(`[settings] Cached accounts.jsonc parse failed: ${e.message}`);
|
|
300
|
+
}
|
|
249
301
|
}
|
|
250
302
|
// Try GDrive
|
|
251
303
|
const content = await gDriveRead("accounts.jsonc");
|
|
252
304
|
if (content) {
|
|
253
305
|
await idbWrite("accounts.jsonc", content);
|
|
254
306
|
try {
|
|
255
|
-
const data =
|
|
307
|
+
const data = parseJsonc(content);
|
|
256
308
|
const raw = data.accounts || (Array.isArray(data) ? data : []);
|
|
257
309
|
return raw.map((a) => normalizeAccount(a, data.name));
|
|
258
310
|
}
|
|
259
|
-
catch {
|
|
311
|
+
catch (e) {
|
|
312
|
+
console.warn(`[settings] GDrive accounts.jsonc parse failed: ${e.message}`);
|
|
313
|
+
}
|
|
260
314
|
}
|
|
261
315
|
return [];
|
|
262
316
|
}
|
|
@@ -266,11 +320,13 @@ export async function loadAccountsFromCloud() {
|
|
|
266
320
|
if (content) {
|
|
267
321
|
await idbWrite("accounts.jsonc", content);
|
|
268
322
|
try {
|
|
269
|
-
const data =
|
|
323
|
+
const data = parseJsonc(content);
|
|
270
324
|
const raw = data.accounts || (Array.isArray(data) ? data : []);
|
|
271
325
|
return raw.map((a) => normalizeAccount(a, data.name));
|
|
272
326
|
}
|
|
273
|
-
catch {
|
|
327
|
+
catch (e) {
|
|
328
|
+
console.warn(`[settings] loadAccountsFromCloud parse failed: ${e.message}`);
|
|
329
|
+
}
|
|
274
330
|
}
|
|
275
331
|
return [];
|
|
276
332
|
}
|
|
@@ -285,7 +341,7 @@ export async function loadPreferences() {
|
|
|
285
341
|
const cached = await idbRead("preferences.jsonc");
|
|
286
342
|
if (cached) {
|
|
287
343
|
try {
|
|
288
|
-
const data =
|
|
344
|
+
const data = parseJsonc(cached);
|
|
289
345
|
return {
|
|
290
346
|
ui: { ...DEFAULT_PREFERENCES.ui, ...data.ui },
|
|
291
347
|
sync: { ...DEFAULT_PREFERENCES.sync, ...data.sync },
|
|
@@ -299,7 +355,7 @@ export async function loadPreferences() {
|
|
|
299
355
|
if (content) {
|
|
300
356
|
await idbWrite("preferences.jsonc", content);
|
|
301
357
|
try {
|
|
302
|
-
const data =
|
|
358
|
+
const data = parseJsonc(content);
|
|
303
359
|
return {
|
|
304
360
|
ui: { ...DEFAULT_PREFERENCES.ui, ...data.ui },
|
|
305
361
|
sync: { ...DEFAULT_PREFERENCES.sync, ...data.sync },
|
|
@@ -341,7 +397,7 @@ export async function loadAllowlist() {
|
|
|
341
397
|
const cached = await idbRead("allowlist.jsonc");
|
|
342
398
|
if (cached) {
|
|
343
399
|
try {
|
|
344
|
-
return
|
|
400
|
+
return parseJsonc(cached);
|
|
345
401
|
}
|
|
346
402
|
catch { /* */ }
|
|
347
403
|
}
|
|
@@ -349,7 +405,7 @@ export async function loadAllowlist() {
|
|
|
349
405
|
if (content) {
|
|
350
406
|
await idbWrite("allowlist.jsonc", content);
|
|
351
407
|
try {
|
|
352
|
-
return
|
|
408
|
+
return parseJsonc(content);
|
|
353
409
|
}
|
|
354
410
|
catch { /* */ }
|
|
355
411
|
}
|
|
@@ -418,7 +474,7 @@ export async function loadDeviceState() {
|
|
|
418
474
|
const cached = await idbRead(filename);
|
|
419
475
|
if (cached) {
|
|
420
476
|
try {
|
|
421
|
-
return
|
|
477
|
+
return parseJsonc(cached);
|
|
422
478
|
}
|
|
423
479
|
catch { /* */ }
|
|
424
480
|
}
|
|
@@ -426,7 +482,7 @@ export async function loadDeviceState() {
|
|
|
426
482
|
if (content) {
|
|
427
483
|
await idbWrite(filename, content);
|
|
428
484
|
try {
|
|
429
|
-
return
|
|
485
|
+
return parseJsonc(content);
|
|
430
486
|
}
|
|
431
487
|
catch { /* */ }
|
|
432
488
|
}
|