@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.
- package/README.md +14 -2
- package/bin/mailx.js +56 -1
- package/client/app.js +100 -14
- package/client/components/folder-picker.js +119 -0
- package/client/components/message-list.js +23 -1
- package/client/components/message-viewer.js +64 -4
- package/client/compose/compose.js +97 -20
- package/client/compose/editor.js +51 -7
- package/client/index.html +18 -0
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/styles/layout.css +64 -14
- package/client/styles/variables.css +1 -0
- package/package.json +7 -7
- package/packages/mailx-host/index.d.ts +1 -0
- package/packages/mailx-host/index.js +1 -0
- package/packages/mailx-host/package.json +4 -1
- package/packages/mailx-imap/index.js +66 -0
- package/packages/mailx-service/index.d.ts +6 -1
- package/packages/mailx-service/index.js +146 -11
- package/packages/mailx-service/jsonrpc.js +4 -0
- package/packages/mailx-types/index.d.ts +21 -0
|
@@ -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 {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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:
|
|
386
|
-
//
|
|
387
|
-
//
|
|
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 (
|
|
401
|
-
init.
|
|
402
|
-
|
|
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
|
-
|
|
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 ──
|
package/client/compose/editor.js
CHANGED
|
@@ -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
|
-
// -
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
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
|
-
|
|
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">×</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">
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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
|
},
|
package/client/styles/layout.css
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
body {
|
|
10
10
|
display: grid;
|
|
11
|
-
|
|
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) —
|
|
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
|
|
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:
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
25
|
+
"@bobfrankston/miscinfo": "^1.0.9",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
28
|
-
"@bobfrankston/mailx-host": "^0.1.
|
|
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.
|
|
89
|
+
"@bobfrankston/miscinfo": "^1.0.9",
|
|
90
90
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
91
|
-
"@bobfrankston/msger": "^0.1.
|
|
92
|
-
"@bobfrankston/mailx-host": "^0.1.
|
|
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.
|
|
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
|