@bobfrankston/rmfmail 1.0.567 → 1.0.589

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.
Files changed (68) hide show
  1. package/TODO.md +18 -3
  2. package/bin/build-rmfmailto-exe.js +83 -0
  3. package/bin/mailto.js +130 -0
  4. package/bin/mailto.js.map +1 -0
  5. package/bin/mailto.ts +122 -0
  6. package/bin/mailx.js +199 -3
  7. package/bin/mailx.js.map +1 -1
  8. package/bin/mailx.ts +188 -3
  9. package/bin/rmfmailto-src/Cargo.lock +92 -0
  10. package/bin/rmfmailto-src/Cargo.toml +20 -0
  11. package/bin/rmfmailto-src/build.rs +27 -0
  12. package/bin/rmfmailto-src/src/main.rs +143 -0
  13. package/bin/rmfmailto.exe +0 -0
  14. package/client/app.js +170 -5
  15. package/client/app.js.map +1 -1
  16. package/client/app.ts +169 -4
  17. package/client/components/alarms.js +5 -2
  18. package/client/components/alarms.js.map +1 -1
  19. package/client/components/alarms.ts +4 -2
  20. package/client/components/folder-tree.js +54 -2
  21. package/client/components/folder-tree.js.map +1 -1
  22. package/client/components/folder-tree.ts +48 -2
  23. package/client/components/message-list.js +6 -3
  24. package/client/components/message-list.js.map +1 -1
  25. package/client/components/message-list.ts +6 -3
  26. package/client/components/message-viewer.js +58 -5
  27. package/client/components/message-viewer.js.map +1 -1
  28. package/client/components/message-viewer.ts +58 -5
  29. package/client/index.html +1 -0
  30. package/client/lib/api-client.js +9 -2
  31. package/client/lib/api-client.js.map +1 -1
  32. package/client/lib/api-client.ts +13 -2
  33. package/client/lib/mailxapi.js +5 -2
  34. package/client/styles/components.css +53 -0
  35. package/package.json +14 -14
  36. package/packages/mailx-imap/index.d.ts +46 -14
  37. package/packages/mailx-imap/index.d.ts.map +1 -1
  38. package/packages/mailx-imap/index.js +527 -187
  39. package/packages/mailx-imap/index.js.map +1 -1
  40. package/packages/mailx-imap/index.ts +518 -172
  41. package/packages/mailx-imap/package-lock.json +2 -2
  42. package/packages/mailx-imap/package.json +1 -1
  43. package/packages/mailx-server/index.js +4 -4
  44. package/packages/mailx-server/index.js.map +1 -1
  45. package/packages/mailx-server/index.ts +4 -4
  46. package/packages/mailx-service/index.d.ts +15 -1
  47. package/packages/mailx-service/index.d.ts.map +1 -1
  48. package/packages/mailx-service/index.js +66 -11
  49. package/packages/mailx-service/index.js.map +1 -1
  50. package/packages/mailx-service/index.ts +64 -11
  51. package/packages/mailx-service/jsonrpc.js +3 -1
  52. package/packages/mailx-service/jsonrpc.js.map +1 -1
  53. package/packages/mailx-service/jsonrpc.ts +3 -1
  54. package/packages/mailx-service/local-store.d.ts +1 -1
  55. package/packages/mailx-service/local-store.d.ts.map +1 -1
  56. package/packages/mailx-service/local-store.js +2 -2
  57. package/packages/mailx-service/local-store.js.map +1 -1
  58. package/packages/mailx-service/local-store.ts +2 -2
  59. package/packages/mailx-service/reconciler.d.ts.map +1 -1
  60. package/packages/mailx-service/reconciler.js +16 -0
  61. package/packages/mailx-service/reconciler.js.map +1 -1
  62. package/packages/mailx-service/reconciler.ts +15 -0
  63. package/packages/mailx-store/db.d.ts +1 -1
  64. package/packages/mailx-store/db.d.ts.map +1 -1
  65. package/packages/mailx-store/db.js +15 -4
  66. package/packages/mailx-store/db.js.map +1 -1
  67. package/packages/mailx-store/db.ts +15 -4
  68. package/packages/mailx-store/package.json +1 -1
