@bobfrankston/mailx 1.0.310 → 1.0.317

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.
@@ -4,7 +4,7 @@
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
6
  import { createEditor } from "./editor.js";
7
- import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
7
+ import { getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
8
8
  /** Close compose window */
9
9
  function closeCompose() {
10
10
  window.close();
@@ -45,15 +45,37 @@ async function loadEditorAssets(type) {
45
45
  }
46
46
  }
47
47
  // ── Determine editor type from settings ──
48
+ //
49
+ // Compose must open fast. The previous flow awaited getVersion() then
50
+ // getSettings() sequentially before the editor was even loaded — any
51
+ // service-side stall (busy sync, slow IMAP, hung OAuth refresh) turned
52
+ // "click Reply" into a multi-second / multi-minute wait with a blank
53
+ // compose window. Local-first: read the editor-type preference from a
54
+ // tiny localStorage cache that we update whenever getSettings succeeds
55
+ // in the background. Default to quill on first run / cache miss.
48
56
  let editorType = "quill";
49
57
  let appSettings = null;
50
58
  try {
51
- await getVersion(); // verify server is up
52
- appSettings = await getSettings();
53
- if (appSettings.ui?.editor === "tiptap")
54
- editorType = "tiptap";
59
+ const cached = localStorage.getItem("mailx-editor-type");
60
+ if (cached === "tiptap" || cached === "quill")
61
+ editorType = cached;
55
62
  }
56
- catch { /* default to quill */ }
63
+ catch { /* private-mode / SecurityError — default quill */ }
64
+ // Refresh the cache asynchronously — doesn't block compose open.
65
+ (async () => {
66
+ try {
67
+ appSettings = await getSettings();
68
+ const next = appSettings?.ui?.editor === "tiptap" ? "tiptap" : "quill";
69
+ try {
70
+ localStorage.setItem("mailx-editor-type", next);
71
+ }
72
+ catch { /* */ }
73
+ // Note: we don't hot-swap the editor if the preference changed while
74
+ // compose was opening — the old type is already instantiated. Next
75
+ // compose open will pick up the new preference.
76
+ }
77
+ catch { /* non-fatal */ }
78
+ })();
57
79
  await loadEditorAssets(editorType);
58
80
  const container = document.getElementById("compose-editor");
59
81
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
@@ -382,26 +404,63 @@ function scheduleDraftSave() {
382
404
  clearTimeout(draftDebounceTimer);
383
405
  draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
384
406
  }
385
- // ── Initialize: always fetch real accounts from the API before applying init, then
386
- // start the auto-save timer. Callers like message-viewer's Edit Draft pass
387
- // init.accounts=[], so we can't trust what's in the init blob. ──
407
+ // ── Initialize: local-first population.
408
+ //
409
+ // Reply / Reply-All / Forward callers pre-populate `init.accounts` with the
410
+ // full account list (app.ts:openCompose). In that common case we do NOT need
411
+ // to call getAccounts() — everything required to fill the compose form is
412
+ // already in sessionStorage and reads synchronously. That turns "click Reply"
413
+ // into an instant-open instead of "wait for getAccounts IPC to respond,
414
+ // which can take >120s when the service is busy syncing / hung on IMAP".
415
+ //
416
+ // getAccounts is still called (non-blocking) to refresh the dropdown with
417
+ // the freshest data — and it IS awaited only in the fallback path where
418
+ // init doesn't have an account list (message-viewer's Edit Draft passes
419
+ // init.accounts=[]).
388
420
  (async () => {
389
- let accounts = [];
390
- try {
391
- accounts = await getAccounts();
392
- }
393
- catch (e) {
394
- console.error("Failed to load accounts:", e);
395
- }
396
421
  const stored = sessionStorage.getItem("composeInit");
397
422
  if (stored) {
398
423
  sessionStorage.removeItem("composeInit");
399
424
  const init = JSON.parse(stored);
400
- if (!init.accounts || init.accounts.length === 0)
401
- init.accounts = accounts;
402
- applyInit(init);
425
+ if (init.accounts && init.accounts.length > 0) {
426
+ // Happy path — init is complete. Apply immediately. Kick
427
+ // getAccounts in the background to refresh the dropdown if the
428
+ // user keeps compose open long enough for the result.
429
+ applyInit(init);
430
+ getAccounts().then((fresh) => {
431
+ if (Array.isArray(fresh) && fresh.length > 0) {
432
+ init.accounts = fresh;
433
+ // Re-populate the From dropdown only — don't clobber
434
+ // anything the user may have already typed.
435
+ try {
436
+ populateFromOptions(fresh);
437
+ }
438
+ catch { /* */ }
439
+ }
440
+ }).catch(() => { });
441
+ }
442
+ else {
443
+ // Edit Draft / other callers that didn't pre-fill accounts.
444
+ // Have to wait on getAccounts here — the From dropdown needs it.
445
+ let fresh = [];
446
+ try {
447
+ fresh = await getAccounts();
448
+ }
449
+ catch (e) {
450
+ console.error("Failed to load accounts:", e);
451
+ }
452
+ init.accounts = fresh;
453
+ applyInit(init);
454
+ }
403
455
  }
404
456
  else {
457
+ let accounts = [];
458
+ try {
459
+ accounts = await getAccounts();
460
+ }
461
+ catch (e) {
462
+ console.error("Failed to load accounts:", e);
463
+ }
405
464
  populateFromOptions(accounts);
406
465
  toInput.focus();
407
466
  }
@@ -459,7 +518,25 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
459
518
  catch (e) {
460
519
  btn.disabled = false;
461
520
  btn.textContent = "Send";
462
- alert(`Send failed: ${e.message}`);
521
+ const msg = e?.message || String(e);
522
+ // Distinguish the IPC-timeout case from real send failures. The
523
+ // service-side send() queues the message to the local DB synchronously
524
+ // before attempting any IMAP/SMTP work — so if the IPC reached Node at
525
+ // all, the message is queued and the background worker will retry it
526
+ // with backoff (X-Mailx-Retry header, 60s settling delay, up to 10
527
+ // attempts). Treating that as a failure that demands a re-click leads
528
+ // to duplicate sends. Tell the user honestly: "probably queued, check
529
+ // Outbox before retrying."
530
+ if (msg.startsWith("mailxapi timeout")) {
531
+ alert("Send is taking longer than expected.\n\n" +
532
+ "The message has likely been queued and will be retried in the background. " +
533
+ "Check the Outbox folder before clicking Send again — clicking Send now may " +
534
+ "produce a duplicate.\n\n" +
535
+ "Your draft is preserved either way.");
536
+ }
537
+ else {
538
+ alert(`Send failed: ${msg}`);
539
+ }
463
540
  }
464
541
  });
465
542
  // ── Close handling ──
@@ -230,19 +230,63 @@ function createQuillEditor(container) {
230
230
  openLinkForRange(q, q.getSelection() || { index: q.getLength() - 1, length: 0 });
231
231
  });
