@bobfrankston/mailx 1.0.449 → 1.0.450

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/app.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
7
- import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
7
+ import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage } from "./components/message-viewer.js";
8
8
  import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
9
9
  import * as messageState from "./lib/message-state.js";
10
10
  // ── New message badge (favicon + title) ──
@@ -409,6 +409,14 @@ document.getElementById("btn-folder-toggle")?.addEventListener("click", () => {
409
409
  const backToList = (e) => {
410
410
  e.preventDefault();
411
411
  e.stopPropagation();
412
+ // If user is in full-screen-viewer mode, the first back tap should exit
413
+ // full-screen and return to the normal narrow split (list + active
414
+ // viewer). It shouldn't also deselect — that would yank the user out two
415
+ // levels in one tap.
416
+ if (document.body.classList.contains("viewer-fullscreen")) {
417
+ document.body.classList.remove("viewer-fullscreen");
418
+ return;
419
+ }
412
420
  document.getElementById("message-viewer")?.classList.remove("narrow-active");
413
421
  document.getElementById("message-list")?.classList.remove("narrow-hidden");
414
422
  // Deselect the message so the viewer component clears. Without this, a
@@ -420,6 +428,10 @@ document.getElementById("btn-back")?.addEventListener("click", backToList);
420
428
  // Android WebView sometimes drops synthetic clicks after a touchend inside a
421
429
  // header bar layered above the iframe — handle touchend explicitly too.
422
430
  document.getElementById("btn-back")?.addEventListener("touchend", backToList);
431
+ // Pop-out viewer button — desktop spawns a floating overlay (multiple at
432
+ // once), mobile toggles `body.viewer-fullscreen` for full-screen reading.
433
+ // Threshold and behavior live in popOutCurrentMessage.
434
+ document.getElementById("mv-popout")?.addEventListener("click", () => popOutCurrentMessage());
423
435
  // Close folder panel when a folder is selected (narrow mode)
424
436
  // Also reset narrow navigation: show message list, hide viewer
425
437
  document.getElementById("folder-tree")?.addEventListener("click", (e) => {
@@ -1168,4 +1168,124 @@ ${csp}
1168
1168
  </script>
1169
1169
  </head><body>${html}</body></html>`;
1170
1170
  }
1171
+ /** Open the current message in a separate view: floating draggable overlay
1172
+ * on desktop (multiple at once, like compose), full-screen mode on mobile.
1173
+ * Threshold matches the layout.css responsive breakpoint so the experience
1174
+ * is consistent with other narrow-mode behavior. Snapshot in time — the
1175
+ * pop-out doesn't auto-update if the user clicks another message. */
1176
+ export function popOutCurrentMessage() {
1177
+ if (!currentMessage)
1178
+ return;
1179
+ const isNarrow = window.innerWidth <= 768;
1180
+ if (isNarrow) {
1181
+ document.body.classList.toggle("viewer-fullscreen");
1182
+ return;
1183
+ }
1184
+ spawnDesktopPopout(currentMessage, currentAccountId);
1185
+ }
1186
+ /** Build a floating overlay carrying a snapshot of the message: header
1187
+ * (subject, from, to, date) + sandboxed body iframe + attachment chips.
1188
+ * Reuses the compose-overlay drag/resize/close pattern. Independent of the
1189
+ * main viewer — opening pop-out for message A then switching the main pane
1190
+ * to message B leaves A visible in its overlay. */
1191
+ function spawnDesktopPopout(msg, accountId) {
1192
+ const wrapper = document.createElement("div");
1193
+ wrapper.className = "compose-overlay viewer-popout";
1194
+ wrapper.style.cssText = "position:fixed;top:60px;right:20px;width:min(720px,55vw);height:min(800px,80vh);z-index:1000;border:1px solid var(--color-border, #ccc);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;background:var(--color-bg, #fff);resize:both;overflow:hidden;";
1195
+ const titleBar = document.createElement("div");
1196
+ titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--color-bg-alt, #e8ecf0);color:var(--color-text, #000);border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;font-size:13px;";
1197
+ const titleText = document.createElement("span");
1198
+ titleText.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-weight:600;";
1199
+ titleText.textContent = msg.subject || "(no subject)";
1200
+ titleBar.appendChild(titleText);
1201
+ const closeBtn = document.createElement("button");
1202
+ closeBtn.textContent = "✕";
1203
+ closeBtn.title = "Close pop-out";
1204
+ closeBtn.style.cssText = "background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 8px;border-radius:4px;flex-shrink:0;";
1205
+ closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
1206
+ closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
1207
+ closeBtn.addEventListener("click", () => wrapper.remove());
1208
+ titleBar.appendChild(closeBtn);
1209
+ const headerInfo = document.createElement("div");
1210
+ headerInfo.style.cssText = "padding:8px 12px;border-bottom:1px solid var(--color-border, #ddd);font-size:13px;line-height:1.4;flex-shrink:0;";
1211
+ const formatAddrLocal = (a) => a.name ? `${a.name} <${a.address}>` : a.address;
1212
+ const fromStr = formatAddrLocal(msg.from || { address: "" });
1213
+ const toStr = (msg.to || []).map(formatAddrLocal).join(", ");
1214
+ const ccStr = msg.cc?.length ? ` Cc: ${msg.cc.map(formatAddrLocal).join(", ")}` : "";
1215
+ const dateStr = msg.date ? new Date(msg.date).toLocaleString() : "";
1216
+ headerInfo.innerHTML =
1217
+ `<div><strong>${escapeHtmlLocal(fromStr)}</strong></div>` +
1218
+ `<div style="color:var(--color-text-muted, #666)">To: ${escapeHtmlLocal(toStr)}${escapeHtmlLocal(ccStr)}</div>` +
1219
+ `<div style="color:var(--color-text-muted, #666);font-size:12px">${escapeHtmlLocal(dateStr)}</div>`;
1220
+ const bodyContainer = document.createElement("div");
1221
+ bodyContainer.style.cssText = "flex:1;overflow:hidden;display:flex;";
1222
+ if (msg.bodyHtml) {
1223
+ const iframe = document.createElement("iframe");
1224
+ iframe.sandbox.add("allow-same-origin");
1225
+ iframe.sandbox.add("allow-popups");
1226
+ iframe.sandbox.add("allow-popups-to-escape-sandbox");
1227
+ iframe.sandbox.add("allow-top-navigation-by-user-activation");
1228
+ iframe.sandbox.add("allow-scripts");
1229
+ iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
1230
+ iframe.style.cssText = "flex:1;border:none;width:100%;background:#fff;";
1231
+ bodyContainer.appendChild(iframe);
1232
+ }
1233
+ else {
1234
+ const pre = document.createElement("pre");
1235
+ pre.style.cssText = "padding:12px;white-space:pre-wrap;word-break:break-word;margin:0;flex:1;overflow:auto;";
1236
+ pre.textContent = msg.bodyText || "(no content)";
1237
+ bodyContainer.appendChild(pre);
1238
+ }
1239
+ // Drag — same pattern as compose-overlay: pointer-events:none on the
1240
+ // iframe so cursor crossing into it doesn't lose drag events.
1241
+ let dragX = 0, dragY = 0;
1242
+ titleBar.addEventListener("mousedown", (e) => {
1243
+ if (e.target === closeBtn)
1244
+ return;
1245
+ e.preventDefault();
1246
+ const rect = wrapper.getBoundingClientRect();
1247
+ dragX = e.clientX - rect.left;
1248
+ dragY = e.clientY - rect.top;
1249
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1250
+ bodyContainer.style.pointerEvents = "none";
1251
+ document.body.style.userSelect = "none";
1252
+ const onMove = (ev) => {
1253
+ ev.preventDefault();
1254
+ const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
1255
+ const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
1256
+ wrapper.style.left = `${left}px`;
1257
+ wrapper.style.top = `${top}px`;
1258
+ wrapper.style.right = "auto";
1259
+ wrapper.style.bottom = "auto";
1260
+ };
1261
+ const onUp = () => {
1262
+ bodyContainer.style.pointerEvents = "";
1263
+ document.body.style.userSelect = "";
1264
+ document.removeEventListener("mousemove", onMove);
1265
+ document.removeEventListener("mouseup", onUp);
1266
+ };
1267
+ document.addEventListener("mousemove", onMove);
1268
+ document.addEventListener("mouseup", onUp);
1269
+ });
1270
+ // Bring to front on click — shared with compose-overlay so they all
1271
+ // restack uniformly.
1272
+ wrapper.addEventListener("mousedown", () => {
1273
+ document.querySelectorAll(".compose-overlay").forEach(el => el.style.zIndex = "1000");
1274
+ wrapper.style.zIndex = "1001";
1275
+ });
1276
+ // Cascade pop-outs so they don't all stack at the same coords.
1277
+ const existing = document.querySelectorAll(".viewer-popout").length;
1278
+ if (existing > 0) {
1279
+ wrapper.style.top = `${60 + existing * 28}px`;
1280
+ wrapper.style.right = `${20 + existing * 28}px`;
1281
+ }
1282
+ void accountId; // accountId reserved for future per-account actions on the popout
1283
+ wrapper.appendChild(titleBar);
1284
+ wrapper.appendChild(headerInfo);
1285
+ wrapper.appendChild(bodyContainer);
1286
+ document.body.appendChild(wrapper);
1287
+ }
1288
+ function escapeHtmlLocal(s) {
1289
+ return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1290
+ }
1171
1291
  //# sourceMappingURL=message-viewer.js.map
package/client/index.html CHANGED
@@ -161,6 +161,7 @@
161
161
  <button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
162
162
  <a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
163
163
  <button class="mv-action" id="mv-view-source" title="View source (.eml)" hidden>Source</button>
164
+ <button class="mv-action" id="mv-popout" title="Pop out (desktop) / Full-screen (mobile)">⤢</button>
164
165
  <button class="mv-action" id="mv-toggle-details" title="Show/hide extra headers">Details</button>
165
166
  </div>
166
167
  <div class="mv-header-info">
@@ -127,6 +127,26 @@ body.calendar-sidebar-on {
127
127
  .main-area.no-preview .message-viewer { display: none; }
128
128
  .main-area.no-preview .message-list { border-right: none; }
129
129
 
130
+ /* Full-screen viewer mode (mobile/Android) — viewer takes the whole app
131
+ surface; folder rail, folder tree, message list and statusbar are hidden.
132
+ Toggle via `body.viewer-fullscreen` from the pop-out / full-screen button.
133
+ On desktop the same button instead spawns a floating overlay (handled in
134
+ message-viewer.ts) and never sets this class. */
135
+ body.viewer-fullscreen .folder-rail,
136
+ body.viewer-fullscreen .folder-tree,
137
+ body.viewer-fullscreen .message-list,
138
+ body.viewer-fullscreen .splitter,
139
+ body.viewer-fullscreen .toolbar,
140
+ body.viewer-fullscreen .statusbar { display: none !important; }
141
+ body.viewer-fullscreen .main-area {
142
+ grid-template-columns: 1fr !important;
143
+ grid-template-rows: 1fr !important;
144
+ }
145
+ body.viewer-fullscreen .message-viewer {
146
+ display: flex !important;
147
+ grid-column: 1 / -1 !important;
148
+ }
149
+
130
150
  /* Splitter */
131
151
  .splitter {
132
152
  background: var(--color-border);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.449",
3
+ "version": "1.0.450",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -141,6 +141,7 @@ export declare class ImapManager extends EventEmitter {
141
141
  * preempt a command mid-flight. */
142
142
  withConnection<T>(accountId: string, fn: (client: any) => Promise<T>, opts?: {
143
143
  slow?: boolean;
144
+ timeoutMs?: number;
144
145
  }): Promise<T>;
145
146
  /** Run the next queued task. Fast lane drains before slow.
146
147
  * Idempotent — safe to call after each task completes; the running
@@ -383,15 +383,32 @@ export class ImapManager extends EventEmitter {
383
383
  queue = { fast: [], slow: [], running: false };
384
384
  this.opsQueues.set(accountId, queue);
385
385
  }
386
+ // Per-task wall-clock cap. Without one, a wedged IMAP command (TCP
387
+ // half-open, server stalled mid-FETCH) keeps the queue's running flag
388
+ // set forever and every subsequent fast-lane task — including the
389
+ // retry button the user just hit — waits behind it. Default is
390
+ // generous; callers driving user-visible reads pass a tighter value.
391
+ const timeoutMs = opts.timeoutMs ?? 90_000;
386
392
  return new Promise((resolve, reject) => {
387
393
  const task = async () => {
394
+ let timer;
388
395
  try {
389
396
  const client = await this.getOpsClient(accountId);
390
- resolve(await fn(client));
397
+ const result = await Promise.race([
398
+ fn(client),
399
+ new Promise((_, rej) => {
400
+ timer = setTimeout(() => rej(new Error(`ops timeout after ${Math.round(timeoutMs / 1000)}s — discarding client`)), timeoutMs);
401
+ }),
402
+ ]);
403
+ clearTimeout(timer);
404
+ resolve(result);
391
405
  }
392
406
  catch (e) {
407
+ clearTimeout(timer);
393
408
  // Discard client on any error — keeping a half-broken
394
- // socket poisoned every subsequent request.
409
+ // socket poisoned every subsequent request. Destroy
410
+ // synchronously kills the in-flight command's socket so
411
+ // the underlying promise rejects and stops holding state.
395
412
  const stale = this.opsClients.get(accountId);
396
413
  this.opsClients.delete(accountId);
397
414
  if (stale) {
@@ -320,8 +320,23 @@ export declare class MailxService {
320
320
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
321
321
  readConfigHelp(name: string): Promise<string>;
322
322
  /** Write a JSONC config file. Validates that the content parses as JSONC
323
- * (loosely — strips comments/trailing commas) before writing. */
323
+ * (loosely — strips comments/trailing commas) before writing.
324
+ * Saves the prior content to a dated backup file first — manual edits
325
+ * occasionally have typos that survive validation (semantically wrong
326
+ * but syntactically OK), and a one-key undo isn't enough; the user
327
+ * asked to be able to recover yesterday's accounts.jsonc. Automatic
328
+ * saveAccounts/saveAllowlist paths skip backups (they're driven by
329
+ * trusted code, not the JSONC editor). */
324
330
  writeJsoncFile(name: string, content: string): Promise<void>;
331
+ /** Read the current content of a config file (cloud or local) so it can
332
+ * be saved as a backup before being overwritten. Returns null if the
333
+ * file doesn't exist yet (first save — nothing to back up). */
334
+ private readJsoncForBackup;
335
+ /** Write the prior content to `<configDir>/backup/<name>.<ts>.bak` and
336
+ * prune so at most 10 backups per file remain AND none are older than 7
337
+ * days. Skipped when previous content is null (first write) or
338
+ * identical to the new content (no-op save). */
339
+ private backupJsoncIfChanged;
325
340
  getSettings(): any;
326
341
  saveSettings(settings: any): void;
327
342
  getStorageInfo(): {
@@ -314,17 +314,16 @@ export class MailxService {
314
314
  let bodyText = "";
315
315
  let hasRemoteContent = false;
316
316
  let attachments = [];
317
- // Wall-clock cap on the server-side body fetch. Without this, a Gmail
318
- // rate-limit cooldown (shared across providers via module-level state)
319
- // can park the request indefinitely — the user sees an infinite
320
- // "Fetching message body..." spinner with no way to recover. The cap
321
- // surfaces a transient error so the viewer can show a retry banner.
322
- const BODY_FETCH_TIMEOUT_MS = 45_000;
317
+ // The per-account ops queue inside ImapManager has its own per-task
318
+ // timeout that destroys a wedged client and unblocks the queue. This
319
+ // outer race is a safety net only — the underlying timeout in
320
+ // withConnection should trigger first.
321
+ const BODY_FETCH_TIMEOUT_MS = 60_000;
323
322
  let raw = null;
324
323
  try {
325
324
  raw = await Promise.race([
326
325
  this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid),
327
- new Promise((_, reject) => setTimeout(() => reject(new Error("body fetch timeout (45s)server is slow or rate-limited")), BODY_FETCH_TIMEOUT_MS)),
326
+ new Promise((_, reject) => setTimeout(() => reject(new Error("body fetch timed outtry again")), BODY_FETCH_TIMEOUT_MS)),
328
327
  ]);
329
328
  }
330
329
  catch (fetchErr) {
@@ -2093,7 +2092,13 @@ export class MailxService {
2093
2092
  return out.join("\n").trim();
2094
2093
  }
2095
2094
  /** Write a JSONC config file. Validates that the content parses as JSONC
2096
- * (loosely — strips comments/trailing commas) before writing. */
2095
+ * (loosely — strips comments/trailing commas) before writing.
2096
+ * Saves the prior content to a dated backup file first — manual edits
2097
+ * occasionally have typos that survive validation (semantically wrong
2098
+ * but syntactically OK), and a one-key undo isn't enough; the user
2099
+ * asked to be able to recover yesterday's accounts.jsonc. Automatic
2100
+ * saveAccounts/saveAllowlist paths skip backups (they're driven by
2101
+ * trusted code, not the JSONC editor). */
2097
2102
  async writeJsoncFile(name, content) {
2098
2103
  const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
2099
2104
  if (!WHITELIST.includes(name))
@@ -2105,6 +2110,8 @@ export class MailxService {
2105
2110
  if (errors.length) {
2106
2111
  throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`);
2107
2112
  }
2113
+ const previous = await this.readJsoncForBackup(name);
2114
+ await this.backupJsoncIfChanged(name, previous, content);
2108
2115
  if (name === "config.jsonc") {
2109
2116
  const configPath = path.join(getConfigDir(), "config.jsonc");
2110
2117
  fs.writeFileSync(configPath, content);
@@ -2113,6 +2120,78 @@ export class MailxService {
2113
2120
  const { cloudWrite } = await import("@bobfrankston/mailx-settings");
2114
2121
  await cloudWrite(name, content); // throws on failure with descriptive error
2115
2122
  }
2123
+ /** Read the current content of a config file (cloud or local) so it can
2124
+ * be saved as a backup before being overwritten. Returns null if the
2125
+ * file doesn't exist yet (first save — nothing to back up). */
2126
+ async readJsoncForBackup(name) {
2127
+ if (name === "config.jsonc") {
2128
+ const configPath = path.join(getConfigDir(), "config.jsonc");
2129
+ try {
2130
+ return fs.readFileSync(configPath, "utf-8");
2131
+ }
2132
+ catch {
2133
+ return null;
2134
+ }
2135
+ }
2136
+ try {
2137
+ const { cloudRead } = await import("@bobfrankston/mailx-settings");
2138
+ return await cloudRead(name);
2139
+ }
2140
+ catch {
2141
+ return null;
2142
+ }
2143
+ }
2144
+ /** Write the prior content to `<configDir>/backup/<name>.<ts>.bak` and
2145
+ * prune so at most 10 backups per file remain AND none are older than 7
2146
+ * days. Skipped when previous content is null (first write) or
2147
+ * identical to the new content (no-op save). */
2148
+ async backupJsoncIfChanged(name, previous, next) {
2149
+ if (previous == null || previous === next)
2150
+ return;
2151
+ const backupDir = path.join(getConfigDir(), "backup");
2152
+ try {
2153
+ fs.mkdirSync(backupDir, { recursive: true });
2154
+ }
2155
+ catch { /* */ }
2156
+ // Filename-safe ISO timestamp (colons become hyphens on Windows).
2157
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
2158
+ const backupPath = path.join(backupDir, `${name}.${stamp}.bak`);
2159
+ try {
2160
+ fs.writeFileSync(backupPath, previous);
2161
+ }
2162
+ catch (e) {
2163
+ console.error(`[backup] failed to write ${backupPath}: ${e.message}`);
2164
+ return; // don't block the save just because backup failed
2165
+ }
2166
+ // Prune: keep at most 10 most-recent for this filename, drop anything
2167
+ // older than 7 days. Whichever cuts more wins.
2168
+ const MAX_KEEP = 10;
2169
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
2170
+ const now = Date.now();
2171
+ let entries;
2172
+ try {
2173
+ entries = fs.readdirSync(backupDir)
2174
+ .filter(f => f.startsWith(`${name}.`) && f.endsWith(".bak"))
2175
+ .map(f => {
2176
+ const p = path.join(backupDir, f);
2177
+ return { path: p, mtime: fs.statSync(p).mtimeMs };
2178
+ })
2179
+ .sort((a, b) => b.mtime - a.mtime); // newest first
2180
+ }
2181
+ catch {
2182
+ return;
2183
+ }
2184
+ for (let i = 0; i < entries.length; i++) {
2185
+ const tooOld = now - entries[i].mtime > MAX_AGE_MS;
2186
+ const tooMany = i >= MAX_KEEP;
2187
+ if (tooOld || tooMany) {
2188
+ try {
2189
+ fs.unlinkSync(entries[i].path);
2190
+ }
2191
+ catch { /* */ }
2192
+ }
2193
+ }
2194
+ }
2116
2195
  // ── Settings ──
2117
2196
  getSettings() {
2118
2197
  return loadSettings();
@@ -1131,6 +1131,10 @@ function installBridge() {
1131
1131
  },
1132
1132
  searchMessages: (query, page, pageSize) => service.search(query, page, pageSize),
1133
1133
  searchContacts: (query) => service.searchContacts(query),
1134
+ listContacts: (query, page = 1, pageSize = 100) => service.listContacts(query || "", page, pageSize),
1135
+ upsertContact: (name, email) => service.upsertContact(name || "", email),
1136
+ deleteContact: (email) => service.deleteContact(email),
1137
+ addContact: (name, email) => service.addContact(name || "", email),
1134
1138
  hasCcHistoryTo: (email) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
1135
1139
  syncAll: async () => { await service.syncAll(); return { ok: true }; },
1136
1140
  syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
@@ -94,6 +94,16 @@ export declare class WebMailxDB {
94
94
  source: string;
95
95
  useCount: number;
96
96
  }[];
97
+ /** Address-book listing. Same shape as mailx-store/db.ts:listContacts so
98
+ * the address-book modal renders identically on desktop and Android. */
99
+ listContacts(query: string, page?: number, pageSize?: number): {
100
+ items: any[];
101
+ total: number;
102
+ page: number;
103
+ pageSize: number;
104
+ };
105
+ upsertContact(name: string, email: string): void;
106
+ deleteContactLocal(email: string): void;
97
107
  /** Q49 heuristic: has the user ever sent to `recipientEmail` with a
98
108
  * non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
99
109
  * the Cc row on reply to a frequent-Cc'd recipient. */
@@ -491,6 +491,46 @@ export class WebMailxDB {
491
491
  WHERE email LIKE ? OR name LIKE ?
492
492
  ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit]);
493
493
  }
494
+ /** Address-book listing. Same shape as mailx-store/db.ts:listContacts so
495
+ * the address-book modal renders identically on desktop and Android. */
496
+ listContacts(query, page = 1, pageSize = 100) {
497
+ query = (query || "").trim();
498
+ const hasQuery = !!query;
499
+ const q = `%${query}%`;
500
+ const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
501
+ const params = hasQuery ? [q, q] : [];
502
+ const totalRow = this.get(`SELECT COUNT(*) as c FROM contacts ${whereClause}`, params);
503
+ const offset = (page - 1) * pageSize;
504
+ const rows = this.all(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
505
+ ${whereClause}
506
+ ORDER BY use_count DESC, last_used DESC
507
+ LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
508
+ return {
509
+ items: rows.map((r) => ({
510
+ name: r.name, email: r.email, source: r.source,
511
+ googleId: r.google_id || null,
512
+ useCount: r.use_count, lastUsed: r.last_used,
513
+ })),
514
+ total: totalRow?.c || 0,
515
+ page, pageSize,
516
+ };
517
+ }
518
+ upsertContact(name, email) {
519
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
520
+ throw new Error(`Invalid email: ${email}`);
521
+ }
522
+ const now = Date.now();
523
+ const existing = this.get("SELECT id FROM contacts WHERE email = ?", [email]);
524
+ if (existing) {
525
+ this.run("UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?", [name || "", now, email]);
526
+ }
527
+ else {
528
+ this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, 0, 0, ?)", [name || "", email, now]);
529
+ }
530
+ }
531
+ deleteContactLocal(email) {
532
+ this.run("DELETE FROM contacts WHERE email = ?", [email]);
533
+ }
494
534
  /** Q49 heuristic: has the user ever sent to `recipientEmail` with a
495
535
  * non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
496
536
  * the Cc row on reply to a frequent-Cc'd recipient. */
@@ -234,6 +234,10 @@ function installBridge() {
234
234
  reauthenticate: (accountId) => sendRpc("reauthenticate", { accountId }),
235
235
  searchMessages: (query, page, pageSize) => sendRpc("searchMessages", { query, page, pageSize }),
236
236
  searchContacts: (query) => sendRpc("searchContacts", { query }),
237
+ listContacts: (query, page = 1, pageSize = 100) => sendRpc("listContacts", { query, page, pageSize }),
238
+ upsertContact: (name, email) => sendRpc("upsertContact", { name, email }),
239
+ deleteContact: (email) => sendRpc("deleteContact", { email }),
240
+ addContact: (name, email) => sendRpc("addContact", { name, email }),
237
241
  getSettings: () => sendRpc("getSettings"),
238
242
  saveSettings: (data) => sendRpc("saveSettingsData", data),
239
243
  allowRemoteContent: (type, value) => sendRpc("allowRemoteContent", { type, value }),
@@ -69,6 +69,14 @@ async function dispatchAction(svc, action, p) {
69
69
  return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
70
70
  case "searchContacts":
71
71
  return svc.searchContacts(p.query);
72
+ case "listContacts":
73
+ return svc.listContacts(p.query || "", p.page || 1, p.pageSize || 100);
74
+ case "upsertContact":
75
+ return svc.upsertContact(p.name || "", p.email);
76
+ case "deleteContact":
77
+ return svc.deleteContact(p.email);
78
+ case "addContact":
79
+ return svc.addContact(p.name || "", p.email);
72
80
  case "getSettings":
73
81
  return svc.getSettings();
74
82
  case "saveSettingsData":
@@ -71,6 +71,25 @@ export declare class WebMailxService {
71
71
  }>;
72
72
  deleteDraft(accountId: string, draftUid: number): Promise<void>;
73
73
  searchContacts(query: string): any[];
74
+ /** Address-book listing — paginated, filterable. Mirrors mailx-service's
75
+ * signature so the same client-side address-book modal works on Android
76
+ * without an "ipc(...).listContacts is not a function" crash. */
77
+ listContacts(query: string, page?: number, pageSize?: number): {
78
+ items: any[];
79
+ total: number;
80
+ page: number;
81
+ pageSize: number;
82
+ };
83
+ /** Manual upsert from the address-book UI. The desktop path queues a
84
+ * Google People sync; Android relies on the desktop pushing changes
85
+ * back, so this is local-only for now. */
86
+ upsertContact(name: string, email: string): {
87
+ ok: true;
88
+ };
89
+ deleteContact(email: string): {
90
+ ok: true;
91
+ };
92
+ addContact(name: string, email: string): boolean;
74
93
  /** Q49 heuristic mirror: true if the user has ever sent a message to
75
94
  * `recipientEmail` that had a non-empty Cc field. Compose uses this to
76
95
  * decide whether to auto-expand the Cc row on reply. */
@@ -478,6 +478,29 @@ export class WebMailxService {
478
478
  return [];
479
479
  return this.db.searchContacts(query);
480
480
  }
481
+ /** Address-book listing — paginated, filterable. Mirrors mailx-service's
482
+ * signature so the same client-side address-book modal works on Android
483
+ * without an "ipc(...).listContacts is not a function" crash. */
484
+ listContacts(query, page = 1, pageSize = 100) {
485
+ return this.db.listContacts(query || "", page, pageSize);
486
+ }
487
+ /** Manual upsert from the address-book UI. The desktop path queues a
488
+ * Google People sync; Android relies on the desktop pushing changes
489
+ * back, so this is local-only for now. */
490
+ upsertContact(name, email) {
491
+ this.db.upsertContact(name || "", email);
492
+ return { ok: true };
493
+ }
494
+ deleteContact(email) {
495
+ this.db.deleteContactLocal(email);
496
+ return { ok: true };
497
+ }
498
+ addContact(name, email) {
499
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
500
+ return false;
501
+ this.db.recordSentAddress(name || "", email);
502
+ return true;
503
+ }
481
504
  /** Q49 heuristic mirror: true if the user has ever sent a message to
482
505
  * `recipientEmail` that had a non-empty Cc field. Compose uses this to
483
506
  * decide whether to auto-expand the Cc row on reply. */