@@ -0,0 +1,92 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "proc-macro2"
7
+ version = "1.0.106"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
10
+ dependencies = [
11
+ "unicode-ident",
12
+ ]
13
+
14
+ [[package]]
15
+ name = "quote"
16
+ version = "1.0.45"
17
+ source = "registry+https://github.com/rust-lang/crates.io-index"
18
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
19
+ dependencies = [
20
+ "proc-macro2",
21
+ ]
22
+
23
+ [[package]]
24
+ name = "rmfmailto"
25
+ version = "1.0.0"
26
+ dependencies = [
27
+ "winres",
28
+ ]
29
+
30
+ [[package]]
31
+ name = "serde"
32
+ version = "1.0.228"
33
+ source = "registry+https://github.com/rust-lang/crates.io-index"
34
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
35
+ dependencies = [
36
+ "serde_core",
37
+ ]
38
+
39
+ [[package]]
40
+ name = "serde_core"
41
+ version = "1.0.228"
42
+ source = "registry+https://github.com/rust-lang/crates.io-index"
43
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
44
+ dependencies = [
45
+ "serde_derive",
46
+ ]
47
+
48
+ [[package]]
49
+ name = "serde_derive"
50
+ version = "1.0.228"
51
+ source = "registry+https://github.com/rust-lang/crates.io-index"
52
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
53
+ dependencies = [
54
+ "proc-macro2",
55
+ "quote",
56
+ "syn",
57
+ ]
58
+
59
+ [[package]]
60
+ name = "syn"
61
+ version = "2.0.117"
62
+ source = "registry+https://github.com/rust-lang/crates.io-index"
63
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
64
+ dependencies = [
65
+ "proc-macro2",
66
+ "quote",
67
+ "unicode-ident",
68
+ ]
69
+
70
+ [[package]]
71
+ name = "toml"
72
+ version = "0.5.11"
73
+ source = "registry+https://github.com/rust-lang/crates.io-index"
74
+ checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
75
+ dependencies = [
76
+ "serde",
77
+ ]
78
+
79
+ [[package]]
80
+ name = "unicode-ident"
81
+ version = "1.0.24"
82
+ source = "registry+https://github.com/rust-lang/crates.io-index"
83
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
84
+
85
+ [[package]]
86
+ name = "winres"
87
+ version = "0.1.12"
88
+ source = "registry+https://github.com/rust-lang/crates.io-index"
89
+ checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
90
+ dependencies = [
91
+ "toml",
92
+ ]
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "rmfmailto"
3
+ version = "1.0.0"
4
+ edition = "2021"
5
+ description = "rmfmail mailto: handler launcher"
6
+ authors = ["Bob Frankston"]
7
+
8
+ [[bin]]
9
+ name = "rmfmailto"
10
+ path = "src/main.rs"
11
+
12
+ [build-dependencies]
13
+ winres = "0.1"
14
+
15
+ [profile.release]
16
+ opt-level = "z"
17
+ lto = true
18
+ codegen-units = 1
19
+ panic = "abort"
20
+ strip = true
@@ -0,0 +1,27 @@
1
+ // Embed Win32 VERSIONINFO so the Win11 "Select an app" picker shows the
2
+ // app as "rmfmail" instead of falling through to the host EXE's PE
3
+ // FileDescription. Keys here populate the picker's display.
4
+
5
+ #[cfg(windows)]
6
+ fn main() {
7
+ let mut res = winres::WindowsResource::new();
8
+ res.set("ProductName", "rmfmail");
9
+ res.set("FileDescription", "rmfmail");
10
+ res.set("CompanyName", "Bob Frankston");
11
+ res.set("LegalCopyright", "MIT");
12
+ res.set("OriginalFilename", "rmfmailto.exe");
13
+ res.set("InternalName", "rmfmailto");
14
+ // ICO can be picked up at registration time via DefaultIcon — embedding
15
+ // here gets it on the picker tile too. The path resolves relative to
16
+ // the crate root at build time.
17
+ let ico = std::path::Path::new("../../client/icon.ico");
18
+ if ico.exists() {
19
+ res.set_icon(ico.to_str().unwrap());
20
+ }
21
+ if let Err(e) = res.compile() {
22
+ eprintln!("winres compile failed: {e}");
23
+ }
24
+ }
25
+
26
+ #[cfg(not(windows))]
27
+ fn main() {}
@@ -0,0 +1,143 @@
1
+ // rmfmailto — Win32 launcher for the OS-level mailto: handler (P115).
2
+ //
3
+ // Exists for ONE reason: Win11's "Select an app to open this mailto" picker
4
+ // reads the EXE's PE FileDescription as the display name. If we register
5
+ // `node.exe ... mailx.js --mailto "%1"` the picker shows "Node.js
6
+ // JavaScript Runtime" — confusing. With this binary registered instead,
7
+ // the picker shows "rmfmail" (set in build.rs's VERSIONINFO).
8
+ //
9
+ // The runtime job is trivial: receive argv (the mailto URL), find node + the
10
+ // installed mailx.js, spawn `node mailx.js --mailto <url>`, exit. Detached
11
+ // so we don't hold a handle on a console window.
12
+ //
13
+ // Lookup order for node + mailx.js:
14
+ // 1. NODE_EXE / RMFMAIL_JS env vars (test override / unusual installs).
15
+ // 2. %APPDATA%\npm\node_modules\@bobfrankston\rmfmail\bin\mailx.js
16
+ // (standard `npm install -g @bobfrankston/rmfmail` location).
17
+ // 3. PATH lookup for `node.exe`.
18
+ // Failing any of those, surface a single MessageBox so the user gets a
19
+ // readable error instead of a silent dead click.
20
+
21
+ #![windows_subsystem = "windows"]
22
+
23
+ use std::env;
24
+ use std::path::PathBuf;
25
+ use std::process::Command;
26
+
27
+ #[cfg(windows)]
28
+ fn show_error(msg: &str) {
29
+ use std::ffi::OsStr;
30
+ use std::iter::once;
31
+ use std::os::windows::ffi::OsStrExt;
32
+ #[link(name = "user32")]
33
+ extern "system" {
34
+ fn MessageBoxW(hwnd: usize, text: *const u16, caption: *const u16, ty: u32) -> i32;
35
+ }
36
+ let to_wide = |s: &str| -> Vec<u16> {
37
+ OsStr::new(s).encode_wide().chain(once(0)).collect()
38
+ };
39
+ let text = to_wide(msg);
40
+ let caption = to_wide("rmfmail mailto handler");
41
+ unsafe { MessageBoxW(0, text.as_ptr(), caption.as_ptr(), 0x10); }
42
+ }
43
+
44
+ #[cfg(not(windows))]
45
+ fn show_error(msg: &str) { eprintln!("{msg}"); }
46
+
47
+ fn find_node() -> Option<PathBuf> {
48
+ if let Ok(p) = env::var("NODE_EXE") {
49
+ let pb = PathBuf::from(p);
50
+ if pb.is_file() { return Some(pb); }
51
+ }
52
+ // Look up node on PATH. Windows's where.exe finds it but isn't always
53
+ // available; instead walk PATH manually.
54
+ let path = env::var_os("PATH")?;
55
+ for dir in env::split_paths(&path) {
56
+ for name in &["node.exe", "node"] {
57
+ let candidate = dir.join(name);
58
+ if candidate.is_file() { return Some(candidate); }
59
+ }
60
+ }
61
+ None
62
+ }
63
+
64
+ fn find_mailx_js() -> Option<PathBuf> {
65
+ if let Ok(p) = env::var("RMFMAIL_JS") {
66
+ let pb = PathBuf::from(p);
67
+ if pb.is_file() { return Some(pb); }
68
+ }
69
+ // Standard global-install location.
70
+ if let Ok(appdata) = env::var("APPDATA") {
71
+ let pb = PathBuf::from(appdata)
72
+ .join("npm").join("node_modules")
73
+ .join("@bobfrankston").join("rmfmail")
74
+ .join("bin").join("mailx.js");
75
+ if pb.is_file() { return Some(pb); }
76
+ }
77
+ // Fallback: relative to this binary, walking up to find `bin/mailx.js`.
78
+ // Lets a developer-mode build (rmfmailto.exe sitting next to mailx.js
79
+ // in `app/bin/`) work without env-var fiddling.
80
+ if let Ok(exe) = env::current_exe() {
81
+ let mut p = exe.clone();
82
+ for _ in 0..5 {
83
+ if let Some(parent) = p.parent() {
84
+ let candidate = parent.join("mailx.js");
85
+ if candidate.is_file() { return Some(candidate); }
86
+ let candidate = parent.join("bin").join("mailx.js");
87
+ if candidate.is_file() { return Some(candidate); }
88
+ p = parent.to_path_buf();
89
+ } else {
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ None
95
+ }
96
+
97
+ fn main() {
98
+ // argv[0] is the exe path itself; argv[1] should be the mailto URL.
99
+ let mailto_url: Option<String> = env::args().nth(1);
100
+ let mailto_url = match mailto_url {
101
+ Some(u) if !u.is_empty() => u,
102
+ _ => {
103
+ show_error("rmfmailto invoked without a mailto URL.\n\nUsage: rmfmailto.exe <mailto:...>");
104
+ std::process::exit(1);
105
+ }
106
+ };
107
+
108
+ let node = match find_node() {
109
+ Some(p) => p,
110
+ None => {
111
+ show_error("Node.js was not found on PATH.\n\nrmfmail requires Node.js to be installed. See https://nodejs.org");
112
+ std::process::exit(2);
113
+ }
114
+ };
115
+ let mailx_js = match find_mailx_js() {
116
+ Some(p) => p,
117
+ None => {
118
+ show_error("rmfmail's mailx.js was not located.\n\nExpected at:\n %APPDATA%\\npm\\node_modules\\@bobfrankston\\rmfmail\\bin\\mailx.js\n\nSet RMFMAIL_JS to override.");
119
+ std::process::exit(3);
120
+ }
121
+ };
122
+
123
+ // Spawn detached so the picker doesn't get a "is this still running?"
124
+ // console window. CREATE_NO_WINDOW (0x08000000) + DETACHED_PROCESS
125
+ // (0x00000008) on Windows; nothing special needed elsewhere.
126
+ let mut cmd = Command::new(&node);
127
+ cmd.arg(&mailx_js).arg("--mailto").arg(&mailto_url);
128
+ #[cfg(windows)]
129
+ {
130
+ use std::os::windows::process::CommandExt;
131
+ cmd.creation_flags(0x08000000 | 0x00000008);
132
+ }
133
+ match cmd.spawn() {
134
+ Ok(_) => {}
135
+ Err(e) => {
136
+ show_error(&format!(
137
+ "Failed to launch rmfmail.\n\nNode: {}\nScript: {}\nError: {e}",
138
+ node.display(), mailx_js.display(),
139
+ ));
140
+ std::process::exit(4);
141
+ }
142
+ }
143
+ }
Binary file
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, getCurrentFocused, releaseFocus, removeMessagesAndReconcile, setRowFlagged, scrollFocusedIntoView, refreshPriorityIndex } from "./components/message-list.js";
7
- import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu } from "./components/message-viewer.js";
7
+ import { getCurrentMessage, initViewer, popOutCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu } 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) ──
@@ -324,9 +324,14 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
324
324
  setTitle(`${APP_NAME} - All Inboxes`);
