@bobfrankston/mailx 1.0.226 → 1.0.228

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/client/app.js CHANGED
@@ -67,12 +67,46 @@ async function updateNewMessageCount() {
67
67
  updateBadge(0);
68
68
  return;
69
69
  }
70
+ const previousBadge = badgeCount;
70
71
  // New messages = increase since last seen
71
72
  const newCount = Math.max(0, totalUnread - lastSeenCount);
72
73
  updateBadge(newCount);
74
+ // Flash the title when new mail arrives and the window isn't focused.
75
+ // Windows' taskbar mirrors document.title so this acts as a taskbar flash.
76
+ if (newCount > previousBadge && document.visibilityState !== "visible") {
77
+ startTitleFlash();
78
+ }
73
79
  }
74
80
  catch { /* offline */ }
75
81
  }
82
+ // ── Taskbar flash via title alternation ──
83
+ let titleFlashTimer = null;
84
+ let titleFlashPhase = false;
85
+ function startTitleFlash() {
86
+ stopTitleFlash();
87
+ titleFlashPhase = true;
88
+ titleFlashTimer = setInterval(() => {
89
+ titleFlashPhase = !titleFlashPhase;
90
+ if (titleFlashPhase) {
91
+ document.title = `✉ NEW MAIL (${badgeCount})`;
92
+ }
93
+ else {
94
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
95
+ }
96
+ }, 1000);
97
+ }
98
+ function stopTitleFlash() {
99
+ if (titleFlashTimer) {
100
+ clearInterval(titleFlashTimer);
101
+ titleFlashTimer = null;
102
+ }
103
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
104
+ }
105
+ document.addEventListener("visibilitychange", () => {
106
+ if (document.visibilityState === "visible")
107
+ stopTitleFlash();
108
+ });
109
+ window.addEventListener("focus", stopTitleFlash);
76
110
  /** Call when user actively views messages — resets the badge */
77
111
  function markAsSeen() {
78
112
  getAccounts().then(async (accounts) => {
@@ -1098,6 +1132,100 @@ optSnippet?.addEventListener("change", () => {
1098
1132
  }
1099
1133
  localStorage.setItem("mailx-snippet", String(optSnippet.checked));
1100
1134
  });
1135
+ // ── JSONC config file editor ──
1136
+ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () => {
1137
+ const settingsDropdown = document.getElementById("settings-dropdown");
1138
+ if (settingsDropdown)
1139
+ settingsDropdown.hidden = true;
1140
+ await openJsoncEditor("accounts.jsonc");
1141
+ });
1142
+ async function openJsoncEditor(initialFile) {
1143
+ const { readJsoncFile, writeJsoncFile } = await import("./lib/api-client.js");
1144
+ const backdrop = document.createElement("div");
1145
+ backdrop.className = "mailx-modal-backdrop";
1146
+ const panel = document.createElement("div");
1147
+ panel.className = "mailx-modal mailx-modal-wide";
1148
+ panel.innerHTML = `
1149
+ <div class="mailx-modal-title">Edit config file</div>
1150
+ <label class="mailx-modal-label">File
1151
+ <select class="mailx-modal-input" id="jsonc-file">
1152
+ <option value="accounts.jsonc">accounts.jsonc</option>
1153
+ <option value="allowlist.jsonc">allowlist.jsonc</option>
1154
+ <option value="clients.jsonc">clients.jsonc</option>
1155
+ </select>
1156
+ </label>
1157
+ <label class="mailx-modal-label">Contents (JSONC — comments and trailing commas allowed)
1158
+ <textarea class="mailx-modal-input mailx-modal-textarea" id="jsonc-content" spellcheck="false"></textarea>
1159
+ </label>
1160
+ <div class="mailx-modal-error" id="jsonc-error" hidden></div>
1161
+ <div class="mailx-modal-buttons">
1162
+ <span class="mailx-modal-spacer"></span>
1163
+ <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
1164
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
1165
+ </div>`;
1166
+ backdrop.appendChild(panel);
1167
+ document.body.appendChild(backdrop);
1168
+ const fileSelect = panel.querySelector("#jsonc-file");
1169
+ const textarea = panel.querySelector("#jsonc-content");
1170
+ const errorEl = panel.querySelector("#jsonc-error");
1171
+ fileSelect.value = initialFile;
1172
+ const loadFile = async () => {
1173
+ textarea.value = "Loading...";
1174
+ errorEl.hidden = true;
1175
+ try {
1176
+ const r = await readJsoncFile(fileSelect.value);
1177
+ textarea.value = r?.content || "";
1178
+ }
1179
+ catch (e) {
1180
+ textarea.value = "";
1181
+ errorEl.textContent = `Failed to load: ${e.message}`;
1182
+ errorEl.hidden = false;
1183
+ }
1184
+ };
1185
+ await loadFile();
1186
+ fileSelect.addEventListener("change", loadFile);
1187
+ const close = () => {
1188
+ backdrop.remove();
1189
+ document.removeEventListener("keydown", onKey, true);
1190
+ };
1191
+ const onKey = (e) => {
1192
+ if (e.key === "Escape") {
1193
+ e.stopPropagation();
1194
+ e.preventDefault();
1195
+ close();
1196
+ }
1197
+ };
1198
+ document.addEventListener("keydown", onKey, true);
1199
+ panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
1200
+ btn.addEventListener("click", async () => {
1201
+ const action = btn.dataset.action;
1202
+ if (action === "cancel") {
1203
+ close();
1204
+ return;
1205
+ }
1206
+ if (action === "save") {
1207
+ errorEl.hidden = true;
1208
+ btn.disabled = true;
1209
+ btn.textContent = "Saving...";
1210
+ try {
1211
+ await writeJsoncFile(fileSelect.value, textarea.value);
1212
+ close();
1213
+ const statusSync = document.getElementById("status-sync");
1214
+ if (statusSync)
1215
+ statusSync.textContent = `Saved ${fileSelect.value} — restart mailx to apply`;
1216
+ }
1217
+ catch (e) {
1218
+ errorEl.textContent = `${e.message}`;
1219
+ errorEl.hidden = false;
1220
+ btn.disabled = false;
1221
+ btn.textContent = "Save";
1222
+ }
1223
+ }
1224
+ });
1225
+ });
1226
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
1227
+ close(); });
1228
+ }
1101
1229
  // Threaded view toggle
