@bobfrankston/mailx 1.0.310 → 1.0.313
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 +19 -1
- 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 +33 -0
- package/packages/mailx-service/index.d.ts +6 -1
- package/packages/mailx-service/index.js +100 -0
- package/packages/mailx-service/jsonrpc.js +4 -0
- package/packages/mailx-types/index.d.ts +21 -0
package/README.md
CHANGED
|
@@ -123,11 +123,16 @@ Gmail OAuth requires a one-time Google Cloud setup:
|
|
|
123
123
|
|
|
124
124
|
## Usage
|
|
125
125
|
|
|
126
|
+
### Layout
|
|
127
|
+
|
|
128
|
+
A vertical **icon rail** sits on the far left (Thunderbird / Dovecot style) with one-click access to Compose, Inbox, All Inboxes, and Settings; Calendar / Tasks / Contacts slots are placeholders until those features land. The rail is always visible on wide and medium screens; it folds into the hamburger menu on narrow ones.
|
|
129
|
+
|
|
126
130
|
### Reading Mail
|
|
127
131
|
|
|
128
132
|
- Click a **folder** to see its messages
|
|
129
133
|
- Click a **message** to read it in the preview pane
|
|
130
134
|
- **All Inboxes** combines inboxes from all accounts (appears with 2+ accounts)
|
|
135
|
+
- A small filled teal dot before the date means the message body is downloaded locally (offline-ready); a hollow circle means not yet prefetched. Prefetch runs as a background task every ~60 s independent of folder sync.
|
|
131
136
|
- Unread counts show on folders; sub-folder counts bubble up to collapsed parents
|
|
132
137
|
- Use the **folder search** box to find folders by name
|
|
133
138
|
|
|
@@ -150,15 +155,18 @@ Gmail OAuth requires a one-time Google Cloud setup:
|
|
|
150
155
|
|
|
151
156
|
### Managing Messages
|
|
152
157
|
|
|
153
|
-
- **Delete** or **Ctrl+D** -- Delete selected messages (moves to Trash)
|
|
158
|
+
- **Delete** or **Ctrl+D** -- Delete selected messages (moves to Trash; second delete in Trash is a hard delete + EXPUNGE)
|
|
154
159
|
- **Ctrl+Z** -- Undo the last **delete or move** (whichever came last, 60s window)
|
|
155
160
|
- **Ctrl+A** -- Select all messages in the list
|
|
156
161
|
- **Drag and drop** -- Move messages to a folder by dragging them
|
|
162
|
+
- **Right-click a message → Move to folder…** -- Searchable folder picker; useful on narrow layouts where the folder tree is hidden
|
|
157
163
|
- Click the **star** column to flag/unflag a message
|
|
158
|
-
- **Unsubscribe** button appears when the message has a List-Unsubscribe header
|
|
164
|
+
- **Unsubscribe** button appears when the message has a List-Unsubscribe header. One-click (RFC 8058) when the sender advertises `List-Unsubscribe-Post: List-Unsubscribe=One-Click`; otherwise opens the URL or a pre-filled compose for `mailto:` lists.
|
|
159
165
|
- **Right-click on a From/To/Cc address** -- Copy name, Copy address, Copy both, Add to contacts, or Reply/Reply All/Forward
|
|
166
|
+
- **Right-click in the message body → Translate** (opt-in) -- Uses the configured AI provider; select text first to translate just the selection. Off by default; enable under **Settings → AI translate**.
|
|
160
167
|
- **Preview pane zoom** -- Ctrl+wheel, Ctrl+= / Ctrl+- / Ctrl+0, or right-click menu (Zoom in/out/reset, Copy, Select all). Persisted across messages.
|
|
161
168
|
- **Cross-folder search results** show the folder name for each hit
|
|
169
|
+
- **Empty Trash / Empty Junk** -- Right-click the folder in the tree → Empty (confirmation prompt)
|
|
162
170
|
|
|
163
171
|
### Searching
|
|
164
172
|
|
|
@@ -191,6 +199,10 @@ Under **View** in the toolbar:
|
|
|
191
199
|
Under **Settings** in the toolbar:
|
|
192
200
|
- **Editor** -- Choose between Quill (default) and tiptap for compose
|
|
193
201
|
- **AI autocomplete** -- Enable LLM-powered writing suggestions (Ollama, Claude, or OpenAI)
|
|
202
|
+
- **AI translate** -- Enable the right-click Translate item in the message viewer (off by default; uses the same provider as autocomplete)
|
|
203
|
+
- **AI proofread** -- Enable the proofread path (off by default; provider method available, UI wiring in progress)
|
|
204
|
+
|
|
205
|
+
All AI features are opt-in. Provider + API key live in the autocomplete settings; toggling a feature only controls whether that specific capability calls out to the provider.
|
|
194
206
|
|
|
195
207
|
### Keyboard Shortcuts
|
|
196
208
|
|
package/bin/mailx.js
CHANGED
|
@@ -21,8 +21,9 @@ import path from "node:path";
|
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import net from "node:net";
|
|
23
23
|
import { ports } from "@bobfrankston/miscinfo";
|
|
24
|
-
import { showMessageBox, showService, setAppName } from "@bobfrankston/mailx-host";
|
|
24
|
+
import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
|
|
25
25
|
setAppName("mailx");
|
|
26
|
+
setAppIcon(path.resolve(import.meta.dirname, "..", "client", "icon.png"));
|
|
26
27
|
const PORT = ports.mailx;
|
|
27
28
|
const args = process.argv.slice(2);
|
|
28
29
|
// Normalize: accept both -flag and --flag
|
|
@@ -146,6 +147,46 @@ for (const arg of args) {
|
|
|
146
147
|
}
|
|
147
148
|
function log(...msg) { if (verbose)
|
|
148
149
|
console.log("[mailx]", ...msg); }
|
|
150
|
+
/** Detect whether we're running with administrator / root privileges.
|
|
151
|
+
* Windows: `net session` requires admin — succeeds silently when elevated,
|
|
152
|
+
* errors "Access is denied" otherwise. Linux/Mac: check process uid.
|
|
153
|
+
* Returns true only when positively detected as elevated; on ambiguity
|
|
154
|
+
* (e.g. child_process spawn failed for non-privilege reasons), returns
|
|
155
|
+
* false so we don't block users on false positives. */
|
|
156
|
+
function isElevated() {
|
|
157
|
+
try {
|
|
158
|
+
if (process.platform === "win32") {
|
|
159
|
+
const { execSync } = require("node:child_process");
|
|
160
|
+
execSync("net session >nul 2>&1", { stdio: "ignore", windowsHide: true });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (typeof process.getuid === "function") {
|
|
164
|
+
return process.getuid() === 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch { /* non-admin → net session fails */ }
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
/** Put up a blocking warning dialog via showMessageBox. Returns the label
|
|
171
|
+
* the user clicked. The default (Quit) is first so Enter dismisses to
|
|
172
|
+
* safety. Caller decides what to do with "Continue anyway". */
|
|
173
|
+
async function warnElevated() {
|
|
174
|
+
const res = await showMessageBox({
|
|
175
|
+
title: "mailx — elevated run not recommended",
|
|
176
|
+
message: "mailx is running with Administrator privileges.\n\n" +
|
|
177
|
+
"This can corrupt the per-user WebView2 profile at\n" +
|
|
178
|
+
"%LOCALAPPDATA%\\msger\\webview2\\ and create admin-owned files\n" +
|
|
179
|
+
"under ~/.mailx/ that later non-admin runs can't write to\n" +
|
|
180
|
+
"(SQLite db, tokens, config).\n\n" +
|
|
181
|
+
"Quit, relaunch from a normal shell, and only use admin if\n" +
|
|
182
|
+
"you specifically know you need it. To bypass this warning\n" +
|
|
183
|
+
"(for scripted admin use), pass --allow-elevated.",
|
|
184
|
+
buttons: ["Quit", "Continue anyway"],
|
|
185
|
+
size: { width: 540, height: 340 },
|
|
186
|
+
escapeCloses: true,
|
|
187
|
+
});
|
|
188
|
+
return res.button;
|
|
189
|
+
}
|
|
149
190
|
// Kill any running mailx server
|
|
150
191
|
if (hasFlag("kill")) {
|
|
151
192
|
log("Killing mailx processes...");
|
|
@@ -731,6 +772,20 @@ async function main() {
|
|
|
731
772
|
log(`Platform: ${process.platform} ${process.arch}`);
|
|
732
773
|
log(`Node: ${process.version}`);
|
|
733
774
|
log(`Mode: ${setupMode ? "setup" : "auto"}`);
|
|
775
|
+
// Refuse to run elevated unless explicitly opted in. An elevated mailx
|
|
776
|
+
// can poison %LOCALAPPDATA%\msger\webview2\ (see msger/notes.md WebView2
|
|
777
|
+
// profile playbook) and create admin-owned files under ~/.mailx/ that
|
|
778
|
+
// later non-admin runs can't write to. `net session` requires admin on
|
|
779
|
+
// Windows; succeeds → admin, fails → non-admin. Linux/Mac use process
|
|
780
|
+
// uid (0 = root). --allow-elevated bypasses for scripted admin use.
|
|
781
|
+
if (!hasFlag("allow-elevated") && !isDaemon && isElevated()) {
|
|
782
|
+
const button = await warnElevated();
|
|
783
|
+
if (button !== "Continue anyway") {
|
|
784
|
+
log("User chose Quit on elevated-run warning. Exiting.");
|
|
785
|
+
process.exit(0);
|
|
786
|
+
}
|
|
787
|
+
log("User chose Continue anyway on elevated-run warning. Proceeding (will likely poison local state).");
|
|
788
|
+
}
|
|
734
789
|
// Test connectivity
|
|
735
790
|
if (testMode) {
|
|
736
791
|
await runTest();
|
package/client/app.js
CHANGED
|
@@ -415,6 +415,28 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
|
|
|
415
415
|
});
|
|
416
416
|
async function openCompose(mode) {
|
|
417
417
|
const current = getCurrentMessage();
|
|
418
|
+
// Reply / Reply-All / Forward all need an original message to populate
|
|
419
|
+
// From, To, Subject, and the quoted body. Two failure modes used to
|
|
420
|
+
// silently produce a blank compose:
|
|
421
|
+
// (1) getCurrentMessage() returns null — viewer still loading, message
|
|
422
|
+
// cleared mid-folder-switch, or fetch failed.
|
|
423
|
+
// (2) currentMessage is set but is a stub — header metadata arrived
|
|
424
|
+
// but body / from / subject haven't been populated yet.
|
|
425
|
+
// Bail out in both cases instead of opening an empty form.
|
|
426
|
+
if (mode === "reply" || mode === "replyAll" || mode === "forward") {
|
|
427
|
+
const m = current?.message;
|
|
428
|
+
const stubReason = !current ? "no current message" :
|
|
429
|
+
!m?.from ? "msg.from missing" :
|
|
430
|
+
!m?.subject && m?.subject !== "" ? "msg.subject missing" :
|
|
431
|
+
(mode !== "forward" && !m?.messageId) ? "msg.messageId missing (can't thread reply)" :
|
|
432
|
+
null;
|
|
433
|
+
if (stubReason) {
|
|
434
|
+
console.warn(`[compose] ${mode} ignored — ${stubReason}; current=`, current);
|
|
435
|
+
alert(`Cannot ${mode === "forward" ? "forward" : "reply to"} this message yet — ` +
|
|
436
|
+
`it's still loading (${stubReason}). Please wait a moment and try again.`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
418
440
|
const accounts = await getAccounts();
|
|
419
441
|
const accountId = current?.accountId || accounts[0]?.id || "";
|
|
420
442
|
const msg = current?.message;
|
|
@@ -431,20 +453,34 @@ async function openCompose(mode) {
|
|
|
431
453
|
references: [],
|
|
432
454
|
accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email })),
|
|
433
455
|
};
|
|
434
|
-
// Auto-detect reply From: if the message was delivered to an identity
|
|
435
|
-
//
|
|
436
|
-
//
|
|
456
|
+
// Auto-detect reply From: if the message was delivered to an identity address
|
|
457
|
+
// (an alias on the account's domain, or the explicit `identityDomains` list
|
|
458
|
+
// in accounts.jsonc), reply from that address instead of the account's
|
|
459
|
+
// primary. Always derive identityDomains from the account email's domain
|
|
460
|
+
// when not configured — explicit list was a regression source (users would
|
|
461
|
+
// see Reply pick the wrong From silently when the list was missing).
|
|
437
462
|
const account = accounts.find((a) => a.id === accountId);
|
|
438
|
-
const
|
|
463
|
+
const explicitDomains = (account?.identityDomains || []).map((d) => d.toLowerCase());
|
|
464
|
+
const accountDomain = (account?.email || "").split("@")[1]?.toLowerCase();
|
|
465
|
+
const identityDomains = explicitDomains.length > 0
|
|
466
|
+
? explicitDomains
|
|
467
|
+
: (accountDomain ? [accountDomain] : []);
|
|
439
468
|
function detectReplyFrom() {
|
|
440
469
|
if (!msg || identityDomains.length === 0)
|
|
441
470
|
return undefined;
|
|
442
|
-
|
|
443
|
-
|
|
471
|
+
// Prefer Delivered-To header (the address the server actually delivered
|
|
472
|
+
// to, which is the alias the message arrived at). Fall back to To, then
|
|
473
|
+
// Cc, in order. Bcc isn't visible to recipients so skipped.
|
|
474
|
+
const candidates = [
|
|
475
|
+
msg.deliveredTo,
|
|
476
|
+
...((msg.to || []).map((a) => a.address)),
|
|
477
|
+
...((msg.cc || []).map((a) => a.address)),
|
|
478
|
+
].filter(Boolean);
|
|
479
|
+
console.log(`[compose] detectReplyFrom: deliveredTo=${msg.deliveredTo}, to=${msg.to?.map((a) => a.address)}, cc=${msg.cc?.map((a) => a.address)}, identityDomains=${identityDomains}, accountEmail=${account?.email}`);
|
|
444
480
|
for (const addr of candidates) {
|
|
445
481
|
const domain = addr.split("@")[1]?.toLowerCase();
|
|
446
|
-
if (domain && identityDomains.some(
|
|
447
|
-
console.log(`[compose]
|
|
482
|
+
if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
|
|
483
|
+
console.log(`[compose] reply From → ${addr}`);
|
|
448
484
|
return addr;
|
|
449
485
|
}
|
|
450
486
|
}
|
|
@@ -789,6 +825,45 @@ document.getElementById("btn-compose")?.addEventListener("click", () => openComp
|
|
|
789
825
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
790
826
|
document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
|
|
791
827
|
document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
|
|
828
|
+
// ── Icon rail wiring ──
|
|
829
|
+
// Rail is the always-visible vertical bar on the far left (Thunderbird/Dovecot
|
|
830
|
+
// style). Mostly mirrors toolbar/menu actions for one-click access; calendar /
|
|
831
|
+
// tasks / contacts buttons are placeholders until those features ship.
|
|
832
|
+
document.getElementById("rail-compose")?.addEventListener("click", () => openCompose("new"));
|
|
833
|
+
document.getElementById("rail-inbox")?.addEventListener("click", () => {
|
|
834
|
+
// Trigger the existing folder-tree click on the first inbox folder.
|
|
835
|
+
const inbox = document.querySelector('.folder-tree .folder-item[data-special-use="inbox"]');
|
|
836
|
+
inbox?.click();
|
|
837
|
+
});
|
|
838
|
+
document.getElementById("rail-unified")?.addEventListener("click", () => {
|
|
839
|
+
const unified = document.querySelector('.folder-tree .all-inboxes')
|
|
840
|
+
|| document.getElementById("ft-all-inboxes");
|
|
841
|
+
unified?.click();
|
|
842
|
+
});
|
|
843
|
+
document.getElementById("rail-settings")?.addEventListener("click", () => {
|
|
844
|
+
document.getElementById("btn-settings")?.click();
|
|
845
|
+
});
|
|
846
|
+
document.getElementById("rail-help")?.addEventListener("click", () => {
|
|
847
|
+
document.getElementById("btn-about")?.click();
|
|
848
|
+
});
|
|
849
|
+
document.getElementById("rail-theme")?.addEventListener("click", () => {
|
|
850
|
+
// Cycle theme: system → dark → light → system.
|
|
851
|
+
const root = document.documentElement;
|
|
852
|
+
const cur = root.getAttribute("data-theme") || "system";
|
|
853
|
+
const next = cur === "system" ? "dark" : cur === "dark" ? "light" : "system";
|
|
854
|
+
root.setAttribute("data-theme", next);
|
|
855
|
+
try {
|
|
856
|
+
localStorage.setItem("mailx-theme", next);
|
|
857
|
+
}
|
|
858
|
+
catch { /* private mode */ }
|
|
859
|
+
});
|
|
860
|
+
// Highlight the current rail target. For now just inbox is the default; once
|
|
861
|
+
// calendar/tasks ship, update this on view change.
|
|
862
|
+
function setRailActive(id) {
|
|
863
|
+
document.querySelectorAll(".rail-btn[data-active]").forEach(el => el.removeAttribute("data-active"));
|
|
864
|
+
document.getElementById(id)?.setAttribute("data-active", "true");
|
|
865
|
+
}
|
|
866
|
+
document.addEventListener("mailx-folder-changed", () => setRailActive("rail-inbox"));
|
|
792
867
|
// Context menu events from message-list right-click
|
|
793
868
|
document.addEventListener("mailx-compose", ((e) => {
|
|
794
869
|
if (e.detail.mode === "draft" && sessionStorage.getItem("composeInit")) {
|
|
@@ -1907,19 +1982,30 @@ optEditorTiptap?.addEventListener("change", () => {
|
|
|
1907
1982
|
if (optEditorTiptap.checked)
|
|
1908
1983
|
saveEditorSetting("tiptap");
|
|
1909
1984
|
});
|
|
1910
|
-
// ── AI
|
|
1985
|
+
// ── AI feature toggles ──
|
|
1986
|
+
// One umbrella settings record (AutocompleteSettings) holds the provider config
|
|
1987
|
+
// + per-feature on/off flags. All features default OFF — user must opt into
|
|
1988
|
+
// each AI behavior individually. Per user preference (2026-04-21).
|
|
1911
1989
|
const optAutocomplete = document.getElementById("opt-autocomplete");
|
|
1912
|
-
|
|
1990
|
+
const optAiTranslate = document.getElementById("opt-ai-translate");
|
|
1991
|
+
const optAiProofread = document.getElementById("opt-ai-proofread");
|
|
1913
1992
|
getAutocompleteSettings().then((ac) => {
|
|
1914
1993
|
if (optAutocomplete)
|
|
1915
|
-
optAutocomplete.checked = ac.enabled
|
|
1994
|
+
optAutocomplete.checked = !!ac.enabled;
|
|
1995
|
+
if (optAiTranslate)
|
|
1996
|
+
optAiTranslate.checked = !!ac.translateEnabled;
|
|
1997
|
+
if (optAiProofread)
|
|
1998
|
+
optAiProofread.checked = !!ac.proofreadEnabled;
|
|
1916
1999
|
}).catch(() => { });
|
|
1917
|
-
|
|
2000
|
+
function persistAi(mutator) {
|
|
1918
2001
|
getAutocompleteSettings().then((ac) => {
|
|
1919
|
-
ac
|
|
2002
|
+
mutator(ac);
|
|
1920
2003
|
saveAutocompleteSettings(ac);
|
|
1921
2004
|
}).catch(() => { });
|
|
1922
|
-
}
|
|
2005
|
+
}
|
|
2006
|
+
optAutocomplete?.addEventListener("change", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));
|
|
2007
|
+
optAiTranslate?.addEventListener("change", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));
|
|
2008
|
+
optAiProofread?.addEventListener("change", () => persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; }));
|
|
1923
2009
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
1924
2010
|
// Wait for server ready signal, then fetch version
|
|
1925
2011
|
const versionPromise = getVersion();
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder picker — small modal for choosing a destination folder.
|
|
3
|
+
* Used by the message-list right-click "Move to folder…" item and any
|
|
4
|
+
* other UI that needs the user to pick a folder.
|
|
5
|
+
*
|
|
6
|
+
* Reads folders from the local DB via getFolders() (local-first — no
|
|
7
|
+
* server round-trip). Filters by typed text. Returns the selected
|
|
8
|
+
* folder, or null if the user dismissed.
|
|
9
|
+
*/
|
|
10
|
+
import { getFolders } from "../lib/api-client.js";
|
|
11
|
+
/** Show a modal folder picker. Returns a promise resolving to the picked
|
|
12
|
+
* folder, or null if dismissed. The list is restricted to one account
|
|
13
|
+
* (the current message's account) so it doesn't get cluttered with
|
|
14
|
+
* unrelated folders; cross-account moves can be added later via an
|
|
15
|
+
* account selector at the top of the picker. */
|
|
16
|
+
export function pickFolder(accountId, opts) {
|
|
17
|
+
return new Promise(async (resolve) => {
|
|
18
|
+
const overlay = document.createElement("div");
|
|
19
|
+
overlay.className = "folder-picker-overlay";
|
|
20
|
+
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;";
|
|
21
|
+
const modal = document.createElement("div");
|
|
22
|
+
modal.className = "folder-picker-modal";
|
|
23
|
+
modal.style.cssText = "background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,0.3);width:380px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;";
|
|
24
|
+
const header = document.createElement("div");
|
|
25
|
+
header.style.cssText = "padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;";
|
|
26
|
+
header.textContent = opts?.title || "Move to folder…";
|
|
27
|
+
modal.appendChild(header);
|
|
28
|
+
const search = document.createElement("input");
|
|
29
|
+
search.type = "text";
|
|
30
|
+
search.placeholder = "Filter folders…";
|
|
31
|
+
search.style.cssText = "margin:8px 12px;padding:6px 10px;border:1px solid var(--border, #ccc);border-radius:4px;font-size:13px;";
|
|
32
|
+
modal.appendChild(search);
|
|
33
|
+
const listEl = document.createElement("div");
|
|
34
|
+
listEl.style.cssText = "flex:1;overflow-y:auto;padding:4px 0;";
|
|
35
|
+
modal.appendChild(listEl);
|
|
36
|
+
const footer = document.createElement("div");
|
|
37
|
+
footer.style.cssText = "padding:8px 12px;border-top:1px solid var(--border, #ddd);display:flex;justify-content:flex-end;gap:8px;";
|
|
38
|
+
const cancelBtn = document.createElement("button");
|
|
39
|
+
cancelBtn.textContent = "Cancel";
|
|
40
|
+
cancelBtn.style.cssText = "padding:6px 14px;cursor:pointer;";
|
|
41
|
+
footer.appendChild(cancelBtn);
|
|
42
|
+
modal.appendChild(footer);
|
|
43
|
+
overlay.appendChild(modal);
|
|
44
|
+
document.body.appendChild(overlay);
|
|
45
|
+
const dismiss = (result) => {
|
|
46
|
+
overlay.remove();
|
|
47
|
+
document.removeEventListener("keydown", onKey);
|
|
48
|
+
resolve(result);
|
|
49
|
+
};
|
|
50
|
+
const onKey = (e) => {
|
|
51
|
+
if (e.key === "Escape") {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
dismiss(null);
|
|
54
|
+
}
|
|
55
|
+
if (e.key === "Enter") {
|
|
56
|
+
const first = listEl.querySelector(".folder-picker-row.match");
|
|
57
|
+
if (first)
|
|
58
|
+
first.click();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
document.addEventListener("keydown", onKey);
|
|
62
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay)
|
|
63
|
+
dismiss(null); });
|
|
64
|
+
cancelBtn.addEventListener("click", () => dismiss(null));
|
|
65
|
+
// Local-first: load from DB synchronously-ish (one IPC round-trip).
|
|
66
|
+
let folders = [];
|
|
67
|
+
try {
|
|
68
|
+
folders = (await getFolders(accountId)) || [];
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
listEl.textContent = "Failed to load folders";
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Hide special-use that don't make sense as targets (Outbox).
|
|
75
|
+
// Allow Trash / Junk so users can manually file into them.
|
|
76
|
+
const excluded = new Set(opts?.excludeFolderIds || []);
|
|
77
|
+
const targets = folders
|
|
78
|
+
.filter((f) => !excluded.has(f.id))
|
|
79
|
+
.filter((f) => f.specialUse !== "outbox")
|
|
80
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
81
|
+
function render(filter) {
|
|
82
|
+
listEl.innerHTML = "";
|
|
83
|
+
const lc = filter.toLowerCase().trim();
|
|
84
|
+
let firstMatchSet = false;
|
|
85
|
+
for (const f of targets) {
|
|
86
|
+
const row = document.createElement("div");
|
|
87
|
+
row.className = "folder-picker-row";
|
|
88
|
+
row.style.cssText = "padding:6px 14px;cursor:pointer;font-size:13px;display:flex;justify-content:space-between;gap:8px;";
|
|
89
|
+
const name = document.createElement("span");
|
|
90
|
+
name.textContent = f.path;
|
|
91
|
+
const tag = document.createElement("span");
|
|
92
|
+
tag.style.cssText = "color:var(--muted, #888);font-size:11px;";
|
|
93
|
+
tag.textContent = f.specialUse || "";
|
|
94
|
+
row.appendChild(name);
|
|
95
|
+
row.appendChild(tag);
|
|
96
|
+
const matches = !lc || f.path.toLowerCase().includes(lc);
|
|
97
|
+
if (matches) {
|
|
98
|
+
row.classList.add("match");
|
|
99
|
+
if (!firstMatchSet) {
|
|
100
|
+
row.style.background = "var(--hover, #eee)";
|
|
101
|
+
firstMatchSet = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
row.addEventListener("mouseenter", () => row.style.background = "var(--hover, #eee)");
|
|
105
|
+
row.addEventListener("mouseleave", () => row.style.background = "");
|
|
106
|
+
row.addEventListener("click", () => {
|
|
107
|
+
dismiss({ accountId, folderId: f.id, folderPath: f.path, folderName: f.path.split(/[./]/).pop() || f.path });
|
|
108
|
+
});
|
|
109
|
+
if (!matches)
|
|
110
|
+
row.style.display = "none";
|
|
111
|
+
listEl.appendChild(row);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
render("");
|
|
115
|
+
search.addEventListener("input", () => render(search.value));
|
|
116
|
+
setTimeout(() => search.focus(), 0);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=folder-picker.js.map
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Message list component — renders paginated message rows.
|
|
3
3
|
* Reads from message-state; operations mutate state, list reacts.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags, getThreadMessages } from "../lib/api-client.js";
|
|
5
|
+
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags, getThreadMessages, moveMessages as apiMoveMessages } from "../lib/api-client.js";
|
|
6
6
|
import * as state from "../lib/message-state.js";
|
|
7
7
|
import { showContextMenu } from "./context-menu.js";
|
|
8
|
+
import { pickFolder } from "./folder-picker.js";
|
|
8
9
|
let onMessageSelect;
|
|
9
10
|
let currentAccountId;
|
|
10
11
|
let currentFolderId;
|
|
@@ -620,6 +621,27 @@ function appendMessages(body, accountId, items) {
|
|
|
620
621
|
action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })),
|
|
621
622
|
},
|
|
622
623
|
{ label: "", action: () => { }, separator: true },
|
|
624
|
+
{
|
|
625
|
+
label: "Move to folder…",
|
|
626
|
+
action: async () => {
|
|
627
|
+
// Move all currently-selected rows (or just this one if it's the only selection)
|
|
628
|
+
const selectedRows = Array.from(document.querySelectorAll(".ml-row.selected"));
|
|
629
|
+
const uids = selectedRows.length > 0
|
|
630
|
+
? selectedRows.map((r) => Number(r.dataset.uid)).filter(u => !isNaN(u))
|
|
631
|
+
: [msg.uid];
|
|
632
|
+
const pick = await pickFolder(msgAccountId, { excludeFolderIds: [msg.folderId] });
|
|
633
|
+
if (!pick)
|
|
634
|
+
return;
|
|
635
|
+
try {
|
|
636
|
+
await apiMoveMessages(msgAccountId, uids, pick.folderId);
|
|
637
|
+
// Remove from local state — reconciler handles server sync.
|
|
638
|
+
state.removeMessages(uids.map(u => ({ accountId: msgAccountId, uid: u })));
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
alert(`Move failed: ${err.message}`);
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
},
|
|
623
645
|
{
|
|
624
646
|
label: "Delete",
|
|
625
647
|
action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
|
|
@@ -64,6 +64,61 @@ function setZoom(z, doc) {
|
|
|
64
64
|
}
|
|
65
65
|
/** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,
|
|
66
66
|
* keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */
|
|
67
|
+
/** Run AI translate on `text` and show result in a small modal. Disabled
|
|
68
|
+
* by default — user enables via Settings (translateEnabled in
|
|
69
|
+
* AutocompleteSettings). When disabled, the modal explains how to enable. */
|
|
70
|
+
async function translateAndShow(text) {
|
|
71
|
+
if (!text.trim())
|
|
72
|
+
return;
|
|
73
|
+
const status = document.getElementById("status-sync");
|
|
74
|
+
if (status)
|
|
75
|
+
status.textContent = "Translating…";
|
|
76
|
+
const overlay = document.createElement("div");
|
|
77
|
+
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;";
|
|
78
|
+
const modal = document.createElement("div");
|
|
79
|
+
modal.style.cssText = "background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;width:560px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 4px 24px rgba(0,0,0,0.3);";
|
|
80
|
+
const header = document.createElement("div");
|
|
81
|
+
header.style.cssText = "padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;display:flex;justify-content:space-between;align-items:center;";
|
|
82
|
+
header.innerHTML = `<span>Translation</span><button style="cursor:pointer;border:0;background:transparent;font-size:16px;" aria-label="Close">×</button>`;
|
|
83
|
+
const body = document.createElement("div");
|
|
84
|
+
body.style.cssText = "flex:1;overflow:auto;padding:12px 14px;white-space:pre-wrap;font-size:13px;line-height:1.45;";
|
|
85
|
+
body.textContent = "Working…";
|
|
86
|
+
modal.appendChild(header);
|
|
87
|
+
modal.appendChild(body);
|
|
88
|
+
overlay.appendChild(modal);
|
|
89
|
+
document.body.appendChild(overlay);
|
|
90
|
+
const close = () => overlay.remove();
|
|
91
|
+
header.querySelector("button")?.addEventListener("click", close);
|
|
92
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay)
|
|
93
|
+
close(); });
|
|
94
|
+
document.addEventListener("keydown", function onKey(e) {
|
|
95
|
+
if (e.key === "Escape") {
|
|
96
|
+
document.removeEventListener("keydown", onKey);
|
|
97
|
+
close();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
const { aiTransform } = await import("../lib/api-client.js");
|
|
102
|
+
const r = await aiTransform({ action: "translate", text, targetLang: "en" });
|
|
103
|
+
if (r.text) {
|
|
104
|
+
body.textContent = r.text;
|
|
105
|
+
if (status)
|
|
106
|
+
status.textContent = "";
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
body.innerHTML = `<div style="color:var(--muted, #888);">No result.</div>` +
|
|
110
|
+
`<div style="margin-top:8px;font-size:12px;color:var(--muted, #888);">${r.reason || ""}</div>` +
|
|
111
|
+
`<div style="margin-top:14px;font-size:12px;">Enable AI translate in Settings → AI features (off by default).</div>`;
|
|
112
|
+
if (status)
|
|
113
|
+
status.textContent = `Translate: ${r.reason || "no result"}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
body.textContent = `Error: ${err?.message || String(err)}`;
|
|
118
|
+
if (status)
|
|
119
|
+
status.textContent = `Translate error: ${err?.message || ""}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
67
122
|
function installPreviewControls(iframe) {
|
|
68
123
|
const attach = () => {
|
|
69
124
|
const doc = iframe.contentDocument;
|
|
@@ -124,18 +179,23 @@ function installPreviewControls(iframe) {
|
|
|
124
179
|
const x = rect.left + me.clientX;
|
|
125
180
|
const y = rect.top + me.clientY;
|
|
126
181
|
const pct = Math.round(previewZoom * 100);
|
|
182
|
+
const sel = doc.defaultView?.getSelection();
|
|
183
|
+
const selectedText = sel?.toString().trim() || "";
|
|
127
184
|
const items = [
|
|
128
185
|
{ label: "Copy", action: () => doc.execCommand("copy") },
|
|
129
186
|
{ label: "Select all", action: () => {
|
|
130
|
-
const
|
|
131
|
-
if (!
|
|
187
|
+
const s = doc.defaultView?.getSelection();
|
|
188
|
+
if (!s)
|
|
132
189
|
return;
|
|
133
190
|
const range = doc.createRange();
|
|
134
191
|
range.selectNodeContents(doc.body);
|
|
135
|
-
|
|
136
|
-
|
|
192
|
+
s.removeAllRanges();
|
|
193
|
+
s.addRange(range);
|
|
137
194
|
} },
|
|
138
195
|
{ label: "", action: () => { }, separator: true },
|
|
196
|
+
{ label: selectedText ? "Translate selection" : "Translate message",
|
|
197
|
+
action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) },
|
|
198
|
+
{ label: "", action: () => { }, separator: true },
|
|
139
199
|
{ label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
|
|
140
200
|
{ label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
|
|
141
201
|
{ label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
|
|
@@ -459,7 +459,25 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
459
459
|
catch (e) {
|
|
460
460
|
btn.disabled = false;
|
|
461
461
|
btn.textContent = "Send";
|
|
462
|
-
|
|
462
|
+
const msg = e?.message || String(e);
|
|
463
|
+
// Distinguish the IPC-timeout case from real send failures. The
|
|
464
|
+
// service-side send() queues the message to the local DB synchronously
|
|
465
|
+
// before attempting any IMAP/SMTP work — so if the IPC reached Node at
|
|
466
|
+
// all, the message is queued and the background worker will retry it
|
|
467
|
+
// with backoff (X-Mailx-Retry header, 60s settling delay, up to 10
|
|
468
|
+
// attempts). Treating that as a failure that demands a re-click leads
|
|
469
|
+
// to duplicate sends. Tell the user honestly: "probably queued, check
|
|
470
|
+
// Outbox before retrying."
|
|
471
|
+
if (msg.startsWith("mailxapi timeout")) {
|
|
472
|
+
alert("Send is taking longer than expected.\n\n" +
|
|
473
|
+
"The message has likely been queued and will be retried in the background. " +
|
|
474
|
+
"Check the Outbox folder before clicking Send again — clicking Send now may " +
|
|
475
|
+
"produce a duplicate.\n\n" +
|
|
476
|
+
"Your draft is preserved either way.");
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
alert(`Send failed: ${msg}`);
|
|
480
|
+
}
|
|
463
481
|
}
|
|
464
482
|
});
|
|
465
483
|
// ── 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.313",
|
|
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.342",
|
|
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.342",
|
|
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
|
}
|
|
@@ -964,6 +964,18 @@ export class ImapManager extends EventEmitter {
|
|
|
964
964
|
if (!inboxDone) {
|
|
965
965
|
console.error(` [sync] ${accountId}: INBOX failed after ${maxAttempts} attempts — will retry next sync cycle`);
|
|
966
966
|
this.emit("syncError", accountId, `INBOX sync failed after ${maxAttempts} attempts`);
|
|
967
|
+
// Even when sync failed, try to prefetch bodies for messages
|
|
968
|
+
// already in the local DB. Prefetch uses a separate body
|
|
969
|
+
// client (not the ops client that just timed out), so a
|
|
970
|
+
// sync timeout on SELECT/SEARCH doesn't necessarily mean
|
|
971
|
+
// body fetches will also fail. Without this, a server
|
|
972
|
+
// having a slow patch would leave every message with a
|
|
973
|
+
// white "not-downloaded" dot indefinitely until sync
|
|
974
|
+
// recovers — even though prior syncs already populated
|
|
975
|
+
// headers that prefetch can flesh out independently.
|
|
976
|
+
if (getPrefetch()) {
|
|
977
|
+
this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
|
|
978
|
+
}
|
|
967
979
|
}
|
|
968
980
|
}
|
|
969
981
|
else {
|
|
@@ -1431,6 +1443,27 @@ export class ImapManager extends EventEmitter {
|
|
|
1431
1443
|
}
|
|
1432
1444
|
}, 30000);
|
|
1433
1445
|
this.syncIntervals.set("actions", actionsInterval);
|
|
1446
|
+
// Body prefetch as a first-class background task — independent of
|
|
1447
|
+
// sync success. Prefetch was previously only triggered from inside
|
|
1448
|
+
// sync, so any account with slow/failing IMAP had its "not downloaded"
|
|
1449
|
+
// dots stuck forever even though body fetches use a separate
|
|
1450
|
+
// connection that might succeed. Every 60s, for every account, fire
|
|
1451
|
+
// prefetchBodies() (cheap when body_path is already populated — just a
|
|
1452
|
+
// DB query that returns 0 rows; the prefetchingAccounts guard
|
|
1453
|
+
// short-circuits concurrent triggers).
|
|
1454
|
+
if (getPrefetch()) {
|
|
1455
|
+
const kickPrefetch = () => {
|
|
1456
|
+
for (const [accountId] of this.configs) {
|
|
1457
|
+
this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e?.message || e}`));
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
// Fire once now so the "not downloaded" dots start filling in
|
|
1461
|
+
// immediately on app start, don't make the user wait a minute.
|
|
1462
|
+
setTimeout(kickPrefetch, 2000);
|
|
1463
|
+
const prefetchInterval = setInterval(kickPrefetch, 60000);
|
|
1464
|
+
this.syncIntervals.set("prefetch", prefetchInterval);
|
|
1465
|
+
console.log(` [periodic] body prefetch every 60s (independent of sync)`);
|
|
1466
|
+
}
|
|
1434
1467
|
// Full sync (all folders + IDLE restart) at configured interval
|
|
1435
1468
|
const fullInterval = setInterval(async () => {
|
|
1436
1469
|
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
|
|
@@ -1005,6 +1005,106 @@ export class MailxService {
|
|
|
1005
1005
|
}
|
|
1006
1006
|
return { suggestion: "" };
|
|
1007
1007
|
}
|
|
1008
|
+
/** Generic AI text transform — translate / proofread / summarize.
|
|
1009
|
+
* Shares the autocomplete provider config (provider, key, model). Each
|
|
1010
|
+
* feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
|
|
1011
|
+
* default false. Returns empty text + reason when disabled or on error. */
|
|
1012
|
+
async aiTransform(req) {
|
|
1013
|
+
const cfg = loadAutocomplete();
|
|
1014
|
+
if (cfg.provider === "off")
|
|
1015
|
+
return { text: "", reason: "AI provider not configured" };
|
|
1016
|
+
const featureGate = {
|
|
1017
|
+
translate: cfg.translateEnabled,
|
|
1018
|
+
proofread: cfg.proofreadEnabled,
|
|
1019
|
+
summarize: cfg.proofreadEnabled, // bundled with proofread for now
|
|
1020
|
+
};
|
|
1021
|
+
if (!featureGate[req.action])
|
|
1022
|
+
return { text: "", reason: `AI ${req.action} disabled in settings` };
|
|
1023
|
+
const text = (req.text || "").slice(0, 8000); // sanity cap
|
|
1024
|
+
if (!text.trim())
|
|
1025
|
+
return { text: "", reason: "no input" };
|
|
1026
|
+
const target = req.targetLang || "en";
|
|
1027
|
+
let systemPrompt;
|
|
1028
|
+
let userPrompt;
|
|
1029
|
+
switch (req.action) {
|
|
1030
|
+
case "translate":
|
|
1031
|
+
systemPrompt = `You are a translator. Render the user's text into ${target}. Preserve formatting (paragraphs, lists). Output ONLY the translation, no explanation.`;
|
|
1032
|
+
userPrompt = text;
|
|
1033
|
+
break;
|
|
1034
|
+
case "proofread":
|
|
1035
|
+
systemPrompt = `You are an editor. Return the user's text with grammar, spelling, and clarity fixed. Preserve voice and meaning. Output ONLY the corrected text, no explanation.`;
|
|
1036
|
+
userPrompt = text;
|
|
1037
|
+
break;
|
|
1038
|
+
case "summarize":
|
|
1039
|
+
systemPrompt = `You are a summarizer. Render the user's text as a short paragraph (2-4 sentences). Output ONLY the summary.`;
|
|
1040
|
+
userPrompt = text;
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
if (cfg.provider === "ollama") {
|
|
1045
|
+
const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
|
|
1046
|
+
method: "POST",
|
|
1047
|
+
headers: { "Content-Type": "application/json" },
|
|
1048
|
+
body: JSON.stringify({
|
|
1049
|
+
model: cfg.ollamaModel,
|
|
1050
|
+
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
|
1051
|
+
stream: false,
|
|
1052
|
+
options: { num_predict: 1024 },
|
|
1053
|
+
}),
|
|
1054
|
+
});
|
|
1055
|
+
if (!res.ok)
|
|
1056
|
+
return { text: "", reason: `ollama ${res.status}` };
|
|
1057
|
+
const data = await res.json();
|
|
1058
|
+
return { text: (data.response || "").trim() };
|
|
1059
|
+
}
|
|
1060
|
+
if (cfg.provider === "claude") {
|
|
1061
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1062
|
+
method: "POST",
|
|
1063
|
+
headers: {
|
|
1064
|
+
"Content-Type": "application/json",
|
|
1065
|
+
"x-api-key": cfg.cloudApiKey,
|
|
1066
|
+
"anthropic-version": "2023-06-01",
|
|
1067
|
+
},
|
|
1068
|
+
body: JSON.stringify({
|
|
1069
|
+
model: cfg.cloudModel,
|
|
1070
|
+
max_tokens: 2048,
|
|
1071
|
+
system: systemPrompt,
|
|
1072
|
+
messages: [{ role: "user", content: userPrompt }],
|
|
1073
|
+
}),
|
|
1074
|
+
});
|
|
1075
|
+
if (!res.ok)
|
|
1076
|
+
return { text: "", reason: `claude ${res.status}` };
|
|
1077
|
+
const data = await res.json();
|
|
1078
|
+
return { text: (data.content?.[0]?.text || "").trim() };
|
|
1079
|
+
}
|
|
1080
|
+
if (cfg.provider === "openai") {
|
|
1081
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1082
|
+
method: "POST",
|
|
1083
|
+
headers: {
|
|
1084
|
+
"Content-Type": "application/json",
|
|
1085
|
+
"Authorization": `Bearer ${cfg.cloudApiKey}`,
|
|
1086
|
+
},
|
|
1087
|
+
body: JSON.stringify({
|
|
1088
|
+
model: cfg.cloudModel,
|
|
1089
|
+
max_tokens: 2048,
|
|
1090
|
+
messages: [
|
|
1091
|
+
{ role: "system", content: systemPrompt },
|
|
1092
|
+
{ role: "user", content: userPrompt },
|
|
1093
|
+
],
|
|
1094
|
+
}),
|
|
1095
|
+
});
|
|
1096
|
+
if (!res.ok)
|
|
1097
|
+
return { text: "", reason: `openai ${res.status}` };
|
|
1098
|
+
const data = await res.json();
|
|
1099
|
+
return { text: (data.choices?.[0]?.message?.content || "").trim() };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
catch (e) {
|
|
1103
|
+
console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
|
|
1104
|
+
return { text: "", reason: e.message };
|
|
1105
|
+
}
|
|
1106
|
+
return { text: "", reason: "no provider matched" };
|
|
1107
|
+
}
|
|
1008
1108
|
}
|
|
1009
1109
|
/** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
|
|
1010
1110
|
function trimSuggestion(text) {
|
|
@@ -137,6 +137,10 @@ async function dispatchAction(svc, action, p) {
|
|
|
137
137
|
case "saveAutocompleteSettings":
|
|
138
138
|
svc.saveAutocompleteSettings(p);
|
|
139
139
|
return { ok: true };
|
|
140
|
+
// AI transform (translate / proofread / summarize) — gated by per-feature
|
|
141
|
+
// toggles in autocomplete settings, both default false.
|
|
142
|
+
case "aiTransform":
|
|
143
|
+
return svc.aiTransform(p);
|
|
140
144
|
// Attachments
|
|
141
145
|
case "getAttachment": {
|
|
142
146
|
const att = await svc.getAttachment(p.accountId, p.uid, p.attachmentId, p.folderId);
|
|
@@ -225,6 +225,12 @@ export interface AutocompleteSettings {
|
|
|
225
225
|
cloudModel: string;
|
|
226
226
|
debounceMs: number;
|
|
227
227
|
maxTokens: number;
|
|
228
|
+
/** Per-feature opt-in for non-autocomplete AI helpers. All default false
|
|
229
|
+
* per user preference (2026-04-21): AI features should be controlled by
|
|
230
|
+
* a flag, initially OFF in settings. Provider config is shared with
|
|
231
|
+
* autocomplete (provider, cloudApiKey, cloudModel, etc.). */
|
|
232
|
+
translateEnabled?: boolean;
|
|
233
|
+
proofreadEnabled?: boolean;
|
|
228
234
|
}
|
|
229
235
|
export interface AutocompleteRequest {
|
|
230
236
|
subject: string;
|
|
@@ -235,6 +241,21 @@ export interface AutocompleteRequest {
|
|
|
235
241
|
export interface AutocompleteResponse {
|
|
236
242
|
suggestion: string;
|
|
237
243
|
}
|
|
244
|
+
export interface AiTransformRequest {
|
|
245
|
+
/** translate = render in `targetLang`; proofread = corrected version
|
|
246
|
+
* with grammar/spelling fixes; summarize = short paragraph summary. */
|
|
247
|
+
action: "translate" | "proofread" | "summarize";
|
|
248
|
+
text: string;
|
|
249
|
+
/** ISO-639-1 (or BCP-47) language code for translate. Defaults to "en". */
|
|
250
|
+
targetLang?: string;
|
|
251
|
+
}
|
|
252
|
+
export interface AiTransformResponse {
|
|
253
|
+
/** Transformed text. Empty when AI is disabled / provider error / feature
|
|
254
|
+
* not enabled — caller should treat empty as "no result". */
|
|
255
|
+
text: string;
|
|
256
|
+
/** Optional reason for empty result, surfaced to UI status bar. */
|
|
257
|
+
reason?: string;
|
|
258
|
+
}
|
|
238
259
|
/** Body storage backend interface -- implementations are swappable */
|
|
239
260
|
export interface MessageStore {
|
|
240
261
|
putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
|