325
325
  setNarrowFolderTitle("All Inboxes");
326
326
  });
327
- initMessageList((accountId, uid, folderId) => {
328
- showMessage(accountId, uid, folderId, currentFolderSpecialUse);
329
- // Narrow screen: show message viewer, hide list
327
+ initMessageList((_accountId, _uid, _folderId) => {
328
+ // The list row's setFocus() already drove the viewer with its own
329
+ // envelope (header + preview paint synchronously, body fetches in
330
+ // background). Calling showMessage here a SECOND time without the
331
+ // envelope wiped the good render and forced a bare "Loading…"
332
+ // placeholder until the body IPC returned — making every click feel
333
+ // like a network round-trip. This callback now only handles the
334
+ // narrow-screen layout switch.
330
335
  if (window.innerWidth <= 768) {
331
336
  document.getElementById("message-viewer")?.classList.add("narrow-active");
332
337
  document.getElementById("message-list")?.classList.add("narrow-hidden");
@@ -1450,8 +1455,10 @@ function doSearch(immediate = false) {
1450
1455
  // "server" which spans all folders on all accounts. Local scope dropdown
1451
1456
  // is unchanged (all/current) for the local-only case.
1452
1457
  const serverCheck = document.getElementById("search-server-too");
1458
+ const trashCheck = document.getElementById("search-include-trash");
1453
1459
  const localScope = searchScope?.value || "all";
1454
1460
  const effectiveScope = serverCheck?.checked ? "server" : localScope;
1461
+ const includeTrash = !!trashCheck?.checked;
1455
1462
  // "This folder" scope: instant client-side filter on debounce, server search on Enter
1456
1463
  if (effectiveScope === "current" && !immediate) {
1457
1464
  // Client-side filter of visible rows
@@ -1465,7 +1472,7 @@ function doSearch(immediate = false) {
1465
1472
  }
1466
1473
  return;
1467
1474
  }
1468
- loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId);
1475
+ loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId, includeTrash);
1469
1476
  setTitle(`${APP_NAME} - Search: ${query}`);
1470
1477
  }
1471
1478
  // Track current folder for scoped search
@@ -1523,6 +1530,7 @@ function rerunActiveSearch() {
1523
1530
  }
1524
1531
  searchScope?.addEventListener("change", rerunActiveSearch);
1525
1532
  document.getElementById("search-server-too")?.addEventListener("change", rerunActiveSearch);
1533
+ document.getElementById("search-include-trash")?.addEventListener("change", rerunActiveSearch);
1526
1534
  // Message state handles move/delete — no manual event listener needed
1527
1535
  // ── Folder filter ──
1528
1536
  const ftFilterInput = document.getElementById("ft-filter-input");
@@ -2286,8 +2294,51 @@ onWsEvent((event) => {
2286
2294
  }
2287
2295
  break;
2288
2296
  }
2297
+ case "openMailto": {
2298
+ // P115: OS-level mailto: handler. CLI dropped a pending file; the
2299
+ // daemon picked it up and forwarded the parsed fields. Open compose
2300
+ // pre-populated. Treat as a fresh New compose, not a reply — the
2301
+ // browser doesn't know about a parent message.
2302
+ openComposeFromMailto({
2303
+ to: Array.isArray(event.to) ? event.to : [],
2304
+ cc: Array.isArray(event.cc) ? event.cc : [],
2305
+ bcc: Array.isArray(event.bcc) ? event.bcc : [],
2306
+ subject: event.subject || "",
2307
+ body: event.body || "",
2308
+ inReplyTo: event.inReplyTo || "",
2309
+ }).catch((e) => console.error("openComposeFromMailto failed:", e?.message || e));
2310
+ break;
2311
+ }
2289
2312
  }
2290
2313
  });