1102
1230
  optThreaded?.addEventListener("change", () => {
1103
1231
  const body = document.getElementById("ml-body");
package/client/index.html CHANGED
@@ -38,6 +38,8 @@
38
38
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
39
39
  <hr class="tb-menu-sep">
40
40
  <label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
41
+ <hr class="tb-menu-sep">
42
+ <button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
41
43
  </div>
42
44
  </div>
43
45
  <span id="app-version" class="app-version">mailx</span>
@@ -149,6 +149,12 @@ export function deleteDraft(accountId, draftUid, draftId) {
149
149
  export function addContact(name, email) {
150
150
  return ipc().addContact?.(name, email);
151
151
  }
152
+ export function readJsoncFile(name) {
153
+ return ipc().readJsoncFile?.(name);
154
+ }
155
+ export function writeJsoncFile(name, content) {
156
+ return ipc().writeJsoncFile?.(name, content);
157
+ }
152
158
  export function setupAccount(name, email, password) {
153
159
  return ipc().setupAccount?.(name, email, password);
154
160
  }
@@ -100,6 +100,12 @@
100
100
  addContact: function(name, email) {
101
101
  return callNode("addContact", { name: name, email: email });
102
102
  },
103
+ readJsoncFile: function(name) {
104
+ return callNode("readJsoncFile", { name: name });
105
+ },
106
+ writeJsoncFile: function(name, content) {
107
+ return callNode("writeJsoncFile", { name: name, content: content });
108
+ },
103
109
  searchContacts: function(query) {
104
110
  return callNode("searchContacts", { query: query });
105
111
  },
@@ -412,6 +412,98 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
412
412
  min-width: 1.5em;
413
413
  text-align: center;
414
414
  }
415
+
416
+ /* Generic modal — used by the JSONC config editor launched from Settings */
417
+ .mailx-modal-backdrop {
418
+ position: fixed;
419
+ inset: 0;
420
+ background: rgba(0, 0, 0, 0.4);
421
+ display: flex;
422
+ align-items: center;
423
+ justify-content: center;
424
+ z-index: 2000;
425
+ }
426
+ .mailx-modal {
427
+ background: var(--color-bg);
428
+ color: var(--color-text);
429
+ padding: var(--gap-lg);
430
+ border-radius: var(--radius-md);
431
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
432
+ min-width: 420px;
433
+ max-width: 90vw;
434
+ display: flex;
435
+ flex-direction: column;
436
+ gap: var(--gap-md);
437
+ font-family: var(--font-ui);
438
+ }
439
+ .mailx-modal-wide {
440
+ width: 80vw;
441
+ max-width: 900px;
442
+ max-height: 85vh;
443
+ }
444
+ .mailx-modal-title {
445
+ font-size: var(--font-size-lg);
446
+ font-weight: 600;
447
+ }
448
+ .mailx-modal-label {
449
+ display: flex;
450
+ flex-direction: column;
451
+ gap: 4px;
452
+ font-size: var(--font-size-sm);
453
+ color: var(--color-text-muted);
454
+ }
455
+ .mailx-modal-input {
456
+ padding: 6px 8px;
457
+ border: 1px solid var(--color-border);
458
+ border-radius: var(--radius-sm);
459
+ background: var(--color-bg-surface);
460
+ color: var(--color-text);
461
+ font-family: var(--font-ui);
462
+ font-size: var(--font-size-base);
463
+ }
464
+ .mailx-modal-textarea {
465
+ min-height: 50vh;
466
+ resize: vertical;
467
+ font-family: var(--font-mono);
468
+ font-size: 13px;
469
+ white-space: pre;
470
+ tab-size: 2;
471
+ }
472
+ .mailx-modal-input:focus {
473
+ outline: 2px solid var(--color-accent);
474
+ outline-offset: -1px;
475
+ }
476
+ .mailx-modal-error {
477
+ color: oklch(0.65 0.2 25);
478
+ font-size: var(--font-size-sm);
479
+ background: color-mix(in oklch, oklch(0.65 0.2 25) 10%, transparent);
480
+ padding: 6px 10px;
481
+ border-radius: var(--radius-sm);
482
+ white-space: pre-wrap;
483
+ }
484
+ .mailx-modal-buttons {
485
+ display: flex;
486
+ gap: var(--gap-sm);
487
+ align-items: center;
488
+ }
489
+ .mailx-modal-spacer { flex: 1; }
490
+ .mailx-modal-btn {
491
+ padding: 6px 14px;
492
+ border: 1px solid var(--color-border);
493
+ border-radius: var(--radius-sm);
494
+ background: var(--color-bg-surface);
495
+ color: var(--color-text);
496
+ cursor: pointer;
497
+ font-size: var(--font-size-sm);
498
+ }
499
+ .mailx-modal-btn:hover { background: var(--color-bg-hover); }
500
+ .mailx-modal-btn-primary {
501
+ background: var(--color-accent);
502
+ color: #fff;
503
+ border-color: transparent;
504
+ font-weight: 500;
505
+ }
506
+ .mailx-modal-btn-primary:hover { filter: brightness(1.1); }
415
507
  .ml-subject {
416
508
  overflow: hidden;
417
509
  text-overflow: ellipsis;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.226",
3
+ "version": "1.0.228",
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,11 +20,11 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow-direct": "^0.1.10",
23
+ "@bobfrankston/iflow-direct": "^0.1.11",
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.288",
27
+ "@bobfrankston/msger": "^0.1.290",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -74,11 +74,11 @@
74
74
  },
75
75
  ".transformedSnapshot": {
76
76
  "dependencies": {
77
- "@bobfrankston/iflow-direct": "^0.1.10",
77
+ "@bobfrankston/iflow-direct": "^0.1.11",
78
78
  "@bobfrankston/iflow-node": "^0.1.2",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.288",
81
+ "@bobfrankston/msger": "^0.1.290",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -59,6 +59,12 @@ export declare class MailxService {
59
59
  * action on From/To/Cc addresses in the message viewer. Just calls the same
60
60
  * validated upsert path as recordSentAddress. */
61
61
  addContact(name: string, email: string): boolean;
62
+ /** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
63
+ * Names are whitelisted so the UI can't read arbitrary files. */
64
+ readJsoncFile(name: string): Promise<string | null>;
65
+ /** Write a JSONC config file. Validates that the content parses as JSONC
66
+ * (loosely — strips comments/trailing commas) before writing. */
67
+ writeJsoncFile(name: string, content: string): Promise<void>;
62
68
  getSettings(): any;
63
69
  saveSettings(settings: any): void;
64
70
  getStorageInfo(): {
@@ -659,6 +659,33 @@ export class MailxService {
659
659
  this.db.recordSentAddress(name || "", email);
660
660
  return true;
661
661
  }
662
+ /** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
663
+ * Names are whitelisted so the UI can't read arbitrary files. */
664
+ async readJsoncFile(name) {
665
+ const whitelist = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
666
+ if (!whitelist.includes(name))
667
+ throw new Error(`File not allowed: ${name}`);
668
+ const { cloudRead } = await import("@bobfrankston/mailx-settings");
669
+ return cloudRead(name);
670
+ }
671
+ /** Write a JSONC config file. Validates that the content parses as JSONC
672
+ * (loosely — strips comments/trailing commas) before writing. */
673
+ async writeJsoncFile(name, content) {
674
+ const whitelist = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
675
+ if (!whitelist.includes(name))
676
+ throw new Error(`File not allowed: ${name}`);
677
+ // Validate the content parses before writing
678
+ const { parse: parseJsonc } = await import("jsonc-parser");
679
+ const errors = [];
680
+ parseJsonc(content, errors, { allowTrailingComma: true });
681
+ if (errors.length) {
682
+ throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`);
683
+ }
684
+ const { cloudWrite } = await import("@bobfrankston/mailx-settings");
685
+ const ok = await cloudWrite(name, content);
686
+ if (!ok)
687
+ throw new Error(`Failed to write ${name}`);
688
+ }
662
689
  // ── Settings ──
663
690
  getSettings() {
664
691
  return loadSettings();
@@ -96,6 +96,11 @@ async function dispatchAction(svc, action, p) {
96
96
  return svc.searchContacts(p.query);
97
97
  case "addContact":
98
98
  return { ok: svc.addContact(p.name, p.email) };
99
+ case "readJsoncFile":
100
+ return { content: await svc.readJsoncFile(p.name) };
101
+ case "writeJsoncFile":
102
+ await svc.writeJsoncFile(p.name, p.content);
103
+ return { ok: true };
99
104
  // Settings
100
105
  case "getSettings":
101
106
  return svc.getSettings();