232
232
  // Paste handling:
233
- // - If clipboard has text/html, let Quill convert it (preserves anchors).
234
- // - If only text/plain and it's a URL, insert as a link (honoring any text
235
- // currently selected the URL becomes the href of that text).
236
- // - Shift+Ctrl+V (handled via contextmenu "Paste as text") or any other
237
- // plain text is inserted verbatim.
233
+ // - text/html clipboard with exactly one anchor (the common "copy a link
234
+ // with anchor text from a webpage" case): take it over from Quill
235
+ // Quill's clipboard module was producing duplicates ("click here" as
236
+ // text PLUS a separate "https://example.com" as a link tail). Insert
237
+ // the anchor's text content as a single linked run.
238
+ // - text/html with richer content: defer to Quill (preserves formatting).
239
+ // - text/plain that's a URL: insert as a link, optionally wrapping any
240
+ // currently-selected text.
241
+ // - Anything else: default Quill behavior (verbatim plain or HTML).
238
242
  q.root.addEventListener("paste", (e) => {
239
243
  const cb = e.clipboardData;
240
244
  if (!cb)
241
245
  return;
242
246
  const html = cb.getData("text/html");
243
247
  const plain = cb.getData("text/plain");
244
- if (html)
245
- return; // Quill handles HTML clipboard natively
248
+ if (html) {
249
+ // Detect "single anchor" clipboard — copy from a browser usually
250
+ // produces something like:
251
+ // <meta charset='utf-8'><a href="https://example.com">click here</a>
252
+ // or wrapped in <html><body>. Parse and check.
253
+ try {
254
+ const tmp = document.createElement("div");
255
+ tmp.innerHTML = html;
256
+ // Strip script/style, then unwrap <html>/<body> noise.
257
+ const root = tmp.querySelector("body") || tmp;
258
+ // Walk for the only meaningful element
259
+ const meaningful = Array.from(root.childNodes).filter(n => {
260
+ if (n.nodeType === Node.TEXT_NODE)
261
+ return (n.textContent || "").trim().length > 0;
262
+ if (n.nodeType === Node.ELEMENT_NODE) {
263
+ const tag = n.tagName.toLowerCase();
264
+ return tag !== "meta" && tag !== "style" && tag !== "script";
265
+ }
266
+ return false;
267
+ });
268
+ if (meaningful.length === 1 && meaningful[0].tagName?.toLowerCase() === "a") {
269
+ const a = meaningful[0];
270
+ const href = a.getAttribute("href") || "";
271
+ const text = (a.textContent || "").trim();
272
+ if (href && text) {
273
+ e.preventDefault();
274
+ const range = q.getSelection(true);
275
+ if (!range)
276
+ return;
277
+ if (range.length > 0) {
278
+ // Selected text exists — replace with the linked anchor text
279
+ q.deleteText(range.index, range.length);
280
+ }
281
+ q.insertText(range.index, text, { link: href });
282
+ q.setSelection(range.index + text.length, 0);
283
+ return;
284
+ }
285
+ }
286
+ }
287
+ catch { /* fall through to Quill default */ }
288
+ return; // Quill handles richer HTML clipboard
289
+ }
246
290
  if (plain && looksLikeUrl(plain)) {
247
291
  e.preventDefault();
248
292
  const range = q.getSelection(true);
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
+ <label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
42
+ <label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
41
43
  <hr class="tb-menu-sep">
42
44
  <button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
43
45
  <button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
@@ -69,6 +71,22 @@
69
71
  <button class="alert-dismiss" id="alert-dismiss" title="Dismiss">&times;</button>
70
72
  </div>
71
73
 
74
+ <aside class="icon-rail" id="icon-rail" aria-label="App rail">
75
+ <div class="rail-top">
76
+ <button class="rail-btn" id="rail-compose" title="Compose (Ctrl+N)" aria-label="Compose">✏</button>
77
+ <button class="rail-btn" id="rail-inbox" title="Inbox" aria-label="Inbox" data-active="true">✉</button>
78
+ <button class="rail-btn" id="rail-unified" title="All Inboxes" aria-label="All Inboxes">⌘</button>
79
+ <button class="rail-btn" id="rail-contacts" title="Contacts (coming soon)" aria-label="Contacts" disabled>👤</button>
80
+ <button class="rail-btn" id="rail-calendar" title="Calendar (Phase 4)" aria-label="Calendar" disabled>📅</button>
81
+ <button class="rail-btn" id="rail-tasks" title="Tasks (Phase 4)" aria-label="Tasks" disabled>☑</button>
82
+ </div>
83
+ <div class="rail-bottom">
84
+ <button class="rail-btn" id="rail-settings" title="Settings" aria-label="Settings">⚙</button>
85
+ <button class="rail-btn" id="rail-theme" title="Toggle theme" aria-label="Toggle theme">◐</button>
86
+ <button class="rail-btn" id="rail-help" title="Help / About" aria-label="Help">?</button>
87
+ </div>
88
+ </aside>
89
+
72
90
  <div class="folder-panel">
73
91
  <div class="ft-filter">
74
92
  <input type="text" id="ft-filter-input" placeholder="Find folder..." autocomplete="off">
@@ -167,6 +167,12 @@ export function readConfigHelp(name) {
167
167
  export function unsubscribeOneClick(url) {
168
168
  return ipc().unsubscribeOneClick?.(url);
169
169
  }
170
+ /** Run an AI text transform (translate / proofread / summarize). Returns
171
+ * empty `text` with a `reason` when the feature is disabled or the provider
172
+ * errors — caller should surface `reason` in a status bar, not throw. */
173
+ export function aiTransform(req) {
174
+ return ipc().aiTransform?.(req) ?? Promise.resolve({ text: "", reason: "AI not available in this host" });
175
+ }
170
176
  export function setupAccount(name, email, password) {
171
177
  return ipc().setupAccount?.(name, email, password);
172
178
  }
@@ -115,6 +115,9 @@
115
115
  unsubscribeOneClick: function(url) {
116
116
  return callNode("unsubscribeOneClick", { url: url });
117
117
  },
118
+ aiTransform: function(req) {
119
+ return callNode("aiTransform", req);
120
+ },
118
121
  searchContacts: function(query) {
119
122
  return callNode("searchContacts", { query: query });
120
123
  },
@@ -8,13 +8,14 @@
8
8
 
9
9
  body {
10
10
  display: grid;
11
- grid-template-columns: var(--folder-width) 1fr;
11
+ /* rail | folders | main */
12
+ grid-template-columns: var(--rail-width, 48px) var(--folder-width) 1fr;
12
13
  grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
13
14
  grid-template-areas:
14
- "toolbar toolbar"
15
- "alert alert"
16
- "folders main"
17
- "status status";
15
+ "toolbar toolbar toolbar"
16
+ "alert alert alert"
17
+ "rail folders main"
18
+ "status status status";
18
19
  height: 100vh;
19
20
  overflow: hidden;
20
21
  font-family: var(--font-ui);
@@ -25,11 +26,55 @@ body {
25
26
  }
26
27
 
27
28
  .toolbar { grid-area: toolbar; }
29
+ .icon-rail { grid-area: rail; }
28
30
  .folder-panel { grid-area: folders; display: flex; flex-direction: column; overflow: hidden; }
29
31
  .folder-tree { flex: 1; overflow-y: auto; }
30
32
  .main-area { grid-area: main; }
31
33
  .status-bar { grid-area: status; }
32
34
 
35
+ /* Vertical icon rail (Dovecot/Thunderbird-style). Always visible on
36
+ wide+medium tiers; collapses on narrow (icons move into the hamburger
37
+ menu — TBD; for now hidden on narrow). */
38
+ .icon-rail {
39
+ display: flex;
40
+ flex-direction: column;
41
+ justify-content: space-between;
42
+ background: var(--color-bg-alt, #f4f4f5);
43
+ border-right: 1px solid var(--color-border);
44
+ padding: 6px 0;
45
+ overflow: hidden;
46
+ }
47
+ .rail-top, .rail-bottom {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 2px;
51
+ }
52
+ .rail-btn {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ width: var(--rail-width, 48px);
57
+ height: 38px;
58
+ border: 0;
59
+ background: transparent;
60
+ cursor: pointer;
61
+ font-size: 16px;
62
+ color: var(--color-text);
63
+ border-left: 3px solid transparent;
64
+ transition: background 0.12s, border-color 0.12s;
65
+ }
66
+ .rail-btn:hover:not([disabled]) {
67
+ background: var(--color-hover, rgba(0,0,0,0.06));
68
+ }
69
+ .rail-btn[data-active="true"] {
70
+ background: var(--color-hover, rgba(0,0,0,0.06));
71
+ border-left-color: var(--color-accent, #1a6dd4);
72
+ }
73
+ .rail-btn[disabled] {
74
+ opacity: 0.35;
75
+ cursor: not-allowed;
76
+ }
77
+
33
78
  /* Main area: message list left, viewer right, vertical splitter */
34
79
  .main-area {
35
80
  display: grid;
@@ -56,22 +101,22 @@ body {
56
101
  background: var(--color-accent);
57
102
  }
58
103
 
59
- /* Responsive: mid-width (tablets, foldables) — hide folders, keep list + preview */
104
+ /* Responsive: mid-width (tablets, foldables) — keep rail + list + viewer; folders overlay */
60
105
  @media (max-width: 1100px) and (min-width: 769px) {
61
106
  body {
62
- grid-template-columns: 1fr;
107
+ grid-template-columns: var(--rail-width, 48px) 1fr;
63
108
  grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
64
109
  grid-template-areas:
65
- "toolbar"
66
- "alert"
67
- "main"
68
- "status";
110
+ "toolbar toolbar"
111
+ "alert alert"
112
+ "rail main"
113
+ "status status";
69
114
  }
70
115
 
71
- /* Folder panel: overlay slide-in from left (same as narrow) */
116
+ /* Folder panel: overlay slide-in from left, sitting just to the right of the rail */
72
117
  .folder-panel {
73
118
  position: fixed;
74
- left: -280px;
119
+ left: calc(var(--rail-width, 48px) - 280px);
75
120
  top: var(--toolbar-height);
76
121
  bottom: var(--statusbar-height);
77
122
  width: 280px;
@@ -81,7 +126,7 @@ body {
81
126
  border-right: 1px solid var(--color-border);
82
127
  box-shadow: 2px 0 8px rgba(0,0,0,0.3);
83
128
  }
84
- .folder-panel.open { left: 0; }
129
+ .folder-panel.open { left: var(--rail-width, 48px); }
85
130
 
86
131
  /* Show hamburger */
87
132
  #btn-menu { display: inline-flex !important; }
@@ -122,6 +167,11 @@ body {
122
167
  "status";
123
168
  }
124
169
 
170
+ /* Rail hidden on narrow — its commands fold into the hamburger / toolbar.
171
+ Future work: a slide-in rail behind the hamburger so power-users on phone
172
+ can still reach calendar/contacts/etc. */
173
+ .icon-rail { display: none; }
174
+
125
175
  /* Folder panel: overlay slide-in from left */
126
176
  .folder-panel {
127
177
  position: fixed;
@@ -3,6 +3,7 @@
3
3
 
4
4
  :root {
5
5
  /* Layout */
6
+ --rail-width: 44px; /* vertical icon rail (Thunderbird/Dovecot style) */
6
7
  --folder-width: 220px;
7
8
  --toolbar-height: 44px;
8
9
  --statusbar-height: 24px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.310",
3
+ "version": "1.0.317",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -22,10 +22,10 @@
22
22
  "dependencies": {
23
23
  "@bobfrankston/iflow-direct": "^0.1.23",
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
- "@bobfrankston/miscinfo": "^1.0.8",
25
+ "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.340",
28
- "@bobfrankston/mailx-host": "^0.1.0",
27
+ "@bobfrankston/msger": "^0.1.343",
28
+ "@bobfrankston/mailx-host": "^0.1.3",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
31
31
  "@capacitor/core": "^8.3.0",
@@ -86,10 +86,10 @@
86
86
  "dependencies": {
87
87
  "@bobfrankston/iflow-direct": "^0.1.23",
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
- "@bobfrankston/miscinfo": "^1.0.8",
89
+ "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.340",
92
- "@bobfrankston/mailx-host": "^0.1.0",
91
+ "@bobfrankston/msger": "^0.1.343",
92
+ "@bobfrankston/mailx-host": "^0.1.3",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
95
95
  "@capacitor/core": "^8.3.0",
@@ -16,5 +16,6 @@ export declare function selectHost(): HostName;
16
16
  export declare const showMessageBox: typeof msger.showMessageBox;
17
17
  export declare const showService: typeof msger.showService;
18
18
  export declare const setAppName: typeof msger.setAppName;
19
+ export declare const setAppIcon: typeof msger.setAppIcon;
19
20
  export declare const hostName: HostName;
20
21
  //# sourceMappingURL=index.d.ts.map
@@ -26,5 +26,6 @@ if (_hostName !== "msger") {
26
26
  export const showMessageBox = msger.showMessageBox;
27
27
  export const showService = msger.showService;
28
28
  export const setAppName = msger.setAppName;
29
+ export const setAppIcon = msger.setAppIcon;
29
30
  export const hostName = _hostName;
30
31
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-host",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Host abstraction for mailx — dispatches to msger or msgview",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -16,5 +16,8 @@
16
16
  "repository": {
17
17
  "type": "git",
18
18
  "url": "git@github.com:BobFrankston/mailx-host.git"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
19
22
  }
20
23
  }
@@ -564,6 +564,11 @@ export class ImapManager extends EventEmitter {
564
564
  const folders = await client.getFolderList();
565
565
  console.log(` [diag] ${accountId}: getFolderList done in ${Date.now() - t0}ms (${folders.length} folders)`);
566
566
  const specialFolders = client.getSpecialFolders(folders);
567
+ // Collect server paths so we can prune anything the server no longer
568
+ // has (user-renamed / -deleted / case-flipped a folder from another
569
+ // client). IMAP paths are case-sensitive, so "Foo" → "foo" is a real
570
+ // delete+create of two distinct mailboxes.
571
+ const serverPaths = new Set();
567
572
  for (const folder of folders) {
568
573
  // Skip non-selectable folders (virtual parents like "Added", "Added2")
569
574
  const flags = folder.flags;
@@ -584,6 +589,34 @@ export class ImapManager extends EventEmitter {
584
589
  else if (specialFolders.archive === folder.path)
585
590
  specialUse = "archive";
586
591
  this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
592
+ serverPaths.add(folder.path);
593
+ }
594
+ // Prune: any local folder whose exact path (case-sensitive) isn't in
595
+ // the server's list has been deleted or renamed server-side. Safety
596
+ // rails: only prune when the server returned a non-empty list (empty
597
+ // result is more likely a transient protocol / auth error than "all
598
+ // your folders were deleted"). Never prune INBOX under any
599
+ // circumstances — even a broken server response shouldn't make us
600
+ // drop the account's primary mailbox. All other special-use folders
601
+ // ARE prunable: if the user actually deleted Sent on the server,
602
+ // we should reflect that locally, and the next sync will re-detect
603
+ // the server's real Sent folder and re-upsert.
604
+ if (folders.length > 0) {
605
+ const localFolders = this.db.getFolders(accountId);
606
+ const stale = localFolders.filter(f => !serverPaths.has(f.path) &&
607
+ f.specialUse !== "inbox");
608
+ for (const f of stale) {
609
+ console.log(` [sync] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — no longer on server`);
610
+ try {
611
+ this.db.deleteFolder(f.id);
612
+ }
613
+ catch (e) {
614
+ console.error(` [sync] ${accountId}: prune failed for "${f.path}": ${e.message}`);
615
+ }
616
+ }
617
+ if (stale.length > 0) {
618
+ this.emit("folderCountsChanged", accountId, {});
619
+ }
587
620
  }
588
621
  this.emit("syncProgress", accountId, "folders", 100);
589
622
  // Notify UI that folder structure changed — triggers tree re-render
@@ -964,6 +997,18 @@ export class ImapManager extends EventEmitter {
964
997
  if (!inboxDone) {
965
998
  console.error(` [sync] ${accountId}: INBOX failed after ${maxAttempts} attempts — will retry next sync cycle`);
966
999
  this.emit("syncError", accountId, `INBOX sync failed after ${maxAttempts} attempts`);
1000
+ // Even when sync failed, try to prefetch bodies for messages
1001
+ // already in the local DB. Prefetch uses a separate body
1002
+ // client (not the ops client that just timed out), so a
1003
+ // sync timeout on SELECT/SEARCH doesn't necessarily mean
1004
+ // body fetches will also fail. Without this, a server
1005
+ // having a slow patch would leave every message with a
1006
+ // white "not-downloaded" dot indefinitely until sync
1007
+ // recovers — even though prior syncs already populated
1008
+ // headers that prefetch can flesh out independently.
1009
+ if (getPrefetch()) {
1010
+ this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
1011
+ }
967
1012
  }
968
1013
  }
969
1014
  else {
@@ -1431,6 +1476,27 @@ export class ImapManager extends EventEmitter {
1431
1476
  }
1432
1477
  }, 30000);
1433
1478
  this.syncIntervals.set("actions", actionsInterval);
1479
+ // Body prefetch as a first-class background task — independent of
1480
+ // sync success. Prefetch was previously only triggered from inside
1481
+ // sync, so any account with slow/failing IMAP had its "not downloaded"
1482
+ // dots stuck forever even though body fetches use a separate
1483
+ // connection that might succeed. Every 60s, for every account, fire
1484
+ // prefetchBodies() (cheap when body_path is already populated — just a
1485
+ // DB query that returns 0 rows; the prefetchingAccounts guard
1486
+ // short-circuits concurrent triggers).
1487
+ if (getPrefetch()) {
1488
+ const kickPrefetch = () => {
1489
+ for (const [accountId] of this.configs) {
1490
+ this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e?.message || e}`));
1491
+ }
1492
+ };
1493
+ // Fire once now so the "not downloaded" dots start filling in
1494
+ // immediately on app start, don't make the user wait a minute.
1495
+ setTimeout(kickPrefetch, 2000);
1496
+ const prefetchInterval = setInterval(kickPrefetch, 60000);
1497
+ this.syncIntervals.set("prefetch", prefetchInterval);
1498
+ console.log(` [periodic] body prefetch every 60s (independent of sync)`);
1499
+ }
1434
1500
  // Full sync (all folders + IDLE restart) at configured interval
1435
1501
  const fullInterval = setInterval(async () => {
1436
1502
  console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { MailxDB } from "@bobfrankston/mailx-store";
7
7
  import { ImapManager } from "@bobfrankston/mailx-imap";
8
- import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings } from "@bobfrankston/mailx-types";
8
+ import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse } from "@bobfrankston/mailx-types";
9
9
  export declare class MailxService {
10
10
  private db;
11
11
  private imapManager;
@@ -101,5 +101,10 @@ export declare class MailxService {
101
101
  getAutocompleteSettings(): AutocompleteSettings;
102
102
  saveAutocompleteSettings(settings: AutocompleteSettings): void;
103
103
  autocomplete(req: AutocompleteRequest): Promise<AutocompleteResponse>;
104
+ /** Generic AI text transform — translate / proofread / summarize.
105
+ * Shares the autocomplete provider config (provider, key, model). Each
106
+ * feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
107
+ * default false. Returns empty text + reason when disabled or on error. */
108
+ aiTransform(req: AiTransformRequest): Promise<AiTransformResponse>;
104
109
  }
105
110
  //# sourceMappingURL=index.d.ts.map