2314
+ /** Open a compose window pre-filled from a mailto: URL. Same plumbing as
2315
+ * openCompose("new") but the init shape comes from the parsed URL instead
2316
+ * of the currently-selected message. The body arrives as plain text from
2317
+ * the URL — we wrap it in a single <p> so Quill renders it as paragraphs
2318
+ * rather than a single line, escaping `<` so embedded angle brackets in a
2319
+ * signature/template don't get interpreted as tags. */
2320
+ async function openComposeFromMailto(m) {
2321
+ const accounts = await getAccounts();
2322
+ const accountId = accounts[0]?.id || "";
2323
+ const escape = (s) => s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
2324
+ const bodyHtml = m.body
2325
+ ? "<p>" + escape(m.body).replace(/\r?\n/g, "</p><p>") + "</p>"
2326
+ : "";
2327
+ const init = {
2328
+ mode: "new",
2329
+ accountId,
2330
+ to: m.to.map(addr => ({ name: "", address: addr })),
2331
+ cc: m.cc.map(addr => ({ name: "", address: addr })),
2332
+ bcc: m.bcc.map(addr => ({ name: "", address: addr })),
2333
+ subject: m.subject,
2334
+ bodyHtml,
2335
+ inReplyTo: m.inReplyTo,
2336
+ references: m.inReplyTo ? [m.inReplyTo] : [],
2337
+ accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
2338
+ };
2339
+ sessionStorage.setItem("composeInit", JSON.stringify(init));
2340
+ showComposeOverlay(m.subject ? `Compose: ${m.subject}` : "Compose");
2341
+ }
2291
2342
  // ── Keyboard shortcuts ──
2292
2343
  // Capture-phase pre-handler: intercept WebView accelerator keys that would
2293
2344
  // otherwise trigger a browser action (Ctrl+R / Ctrl+Shift+R = reload). Call
@@ -2789,6 +2840,120 @@ function applyThreadFilter() {
2789
2840
  console.error("alarm poller init failed:", e?.message || e);
2790
2841
  }
2791
2842
  })();
2843
+ // Pull-to-refresh: touch-drag down at the top of the message list fires a
2844
+ // sync, same as F5 / btn-sync. Standard Android / Thunderbird / mobile
2845
+ // pattern. The indicator slides down with the drag and rotates the chevron
2846
+ // once the user crosses the trigger threshold; releasing past the threshold
2847
+ // fires triggerSync(). Below threshold = snap back without action.
2848
+ //
2849
+ // Touch-only by design — desktop has the F5 / sync button. Pointer events
2850
+ // would also catch mouse-drag, which is more annoying than useful (people
2851
+ // drag the scrollbar). The `e.touches.length === 1` check skips two-finger
2852
+ // gestures (pinch-zoom, two-finger scroll).
2853
+ (() => {
2854
+ const PTR_THRESHOLD_PX = 80; // pull this far to arm the trigger
2855
+ const PTR_MAX_PX = 120; // cap the visual translate
2856
+ const mlBody = document.getElementById("ml-body");
2857
+ if (!mlBody)
2858
+ return;
2859
+ const indicator = document.createElement("div");
2860
+ indicator.className = "ptr-indicator";
2861
+ indicator.innerHTML = `<span class="ptr-chev"></span><span class="ptr-label">Pull to refresh</span>`;
2862
+ mlBody.parentElement?.insertBefore(indicator, mlBody);
2863
+ const labelEl = indicator.querySelector(".ptr-label");
2864
+ let startY = 0;
2865
+ let pullY = 0;
2866
+ let active = false;
2867
+ let refreshing = false;
2868
+ const setPull = (px, armed) => {
2869
+ const clamped = Math.min(PTR_MAX_PX, Math.max(0, px));
2870
+ indicator.classList.toggle("armed", armed);
2871
+ indicator.style.transform = `translateY(calc(-100% + ${clamped}px))`;
2872
+ };
2873
+ const reset = () => {
2874
+ indicator.classList.remove("dragging", "armed");
2875
+ indicator.style.transform = "";
2876
+ };
2877
+ mlBody.addEventListener("touchstart", (e) => {
2878
+ if (refreshing)
2879
+ return;
2880
+ if (e.touches.length !== 1)
2881
+ return;
2882
+ if (mlBody.scrollTop > 0)
2883
+ return;
2884
+ startY = e.touches[0].clientY;
2885
+ pullY = 0;
2886
+ active = true;
2887
+ indicator.classList.add("dragging");
2888
+ labelEl.textContent = "Pull to refresh";
2889
+ }, { passive: true });
2890
+ mlBody.addEventListener("touchmove", (e) => {
2891
+ if (!active || refreshing)
2892
+ return;
2893
+ const dy = e.touches[0].clientY - startY;
2894
+ if (dy <= 0) {
2895
+ // User scrolled up past the start — abandon, let normal scroll
2896
+ // take over without reverting (the touchend handler will reset).
2897
+ pullY = 0;
2898
+ setPull(0, false);
2899
+ return;
2900
+ }
2901
+ // Resist past 0 — pull "stretches" rather than scrolls. preventDefault
2902
+ // is necessary to keep the browser from interpreting the drag as
2903
+ // overscroll / browser-level pull-to-refresh.
2904
+ if (e.cancelable)
2905
+ e.preventDefault();
2906
+ pullY = dy * 0.5; // 0.5 = rubber-band damping
2907
+ const armed = pullY >= PTR_THRESHOLD_PX;
2908
+ labelEl.textContent = armed ? "Release to refresh" : "Pull to refresh";
2909
+ setPull(pullY, armed);
2910
+ }, { passive: false });
2911
+ const finish = async () => {
2912
+ if (!active)
2913
+ return;
2914
+ active = false;
2915
+ if (pullY < PTR_THRESHOLD_PX) {
2916
+ reset();
2917
+ return;
2918
+ }
2919
+ // Armed — show spinner, fire sync, clear when complete.
2920
+ refreshing = true;
2921
+ indicator.classList.remove("armed", "dragging");
2922
+ indicator.classList.add("refreshing");
2923
+ indicator.style.transform = `translateY(calc(-100% + 60px))`;
2924
+ labelEl.textContent = "Refreshing...";
2925
+ try {
2926
+ const { triggerSync } = await import("./lib/api-client.js");
2927
+ await triggerSync();
2928
+ }
2929
+ catch (e) {
2930
+ console.error("pull-to-refresh failed:", e?.message || e);
2931
+ }
2932
+ refreshing = false;
2933
+ indicator.classList.remove("refreshing");
2934
+ reset();
2935
+ };
2936
+ mlBody.addEventListener("touchend", () => { finish(); }, { passive: true });
2937
+ mlBody.addEventListener("touchcancel", () => { active = false; reset(); }, { passive: true });
2938
+ })();
2939
+ // P115: pick up a pending mailto: drop file dropped by the CLI invocation
2940
+ // that spawned us (or by an earlier --mailto run that the user hasn't yet
2941
+ // acknowledged). The IPC call deletes the file as part of reading it, so a
2942
+ // race with the daemon's fs.watch is harmless — whichever fires first wins
2943
+ // and the other gets null. Subsequent live clicks during this session
2944
+ // arrive via the `openMailto` event handled in onEvent above.
2945
+ (async () => {
2946
+ try {
2947
+ const { consumePendingMailto } = await import("./lib/api-client.js");
2948
+ const data = await consumePendingMailto();
2949
+ if (data && (data.to.length || data.subject || data.body)) {
2950
+ await openComposeFromMailto(data);
2951
+ }
2952
+ }
2953
+ catch (e) {
2954
+ console.error("pending mailto pickup failed:", e?.message || e);
2955
+ }
2956
+ })();
2792
2957
  // Two-line toggle
2793
2958
  optTwoLine?.addEventListener("change", () => {
2794
2959
  const list = document.getElementById("message-list");