@bobfrankston/mailx 1.0.451 → 1.0.452

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 (198) hide show
  1. package/bin/mailx.js.map +1 -0
  2. package/bin/mailx.ts +1498 -0
  3. package/bin/postinstall.js.map +1 -0
  4. package/bin/postinstall.ts +41 -0
  5. package/bin/tsconfig.json +10 -0
  6. package/client/.gitattributes +10 -0
  7. package/client/app.js +51 -2
  8. package/client/app.js.map +1 -0
  9. package/client/app.ts +3112 -0
  10. package/client/components/address-book.js.map +1 -0
  11. package/client/components/address-book.ts +204 -0
  12. package/client/components/alarms.js.map +1 -0
  13. package/client/components/alarms.ts +276 -0
  14. package/client/components/calendar-sidebar.js.map +1 -0
  15. package/client/components/calendar-sidebar.ts +474 -0
  16. package/client/components/calendar.js.map +1 -0
  17. package/client/components/calendar.ts +211 -0
  18. package/client/components/context-menu.js.map +1 -0
  19. package/client/components/context-menu.ts +95 -0
  20. package/client/components/folder-picker.js.map +1 -0
  21. package/client/components/folder-picker.ts +127 -0
  22. package/client/components/folder-tree.js.map +1 -0
  23. package/client/components/folder-tree.ts +1069 -0
  24. package/client/components/message-list.js.map +1 -0
  25. package/client/components/message-list.ts +1129 -0
  26. package/client/components/message-viewer.js.map +1 -0
  27. package/client/components/message-viewer.ts +1257 -0
  28. package/client/components/outbox-view.js.map +1 -0
  29. package/client/components/outbox-view.ts +102 -0
  30. package/client/components/tasks.js.map +1 -0
  31. package/client/components/tasks.ts +234 -0
  32. package/client/compose/compose.js.map +1 -0
  33. package/client/compose/compose.ts +1231 -0
  34. package/client/compose/editor.js.map +1 -0
  35. package/client/compose/editor.ts +599 -0
  36. package/client/compose/ghost-text.js.map +1 -0
  37. package/client/compose/ghost-text.ts +140 -0
  38. package/client/index.html +1 -0
  39. package/client/lib/android-bootstrap.js.map +1 -0
  40. package/client/lib/android-bootstrap.ts +9 -0
  41. package/client/lib/api-client.js.map +1 -0
  42. package/client/lib/api-client.ts +439 -0
  43. package/client/lib/local-service.js.map +1 -0
  44. package/client/lib/local-service.ts +646 -0
  45. package/client/lib/local-store.js.map +1 -0
  46. package/client/lib/local-store.ts +283 -0
  47. package/client/lib/message-state.js.map +1 -0
  48. package/client/lib/message-state.ts +140 -0
  49. package/client/tsconfig.json +19 -0
  50. package/package.json +15 -15
  51. package/packages/mailx-api/.gitattributes +10 -0
  52. package/packages/mailx-api/index.d.ts.map +1 -0
  53. package/packages/mailx-api/index.js.map +1 -0
  54. package/packages/mailx-api/index.ts +283 -0
  55. package/packages/mailx-api/tsconfig.json +9 -0
  56. package/packages/mailx-compose/.gitattributes +10 -0
  57. package/packages/mailx-compose/index.d.ts.map +1 -0
  58. package/packages/mailx-compose/index.js.map +1 -0
  59. package/packages/mailx-compose/index.ts +85 -0
  60. package/packages/mailx-compose/tsconfig.json +9 -0
  61. package/packages/mailx-core/index.d.ts.map +1 -0
  62. package/packages/mailx-core/index.js.map +1 -0
  63. package/packages/mailx-core/index.ts +424 -0
  64. package/packages/mailx-core/ipc.d.ts.map +1 -0
  65. package/packages/mailx-core/ipc.js.map +1 -0
  66. package/packages/mailx-core/ipc.ts +62 -0
  67. package/packages/mailx-core/tsconfig.json +9 -0
  68. package/packages/mailx-host/.gitattributes +10 -0
  69. package/packages/mailx-host/index.d.ts.map +1 -0
  70. package/packages/mailx-host/index.js.map +1 -0
  71. package/packages/mailx-host/index.ts +38 -0
  72. package/packages/mailx-host/package.json +10 -2
  73. package/packages/mailx-host/tsconfig.json +9 -0
  74. package/packages/mailx-send/.gitattributes +10 -0
  75. package/packages/mailx-send/cli-queue.d.ts.map +1 -0
  76. package/packages/mailx-send/cli-queue.js.map +1 -0
  77. package/packages/mailx-send/cli-queue.ts +62 -0
  78. package/packages/mailx-send/cli-send.d.ts.map +1 -0
  79. package/packages/mailx-send/cli-send.js.map +1 -0
  80. package/packages/mailx-send/cli-send.ts +83 -0
  81. package/packages/mailx-send/cli.d.ts.map +1 -0
  82. package/packages/mailx-send/cli.js.map +1 -0
  83. package/packages/mailx-send/cli.ts +126 -0
  84. package/packages/mailx-send/index.d.ts.map +1 -0
  85. package/packages/mailx-send/index.js.map +1 -0
  86. package/packages/mailx-send/index.ts +333 -0
  87. package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
  88. package/packages/mailx-send/mailsend/cli.js.map +1 -0
  89. package/packages/mailx-send/mailsend/cli.ts +81 -0
  90. package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
  91. package/packages/mailx-send/mailsend/index.js.map +1 -0
  92. package/packages/mailx-send/mailsend/index.ts +333 -0
  93. package/packages/mailx-send/mailsend/package-lock.json +65 -0
  94. package/packages/mailx-send/mailsend/tsconfig.json +21 -0
  95. package/packages/mailx-send/package-lock.json +65 -0
  96. package/packages/mailx-send/package.json +1 -1
  97. package/packages/mailx-send/tsconfig.json +21 -0
  98. package/packages/mailx-server/.gitattributes +10 -0
  99. package/packages/mailx-server/index.d.ts.map +1 -0
  100. package/packages/mailx-server/index.js.map +1 -0
  101. package/packages/mailx-server/index.ts +429 -0
  102. package/packages/mailx-server/tsconfig.json +9 -0
  103. package/packages/mailx-service/google-sync.d.ts.map +1 -0
  104. package/packages/mailx-service/google-sync.js.map +1 -0
  105. package/packages/mailx-service/google-sync.ts +238 -0
  106. package/packages/mailx-service/index.d.ts.map +1 -0
  107. package/packages/mailx-service/index.js.map +1 -0
  108. package/packages/mailx-service/index.ts +2461 -0
  109. package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
  110. package/packages/mailx-service/jsonrpc.js.map +1 -0
  111. package/packages/mailx-service/jsonrpc.ts +268 -0
  112. package/packages/mailx-service/tsconfig.json +9 -0
  113. package/packages/mailx-settings/.gitattributes +10 -0
  114. package/packages/mailx-settings/cloud.d.ts.map +1 -0
  115. package/packages/mailx-settings/cloud.js.map +1 -0
  116. package/packages/mailx-settings/cloud.ts +388 -0
  117. package/packages/mailx-settings/index.d.ts.map +1 -0
  118. package/packages/mailx-settings/index.js.map +1 -0
  119. package/packages/mailx-settings/index.ts +892 -0
  120. package/packages/mailx-settings/tsconfig.json +9 -0
  121. package/packages/mailx-store/.gitattributes +10 -0
  122. package/packages/mailx-store/db.d.ts.map +1 -0
  123. package/packages/mailx-store/db.js.map +1 -0
  124. package/packages/mailx-store/db.ts +2007 -0
  125. package/packages/mailx-store/file-store.d.ts.map +1 -0
  126. package/packages/mailx-store/file-store.js.map +1 -0
  127. package/packages/mailx-store/file-store.ts +82 -0
  128. package/packages/mailx-store/index.d.ts.map +1 -0
  129. package/packages/mailx-store/index.js.map +1 -0
  130. package/packages/mailx-store/index.ts +7 -0
  131. package/packages/mailx-store/tsconfig.json +9 -0
  132. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
  133. package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
  134. package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
  135. package/packages/mailx-store-web/db.d.ts.map +1 -0
  136. package/packages/mailx-store-web/db.js.map +1 -0
  137. package/packages/mailx-store-web/db.ts +756 -0
  138. package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
  139. package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
  140. package/packages/mailx-store-web/gmail-api-web.ts +11 -0
  141. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
  142. package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
  143. package/packages/mailx-store-web/imap-web-provider.ts +156 -0
  144. package/packages/mailx-store-web/index.d.ts.map +1 -0
  145. package/packages/mailx-store-web/index.js.map +1 -0
  146. package/packages/mailx-store-web/index.ts +10 -0
  147. package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
  148. package/packages/mailx-store-web/main-thread-host.js.map +1 -0
  149. package/packages/mailx-store-web/main-thread-host.ts +322 -0
  150. package/packages/mailx-store-web/package.json +4 -4
  151. package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
  152. package/packages/mailx-store-web/provider-types.js.map +1 -0
  153. package/packages/mailx-store-web/provider-types.ts +7 -0
  154. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
  155. package/packages/mailx-store-web/sync-manager.js.map +1 -0
  156. package/packages/mailx-store-web/sync-manager.ts +508 -0
  157. package/packages/mailx-store-web/tsconfig.json +10 -0
  158. package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
  159. package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
  160. package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
  161. package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
  162. package/packages/mailx-store-web/web-message-store.js.map +1 -0
  163. package/packages/mailx-store-web/web-message-store.ts +97 -0
  164. package/packages/mailx-store-web/web-service.d.ts.map +1 -0
  165. package/packages/mailx-store-web/web-service.js.map +1 -0
  166. package/packages/mailx-store-web/web-service.ts +616 -0
  167. package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
  168. package/packages/mailx-store-web/web-settings.js.map +1 -0
  169. package/packages/mailx-store-web/web-settings.ts +522 -0
  170. package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
  171. package/packages/mailx-store-web/worker-entry.js.map +1 -0
  172. package/packages/mailx-store-web/worker-entry.ts +215 -0
  173. package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
  174. package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
  175. package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
  176. package/packages/mailx-types/.gitattributes +10 -0
  177. package/packages/mailx-types/index.d.ts.map +1 -0
  178. package/packages/mailx-types/index.js.map +1 -0
  179. package/packages/mailx-types/index.ts +498 -0
  180. package/packages/mailx-types/tsconfig.json +9 -0
  181. package/tsconfig.base.json +2 -1
  182. package/tsconfig.json +9 -0
  183. package/build-apk.cmd +0 -3
  184. package/npmg.bat +0 -6
  185. package/packages/mailx-imap/index.d.ts +0 -442
  186. package/packages/mailx-imap/index.js +0 -3684
  187. package/packages/mailx-imap/package.json +0 -25
  188. package/packages/mailx-imap/providers/gmail-api.d.ts +0 -8
  189. package/packages/mailx-imap/providers/gmail-api.js +0 -8
  190. package/packages/mailx-imap/providers/types.d.ts +0 -9
  191. package/packages/mailx-imap/providers/types.js +0 -9
  192. package/packages/mailx-imap/tsconfig.tsbuildinfo +0 -1
  193. package/rebuild.cmd +0 -23
  194. package/tdview.cmd +0 -2
  195. package/temp.ps1 +0 -10
  196. package/test-smtp-direct.mjs +0 -4
  197. package/unbash.cmd +0 -55
  198. package/unwedge.cmd +0 -1
package/client/app.ts ADDED
@@ -0,0 +1,3112 @@
1
+ /**
2
+ * mailx client entry point.
3
+ * Wires together all UI components and WebSocket connection.
4
+ */
5
+
6
+ import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from "./components/folder-tree.js";
7
+ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
8
+ import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage } from "./components/message-viewer.js";
9
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
10
+ import * as messageState from "./lib/message-state.js";
11
+
12
+ // ── New message badge (favicon + title) ──
13
+ let baseTitle = "mailx";
14
+ let lastSeenCount = 0;
15
+ let badgeCount = 0;
16
+
17
+ function updateBadge(count: number): void {
18
+ badgeCount = count;
19
+ // Update title
20
+ document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
21
+ // Generate a single badge bitmap used for both the favicon (visible on
22
+ // browser tabs / mobile homescreen) AND the Windows taskbar overlay
23
+ // icon (visible as a Thunderbird-style corner pill on the taskbar
24
+ // button when running via msger). Rendered once, consumed twice.
25
+ const canvas = document.createElement("canvas");
26
+ canvas.width = 32;
27
+ canvas.height = 32;
28
+ const ctx = canvas.getContext("2d")!;
29
+ // Base envelope icon (always drawn — so the favicon is a recognizable
30
+ // mailx icon even at 0 count).
31
+ ctx.fillStyle = "#4a7ccc";
32
+ ctx.fillRect(2, 8, 28, 20);
33
+ ctx.fillStyle = "#6a9cec";
34
+ ctx.beginPath();
35
+ ctx.moveTo(2, 8);
36
+ ctx.lineTo(16, 20);
37
+ ctx.lineTo(30, 8);
38
+ ctx.fill();
39
+ if (count > 0) {
40
+ // Red badge circle with count
41
+ ctx.fillStyle = "#e33";
42
+ ctx.beginPath();
43
+ ctx.arc(24, 8, 8, 0, Math.PI * 2);
44
+ ctx.fill();
45
+ ctx.fillStyle = "#fff";
46
+ ctx.font = "bold 11px sans-serif";
47
+ ctx.textAlign = "center";
48
+ ctx.textBaseline = "middle";
49
+ ctx.fillText(count > 99 ? "99+" : String(count), 24, 8);
50
+ }
51
+ // Set as favicon
52
+ let link = document.querySelector("link[rel='icon']") as HTMLLinkElement;
53
+ if (!link) {
54
+ link = document.createElement("link");
55
+ link.rel = "icon";
56
+ document.head.appendChild(link);
57
+ }
58
+ const dataUrl = canvas.toDataURL("image/png");
59
+ link.href = dataUrl;
60
+
61
+ // Also push to the Windows taskbar overlay via msger's IPC helper —
62
+ // no-op on Linux/Mac. For count=0, render a dedicated "no-overlay"
63
+ // icon that's all-transparent so the base icon shows cleanly.
64
+ try {
65
+ const msgapi: any = (window as any).msgapi;
66
+ if (msgapi?.setTaskbarOverlay) {
67
+ if (count > 0) {
68
+ // strip "data:image/png;base64," prefix → base64 only
69
+ const b64 = dataUrl.split(",")[1] || "";
70
+ msgapi.setTaskbarOverlay(b64, `${count} unread`);
71
+ } else {
72
+ msgapi.setTaskbarOverlay("", "");
73
+ }
74
+ }
75
+ } catch { /* msgapi unavailable in browser fallback */ }
76
+ }
77
+
78
+ async function updateNewMessageCount(): Promise<void> {
79
+ try {
80
+ const accounts = await getAccounts();
81
+ let totalUnread = 0;
82
+ for (const acct of accounts) {
83
+ const folders = await getFolders(acct.id);
84
+ const inbox = folders.find((f: any) => f.specialUse === "inbox");
85
+ if (inbox) totalUnread += inbox.unreadCount || 0;
86
+ }
87
+ // Rail badge: unread count on the Inbox and Unified-inbox rail buttons.
88
+ // Visible even when those views aren't the active one — part of C33
89
+ // "rail icon badges for unread counts."
90
+ updateRailBadge("rail-inbox", totalUnread);
91
+ updateRailBadge("rail-unified", totalUnread);
92
+ // First load: set baseline
93
+ if (lastSeenCount === 0) { lastSeenCount = totalUnread; updateBadge(0); return; }
94
+ const previousBadge = badgeCount;
95
+ // New messages = increase since last seen
96
+ const newCount = Math.max(0, totalUnread - lastSeenCount);
97
+ updateBadge(newCount);
98
+ // Flash the title when new mail arrives and the window isn't focused.
99
+ // Windows' taskbar mirrors document.title so this acts as a taskbar flash.
100
+ if (newCount > previousBadge && document.visibilityState !== "visible") {
101
+ startTitleFlash();
102
+ }
103
+ } catch { /* offline */ }
104
+ }
105
+
106
+ function updateRailBadge(buttonId: string, count: number): void {
107
+ const btn = document.getElementById(buttonId);
108
+ if (!btn) return;
109
+ let badge = btn.querySelector<HTMLElement>(".rail-badge");
110
+ if (count <= 0) {
111
+ if (badge) badge.remove();
112
+ return;
113
+ }
114
+ if (!badge) {
115
+ badge = document.createElement("span");
116
+ badge.className = "rail-badge";
117
+ btn.appendChild(badge);
118
+ }
119
+ badge.textContent = count > 999 ? "999+" : String(count);
120
+ }
121
+
122
+ // ── Taskbar flash via title alternation ──
123
+ let titleFlashTimer: ReturnType<typeof setInterval> | null = null;
124
+ let titleFlashPhase = false;
125
+
126
+ function startTitleFlash(): void {
127
+ stopTitleFlash();
128
+ titleFlashPhase = true;
129
+ titleFlashTimer = setInterval(() => {
130
+ titleFlashPhase = !titleFlashPhase;
131
+ if (titleFlashPhase) {
132
+ document.title = `✉ NEW MAIL (${badgeCount})`;
133
+ } else {
134
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
135
+ }
136
+ }, 1000);
137
+ }
138
+
139
+ function stopTitleFlash(): void {
140
+ if (titleFlashTimer) { clearInterval(titleFlashTimer); titleFlashTimer = null; }
141
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
142
+ }
143
+
144
+ document.addEventListener("visibilitychange", () => {
145
+ if (document.visibilityState === "visible") stopTitleFlash();
146
+ });
147
+ window.addEventListener("focus", stopTitleFlash);
148
+
149
+ /** Call when user actively views messages — resets the badge */
150
+ function markAsSeen(): void {
151
+ getAccounts().then(async (accounts: any[]) => {
152
+ let total = 0;
153
+ for (const acct of accounts) {
154
+ const folders = await getFolders(acct.id);
155
+ const inbox = folders.find((f: any) => f.specialUse === "inbox");
156
+ if (inbox) total += inbox.unreadCount || 0;
157
+ }
158
+ lastSeenCount = total;
159
+ updateBadge(0);
160
+ }).catch(() => {});
161
+ }
162
+
163
+ function setTitle(title: string): void {
164
+ baseTitle = title;
165
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
166
+ }
167
+
168
+ // ── Alert banner ──
169
+ const alertBanner = document.getElementById("alert-banner");
170
+ const alertText = document.getElementById("alert-text");
171
+ const alertDismiss = document.getElementById("alert-dismiss");
172
+ const dismissedAlerts = new Set<string>();
173
+
174
+ let alertAutoDismissTimer: ReturnType<typeof setTimeout> | null = null;
175
+ function showAlert(message: string, key?: string, opts?: { sticky?: boolean }): void {
176
+ if (key && dismissedAlerts.has(key)) return;
177
+ if (alertBanner && alertText) {
178
+ alertText.textContent = message;
179
+ alertBanner.hidden = false;
180
+ alertBanner.dataset.key = key || "";
181
+ // Q65: auto-dismiss non-critical banners after 30s; sticky ones
182
+ // (acct-*, ws-error, config-restart) keep showing until user acts.
183
+ if (alertAutoDismissTimer) { clearTimeout(alertAutoDismissTimer); alertAutoDismissTimer = null; }
184
+ const isCritical = !!opts?.sticky
185
+ || (key?.startsWith("acct-"))
186
+ || key === "ws-error"
187
+ || key === "config-restart";
188
+ if (!isCritical) {
189
+ alertAutoDismissTimer = setTimeout(() => {
190
+ if (alertBanner && alertBanner.dataset.key === (key || "")) {
191
+ alertBanner.hidden = true;
192
+ }
193
+ alertAutoDismissTimer = null;
194
+ }, 30_000);
195
+ }
196
+ }
197
+ }
198
+
199
+ function hideAlert(): void {
200
+ if (alertBanner) {
201
+ const key = alertBanner.dataset.key;
202
+ if (key) dismissedAlerts.add(key);
203
+ alertBanner.hidden = true;
204
+ }
205
+ }
206
+
207
+ alertDismiss?.addEventListener("click", hideAlert);
208
+
209
+ /** Show the alert banner with a "Restart" button wired to the mailxapi
210
+ * restartDaemon action. Used when a watched config file whose changes
211
+ * don't apply live (accounts.jsonc) has been modified. */
212
+ function showRestartForConfigBanner(): void {
213
+ if (!alertBanner || !alertText) return;
214
+ // Timestamp in the banner so repeated / spurious fires are visually
215
+ // distinguishable (and the user can see when the change actually
216
+ // happened, useful for debugging false triggers).
217
+ const ts = new Date().toLocaleTimeString([], { hour12: false });
218
+ alertText.textContent = `[${ts}] accounts.jsonc changed — restart to apply.`;
219
+ alertBanner.hidden = false;
220
+ alertBanner.dataset.key = "config-restart";
221
+ // Avoid duplicate buttons across repeat changes.
222
+ const existing = alertBanner.querySelector("#alert-restart-btn");
223
+ if (existing) return;
224
+ const btn = document.createElement("button");
225
+ btn.id = "alert-restart-btn";
226
+ btn.textContent = "Restart now";
227
+ btn.style.cssText = "margin-left: 12px; padding: 3px 12px; cursor: pointer;";
228
+ btn.addEventListener("click", async () => {
229
+ btn.disabled = true;
230
+ btn.textContent = "Restarting…";
231
+ try {
232
+ const ipc: any = (window as any).mailxapi;
233
+ if (ipc?.restartDaemon) {
234
+ await ipc.restartDaemon();
235
+ // Service is going down; the WebView should reload shortly
236
+ // when the replacement daemon takes over. Force a reload
237
+ // after a short delay in case the event doesn't arrive.
238
+ setTimeout(() => location.reload(), 2000);
239
+ } else {
240
+ // Non-IPC (server/browser mode) — location reload won't
241
+ // restart the daemon but at least gives the user feedback.
242
+ location.reload();
243
+ }
244
+ } catch (e: any) {
245
+ btn.textContent = `Failed: ${e?.message || e}`;
246
+ btn.disabled = false;
247
+ }
248
+ });
249
+ alertText.after(btn);
250
+ }
251
+
252
+ // ── Wire up components ──
253
+
254
+ const folderTree = document.getElementById("folder-tree")!;
255
+ let currentFolderSpecialUse = "";
256
+
257
+ function clearViewer(): void {
258
+ messageState.select(null); // Deselect — viewer clears via subscription
259
+ }
260
+
261
+ // Anyone can ask the viewer to clear by dispatching a `mailx-clear-viewer`
262
+ // CustomEvent on document. Used by message-list's loadSearchResults so the
263
+ // stale preview from the prior selection doesn't linger over the new search
264
+ // results. Slice D will replace this with row-object-level `unfocus()`.
265
+ document.addEventListener("mailx-clear-viewer", () => clearViewer());
266
+
267
+ const folderTitleEl = document.getElementById("ml-folder-title");
268
+ let currentFolderName = "";
269
+ let currentFolderSyncedAt: number | undefined;
270
+
271
+ function formatAge(ms: number): string {
272
+ const s = Math.round(ms / 1000);
273
+ if (s < 60) return `${s}s ago`;
274
+ const m = Math.round(s / 60);
275
+ if (m < 60) return `${m}m ago`;
276
+ const h = Math.round(m / 60);
277
+ if (h < 24) return `${h}h ago`;
278
+ return `${Math.round(h / 24)}d ago`;
279
+ }
280
+
281
+ function renderNarrowFolderTitle(): void {
282
+ if (!folderTitleEl) return;
283
+ if (currentFolderSyncedAt) {
284
+ const age = formatAge(Date.now() - currentFolderSyncedAt);
285
+ folderTitleEl.innerHTML = `${currentFolderName}<span class="ml-folder-age"> · ${age}</span>`;
286
+ folderTitleEl.title = `Last synced ${new Date(currentFolderSyncedAt).toLocaleTimeString()}`;
287
+ } else {
288
+ folderTitleEl.textContent = currentFolderName;
289
+ folderTitleEl.title = "";
290
+ }
291
+ }
292
+
293
+ function setNarrowFolderTitle(name: string): void {
294
+ currentFolderName = name;
295
+ currentFolderSyncedAt = getFolderSynced(currentAccountId, currentFolderId);
296
+ renderNarrowFolderTitle();
297
+ }
298
+
299
+ // Tick the "3m ago" text every 30s so it stays truthful without flooding repaints.
300
+ setInterval(() => {
301
+ if (currentFolderSyncedAt) renderNarrowFolderTitle();
302
+ }, 30_000);
303
+
304
+ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
305
+ currentFolderSpecialUse = specialUse;
306
+ currentAccountId = accountId;
307
+ currentFolderId = folderId;
308
+ if (searchInput) searchInput.value = "";
309
+ markAsSeen();
310
+ clearViewer();
311
+ loadMessages(accountId, folderId, 1, specialUse);
312
+ setTitle(`mailx - ${folderName}`);
313
+ setNarrowFolderTitle(folderName);
314
+ document.dispatchEvent(new CustomEvent("mailx-folder-changed", { detail: { accountId, folderId } }));
315
+ }, () => {
316
+ // Unified inbox handler
317
+ currentFolderSpecialUse = "inbox";
318
+ clearViewer();
319
+ loadUnifiedInbox();
320
+ setTitle("mailx - All Inboxes");
321
+ setNarrowFolderTitle("All Inboxes");
322
+ });
323
+
324
+ initMessageList((accountId, uid, folderId) => {
325
+ showMessage(accountId, uid, folderId, currentFolderSpecialUse);
326
+ // Narrow screen: show message viewer, hide list
327
+ if (window.innerWidth <= 768) {
328
+ document.getElementById("message-viewer")?.classList.add("narrow-active");
329
+ document.getElementById("message-list")?.classList.add("narrow-hidden");
330
+ }
331
+ });
332
+ initViewer();
333
+
334
+ // Status bar: show selected message UID/folder for debugging
335
+ messageState.subscribe((change) => {
336
+ if (change === "selected" || change === "removed") {
337
+ const acctEl = document.getElementById("status-accounts");
338
+ if (!acctEl) return;
339
+ const sel = messageState.getSelected();
340
+ if (sel) {
341
+ acctEl.textContent = `${sel.accountId}/uid:${sel.uid} folder:${sel.folderId}`;
342
+ acctEl.style.color = "";
343
+ } else {
344
+ acctEl.textContent = "";
345
+ }
346
+ }
347
+ });
348
+
349
+ // Q53: per-account last-sync timestamps surfaced via the status-sync hover.
350
+ const lastSyncByAccount: Record<string, number> = {};
351
+ function recordAccountSync(accountId: string): void {
352
+ lastSyncByAccount[accountId] = Date.now();
353
+ refreshSyncTooltip();
354
+ }
355
+ function refreshSyncTooltip(): void {
356
+ const el = document.getElementById("status-sync");
357
+ if (!el) return;
358
+ const accts = Object.keys(lastSyncByAccount).sort();
359
+ if (accts.length === 0) { el.title = ""; return; }
360
+ el.title = "Last sync:\n" + accts.map(a => {
361
+ const ts = lastSyncByAccount[a];
362
+ const d = new Date(ts);
363
+ return ` ${a}: ${d.toLocaleTimeString()} (${formatAge(Date.now() - ts)})`;
364
+ }).join("\n");
365
+ }
366
+ // Refresh the tooltip every 30s so the "(12m ago)" stays current even with
367
+ // no new sync events.
368
+ setInterval(refreshSyncTooltip, 30_000);
369
+
370
+ // ── Auto two-line when message list is narrow ──
371
+ const messageList = document.getElementById("message-list");
372
+ if (messageList) {
373
+ const twoLineThreshold = 600; // px — switch to two-line below this width
374
+ const userTwoLine = localStorage.getItem("mailx-two-line") === "true";
375
+ new ResizeObserver(([entry]) => {
376
+ const narrow = entry.contentRect.width < twoLineThreshold;
377
+ // Auto two-line when narrow, respect user preference when wide
378
+ if (narrow) {
379
+ messageList.classList.add("two-line");
380
+ } else if (!userTwoLine) {
381
+ messageList.classList.remove("two-line");
382
+ }
383
+ }).observe(messageList);
384
+ }
385
+
386
+ // ── Narrow/medium drawer toggles ──
387
+ // Hamburger (☰): rail drawer on narrow; on wider tiers the rail is already
388
+ // visible so this is a no-op visually (the toggle still fires but the rail
389
+ // has no `.open` style to invoke).
390
+ // Folder (📁): folder-panel drawer on any tier where it's positioned as an
391
+ // overlay (medium + narrow).
392
+ document.getElementById("btn-menu")?.addEventListener("click", () => {
393
+ document.querySelector(".icon-rail")?.classList.toggle("open");
394
+ // Rail drawer and folder drawer are mutually exclusive — opening one
395
+ // closes the other so they don't fight for the left edge.
396
+ document.querySelector(".folder-panel")?.classList.remove("open");
397
+ });
398
+ document.getElementById("btn-folder-toggle")?.addEventListener("click", () => {
399
+ document.querySelector(".folder-panel")?.classList.toggle("open");
400
+ document.querySelector(".icon-rail")?.classList.remove("open");
401
+ });
402
+
403
+ const backToList = (e: Event) => {
404
+ e.preventDefault();
405
+ e.stopPropagation();
406
+ // If user is in full-screen-viewer mode, the first back tap should exit
407
+ // full-screen and return to the normal narrow split (list + active
408
+ // viewer). It shouldn't also deselect — that would yank the user out two
409
+ // levels in one tap.
410
+ if (document.body.classList.contains("viewer-fullscreen")) {
411
+ document.body.classList.remove("viewer-fullscreen");
412
+ return;
413
+ }
414
+ document.getElementById("message-viewer")?.classList.remove("narrow-active");
415
+ document.getElementById("message-list")?.classList.remove("narrow-hidden");
416
+ // Deselect the message so the viewer component clears. Without this, a
417
+ // subsequent "selected" state change (e.g. sync reload) could re-show the
418
+ // same message and re-trigger narrow-active.
419
+ messageState.select(null);
420
+ };
421
+ document.getElementById("btn-back")?.addEventListener("click", backToList);
422
+ // Android WebView sometimes drops synthetic clicks after a touchend inside a
423
+ // header bar layered above the iframe — handle touchend explicitly too.
424
+ document.getElementById("btn-back")?.addEventListener("touchend", backToList);
425
+
426
+ // Pop-out viewer button — desktop spawns a floating overlay (multiple at
427
+ // once), mobile toggles `body.viewer-fullscreen` for full-screen reading.
428
+ // Threshold and behavior live in popOutCurrentMessage.
429
+ document.getElementById("mv-popout")?.addEventListener("click", () => popOutCurrentMessage());
430
+
431
+ // Close folder panel when a folder is selected (narrow mode)
432
+ // Also reset narrow navigation: show message list, hide viewer
433
+ document.getElementById("folder-tree")?.addEventListener("click", (e) => {
434
+ if (window.innerWidth <= 768 && (e.target as HTMLElement).closest(".ft-folder")) {
435
+ document.querySelector(".folder-panel")?.classList.remove("open");
436
+ document.getElementById("message-viewer")?.classList.remove("narrow-active");
437
+ document.getElementById("message-list")?.classList.remove("narrow-hidden");
438
+ }
439
+ });
440
+
441
+ // Close folder overlay when user clicks outside it (narrow mode OR
442
+ // medium-width mode where the folder panel slides in as an overlay).
443
+ // Uses capture phase so it beats any child handler that might stopPropagation.
444
+ document.addEventListener("pointerdown", (e) => {
445
+ const panel = document.querySelector(".folder-panel");
446
+ if (!panel || !panel.classList.contains("open")) return;
447
+ const target = e.target as HTMLElement;
448
+ // Ignore clicks inside the panel itself and on either toggle button.
449
+ // Without `#btn-folder-toggle` in this list, clicking the folder icon
450
+ // while the panel is open closed it here (capture phase) then the click
451
+ // handler reopened it — net effect: panel stuck open, "doesn't toggle".
452
+ if (target.closest(".folder-panel")
453
+ || target.closest("#btn-menu")
454
+ || target.closest("#btn-folder-toggle")) return;
455
+ // Only auto-dismiss when we're in overlay mode (small or medium screens).
456
+ // On wide screens the panel is a permanent column and the "open" class
457
+ // is irrelevant.
458
+ if (window.innerWidth <= 1100 || window.innerHeight <= 600) {
459
+ panel.classList.remove("open");
460
+ }
461
+ }, true);
462
+
463
+ // ── Toolbar actions ──
464
+
465
+ document.getElementById("btn-sync")?.addEventListener("click", async () => {
466
+ const btn = document.getElementById("btn-sync") as HTMLButtonElement;
467
+ btn.disabled = true;
468
+ btn.classList.add("syncing");
469
+ const statusSync = document.getElementById("status-sync");
470
+ if (statusSync) statusSync.textContent = "Syncing...";
471
+
472
+ try {
473
+ await triggerSync();
474
+ // Button stays spinning — WebSocket syncProgress/folderCountsChanged will update UI
475
+ // Set a timeout to re-enable if no WebSocket response
476
+ setTimeout(() => {
477
+ btn.disabled = false;
478
+ btn.classList.remove("syncing");
479
+ refreshFolderTree();
480
+ reloadCurrentFolder();
481
+ if (statusSync && statusSync.textContent === "Syncing...") {
482
+ statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`;
483
+ }
484
+ }, 30000);
485
+ } catch (e: any) {
486
+ if (statusSync) statusSync.textContent = `Sync error: ${e.message}`;
487
+ btn.disabled = false;
488
+ btn.classList.remove("syncing");
489
+ }
490
+ });
491
+
492
+ // Restart menu dropdown
493
+ const restartBtn = document.getElementById("btn-restart");
494
+ const restartDropdown = document.getElementById("restart-dropdown");
495
+ restartBtn?.addEventListener("click", () => {
496
+ if (restartDropdown) restartDropdown.hidden = !restartDropdown.hidden;
497
+ });
498
+ document.addEventListener("click", (e) => {
499
+ if (restartDropdown && !restartDropdown.hidden && !(e.target as HTMLElement).closest("#restart-menu")) {
500
+ restartDropdown.hidden = true;
501
+ }
502
+ });
503
+
504
+ document.getElementById("btn-restart-quick")?.addEventListener("click", async () => {
505
+ if (restartDropdown) restartDropdown.hidden = true;
506
+ if (isApp) {
507
+ // Android has no daemon — only the WebView. Reload-the-page is the
508
+ // right action there. Desktop IPC mode is a different story below.
509
+ if ((window as any).mailxapi?.platform === "android") {
510
+ const f = document.createElement("iframe");
511
+ f.style.display = "none";
512
+ f.src = "mailxapi://checkUpdate";
513
+ document.body.appendChild(f);
514
+ setTimeout(() => f.remove(), 100);
515
+ location.reload();
516
+ return;
517
+ }
518
+ // Desktop IPC mode: there IS a daemon (the --daemon child of mailx)
519
+ // running mailx-service / mailx-imap / mailx-store. Just calling
520
+ // location.reload() reloads the WebView but the daemon keeps running
521
+ // the old code, so daemon-side changes (sync, store, IPC handlers)
522
+ // don't get picked up. Trigger restartDaemon — it spawns a fresh
523
+ // `mailx` process, hands off the instance.json slot, then gracefully
524
+ // shuts down the current daemon. The UI reloads after a short delay
525
+ // so the new daemon's WebView replaces this one.
526
+ const statusSync = document.getElementById("status-sync");
527
+ if (statusSync) statusSync.textContent = "Restarting...";
528
+ const ipc = (window as any).mailxapi;
529
+ if (ipc?.restartDaemon) {
530
+ try { await ipc.restartDaemon(); } catch { /* daemon shutting down */ }
531
+ setTimeout(() => location.reload(), 2000);
532
+ } else {
533
+ // Older host with no restartDaemon IPC — fall back to UI reload.
534
+ location.reload();
535
+ }
536
+ } else {
537
+ const statusSync = document.getElementById("status-sync");
538
+ if (statusSync) statusSync.textContent = "Restarting...";
539
+ try { await restartServer(); } catch { /* server is shutting down */ }
540
+ }
541
+ });
542
+
543
+ document.getElementById("btn-update")?.addEventListener("click", async () => {
544
+ if (restartDropdown) restartDropdown.hidden = true;
545
+ const statusSync = document.getElementById("status-sync");
546
+ if (statusSync) statusSync.textContent = "Checking for updates...";
547
+ const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;
548
+ if (ipc?.performUpdate) {
549
+ if (statusSync) statusSync.textContent = "Updating... mailx will restart when done";
550
+ ipc.performUpdate();
551
+ } else if (statusSync) {
552
+ statusSync.textContent = "Update not available in this mode";
553
+ }
554
+ });
555
+
556
+ document.getElementById("btn-rebuild")?.addEventListener("click", async () => {
557
+ if (restartDropdown) restartDropdown.hidden = true;
558
+ if (!confirm("Rebuild local cache?\n\nThis wipes the local database and message store, then re-downloads everything.\nAccounts and settings are preserved.\n\nThis is safe and usually takes just a few minutes.")) return;
559
+ const statusSync = document.getElementById("status-sync");
560
+ if (statusSync) statusSync.textContent = "Rebuilding...";
561
+ try { await restartServer(); } catch { /* restarting */ }
562
+ });
563
+
564
+ document.getElementById("btn-factory-reset")?.addEventListener("click", async () => {
565
+ if (restartDropdown) restartDropdown.hidden = true;
566
+ if (!confirm("Factory reset?\n\nThis deletes ALL data — accounts, settings, messages, cache.\nYou will need to set up your account again.")) return;
567
+ const ipc = (window as any).mailxapi;
568
+ if (ipc?.resetAll) {
569
+ await ipc.resetAll();
570
+ } else {
571
+ // Fallback: clear IndexedDB + localStorage manually
572
+ const dbs = await indexedDB.databases();
573
+ for (const db of dbs) { if (db.name) indexedDB.deleteDatabase(db.name); }
574
+ localStorage.clear();
575
+ location.reload();
576
+ }
577
+ });
578
+
579
+ // ── Compose / Reply / Forward ──
580
+
581
+ type ComposeMode = "new" | "reply" | "replyAll" | "forward";
582
+
583
+ async function openCompose(mode: ComposeMode): Promise<void> {
584
+ logClientEvent("openCompose-entry", { mode });
585
+ const current = getCurrentMessage();
586
+ // Local-first: if the row is selected we already have its headers in the
587
+ // local DB. Populate the compose form unconditionally; the user can edit
588
+ // anything missing. Don't show "still loading" alerts — the message IS
589
+ // loaded (it's in the list), body is a separate fetch that isn't needed
590
+ // for Reply's headers. Missing fields become empty strings.
591
+ if ((mode === "reply" || mode === "replyAll" || mode === "forward") && !current) {
592
+ // Only true blocker: no message selected at all.
593
+ console.warn(`[compose] ${mode} — no message selected`);
594
+ return;
595
+ }
596
+ const accounts = await getAccounts();
597
+ const accountId = current?.accountId || accounts[0]?.id || "";
598
+ const msg = current?.message;
599
+ const rePrefix = /^(re|fwd?):\s*/i;
600
+ const cleanSubject = msg ? msg.subject.replace(rePrefix, "") : "";
601
+
602
+ const init: any = {
603
+ mode,
604
+ accountId,
605
+ to: [],
606
+ cc: [],
607
+ subject: "",
608
+ bodyHtml: "",
609
+ inReplyTo: "",
610
+ references: [],
611
+ accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
612
+ };
613
+
614
+ // Auto-detect reply From: if the message was delivered to an identity address
615
+ // (an alias on the account's domain, or the explicit `identityDomains` list
616
+ // in accounts.jsonc), reply from that address instead of the account's
617
+ // primary. Always derive identityDomains from the account email's domain
618
+ // when not configured — explicit list was a regression source (users would
619
+ // see Reply pick the wrong From silently when the list was missing).
620
+ const account = accounts.find((a: any) => a.id === accountId);
621
+ const explicitDomains: string[] = (account?.identityDomains || []).map((d: string) => d.toLowerCase());
622
+ const accountDomain = (account?.email || "").split("@")[1]?.toLowerCase();
623
+ const identityDomains: string[] = explicitDomains.length > 0
624
+ ? explicitDomains
625
+ : (accountDomain ? [accountDomain] : []);
626
+ function detectReplyFrom(): string | undefined {
627
+ if (!msg) return undefined;
628
+ // Delivered-To is set by the receiving server — it IS an identity at this
629
+ // account, by definition. Trust it unconditionally when present (after
630
+ // deliveredToPrefix stripping in the service). Fall back to To/Cc only
631
+ // when their domain matches the account's identityDomains, since To/Cc
632
+ // can be set by the sender and aren't authoritative.
633
+ if (msg.deliveredTo) {
634
+ console.log(`[compose] reply From → ${msg.deliveredTo} (Delivered-To)`);
635
+ return msg.deliveredTo;
636
+ }
637
+ if (identityDomains.length === 0) return undefined;
638
+ const candidates: string[] = [
639
+ ...((msg.to || []).map((a: any) => a.address)),
640
+ ...((msg.cc || []).map((a: any) => a.address)),
641
+ ].filter(Boolean);
642
+ for (const addr of candidates) {
643
+ const domain = addr.split("@")[1]?.toLowerCase();
644
+ if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
645
+ console.log(`[compose] reply From → ${addr} (To/Cc match)`);
646
+ return addr;
647
+ }
648
+ }
649
+ console.log(`[compose] no identity match`);
650
+ return undefined;
651
+ }
652
+
653
+ // Defensive: msg.from / msg.to may be missing on rows that arrived before
654
+ // headers finished loading. Don't push undefined into init.to — that
655
+ // bubbles to the compose form as literal "undefined". Empty-out gracefully.
656
+ if (msg && mode === "reply") {
657
+ init.to = msg.from ? [msg.from] : [];
658
+ init.subject = `Re: ${cleanSubject}`;
659
+ init.bodyHtml = quoteBody(msg);
660
+ init.inReplyTo = msg.messageId || "";
661
+ init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
662
+ init.fromAddress = detectReplyFrom();
663
+ } else if (msg && mode === "replyAll") {
664
+ const toList: any[] = msg.from ? [msg.from] : [];
665
+ if (Array.isArray(msg.to)) {
666
+ for (const a of msg.to) {
667
+ if (a?.address && a.address !== msg.from?.address) toList.push(a);
668
+ }
669
+ }
670
+ init.to = toList;
671
+ init.cc = Array.isArray(msg.cc) ? msg.cc : [];
672
+ init.subject = `Re: ${cleanSubject}`;
673
+ init.bodyHtml = quoteBody(msg);
674
+ init.inReplyTo = msg.messageId || "";
675
+ init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
676
+ init.fromAddress = detectReplyFrom();
677
+ } else if (msg && mode === "forward") {
678
+ init.subject = `Fwd: ${cleanSubject}`;
679
+ init.bodyHtml = forwardBody(msg);
680
+ init.fromAddress = detectReplyFrom();
681
+ }
682
+
683
+
684
+ // Store init data for compose window to pick up
685
+ sessionStorage.setItem("composeInit", JSON.stringify(init));
686
+ // Inline compose: load compose.html in an overlay iframe (same origin, same IPC)
687
+ // Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
688
+ // Title reflects mode + subject so the user can see what they're replying to
689
+ // ("Re: Stars of STEM 2026" instead of just "Compose"). Forward shows the
690
+ // forward target subject; new compose stays generic.
691
+ const titlePrefix =
692
+ mode === "reply" ? "Reply" :
693
+ mode === "replyAll" ? "Reply All" :
694
+ mode === "forward" ? "Forward" :
695
+ "Compose";
696
+ const titleSubject = mode === "new" ? "" : (msg?.subject || init.subject || "");
697
+ showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
698
+ }
699
+
700
+ function showComposeOverlay(title = "Compose"): void {
701
+ const wrapper = document.createElement("div");
702
+ wrapper.className = "compose-overlay";
703
+ // Full-screen on small/short screens, floating on larger
704
+ const isSmall = window.innerWidth <= 768 || window.innerHeight <= 600;
705
+ if (isSmall) {
706
+ wrapper.style.cssText = "position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:#fff;";
707
+ } else {
708
+ wrapper.style.cssText = "position:fixed;bottom:0;right:16px;width:min(900px,55vw);height:min(700px,70vh);z-index:1000;border-radius:8px 8px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;";
709
+ }
710
+
711
+ // Title bar — drag to move, close button
712
+ const titleBar = document.createElement("div");
713
+ titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;";
714
+ titleBar.textContent = title;
715
+
716
+ const closeBtn = document.createElement("button");
717
+ closeBtn.textContent = "✕";
718
+ closeBtn.title = "Save draft and close";
719
+ closeBtn.style.cssText = "background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 6px;border-radius:4px;";
720
+ closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
721
+ closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
722
+ closeBtn.addEventListener("click", () => {
723
+ // compose.ts handles the prompt (Save/Discard/Cancel) and then calls
724
+ // window.close() which is redirected to wrapper.remove() at line below.
725
+ // If the user cancels the prompt, closeCompose() is never called and
726
+ // the wrapper stays. Don't force-remove on a timer — that defeats Cancel.
727
+ try {
728
+ const win = frame.contentWindow;
729
+ if (win) win.dispatchEvent(new Event("compose-save-and-close"));
730
+ } catch { /* */ }
731
+ });
732
+ titleBar.appendChild(closeBtn);
733
+
734
+ // Drag to move. While dragging we set pointer-events:none on the iframe
735
+ // so mouse events don't get swallowed by the inner document the moment
736
+ // the cursor crosses into the iframe region. Without that, drag only
737
+ // worked if you stayed on the title bar pixels, which is why it felt
738
+ // broken except at the lower-right (resize grip) corner.
739
+ let dragX = 0, dragY = 0;
740
+ titleBar.addEventListener("mousedown", (e: MouseEvent) => {
741
+ if (e.target === closeBtn) return;
742
+ e.preventDefault();
743
+ const rect = wrapper.getBoundingClientRect();
744
+ dragX = e.clientX - rect.left;
745
+ dragY = e.clientY - rect.top;
746
+ // Clamp movement to the viewport so the title bar stays grabbable.
747
+ const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val));
748
+ frame.style.pointerEvents = "none";
749
+ document.body.style.userSelect = "none";
750
+ const onMove = (ev: MouseEvent) => {
751
+ ev.preventDefault();
752
+ const w = wrapper.offsetWidth;
753
+ const h = wrapper.offsetHeight;
754
+ const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
755
+ const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
756
+ wrapper.style.left = `${left}px`;
757
+ wrapper.style.top = `${top}px`;
758
+ wrapper.style.bottom = "auto";
759
+ wrapper.style.right = "auto";
760
+ };
761
+ const onUp = () => {
762
+ frame.style.pointerEvents = "";
763
+ document.body.style.userSelect = "";
764
+ document.removeEventListener("mousemove", onMove);
765
+ document.removeEventListener("mouseup", onUp);
766
+ };
767
+ document.addEventListener("mousemove", onMove);
768
+ document.addEventListener("mouseup", onUp);
769
+ });
770
+
771
+ const frame = document.createElement("iframe");
772
+ frame.src = "compose/compose.html";
773
+ frame.style.cssText = "flex:1;border:none;background:#fff;width:100%;";
774
+
775
+ // Close when compose calls window.close()
776
+ frame.addEventListener("load", () => {
777
+ try {
778
+ const win = frame.contentWindow;
779
+ if (win) {
780
+ (win as any).close = () => wrapper.remove();
781
+ }
782
+ } catch { /* cross-origin safety */ }
783
+ });
784
+
785
+ // Bring to front on click
786
+ wrapper.addEventListener("mousedown", () => {
787
+ document.querySelectorAll(".compose-overlay").forEach(el => (el as HTMLElement).style.zIndex = "1000");
788
+ wrapper.style.zIndex = "1001";
789
+ });
790
+
791
+ wrapper.appendChild(titleBar);
792
+ wrapper.appendChild(frame);
793
+ document.body.appendChild(wrapper);
794
+ }
795
+
796
+ // Marketing-email layout tables (deeply nested, fixed widths) collapse to
797
+ // 30-40px columns inside a phone-width compose pane and wrap text
798
+ // character-by-character. Strip styles + flatten tables before quoting.
799
+ function sanitizeQuotedBody(msg: any): string {
800
+ let body = msg.bodyHtml || `<pre>${msg.bodyText || ""}</pre>`;
801
+ body = body.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
802
+ body = body.replace(/\s+style="[^"]*"/gi, "");
803
+ body = body.replace(/\s+class="[^"]*"/gi, "");
804
+ body = body.replace(/\s+(width|height|align|valign|bgcolor|cellpadding|cellspacing|border)="[^"]*"/gi, "");
805
+ body = body.replace(/<table[^>]*>/gi, "<div>").replace(/<\/table>/gi, "</div>");
806
+ body = body.replace(/<t[rdh][^>]*>/gi, "").replace(/<\/t[rdh]>/gi, " ");
807
+ body = body.replace(/<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gi, "");
808
+ return body;
809
+ }
810
+
811
+ function quoteBody(msg: any): string {
812
+ const date = new Date(msg.date).toLocaleString();
813
+ const from = msg.from.name ? `${msg.from.name} &lt;${msg.from.address}&gt;` : msg.from.address;
814
+ const body = sanitizeQuotedBody(msg);
815
+ // Two blank lines above the quote so the cursor lands with breathing room
816
+ // between the user's reply and the "On ... wrote:" line.
817
+ return `<br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
818
+ }
819
+
820
+ function forwardBody(msg: any): string {
821
+ const date = new Date(msg.date).toLocaleString();
822
+ const from = msg.from.name ? `${msg.from.name} &lt;${msg.from.address}&gt;` : msg.from.address;
823
+ const to = msg.to.map((a: any) => a.name ? `${a.name} &lt;${a.address}&gt;` : a.address).join(", ");
824
+ const body = sanitizeQuotedBody(msg);
825
+ return `<br><br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
826
+ }
827
+
828
+ // ── Delete with undo ──
829
+
830
+ interface DeletedMessage {
831
+ accountId: string;
832
+ uid: number;
833
+ folderId: number;
834
+ subject: string;
835
+ }
836
+
837
+ interface MovedBatch {
838
+ messages: { accountId: string; uid: number; sourceFolderId: number }[];
839
+ targetAccountId: string;
840
+ targetFolderId: number;
841
+ }
842
+
843
+ let lastDeleted: DeletedMessage | null = null;
844
+ let lastMoved: MovedBatch | null = null;
845
+ let undoTimeout: ReturnType<typeof setTimeout> | null = null;
846
+
847
+ async function deleteSelectedMessages(): Promise<void> {
848
+ const selected = getSelectedMessages();
849
+
850
+ // Fall back to single message from viewer if nothing selected in list
851
+ if (selected.length === 0) {
852
+ const current = getCurrentMessage();
853
+ if (!current) return;
854
+ selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
855
+ }
856
+
857
+ const statusSync = document.getElementById("status-sync");
858
+
859
+ try {
860
+ // Delete on server — group by account for bulk operations
861
+ const byAccount = new Map<string, number[]>();
862
+ for (const msg of selected) {
863
+ const uids = byAccount.get(msg.accountId) || [];
864
+ uids.push(msg.uid);
865
+ byAccount.set(msg.accountId, uids);
866
+ }
867
+ for (const [accountId, uids] of byAccount) {
868
+ await deleteMessages(accountId, uids);
869
+ }
870
+
871
+ // Undo supports the last batch
872
+ if (selected.length === 1) {
873
+ lastDeleted = { ...selected[0], subject: "" };
874
+ if (statusSync) statusSync.textContent = `Trashed 1 message (syncing) — Ctrl+Z to undo`;
875
+ } else {
876
+ lastDeleted = null;
877
+ if (statusSync) statusSync.textContent = `Trashed ${selected.length} messages (syncing)`;
878
+ }
879
+
880
+ if (undoTimeout) clearTimeout(undoTimeout);
881
+ undoTimeout = setTimeout(() => {
882
+ lastDeleted = null;
883
+ if (statusSync?.textContent?.includes("undo")) statusSync.textContent = "";
884
+ }, 30000);
885
+
886
+ // Remove from shared state — list and viewer update automatically
887
+ messageState.removeMessages(selected);
888
+ } catch (e: any) {
889
+ console.error(`Delete failed: ${e.message}`);
890
+ }
891
+ }
892
+
893
+ async function undoDelete(): Promise<void> {
894
+ if (!lastDeleted) return;
895
+ const { accountId, uid, folderId } = lastDeleted;
896
+
897
+ try {
898
+ await undeleteMessage(accountId, uid, folderId);
899
+
900
+ const statusSync = document.getElementById("status-sync");
901
+ if (statusSync) statusSync.textContent = "Message restored";
902
+ lastDeleted = null;
903
+ if (undoTimeout) clearTimeout(undoTimeout);
904
+ reloadCurrentFolder();
905
+ } catch (e: any) {
906
+ console.error(`Undo failed: ${e.message}`);
907
+ }
908
+ }
909
+
910
+ async function undoMove(): Promise<void> {
911
+ if (!lastMoved) return;
912
+ const { messages } = lastMoved;
913
+ const statusSync = document.getElementById("status-sync");
914
+ try {
915
+ // Group by (sourceAccountId, sourceFolderId) and move each group back
916
+ const byDest = new Map<string, { accountId: string; folderId: number; uids: number[] }>();
917
+ for (const m of messages) {
918
+ const key = `${m.accountId}:${m.sourceFolderId}`;
919
+ if (!byDest.has(key)) byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
920
+ byDest.get(key)!.uids.push(m.uid);
921
+ }
922
+ const { moveMessages, moveMessage } = await import("./lib/api-client.js");
923
+ for (const group of byDest.values()) {
924
+ if (group.uids.length === 1) await moveMessage(group.accountId, group.uids[0], group.folderId);
925
+ else await moveMessages(group.accountId, group.uids, group.folderId);
926
+ }
927
+ if (statusSync) statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
928
+ lastMoved = null;
929
+ if (undoTimeout) clearTimeout(undoTimeout);
930
+ reloadCurrentFolder();
931
+ } catch (e: any) {
932
+ console.error(`Undo move failed: ${e.message}`);
933
+ if (statusSync) statusSync.textContent = `Undo move failed: ${e.message}`;
934
+ }
935
+ }
936
+
937
+ // Listen for the "mailx-moved" custom event emitted by folder-tree's drop
938
+ // handler so Ctrl+Z can reverse the most recent move.
939
+ document.addEventListener("mailx-moved", (e: any) => {
940
+ lastMoved = e.detail as MovedBatch;
941
+ lastDeleted = null; // Ctrl+Z undoes whichever came last
942
+ if (undoTimeout) clearTimeout(undoTimeout);
943
+ undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
944
+ });
945
+
946
+ document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
947
+ // Same handlers also bound to the top-toolbar icons so delete/spam work
948
+ // regardless of whether a message is open in the viewer. Useful for quick
949
+ // triage from a list-only view.
950
+ document.getElementById("btn-tb-delete")?.addEventListener("click", deleteSelectedMessages);
951
+ document.getElementById("btn-tb-spam")?.addEventListener("click", spamSelectedMessages);
952
+
953
+ // ── Flag toggle ──
954
+ document.getElementById("btn-flag")?.addEventListener("click", async () => {
955
+ const sel = messageState.getSelected();
956
+ if (!sel) return;
957
+ const isFlagged = sel.flags.includes("\\Flagged");
958
+ const newFlags = isFlagged
959
+ ? sel.flags.filter((f: string) => f !== "\\Flagged")
960
+ : [...sel.flags, "\\Flagged"];
961
+ try {
962
+ await updateFlags(sel.accountId, sel.uid, newFlags);
963
+ sel.flags = newFlags;
964
+ messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
965
+ // Update the message-list row's flag indicator
966
+ const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`) as HTMLElement;
967
+ if (row) {
968
+ row.classList.toggle("flagged", newFlags.includes("\\Flagged"));
969
+ const flagEl = row.querySelector(".ml-flag");
970
+ if (flagEl) flagEl.textContent = newFlags.includes("\\Flagged") ? "\u2605" : "\u2606";
971
+ }
972
+ } catch (e: unknown) {
973
+ console.error(`Flag toggle failed: ${(e as Error).message}`);
974
+ }
975
+ });
976
+
977
+ async function spamSelectedMessages(): Promise<void> {
978
+ console.log("[spam] click — finding selection");
979
+ const selected = getSelectedMessages();
980
+ if (selected.length === 0) {
981
+ const current = getCurrentMessage();
982
+ if (!current) {
983
+ console.warn("[spam] no message selected and none in viewer — nothing to do");
984
+ alert("No message selected. Click a message first, then the spam button.");
985
+ return;
986
+ }
987
+ selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
988
+ }
989
+ console.log(`[spam] marking ${selected.length} message(s):`, selected);
990
+ const statusSync = document.getElementById("status-sync");
991
+ // Optimistic: remove from list immediately so the user sees action happen.
992
+ // If the IPC fails, put them back. This matches local-first — the server
993
+ // sync is a background detail, the user's action should feel instant.
994
+ const snapshot = [...selected];
995
+ messageState.removeMessages(selected);
996
+ try {
997
+ const byAccount = new Map<string, number[]>();
998
+ for (const msg of snapshot) {
999
+ const uids = byAccount.get(msg.accountId) || [];
1000
+ uids.push(msg.uid);
1001
+ byAccount.set(msg.accountId, uids);
1002
+ }
1003
+ for (const [accountId, uids] of byAccount) {
1004
+ const result = await markAsSpamMessages(accountId, uids);
1005
+ console.log(`[spam] ${accountId}: moved ${result?.moved ?? uids.length} to folderId=${result?.targetFolderId}`);
1006
+ }
1007
+ if (statusSync) statusSync.textContent = `Spam: ${snapshot.length} queued — pending server sync`;
1008
+ } catch (e: any) {
1009
+ console.error(`[spam] failed:`, e);
1010
+ if (statusSync) statusSync.textContent = `Spam failed: ${e?.message || e}`;
1011
+ alert(`Mark-as-spam failed: ${e?.message || e}\n\n${selected.length} message(s) stayed in the list; check Settings → account spam folder and accounts.jsonc.`);
1012
+ // Best-effort restore: re-set the messages we optimistically removed.
1013
+ // removeMessages has no inverse in message-state, so we'll rely on the
1014
+ // next folder reload to repopulate. Surface the failure clearly.
1015
+ }
1016
+ }
1017
+
1018
+ document.getElementById("btn-spam")?.addEventListener("click", spamSelectedMessages);
1019
+
1020
+ /** Show/hide the Spam button based on whether the current account has "spam" configured. */
1021
+ async function refreshSpamButtonVisibility(): Promise<void> {
1022
+ const btn = document.getElementById("btn-spam") as HTMLButtonElement | null;
1023
+ if (!btn) return;
1024
+ const current = getCurrentMessage();
1025
+ const accountId = current?.accountId || currentAccountId;
1026
+ if (!accountId) { btn.hidden = true; return; }
1027
+ try {
1028
+ const accounts = await getAccounts();
1029
+ const acct = accounts.find((a: any) => a.id === accountId);
1030
+ btn.hidden = !acct?.spam;
1031
+ } catch { btn.hidden = true; }
1032
+ }
1033
+
1034
+ document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
1035
+ document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
1036
+
1037
+ // Q100 placeholder — append a row to ~/.mailx/spam.csv for later analysis.
1038
+ // No folder move, no flag change, no auto-delete. Button is always visible
1039
+ // (no configuration required; unlike btn-spam which needs a junk folder).
1040
+ document.getElementById("btn-spam-report")?.addEventListener("click", async () => {
1041
+ const current = getCurrentMessage();
1042
+ const msg = current?.message;
1043
+ const accountId = current?.accountId;
1044
+ if (!msg || !accountId) return;
1045
+ const btn = document.getElementById("btn-spam-report") as HTMLButtonElement;
1046
+ const originalLabel = btn.textContent;
1047
+ btn.disabled = true;
1048
+ btn.textContent = "…";
1049
+ try {
1050
+ const { recordSpamReport } = await import("./lib/api-client.js");
1051
+ await recordSpamReport(accountId, msg.uid, msg.folderId);
1052
+ btn.textContent = "✓";
1053
+ const status = document.getElementById("status-sync");
1054
+ if (status) status.textContent = "Logged to ~/.mailx/spam.csv";
1055
+ setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
1056
+ } catch (e: any) {
1057
+ btn.textContent = "✗";
1058
+ const status = document.getElementById("status-sync");
1059
+ if (status) status.textContent = `Spam log failed: ${e?.message || e}`;
1060
+ setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 2500);
1061
+ }
1062
+ });
1063
+
1064
+ document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
1065
+
1066
+ document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
1067
+ // Toggle \Seen on the currently-selected message. Mirrors the R
1068
+ // keyboard shortcut and the right-click "Mark unread" menu item, but
1069
+ // as a visible toolbar button so users discover the behavior.
1070
+ const sel = messageState.getSelected();
1071
+ if (!sel) return;
1072
+ const isSeen = sel.flags.includes("\\Seen");
1073
+ const newFlags = isSeen
1074
+ ? sel.flags.filter((f: string) => f !== "\\Seen")
1075
+ : [...sel.flags, "\\Seen"];
1076
+ updateFlags(sel.accountId, sel.uid, newFlags).then(() => {
1077
+ sel.flags = newFlags;
1078
+ messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
1079
+ const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`) as HTMLElement;
1080
+ if (row) row.classList.toggle("unread", !newFlags.includes("\\Seen"));
1081
+ }).catch(() => {});
1082
+ });
1083
+
1084
+ document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
1085
+ document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
1086
+ document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
1087
+
1088
+ // ── Icon rail wiring ──
1089
+ // Rail is the always-visible vertical bar on the far left (Thunderbird/Dovecot
1090
+ // style). Mostly mirrors toolbar/menu actions for one-click access; calendar /
1091
+ // tasks / contacts buttons are placeholders until those features ship.
1092
+ document.getElementById("rail-compose")?.addEventListener("click", () => openCompose("new"));
1093
+ document.getElementById("rail-inbox")?.addEventListener("click", () => {
1094
+ // Trigger the existing folder-tree click on the first inbox folder.
1095
+ const inbox = document.querySelector('.folder-tree .folder-item[data-special-use="inbox"]') as HTMLElement | null;
1096
+ inbox?.click();
1097
+ });
1098
+ document.getElementById("rail-unified")?.addEventListener("click", () => {
1099
+ const unified = document.querySelector('.folder-tree .all-inboxes') as HTMLElement | null
1100
+ || document.getElementById("ft-all-inboxes");
1101
+ unified?.click();
1102
+ });
1103
+ document.getElementById("rail-contacts")?.addEventListener("click", async () => {
1104
+ const { openAddressBook } = await import("./components/address-book.js");
1105
+ openAddressBook();
1106
+ setRailActive("rail-contacts");
1107
+ });
1108
+ // Q114 decided 2026-04-24: full-screen calendar/tasks modals are
1109
+ // temporarily retired — the right-docked sidebar (calendar-sidebar.ts)
1110
+ // owns both views. Rail buttons now just reveal the sidebar. Files kept
1111
+ // (`calendar.ts`, `tasks.ts`) for potential revival; not imported.
1112
+ document.getElementById("rail-calendar")?.addEventListener("click", async () => {
1113
+ const { showCalendarSidebar } = await import("./components/calendar-sidebar.js");
1114
+ await showCalendarSidebar();
1115
+ // Flip the View-menu checkbox so the on-state stays coherent across paths.
1116
+ const optSidebar = document.getElementById("opt-calendar-sidebar") as HTMLInputElement | null;
1117
+ if (optSidebar) optSidebar.checked = true;
1118
+ setRailActive("rail-calendar");
1119
+ });
1120
+ document.getElementById("rail-tasks")?.addEventListener("click", async () => {
1121
+ const { showCalendarSidebar } = await import("./components/calendar-sidebar.js");
1122
+ await showCalendarSidebar();
1123
+ // Scroll the sidebar to the tasks section if possible.
1124
+ document.getElementById("cal-side-tasks")?.scrollIntoView({ block: "start", behavior: "smooth" });
1125
+ const optSidebar = document.getElementById("opt-calendar-sidebar") as HTMLInputElement | null;
1126
+ if (optSidebar) optSidebar.checked = true;
1127
+ setRailActive("rail-tasks");
1128
+ });
1129
+ /** Open a toolbar dropdown (View / Settings) anchored to a rail icon.
1130
+ *
1131
+ * In wide mode the toolbar buttons own the menu and do their own toggle.
1132
+ * In narrow mode the toolbar is `display:none`, so the dropdown — which
1133
+ * lives as a child of `.tb-menu` inside the toolbar — is invisible no
1134
+ * matter what `hidden` is set to. Forwarding the rail click to the
1135
+ * toolbar button toggled `hidden` but the user still saw nothing, hence
1136
+ * the "setup icon does nothing" bug report.
1137
+ *
1138
+ * The fix: when a rail icon opens the menu, reparent the dropdown to
1139
+ * `<body>` (so the toolbar's display:none can't hide it), switch to
1140
+ * `position: fixed` anchored to the icon, and let the existing menu
1141
+ * handlers fire normally — same dropdown DOM, same listeners, same
1142
+ * content. We never put the dropdown back: the toolbar handler also
1143
+ * works on a body-attached dropdown (it's just toggling .hidden), and
1144
+ * re-parenting on every toggle would defeat any focus state inside. */
1145
+ function openMenuFromRail(dropdownId: string, anchor: HTMLElement): void {
1146
+ const dd = document.getElementById(dropdownId);
1147
+ if (!dd) return;
1148
+ if (!dd.hidden) { dd.hidden = true; return; }
1149
+ // Close sibling rail-opened menus so two don't stack.
1150
+ for (const id of ["settings-dropdown", "view-dropdown", "restart-dropdown"]) {
1151
+ const other = document.getElementById(id);
1152
+ if (other && other !== dd) other.hidden = true;
1153
+ }
1154
+ if (dd.parentElement?.classList.contains("tb-menu")) {
1155
+ document.body.appendChild(dd);
1156
+ }
1157
+ const rect = anchor.getBoundingClientRect();
1158
+ dd.style.position = "fixed";
1159
+ // Anchor to the right of the rail icon, top-aligned. Clamp so the menu
1160
+ // doesn't fall off the right edge on narrow screens.
1161
+ const minWidth = 220;
1162
+ const left = Math.min(rect.right + 6, window.innerWidth - minWidth - 8);
1163
+ dd.style.left = `${Math.max(8, left)}px`;
1164
+ dd.style.top = `${Math.max(8, rect.top)}px`;
1165
+ dd.style.zIndex = "10000";
1166
+ dd.hidden = false;
1167
+ }
1168
+
1169
+ document.getElementById("rail-settings")?.addEventListener("click", (e) => {
1170
+ e.stopPropagation();
1171
+ openMenuFromRail("settings-dropdown", e.currentTarget as HTMLElement);
1172
+ });
1173
+ document.getElementById("rail-view")?.addEventListener("click", (e) => {
1174
+ e.stopPropagation();
1175
+ openMenuFromRail("view-dropdown", e.currentTarget as HTMLElement);
1176
+ });
1177
+ document.getElementById("rail-help")?.addEventListener("click", () => {
1178
+ document.getElementById("btn-about")?.click();
1179
+ });
1180
+ document.getElementById("rail-theme")?.addEventListener("click", () => {
1181
+ // Rail theme icon cycles system → dark → light → system. Settings menu
1182
+ // exposes the same three as radio buttons for direct selection.
1183
+ const root = document.documentElement;
1184
+ const cur = root.getAttribute("data-theme") || "system";
1185
+ const next = cur === "system" ? "dark" : cur === "dark" ? "light" : "system";
1186
+ applyTheme(next);
1187
+ });
1188
+
1189
+ function applyTheme(theme: "system" | "light" | "dark"): void {
1190
+ document.documentElement.setAttribute("data-theme", theme);
1191
+ try { localStorage.setItem("mailx-theme", theme); } catch { /* private mode */ }
1192
+ // Reflect in the Settings menu radios so the two paths stay in sync.
1193
+ const radio = document.getElementById(`opt-theme-${theme}`) as HTMLInputElement | null;
1194
+ if (radio) radio.checked = true;
1195
+ }
1196
+
1197
+ // Restore saved theme + wire the Settings radios. Defaults to "system".
1198
+ (() => {
1199
+ const saved = (() => { try { return localStorage.getItem("mailx-theme") || "system"; } catch { return "system"; } })();
1200
+ applyTheme(saved as "system" | "light" | "dark");
1201
+ for (const t of ["system", "light", "dark"] as const) {
1202
+ document.getElementById(`opt-theme-${t}`)?.addEventListener("change", (e) => {
1203
+ if ((e.target as HTMLInputElement).checked) applyTheme(t);
1204
+ });
1205
+ }
1206
+ })();
1207
+ // Highlight the current rail target. For now just inbox is the default; once
1208
+ // calendar/tasks ship, update this on view change.
1209
+ function setRailActive(id: string): void {
1210
+ document.querySelectorAll(".rail-btn[data-active]").forEach(el => el.removeAttribute("data-active"));
1211
+ document.getElementById(id)?.setAttribute("data-active", "true");
1212
+ }
1213
+ document.addEventListener("mailx-folder-changed", () => setRailActive("rail-inbox"));
1214
+
1215
+ // Context menu events from message-list right-click
1216
+ document.addEventListener("mailx-compose", ((e: CustomEvent) => {
1217
+ if (e.detail.mode === "draft" && sessionStorage.getItem("composeInit")) {
1218
+ // Draft already stored by viewer — just show overlay
1219
+ showComposeOverlay();
1220
+ } else {
1221
+ openCompose(e.detail.mode);
1222
+ }
1223
+ }) as EventListener);
1224
+ document.addEventListener("mailx-delete", () => deleteSelectedMessages());
1225
+
1226
+ // ── Search ──
1227
+
1228
+ let searchTimeout: ReturnType<typeof setTimeout>;
1229
+ const searchInput = document.getElementById("search-input") as HTMLInputElement;
1230
+ const searchScope = document.getElementById("search-scope") as HTMLSelectElement;
1231
+
1232
+ function doSearch(immediate = false): void {
1233
+ const query = searchInput.value.trim();
1234
+ if (query.length === 0) { reloadCurrentFolder(); return; }
1235
+ if (query.length < 2 && !immediate) return;
1236
+
1237
+ // P20: orthogonal "Server" checkbox. When checked, scope switches to
1238
+ // "server" which spans all folders on all accounts. Local scope dropdown
1239
+ // is unchanged (all/current) for the local-only case.
1240
+ const serverCheck = document.getElementById("search-server-too") as HTMLInputElement | null;
1241
+ const localScope = searchScope?.value || "all";
1242
+ const effectiveScope = serverCheck?.checked ? "server" : localScope;
1243
+
1244
+ // "This folder" scope: instant client-side filter on debounce, server search on Enter
1245
+ if (effectiveScope === "current" && !immediate) {
1246
+ // Client-side filter of visible rows
1247
+ const body = document.getElementById("ml-body");
1248
+ if (body) {
1249
+ const lower = query.toLowerCase();
1250
+ for (const row of body.querySelectorAll(".ml-row")) {
1251
+ const text = row.textContent?.toLowerCase() || "";
1252
+ (row as HTMLElement).classList.toggle("filter-hidden", !text.includes(lower));
1253
+ }
1254
+ }
1255
+ return;
1256
+ }
1257
+
1258
+ loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId);
1259
+ setTitle(`mailx - Search: ${query}`);
1260
+ }
1261
+
1262
+ // Track current folder for scoped search
1263
+ let currentAccountId = "";
1264
+ let currentFolderId = 0;
1265
+ let reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
1266
+
1267
+ searchInput?.addEventListener("input", () => {
1268
+ clearTimeout(searchTimeout);
1269
+ if (searchInput.value.trim() === "") {
1270
+ // Cleared — reset immediately, no debounce. Must exit search mode
1271
+ // first; otherwise reloadCurrentFolder() sees searchMode=true and
1272
+ // re-runs the stale query (user-reported regression 2026-04-24).
1273
+ clearSearchMode();
1274
+ const body = document.getElementById("ml-body");
1275
+ if (body) body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1276
+ reloadCurrentFolder();
1277
+ setTitle("mailx");
1278
+ } else {
1279
+ searchTimeout = setTimeout(() => doSearch(false), 300);
1280
+ }
1281
+ });
1282
+ searchInput?.addEventListener("keydown", (e) => {
1283
+ if (e.key === "Enter") {
1284
+ clearTimeout(searchTimeout);
1285
+ doSearch(true);
1286
+ }
1287
+ if (e.key === "Escape") {
1288
+ searchInput.value = "";
1289
+ clearSearchMode();
1290
+ // Clear any client-side filters
1291
+ const body = document.getElementById("ml-body");
1292
+ if (body) body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1293
+ reloadCurrentFolder();
1294
+ setTitle("mailx");
1295
+ }
1296
+ });
1297
+
1298
+ // Re-run the active search when the scope dropdown or "server too" checkbox
1299
+ // flips. Without this, switching all/current/server after typing the query
1300
+ // left the old result set on screen — the controls looked like they did
1301
+ // nothing. Treat the change as `immediate=true` so the user sees the new
1302
+ // scope's results without having to retype Enter; clear any client-side
1303
+ // filter-hidden flags from the prior "current folder" path so the row set
1304
+ // resets cleanly.
1305
+ function rerunActiveSearch(): void {
1306
+ if (!searchInput || searchInput.value.trim() === "") return;
1307
+ const body = document.getElementById("ml-body");
1308
+ if (body) body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1309
+ clearTimeout(searchTimeout);
1310
+ doSearch(true);
1311
+ }
1312
+ searchScope?.addEventListener("change", rerunActiveSearch);
1313
+ document.getElementById("search-server-too")?.addEventListener("change", rerunActiveSearch);
1314
+
1315
+ // Message state handles move/delete — no manual event listener needed
1316
+
1317
+ // ── Folder filter ──
1318
+ const ftFilterInput = document.getElementById("ft-filter-input") as HTMLInputElement;
1319
+ if (ftFilterInput) {
1320
+ ftFilterInput.addEventListener("input", () => {
1321
+ const query = ftFilterInput.value.toLowerCase();
1322
+ const tree = document.getElementById("folder-tree");
1323
+ if (!tree) return;
1324
+
1325
+ if (!query) {
1326
+ // Clear filter — show everything
1327
+ tree.querySelectorAll(".ft-filter-hidden").forEach(el => el.classList.remove("ft-filter-hidden"));
1328
+ return;
1329
+ }
1330
+
1331
+ // Hide all folders first, then show matches + their parent accounts
1332
+ const folders = tree.querySelectorAll(".ft-folder");
1333
+ const accounts = tree.querySelectorAll(".ft-account");
1334
+
1335
+ for (const acct of accounts) (acct as HTMLElement).classList.add("ft-filter-hidden");
1336
+ for (const f of folders) (f as HTMLElement).classList.add("ft-filter-hidden");
1337
+
1338
+ for (const f of folders) {
1339
+ const name = f.querySelector(".ft-folder-name")?.textContent?.toLowerCase() || "";
1340
+ if (name.includes(query)) {
1341
+ (f as HTMLElement).classList.remove("ft-filter-hidden");
1342
+ // Show parent account
1343
+ const acct = f.closest(".ft-account");
1344
+ if (acct) (acct as HTMLElement).classList.remove("ft-filter-hidden");
1345
+ }
1346
+ }
1347
+
1348
+ // Also show unified inbox if it matches
1349
+ const unified = tree.querySelector(".ft-unified");
1350
+ if (unified) {
1351
+ const text = unified.textContent?.toLowerCase() || "";
1352
+ (unified as HTMLElement).classList.toggle("ft-filter-hidden", !text.includes(query));
1353
+ }
1354
+ });
1355
+
1356
+ ftFilterInput.addEventListener("keydown", (e) => {
1357
+ if (e.key === "Escape") {
1358
+ ftFilterInput.value = "";
1359
+ ftFilterInput.dispatchEvent(new Event("input"));
1360
+ }
1361
+ });
1362
+ }
1363
+
1364
+ // ── Open links from email body in system browser ──
1365
+
1366
+ window.addEventListener("message", (e) => {
1367
+ // Relay traces from iframes (compose) to Node via our working bridge.
1368
+ // The iframe calls logClientEvent which tries its own bridge first; if
1369
+ // that path is broken it also posts here as backup. Tag gets a `via-relay`
1370
+ // suffix when the iframe couldn't reach its own bridge — that alone
1371
+ // diagnoses whether the iframe bridge works.
1372
+ if (e.data?.type === "mailx-trace" && typeof e.data.tag === "string") {
1373
+ const relayTag = e.data.bridged ? e.data.tag : `${e.data.tag} (via-relay)`;
1374
+ logClientEvent(relayTag, e.data.data);
1375
+ return;
1376
+ }
1377
+ // Compose-send relay: iframe posts the send request here because its own
1378
+ // bridge call to sendMessage was failing to reach Node. This window's
1379
+ // bridge is proven (getAccounts / getOutboxStatus run every few seconds
1380
+ // with no failures), so we do the IPC from here and post the result back
1381
+ // to the iframe via its source. `e.source` is the iframe's window; use it
1382
+ // so targeting works even if the iframe moves in the DOM.
1383
+ // S61 2026-04-24: parent-relay compose close. On Android the
1384
+ // window.close() override applied in `frame.onload` doesn't always fire
1385
+ // (WebView2 / MAUI WebView dispatches close to the shell in some cases),
1386
+ // leaving the compose overlay stuck after Send. postMessage is reliable;
1387
+ // compose.ts's closeCompose() posts this, and we find-and-remove the
1388
+ // overlay whose iframe window matches e.source.
1389
+ if (e.data?.type === "mailx-compose-close") {
1390
+ const src = e.source as Window | null;
1391
+ document.querySelectorAll<HTMLElement>(".compose-overlay").forEach(el => {
1392
+ const iframe = el.querySelector<HTMLIFrameElement>("iframe");
1393
+ if (!src || iframe?.contentWindow === src) el.remove();
1394
+ });
1395
+ return;
1396
+ }
1397
+ if (e.data?.type === "mailx-compose-send" && e.data.id && e.data.body) {
1398
+ const src = e.source as Window | null;
1399
+ const id = e.data.id;
1400
+ logClientEvent("relay-compose-send-received", { id });
1401
+ (async () => {
1402
+ try {
1403
+ await apiSendMessage(e.data.body);
1404
+ logClientEvent("relay-compose-send-ok", { id });
1405
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: true }, "*" as any);
1406
+ } catch (err: any) {
1407
+ const msg = err?.message || String(err);
1408
+ logClientEvent("relay-compose-send-error", { id, error: msg });
1409
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: false, error: msg }, "*" as any);
1410
+ }
1411
+ })();
1412
+ return;
1413
+ }
1414
+ // Generic IPC relay: the iframe's api-client routes every IPC call through
1415
+ // postMessage when it's running in a child frame. Same reason as the
1416
+ // compose-send relay — sendMessage wasn't the only method the iframe's
1417
+ // bridge dropped; saveDraft hit the same wall ("Draft save failed: mailxapi
1418
+ // timeout"). This handler invokes the named method on THIS window's
1419
+ // mailxapi and posts the result back to the iframe.
1420
+ if (e.data?.type === "mailx-ipc" && e.data.id && e.data.method) {
1421
+ const src = e.source as Window | null;
1422
+ const { id, method, args } = e.data;
1423
+ const bridge = (window as any).mailxapi;
1424
+ const fn = bridge?.[method];
1425
+ if (typeof fn !== "function") {
1426
+ src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: `parent bridge has no method "${method}"` }, "*" as any);
1427
+ return;
1428
+ }
1429
+ try {
1430
+ const result = fn.apply(bridge, args || []);
1431
+ Promise.resolve(result).then(
1432
+ (value) => src?.postMessage({ type: "mailx-ipc-result", id, ok: true, result: value }, "*" as any),
1433
+ (err) => src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*" as any),
1434
+ );
1435
+ } catch (err: any) {
1436
+ src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*" as any);
1437
+ }
1438
+ return;
1439
+ }
1440
+ if (e.data?.type === "openLink" && e.data.url) {
1441
+ window.open(e.data.url, "_blank", "noopener,noreferrer");
1442
+ }
1443
+ if (e.data?.type === "linkClick" && e.data.url) {
1444
+ const url = e.data.url;
1445
+ if ((window as any).mailxapi?.platform === "android") {
1446
+ // Android: use mailxapi:// bridge scheme — OnNavigating intercepts it
1447
+ // and opens in system browser. Raw http:// in sub-frames doesn't trigger OnNavigating.
1448
+ const f = document.createElement("iframe");
1449
+ f.style.display = "none";
1450
+ f.src = `mailxapi://openurl?url=${encodeURIComponent(url)}`;
1451
+ document.body.appendChild(f);
1452
+ setTimeout(() => f.remove(), 500);
1453
+ } else {
1454
+ window.open(url, "_blank", "noopener,noreferrer");
1455
+ }
1456
+ }
1457
+ if (e.data?.type === "linkContextMenu") {
1458
+ // C29: right-click in body iframe → Open / Save / Copy URL menu.
1459
+ // Iframe's clientX/Y is relative to the iframe; translate to viewport.
1460
+ let iframeRect: DOMRect | null = null;
1461
+ for (const f of Array.from(document.querySelectorAll("iframe"))) {
1462
+ if ((f as HTMLIFrameElement).contentWindow === e.source) { iframeRect = f.getBoundingClientRect(); break; }
1463
+ }
1464
+ const x = (iframeRect?.left || 0) + (e.data.x || 0);
1465
+ const y = (iframeRect?.top || 0) + (e.data.y || 0);
1466
+ const url: string = e.data.url || "";
1467
+ // Find a sensible filename for the Save action.
1468
+ const guessName = (() => {
1469
+ try {
1470
+ const u = new URL(url);
1471
+ const last = u.pathname.split("/").pop() || "";
1472
+ return last && last.includes(".") ? last : "";
1473
+ } catch { return ""; }
1474
+ })();
1475
+ const items: { label: string; action: () => void }[] = [
1476
+ { label: "Open in browser", action: () => {
1477
+ window.open(url, "_blank", "noopener,noreferrer");
1478
+ }},
1479
+ { label: guessName ? `Save "${guessName}"…` : "Save link as…", action: () => {
1480
+ // Trigger a download via anchor with download attr.
1481
+ const a = document.createElement("a");
1482
+ a.href = url;
1483
+ if (guessName) a.download = guessName;
1484
+ else a.download = "";
1485
+ a.style.display = "none";
1486
+ document.body.appendChild(a);
1487
+ a.click();
1488
+ setTimeout(() => a.remove(), 1000);
1489
+ }},
1490
+ { label: "Copy URL", action: async () => {
1491
+ try { await navigator.clipboard.writeText(url); }
1492
+ catch { prompt("URL:", url); }
1493
+ }},
1494
+ { label: "Copy link text", action: async () => {
1495
+ try { await navigator.clipboard.writeText(e.data.text || url); }
1496
+ catch { prompt("Text:", e.data.text || url); }
1497
+ }},
1498
+ ];
1499
+ // Build a tiny inline menu (showContextMenu would do but it's in components/).
1500
+ const menu = document.createElement("div");
1501
+ menu.style.cssText = `position:fixed;z-index:2400;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:180px;`;
1502
+ menu.style.left = `${Math.min(x, window.innerWidth - 200)}px`;
1503
+ menu.style.top = `${Math.min(y, window.innerHeight - 200)}px`;
1504
+ // mousedown inside the menu must NOT reach the document-level
1505
+ // dismiss handler — otherwise the menu is removed before click
1506
+ // fires on the row and the action silently no-ops (user report
1507
+ // 2026-04-24). Stop propagation at the menu root covers every row.
1508
+ menu.addEventListener("mousedown", (ev) => ev.stopPropagation());
1509
+ for (const it of items) {
1510
+ const row = document.createElement("div");
1511
+ row.textContent = it.label;
1512
+ row.style.cssText = `padding:6px 12px;cursor:pointer;`;
1513
+ row.addEventListener("mouseenter", () => row.style.background = "var(--color-bg-hover)");
1514
+ row.addEventListener("mouseleave", () => row.style.background = "");
1515
+ row.addEventListener("click", () => { menu.remove(); it.action(); });
1516
+ menu.appendChild(row);
1517
+ }
1518
+ document.body.appendChild(menu);
1519
+ const dismiss = () => { menu.remove(); document.removeEventListener("mousedown", dismiss); document.removeEventListener("keydown", dismiss); };
1520
+ setTimeout(() => {
1521
+ document.addEventListener("mousedown", dismiss);
1522
+ document.addEventListener("keydown", dismiss);
1523
+ }, 0);
1524
+ return;
1525
+ }
1526
+ if (e.data?.type === "mailx-send-error") {
1527
+ // Send failed AFTER compose closed (fire-and-forget model). Surface in
1528
+ // the status bar so the user sees something instead of the silence.
1529
+ const statusSync = document.getElementById("status-sync");
1530
+ if (statusSync) {
1531
+ statusSync.textContent = `Send failed: ${e.data.message}`;
1532
+ statusSync.style.color = "oklch(0.65 0.2 25)";
1533
+ }
1534
+ return;
1535
+ }
1536
+ if (e.data?.type === "linkHover") {
1537
+ // Cancel any pending show — every hoverover/hoverout from the iframe
1538
+ // triggers this branch. Without the timer, the popover appears
1539
+ // instantly and lingers when the user moves to do anything else,
1540
+ // including punching through the compose overlay (which sits at
1541
+ // z-index 1000 — popover was at 10000, hence the bug in the
1542
+ // screenshot). Now: 500ms hover delay; suppressed entirely when
1543
+ // any overlay (compose, modal) is open; auto-dismissed on click,
1544
+ // scroll, blur, or any keypress.
1545
+ const w = window as any;
1546
+ if (w._linkHoverShowTimer) { clearTimeout(w._linkHoverShowTimer); w._linkHoverShowTimer = null; }
1547
+ let pop = document.getElementById("link-hover-popover") as HTMLDivElement | null;
1548
+ const hidePop = () => { if (pop) pop.style.display = "none"; };
1549
+ if (!e.data.url) { hidePop(); return; }
1550
+ // Suppress when compose / modal overlay is up — user shouldn't see
1551
+ // a tooltip for a link they can't reach without dismissing first.
1552
+ if (document.querySelector(".compose-overlay, .mailx-modal-backdrop")) { hidePop(); return; }
1553
+ const data = e.data;
1554
+ const source = e.source;
1555
+ w._linkHoverShowTimer = setTimeout(() => {
1556
+ // Re-check overlay state at fire time — overlay may have appeared
1557
+ // during the 500ms wait.
1558
+ if (document.querySelector(".compose-overlay, .mailx-modal-backdrop")) return;
1559
+ if (!pop) {
1560
+ pop = document.createElement("div");
1561
+ pop.id = "link-hover-popover";
1562
+ // z-index 500 — above the message body iframe (no z-index)
1563
+ // but BELOW the compose overlay (z-index 1000) and modals (2000).
1564
+ pop.style.cssText = "position:fixed;z-index:500;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;";
1565
+ document.body.appendChild(pop);
1566
+ // One-time dismissers on the popover lifetime.
1567
+ const dismiss = () => hidePop();
1568
+ document.addEventListener("mousedown", dismiss, true);
1569
+ document.addEventListener("scroll", dismiss, true);
1570
+ document.addEventListener("keydown", dismiss, true);
1571
+ window.addEventListener("blur", dismiss);
1572
+ }
1573
+ pop.textContent = data.url;
1574
+ pop.style.display = "block";
1575
+ let iframeRect: DOMRect | null = null;
1576
+ for (const f of Array.from(document.querySelectorAll("iframe"))) {
1577
+ if ((f as HTMLIFrameElement).contentWindow === source) { iframeRect = f.getBoundingClientRect(); break; }
1578
+ }
1579
+ const r = data.rect;
1580
+ if (iframeRect && r) {
1581
+ const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));
1582
+ let y = iframeRect.top + r.bottom + 4;
1583
+ if (y + 60 > window.innerHeight) y = Math.max(4, iframeRect.top + r.top - 60);
1584
+ pop.style.left = x + "px";
1585
+ pop.style.top = y + "px";
1586
+ }
1587
+ }, 500);
1588
+ }
1589
+ if (e.data?.type === "previewKey" && typeof e.data.key === "string") {
1590
+ // Re-dispatch as a real keydown on document so the hotkey handler
1591
+ // below runs the same code path as a list-focused keypress. Used
1592
+ // when focus is inside the sandboxed preview iframe — works on
1593
+ // platforms where parent-side contentDocument listeners don't.
1594
+ const ev = new KeyboardEvent("keydown", {
1595
+ key: e.data.key, code: e.data.code || "",
1596
+ ctrlKey: !!e.data.ctrlKey, shiftKey: !!e.data.shiftKey,
1597
+ altKey: !!e.data.altKey, metaKey: !!e.data.metaKey,
1598
+ bubbles: true, cancelable: true,
1599
+ });
1600
+ document.dispatchEvent(ev);
1601
+ }
1602
+ });
1603
+
1604
+ // ── Splitter drag ──
1605
+
1606
+ const splitter = document.getElementById("splitter-h");
1607
+ if (splitter) {
1608
+ // Restore saved position
1609
+ const saved = localStorage.getItem("mailx-split");
1610
+ if (saved) document.documentElement.style.setProperty("--list-viewer-split", saved);
1611
+
1612
+ let dragging = false;
1613
+ let startX: number;
1614
+ let startSplit: number;
1615
+
1616
+ splitter.addEventListener("pointerdown", (e: PointerEvent) => {
1617
+ dragging = true;
1618
+ startX = e.clientX;
1619
+ const mainArea = document.querySelector(".main-area") as HTMLElement;
1620
+ startSplit = mainArea.getBoundingClientRect().width * (parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--list-viewer-split")) / 100);
1621
+ splitter.setPointerCapture(e.pointerId);
1622
+ });
1623
+
1624
+ splitter.addEventListener("pointermove", (e: PointerEvent) => {
1625
+ if (!dragging) return;
1626
+ const mainArea = document.querySelector(".main-area") as HTMLElement;
1627
+ const totalWidth = mainArea.getBoundingClientRect().width;
1628
+ const newSplit = ((startSplit + (e.clientX - startX)) / totalWidth) * 100;
1629
+ const clamped = Math.max(15, Math.min(85, newSplit));
1630
+ const val = `${clamped}%`;
1631
+ document.documentElement.style.setProperty("--list-viewer-split", val);
1632
+ localStorage.setItem("mailx-split", val);
1633
+ });
1634
+
1635
+ splitter.addEventListener("pointerup", () => { dragging = false; });
1636
+ }
1637
+
1638
+ // ── WebSocket for live updates ──
1639
+
1640
+ connectWebSocket();
1641
+
1642
+ onWsEvent((event) => {
1643
+ const statusSync = document.getElementById("status-sync");
1644
+ const startupStatus = document.getElementById("startup-status");
1645
+
1646
+ switch (event.type) {
1647
+ case "connected":
1648
+ if (statusSync) statusSync.textContent = "Connected";
1649
+ if (startupStatus) startupStatus.textContent = "Loading accounts...";
1650
+ // Don't refresh folder tree on connect — it's already loaded by initFolderTree
1651
+ break;
1652
+ case "syncProgress": {
1653
+ // Aggregate folders phases ("folders:<path>" when starting a folder,
1654
+ // "folders-done" between folders) print as a proportion so the user
1655
+ // can see forward progress instead of a meaningless "47%". Older
1656
+ // phase strings ("sync:<path>", "folders") still render raw.
1657
+ let label = `${event.phase} ${event.progress || 0}%`;
1658
+ if (typeof event.phase === "string" && event.phase.startsWith("folders:")) {
1659
+ const folderPath = event.phase.slice("folders:".length);
1660
+ label = `folders — ${folderPath} (${event.progress || 0}%)`;
1661
+ } else if (event.phase === "folders-done") {
1662
+ label = `folders ${event.progress || 0}% done`;
1663
+ }
1664
+ if (statusSync) statusSync.textContent = `Syncing ${event.accountId}: ${label}`;
1665
+ if (startupStatus) startupStatus.textContent = `Syncing ${event.accountId}: ${label}`;
1666
+ // Mark syncing folder in tree — bubble up to visible parent if collapsed
1667
+ const syncPath = event.phase?.startsWith("sync:") ? event.phase.slice(5) : null;
1668
+ // Clear previous syncing markers for this account
1669
+ document.querySelectorAll(`.ft-folder.ft-syncing[data-account-id="${event.accountId}"]`).forEach(el => el.classList.remove("ft-syncing"));
1670
+ if (syncPath && event.progress < 100) {
1671
+ // Try exact match first
1672
+ let folderEl = document.querySelector(`.ft-folder[data-account-id="${event.accountId}"][data-folder-path="${CSS.escape(syncPath)}"]`);
1673
+ if (!folderEl) {
1674
+ // Folder not visible (parent collapsed) — find nearest visible ancestor
1675
+ const parts = syncPath.split(/[./]/);
1676
+ for (let i = parts.length - 1; i >= 1; i--) {
1677
+ const parentPath = parts.slice(0, i).join(".");
1678
+ folderEl = document.querySelector(`.ft-folder[data-account-id="${event.accountId}"][data-folder-path="${CSS.escape(parentPath)}"]`);
1679
+ if (folderEl) break;
1680
+ }
1681
+ }
1682
+ if (folderEl) folderEl.classList.add("ft-syncing");
1683
+ }
1684
+ break;
1685
+ }
1686
+ case "syncComplete":
1687
+ // After sync completes, refresh the folder tree (critical for first-run on Android
1688
+ // where folders don't exist until sync fetches them from Gmail API)
1689
+ refreshFolderTree();
1690
+ // Q53: track per-account last-sync timestamp for the status-bar hover.
1691
+ recordAccountSync(event.accountId);
1692
+ break;
1693
+ case "folderSynced":
1694
+ // Per-folder timestamps — drives the tooltip + freshness dot.
1695
+ for (const entry of event.entries || []) {
1696
+ setFolderSynced(event.accountId, entry.folderId, entry.syncedAt);
1697
+ if (currentFolderId === entry.folderId && currentAccountId === event.accountId) {
1698
+ currentFolderSyncedAt = entry.syncedAt;
1699
+ renderNarrowFolderTitle();
1700
+ }
1701
+ }
1702
+ break;
1703
+ case "folderCountsChanged": {
1704
+ // Incremental update only — updateFolderCounts patches badge counts
1705
+ // in-place and falls back to a full refreshFolderTree() when the
1706
+ // folder structure has actually changed. Calling both was doing a
1707
+ // 300 ms debounced rebuild on every sync tick even when just the
1708
+ // unread count moved — visible as folder-tree flicker on Dovecot
1709
+ // accounts where STATUS polls fire frequently.
1710
+ updateFolderCounts();
1711
+ updateNewMessageCount();
1712
+ // Debounced silent reload — preserves scroll position, selection, and viewer
1713
+ if (reloadDebounceTimer) clearTimeout(reloadDebounceTimer);
1714
+ reloadDebounceTimer = setTimeout(() => {
1715
+ reloadDebounceTimer = null;
1716
+ reloadCurrentFolder();
1717
+ }, 2000);
1718
+ // Sync succeeded — clear any transient error banner and re-enable sync button
1719
+ hideAlert();
1720
+ const syncBtn = document.getElementById("btn-sync") as HTMLButtonElement;
1721
+ if (syncBtn) { syncBtn.disabled = false; syncBtn.classList.remove("syncing"); }
1722
+ if (statusSync) statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`;
1723
+ break;
1724
+ }
1725
+ case "updateAvailable": {
1726
+ const banner = document.getElementById("alert-banner");
1727
+ const text = document.getElementById("alert-text");
1728
+ if (banner && text) {
1729
+ banner.hidden = false;
1730
+ banner.style.background = "oklch(0.45 0.12 250)";
1731
+ text.innerHTML = `mailx ${event.latest} available (you have ${event.current}) — <button id="btn-do-update" style="background:none;border:1px solid #fff;color:#fff;padding:0.15em 0.5em;border-radius:3px;cursor:pointer;margin-left:0.5em">Update now</button>`;
1732
+ document.getElementById("btn-do-update")?.addEventListener("click", () => {
1733
+ text.textContent = "Updating... mailx will restart when done";
1734
+ // performUpdate runs npm install then restarts the service
1735
+ const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;
1736
+ if (ipc?.performUpdate) ipc.performUpdate();
1737
+ });
1738
+ }
1739
+ break;
1740
+ }
1741
+ case "syncActionFailed": {
1742
+ // Surface sync failures (move/delete/flag not applied on server)
1743
+ // so the user knows local-first actions haven't propagated yet.
1744
+ const action = event.action === "move" ? "Move" : event.action === "delete" ? "Delete" : event.action;
1745
+ if (statusSync) statusSync.textContent = `Sync failed: ${action} — ${event.error}`;
1746
+ break;
1747
+ }
1748
+ case "reload":
1749
+ location.reload();
1750
+ break;
1751
+ case "bodyCached":
1752
+ // Prefetch (or on-demand fetch) downloaded a body — flip the
1753
+ // "not-downloaded" indicator to the teal dot for any rows in view.
1754
+ markBodiesCached(event.items || []);
1755
+ break;
1756
+ case "configChanged":
1757
+ // A watched config file was modified — could be user edit via the
1758
+ // JSONC editor, a GDrive sync, or mailx itself saving (e.g.
1759
+ // allowlist update on "allow sender").
1760
+ //
1761
+ // For accounts.jsonc specifically, surface a sticky banner with a
1762
+ // Restart button — the file change has no effect on the running
1763
+ // daemon (IMAP connections, token caches, sync loops use the old
1764
+ // config snapshot), and users shouldn't need `mailx -kill` just
1765
+ // to apply an edit. For other files (allowlist / clients /
1766
+ // config) the service handles live, a status-bar flash suffices.
1767
+ if (statusSync) {
1768
+ statusSync.textContent = `${event.filename} updated`;
1769
+ setTimeout(() => {
1770
+ if (statusSync.textContent === `${event.filename} updated`) statusSync.textContent = "";
1771
+ }, 8000);
1772
+ }
1773
+ if (event.filename && /accounts\.jsonc/i.test(String(event.filename))) {
1774
+ showRestartForConfigBanner();
1775
+ }
1776
+ break;
1777
+ case "cloudError":
1778
+ // Cloud read/write failed (Google Drive auth/network/etc.). Show a
1779
+ // sticky banner so the user knows the change wasn't synced. When
1780
+ // error is null, the next successful op cleared it — hide it.
1781
+ if (event.error) {
1782
+ const where = event.filename ? ` (${event.op || "sync"} ${event.filename})` : "";
1783
+ showAlert(`Cloud sync error${where}: ${event.error}`, "cloud-error");
1784
+ } else {
1785
+ // Only hide if the visible banner is the cloud-error one
1786
+ if (alertBanner && alertBanner.dataset.key === "cloud-error") {
1787
+ alertBanner.hidden = true;
1788
+ dismissedAlerts.delete("cloud-error");
1789
+ }
1790
+ }
1791
+ break;
1792
+ case "error":
1793
+ if (statusSync) statusSync.textContent = `Error: ${event.message}`;
1794
+ showAlert(event.message, "ws-error");
1795
+ break;
1796
+ case "outboxStatus":
1797
+ renderOutboxStatus(event);
1798
+ break;
1799
+ case "calendarUpdated":
1800
+ case "tasksUpdated":
1801
+ // Reauth succeeded (or was never broken): clear any lingering
1802
+ // scope banner for this feature. Handled here (not just in the
1803
+ // sidebar) because the global fallback banner isn't tied to the
1804
+ // sidebar's lifecycle.
1805
+ if (alertBanner && /^scope-(calendar|tasks|google)$/.test(alertBanner.dataset.key || "")) {
1806
+ alertBanner.hidden = true;
1807
+ alertBanner.dataset.key = "";
1808
+ alertBanner.querySelector(".status-action")?.remove();
1809
+ }
1810
+ break;
1811
+ case "authScopeError": {
1812
+ // Fallback banner: calendar-sidebar.ts already shows this inline
1813
+ // when the sidebar is visible, but if the user has the sidebar
1814
+ // off or is on a narrow tier where it's hidden, the error would
1815
+ // otherwise be invisible. Global banner with the same button.
1816
+ const feat = event.feature || "google";
1817
+ const key = `scope-${feat}`;
1818
+ const msg = event.message || `Google ${feat} access needs re-consent.`;
1819
+ showAlert(msg, key, { sticky: true });
1820
+ const bannerText = document.getElementById("alert-text");
1821
+ if (bannerText && bannerText.textContent === msg) {
1822
+ const existing = bannerText.parentElement?.querySelector(".status-action");
1823
+ if (!existing) {
1824
+ const btn = document.createElement("button");
1825
+ btn.className = "status-action";
1826
+ btn.textContent = "Re-authenticate";
1827
+ btn.addEventListener("click", async () => {
1828
+ btn.disabled = true;
1829
+ btn.textContent = "Opening browser…";
1830
+ try {
1831
+ const { reauthGoogleScopes } = await import("./lib/api-client.js");
1832
+ await reauthGoogleScopes();
1833
+ btn.textContent = "Consent opened — finish in browser";
1834
+ } catch (err: any) {
1835
+ btn.disabled = false;
1836
+ btn.textContent = `Failed: ${err?.message || err}`;
1837
+ }
1838
+ });
1839
+ bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
1840
+ }
1841
+ }
1842
+ break;
1843
+ }
1844
+ case "accountError": {
1845
+ // Show actual error + hint in banner
1846
+ const msg = `${event.accountId}: ${event.error}`;
1847
+ showAlert(msg, `acct-${event.accountId}`);
1848
+ // Add action button: Re-authenticate for OAuth, Retry for password accounts
1849
+ const bannerText = document.getElementById("alert-text");
1850
+ if (bannerText && bannerText.textContent === msg) {
1851
+ const existing = bannerText.parentElement?.querySelector(".status-action");
1852
+ if (!existing) {
1853
+ const btn = document.createElement("button");
1854
+ btn.className = "status-action";
1855
+ if (event.isOAuth) {
1856
+ btn.textContent = "Re-authenticate";
1857
+ btn.addEventListener("click", async () => {
1858
+ btn.disabled = true;
1859
+ btn.textContent = "Authenticating...";
1860
+ try {
1861
+ const data = await reauthenticate(event.accountId);
1862
+ if (data.ok) {
1863
+ hideAlert();
1864
+ const acctEl = document.getElementById("status-accounts");
1865
+ if (acctEl) { acctEl.textContent = `${event.accountId}: reconnected`; acctEl.style.color = ""; }
1866
+ } else {
1867
+ btn.textContent = "Re-authenticate";
1868
+ btn.disabled = false;
1869
+ }
1870
+ } catch {
1871
+ btn.textContent = "Re-authenticate";
1872
+ btn.disabled = false;
1873
+ }
1874
+ });
1875
+ } else {
1876
+ btn.textContent = "Retry";
1877
+ btn.addEventListener("click", async () => {
1878
+ btn.disabled = true;
1879
+ btn.textContent = "Syncing...";
1880
+ try {
1881
+ const data = await syncAccount(event.accountId);
1882
+ if (data.ok) {
1883
+ hideAlert();
1884
+ const acctEl = document.getElementById("status-accounts");
1885
+ if (acctEl) { acctEl.textContent = `${event.accountId}: reconnected`; acctEl.style.color = ""; }
1886
+ } else {
1887
+ btn.textContent = "Retry";
1888
+ btn.disabled = false;
1889
+ }
1890
+ } catch {
1891
+ btn.textContent = "Retry";
1892
+ btn.disabled = false;
1893
+ }
1894
+ });
1895
+ }
1896
+ bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
1897
+ }
1898
+ }
1899
+ // Also show in status bar
1900
+ const acctEl = document.getElementById("status-accounts");
1901
+ if (acctEl) {
1902
+ acctEl.textContent = `${event.accountId}: ${event.hint}`;
1903
+ acctEl.style.color = "oklch(0.65 0.2 25)";
1904
+ }
1905
+ break;
1906
+ }
1907
+ }
1908
+ });
1909
+
1910
+ // ── Keyboard shortcuts ──
1911
+
1912
+ document.addEventListener("keydown", (e) => {
1913
+ // Ctrl+N or Ctrl+Shift+M = Compose
1914
+ if ((e.ctrlKey && e.key === "n") || (e.ctrlKey && e.shiftKey && e.key === "M")) {
1915
+ e.preventDefault();
1916
+ openCompose("new");
1917
+ }
1918
+ // Ctrl+R = Reply (without Shift)
1919
+ if (e.ctrlKey && e.key === "r" && !e.shiftKey && !e.altKey && !e.metaKey) {
1920
+ e.preventDefault();
1921
+ openCompose("reply");
1922
+ }
1923
+ // Ctrl+Shift+R = Reply All
1924
+ if (e.ctrlKey && e.shiftKey && e.key === "R" && !e.altKey && !e.metaKey) {
1925
+ e.preventDefault();
1926
+ openCompose("replyAll");
1927
+ }
1928
+ // Ctrl+F = Forward (without Shift). Use toLowerCase so a Caps-Lock or
1929
+ // shifted state doesn't bypass us. Single handler — the previous
1930
+ // duplicate fired openCompose twice, which double-loaded the compose
1931
+ // iframe and the second copy got an empty sessionStorage (the first
1932
+ // had already consumed it), producing an empty Forward form.
1933
+ if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === "f" || e.key === "F")) {
1934
+ e.preventDefault();
1935
+ openCompose("forward");
1936
+ }
1937
+ // Ctrl+A = Select all visible messages
1938
+ if (e.ctrlKey && e.key === "a") {
1939
+ const mlBody = document.getElementById("ml-body");
1940
+ if (mlBody && document.activeElement?.closest(".message-list, .ml-body, body")) {
1941
+ e.preventDefault();
1942
+ mlBody.querySelectorAll(".ml-row").forEach(r => r.classList.add("selected"));
1943
+ }
1944
+ }
1945
+ // Ctrl+D or Delete = Delete selected messages.
1946
+ // P15: don't hijack Delete inside text inputs / textareas / contenteditable
1947
+ // — JSONC editor's Delete key was being eaten because we always preventDefault'd.
1948
+ if ((e.ctrlKey && e.key === "d") || e.key === "Delete") {
1949
+ const t = e.target as HTMLElement | null;
1950
+ const tag = t?.tagName;
1951
+ const editable = t?.isContentEditable;
1952
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable) return;
1953
+ e.preventDefault();
1954
+ deleteSelectedMessages();
1955
+ }
1956
+ // Ctrl+Z = Undo the most recent delete or move
1957
+ if (e.ctrlKey && e.key === "z") {
1958
+ if (lastMoved) {
1959
+ e.preventDefault();
1960
+ undoMove();
1961
+ } else if (lastDeleted) {
1962
+ e.preventDefault();
1963
+ undoDelete();
1964
+ }
1965
+ }
1966
+ // F5 = Sync
1967
+ if (e.key === "F5") {
1968
+ e.preventDefault();
1969
+ document.getElementById("btn-sync")?.click();
1970
+ }
1971
+ // R = Toggle read/unread
1972
+ if (e.key.toLowerCase() === "r" && !e.ctrlKey && !e.metaKey && !e.altKey) {
1973
+ const active = document.activeElement;
1974
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT")) return;
1975
+ const sel = messageState.getSelected();
1976
+ if (!sel) return;
1977
+ e.preventDefault();
1978
+ const isSeen = sel.flags.includes("\\Seen");
1979
+ const newFlags = isSeen
1980
+ ? sel.flags.filter((f: string) => f !== "\\Seen")
1981
+ : [...sel.flags, "\\Seen"];
1982
+ updateFlags(sel.accountId, sel.uid, newFlags).then(() => {
1983
+ sel.flags = newFlags;
1984
+ messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
1985
+ const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`) as HTMLElement;
1986
+ if (row) row.classList.toggle("unread", !newFlags.includes("\\Seen"));
1987
+ }).catch(() => {});
1988
+ }
1989
+ // Arrow keys + Home/End/PgUp/PgDn — navigate message list (Q58).
1990
+ if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(e.key)) {
1991
+ const active = document.activeElement;
1992
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT")) return;
1993
+ const body = document.getElementById("ml-body");
1994
+ if (!body) return;
1995
+ const rows = Array.from(body.querySelectorAll<HTMLElement>(".ml-row"));
1996
+ if (rows.length === 0) return;
1997
+ const selected = body.querySelector<HTMLElement>(".ml-row.selected");
1998
+ const idx = selected ? rows.indexOf(selected) : -1;
1999
+ let target: HTMLElement | undefined;
2000
+ if (e.key === "ArrowDown") target = rows[idx + 1] || rows[idx];
2001
+ else if (e.key === "ArrowUp") target = rows[Math.max(0, idx - 1)];
2002
+ else if (e.key === "Home") target = rows[0];
2003
+ else if (e.key === "End") target = rows[rows.length - 1];
2004
+ else if (e.key === "PageDown") target = rows[Math.min(rows.length - 1, idx + 10)];
2005
+ else if (e.key === "PageUp") target = rows[Math.max(0, idx - 10)];
2006
+ if (target && (!selected || target !== selected)) {
2007
+ e.preventDefault();
2008
+ target.click();
2009
+ target.scrollIntoView({ block: "nearest" });
2010
+ }
2011
+ }
2012
+ });
2013
+
2014
+ // ── View menu ──
2015
+
2016
+ const viewBtn = document.getElementById("btn-view");
2017
+ const viewDropdown = document.getElementById("view-dropdown");
2018
+ const optTwoLine = document.getElementById("opt-two-line") as HTMLInputElement;
2019
+ const optPreview = document.getElementById("opt-preview") as HTMLInputElement;
2020
+ const optSnippet = document.getElementById("opt-snippet") as HTMLInputElement;
2021
+ const optThreaded = document.getElementById("opt-threaded") as HTMLInputElement;
2022
+ const optFlagged = document.getElementById("opt-flagged") as HTMLInputElement;
2023
+ const optFolderCounts = document.getElementById("opt-folder-counts") as HTMLInputElement;
2024
+ const optCalendarSidebar = document.getElementById("opt-calendar-sidebar") as HTMLInputElement;
2025
+ const optThreadFilter = document.getElementById("opt-thread-filter") as HTMLInputElement;
2026
+
2027
+ // Toggle dropdown — also close any other open toolbar menu so they can't
2028
+ // overlap. Without this, opening View while Settings was already open left
2029
+ // both visible at once (user-reported screenshot).
2030
+ viewBtn?.addEventListener("click", (e) => {
2031
+ e.stopPropagation();
2032
+ const settingsDd = document.getElementById("settings-dropdown");
2033
+ if (settingsDd) settingsDd.hidden = true;
2034
+ const restartDd = document.getElementById("restart-dropdown");
2035
+ if (restartDd) restartDd.hidden = true;
2036
+ if (viewDropdown) viewDropdown.hidden = !viewDropdown.hidden;
2037
+ });
2038
+ document.addEventListener("click", (e) => {
2039
+ // Only close when the click is genuinely outside the menu container.
2040
+ // The earlier unconditional close had two problems: clicks on radio
2041
+ // buttons / checkboxes INSIDE the menu also closed it (so the user
2042
+ // couldn't toggle anything without reopening), and any handler
2043
+ // upstream that consumed the event (preventDefault paths, focus
2044
+ // shifts) could keep the menu open inappropriately. The closest()
2045
+ // check ensures inside-clicks pass through and outside-clicks close.
2046
+ const target = e.target as HTMLElement | null;
2047
+ if (viewDropdown && !viewDropdown.hidden && !target?.closest("#view-menu") && !target?.closest("#view-dropdown")) {
2048
+ viewDropdown.hidden = true;
2049
+ }
2050
+ if (settingsDropdown && !settingsDropdown.hidden && !target?.closest("#settings-menu") && !target?.closest("#settings-dropdown")) {
2051
+ settingsDropdown.hidden = true;
2052
+ }
2053
+ });
2054
+
2055
+ // Restore saved view settings
2056
+ const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
2057
+ const savedPreview = localStorage.getItem("mailx-preview") !== "false"; // default true
2058
+ const savedSnippet = localStorage.getItem("mailx-snippet") !== "false"; // default true
2059
+ const savedThreaded = localStorage.getItem("mailx-threaded") === "true";
2060
+ const savedFlagged = localStorage.getItem("mailx-flagged") === "true";
2061
+ const savedFolderCounts = localStorage.getItem("mailx-folder-counts") === "true";
2062
+ if (optTwoLine) optTwoLine.checked = savedTwoLine;
2063
+ if (optPreview) optPreview.checked = savedPreview;
2064
+ if (optSnippet) optSnippet.checked = savedSnippet;
2065
+ if (optThreaded) optThreaded.checked = savedThreaded;
2066
+ if (optFlagged) optFlagged.checked = savedFlagged;
2067
+ if (optFolderCounts) optFolderCounts.checked = savedFolderCounts;
2068
+ if (savedTwoLine) document.getElementById("message-list")?.classList.add("two-line");
2069
+ if (!savedPreview) document.querySelector(".main-area")?.classList.add("no-preview");
2070
+ if (!savedSnippet) document.getElementById("message-list")?.classList.add("no-snippets");
2071
+ if (savedThreaded) document.getElementById("ml-body")?.classList.add("threaded");
2072
+ if (savedFlagged) document.getElementById("ml-body")?.classList.add("flagged-only");
2073
+ if (savedFolderCounts) document.getElementById("folder-tree")?.classList.add("show-folder-counts");
2074
+
2075
+ // "Only this conversation" toggle — hides rows whose threadId differs from
2076
+ // the currently-selected message's threadId. Client-side only (no server
2077
+ // round-trip); toggling off restores the full list. Persisted per-session
2078
+ // but not across reloads (thread context is tied to current selection).
2079
+ optThreadFilter?.addEventListener("change", () => {
2080
+ const body = document.getElementById("ml-body");
2081
+ if (!body) return;
2082
+ body.classList.toggle("thread-filter-on", optThreadFilter.checked);
2083
+ applyThreadFilter();
2084
+ });
2085
+ messageState.subscribe(() => applyThreadFilter());
2086
+
2087
+ function applyThreadFilter(): void {
2088
+ const body = document.getElementById("ml-body");
2089
+ if (!body) return;
2090
+ if (!optThreadFilter?.checked) {
2091
+ body.querySelectorAll<HTMLElement>(".ml-row.thread-filter-hidden")
2092
+ .forEach(r => r.classList.remove("thread-filter-hidden"));
2093
+ return;
2094
+ }
2095
+ const sel = messageState.getSelected() as any;
2096
+ const tid = sel?.threadId;
2097
+ if (!tid) return;
2098
+ body.querySelectorAll<HTMLElement>(".ml-row").forEach(r => {
2099
+ const rowTid = r.dataset.threadId;
2100
+ if (rowTid === tid || r.classList.contains("selected")) {
2101
+ r.classList.remove("thread-filter-hidden");
2102
+ } else {
2103
+ r.classList.add("thread-filter-hidden");
2104
+ }
2105
+ });
2106
+ }
2107
+
2108
+ // S51 — Calendar sidebar: View-menu toggle, restore from localStorage,
2109
+ // hide auto-magically on narrow screens (CSS handles that).
2110
+ (async () => {
2111
+ const { initCalendarSidebar, isCalendarSidebarOn, showCalendarSidebar, hideCalendarSidebar } =
2112
+ await import("./components/calendar-sidebar.js");
2113
+ initCalendarSidebar();
2114
+ const on = isCalendarSidebarOn();
2115
+ if (optCalendarSidebar) optCalendarSidebar.checked = on;
2116
+ if (on) await showCalendarSidebar();
2117
+ optCalendarSidebar?.addEventListener("change", () => {
2118
+ if (optCalendarSidebar.checked) showCalendarSidebar();
2119
+ else hideCalendarSidebar();
2120
+ });
2121
+ })();
2122
+
2123
+ // P17 / Q104: alarm subsystem — Thunderbird/Outlook-style popup with
2124
+ // snooze + dismiss. Covers calendar events + tasks today; mail reminders
2125
+ // will slot in here when the mail-reminder feature lands.
2126
+ (async () => {
2127
+ try {
2128
+ const { startAlarmPoller } = await import("./components/alarms.js");
2129
+ startAlarmPoller();
2130
+ } catch (e: any) {
2131
+ console.error("alarm poller init failed:", e?.message || e);
2132
+ }
2133
+ })();
2134
+
2135
+ // Two-line toggle
2136
+ optTwoLine?.addEventListener("change", () => {
2137
+ const list = document.getElementById("message-list");
2138
+ if (optTwoLine.checked) {
2139
+ list?.classList.add("two-line");
2140
+ } else {
2141
+ list?.classList.remove("two-line");
2142
+ }
2143
+ localStorage.setItem("mailx-two-line", String(optTwoLine.checked));
2144
+ });
2145
+
2146
+ // Preview pane toggle
2147
+ optPreview?.addEventListener("change", () => {
2148
+ const main = document.querySelector(".main-area");
2149
+ if (optPreview.checked) {
2150
+ main?.classList.remove("no-preview");
2151
+ } else {
2152
+ main?.classList.add("no-preview");
2153
+ }
2154
+ localStorage.setItem("mailx-preview", String(optPreview.checked));
2155
+ });
2156
+
2157
+ // Preview snippet toggle
2158
+ optSnippet?.addEventListener("change", () => {
2159
+ const list = document.getElementById("message-list");
2160
+ if (optSnippet.checked) {
2161
+ list?.classList.remove("no-snippets");
2162
+ } else {
2163
+ list?.classList.add("no-snippets");
2164
+ }
2165
+ localStorage.setItem("mailx-snippet", String(optSnippet.checked));
2166
+ });
2167
+
2168
+ // ── JSONC config file editor ──
2169
+ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () => {
2170
+ const settingsDropdown = document.getElementById("settings-dropdown");
2171
+ if (settingsDropdown) settingsDropdown.hidden = true;
2172
+ await openJsoncEditor("accounts.jsonc");
2173
+ });
2174
+ // Allow other components (remote-content banner, etc.) to open the editor
2175
+ // pre-selected to a specific file.
2176
+ document.addEventListener("mailx-open-jsonc-editor", async (ev: Event) => {
2177
+ const file = ((ev as CustomEvent).detail?.file as string) || "accounts.jsonc";
2178
+ await openJsoncEditor(file);
2179
+ });
2180
+ // Q61: open ~/.mailx in OS file explorer.
2181
+ document.getElementById("btn-open-mailx-dir")?.addEventListener("click", async () => {
2182
+ const settingsDropdown = document.getElementById("settings-dropdown");
2183
+ if (settingsDropdown) settingsDropdown.hidden = true;
2184
+ try {
2185
+ const { openLocalPath } = await import("./lib/api-client.js");
2186
+ await openLocalPath("config");
2187
+ } catch (e: any) {
2188
+ alert(`Couldn't open folder: ${e?.message || e}`);
2189
+ }
2190
+ });
2191
+ // Q62: open today's log file.
2192
+ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
2193
+ const settingsDropdown = document.getElementById("settings-dropdown");
2194
+ if (settingsDropdown) settingsDropdown.hidden = true;
2195
+ try {
2196
+ const { openLocalPath } = await import("./lib/api-client.js");
2197
+ await openLocalPath("log");
2198
+ } catch (e: any) {
2199
+ alert(`Couldn't open log: ${e?.message || e}`);
2200
+ }
2201
+ });
2202
+
2203
+ async function openJsoncEditor(initialFile: string): Promise<void> {
2204
+ const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
2205
+
2206
+ const backdrop = document.createElement("div");
2207
+ backdrop.className = "mailx-modal-backdrop";
2208
+ const panel = document.createElement("div");
2209
+ panel.className = "mailx-modal mailx-modal-wide";
2210
+ panel.innerHTML = `
2211
+ <div class="mailx-modal-title">
2212
+ <span class="mailx-modal-title-text">Edit config file</span>
2213
+ <button type="button" class="mailx-modal-close" id="jsonc-close" title="Close (Esc)" aria-label="Close">&times;</button>
2214
+ </div>
2215
+ <label class="mailx-modal-label">File
2216
+ <select class="mailx-modal-input" id="jsonc-file">
2217
+ <option value="accounts.jsonc">accounts.jsonc — accounts (shared via Google Drive)</option>
2218
+ <option value="contacts.jsonc">contacts.jsonc — preferred + denylist + discovered (shared)</option>
2219
+ <option value="allowlist.jsonc">allowlist.jsonc — remote-content allowlist (shared)</option>
2220
+ <option value="clients.jsonc">clients.jsonc — per-device registrations (shared)</option>
2221
+ <option value="config.jsonc">config.jsonc — local per-machine overrides (not synced)</option>
2222
+ </select>
2223
+ </label>
2224
+ <div class="mailx-modal-split">
2225
+ <label class="mailx-modal-label mailx-modal-split-left">Contents (JSONC — comments and trailing commas allowed)
2226
+ <div class="jsonc-editor-wrap">
2227
+ <div class="jsonc-gutter" id="jsonc-gutter" aria-hidden="true"></div>
2228
+ <textarea class="mailx-modal-input mailx-modal-textarea jsonc-textarea" id="jsonc-content" spellcheck="false"></textarea>
2229
+ </div>
2230
+ </label>
2231
+ <div class="mailx-modal-split-right mailx-help-panel">
2232
+ <div class="mailx-help-title">
2233
+ <button type="button" class="mailx-help-toggle" id="jsonc-help-toggle" aria-expanded="true" title="Hide/show help">▾ Help</button>
2234
+ </div>
2235
+ <div class="mailx-help-body" id="jsonc-help-body"></div>
2236
+ </div>
2237
+ </div>
2238
+ <div class="mailx-modal-error" id="jsonc-error" hidden></div>
2239
+ <div class="mailx-modal-buttons">
2240
+ <button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
2241
+ <span class="mailx-modal-spacer"></span>
2242
+ <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
2243
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
2244
+ </div>`;
2245
+ backdrop.appendChild(panel);
2246
+ document.body.appendChild(backdrop);
2247
+
2248
+ const fileSelect = panel.querySelector<HTMLSelectElement>("#jsonc-file")!;
2249
+ const textarea = panel.querySelector<HTMLTextAreaElement>("#jsonc-content")!;
2250
+ const gutter = panel.querySelector<HTMLElement>("#jsonc-gutter")!;
2251
+ const errorEl = panel.querySelector<HTMLElement>("#jsonc-error")!;
2252
+ const saveBtn = panel.querySelector<HTMLButtonElement>('[data-action="save"]')!;
2253
+ const helpBody = panel.querySelector<HTMLElement>("#jsonc-help-body")!;
2254
+ const helpToggle = panel.querySelector<HTMLButtonElement>("#jsonc-help-toggle")!;
2255
+ const helpPanel = panel.querySelector<HTMLElement>(".mailx-help-panel")!;
2256
+ fileSelect.value = initialFile;
2257
+
2258
+ // Line-number gutter — recomputed whenever the textarea content changes,
2259
+ // scroll-synced so numbers stay aligned. errorLine (1-based) is highlighted
2260
+ // red so the "Line N, col M" error message in the status bar points at a
2261
+ // visible marker in the gutter.
2262
+ let errorLine = 0;
2263
+ const renderGutter = () => {
2264
+ const lines = textarea.value.split("\n").length;
2265
+ let html = "";
2266
+ for (let i = 1; i <= lines; i++) {
2267
+ html += i === errorLine
2268
+ ? `<div class="jsonc-gutter-line jsonc-gutter-error">${i}</div>`
2269
+ : `<div class="jsonc-gutter-line">${i}</div>`;
2270
+ }
2271
+ gutter.innerHTML = html;
2272
+ };
2273
+ const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };
2274
+ textarea.addEventListener("scroll", syncScroll);
2275
+ textarea.addEventListener("input", renderGutter);
2276
+
2277
+ helpToggle.addEventListener("click", () => {
2278
+ const open = helpPanel.classList.toggle("mailx-help-collapsed");
2279
+ helpToggle.textContent = open ? "▸ Help" : "▾ Help";
2280
+ helpToggle.setAttribute("aria-expanded", open ? "false" : "true");
2281
+ });
2282
+
2283
+ const loadHelp = async () => {
2284
+ helpBody.textContent = "Loading help…";
2285
+ try {
2286
+ const r = await readConfigHelp(fileSelect.value);
2287
+ const md = (r?.content || "").trim();
2288
+ helpBody.innerHTML = md ? renderMarkdown(md) : "<em>No help available for this file.</em>";
2289
+ } catch (e: any) {
2290
+ helpBody.textContent = `Help unavailable: ${e.message}`;
2291
+ }
2292
+ };
2293
+
2294
+ const clearValidation = () => {
2295
+ errorEl.hidden = true;
2296
+ errorEl.textContent = "";
2297
+ textarea.classList.remove("mailx-modal-input-error");
2298
+ saveBtn.disabled = false;
2299
+ errorLine = 0;
2300
+ renderGutter();
2301
+ };
2302
+ const showValidation = (err: { message: string; pos: number; line: number; col: number }) => {
2303
+ // CRITICAL: do NOT move the cursor here. Validation fires every 600ms
2304
+ // while the user types; auto-selecting the error position yanked the
2305
+ // cursor mid-edit and made fixing the error impossible (the user
2306
+ // reported this as a fatal bug — the very mechanism preventing a save
2307
+ // was preventing the fix). Location is shown via the gutter highlight
2308
+ // + the "Line N, col M" message, and the user can click "Jump" to
2309
+ // explicitly navigate.
2310
+ errorEl.innerHTML = "";
2311
+ const text = document.createElement("span");
2312
+ text.textContent = `Line ${err.line}, col ${err.col}: ${err.message} `;
2313
+ const jumpBtn = document.createElement("button");
2314
+ jumpBtn.type = "button";
2315
+ jumpBtn.className = "mailx-modal-btn mailx-modal-btn-link";
2316
+ jumpBtn.textContent = "Jump to error";
2317
+ jumpBtn.addEventListener("click", () => {
2318
+ textarea.focus();
2319
+ try { textarea.setSelectionRange(err.pos, err.pos + 1); } catch { /* */ }
2320
+ });
2321
+ errorEl.appendChild(text);
2322
+ errorEl.appendChild(jumpBtn);
2323
+ errorEl.hidden = false;
2324
+ textarea.classList.add("mailx-modal-input-error");
2325
+ saveBtn.disabled = true;
2326
+ errorLine = err.line;
2327
+ renderGutter();
2328
+ };
2329
+
2330
+ let validateTimer: number | undefined;
2331
+ const scheduleValidate = () => {
2332
+ if (validateTimer) window.clearTimeout(validateTimer);
2333
+ validateTimer = window.setTimeout(() => {
2334
+ const err = validateJsonc(textarea.value);
2335
+ if (err) showValidation(err); else clearValidation();
2336
+ }, 600);
2337
+ };
2338
+ textarea.addEventListener("input", scheduleValidate);
2339
+
2340
+ const loadFile = async () => {
2341
+ textarea.value = "Loading...";
2342
+ clearValidation();
2343
+ renderGutter();
2344
+ try {
2345
+ const r = await readJsoncFile(fileSelect.value);
2346
+ textarea.value = r?.content || "";
2347
+ renderGutter();
2348
+ scheduleValidate();
2349
+ } catch (e: any) {
2350
+ textarea.value = "";
2351
+ renderGutter();
2352
+ errorEl.textContent = `Failed to load: ${e.message}`;
2353
+ errorEl.hidden = false;
2354
+ }
2355
+ };
2356
+ await Promise.all([loadFile(), loadHelp()]);
2357
+ fileSelect.addEventListener("change", () => { loadFile(); loadHelp(); });
2358
+
2359
+ const close = () => {
2360
+ if (validateTimer) window.clearTimeout(validateTimer);
2361
+ backdrop.remove();
2362
+ document.removeEventListener("keydown", onKey, true);
2363
+ };
2364
+ const onKey = (e: KeyboardEvent) => {
2365
+ if (e.key === "Escape") { e.stopPropagation(); e.preventDefault(); close(); }
2366
+ };
2367
+ document.addEventListener("keydown", onKey, true);
2368
+ panel.querySelector<HTMLButtonElement>("#jsonc-close")!.addEventListener("click", close);
2369
+
2370
+ panel.querySelectorAll<HTMLButtonElement>(".mailx-modal-btn").forEach(btn => {
2371
+ btn.addEventListener("click", async () => {
2372
+ const action = btn.dataset.action;
2373
+ if (action === "cancel") { close(); return; }
2374
+ if (action === "format") {
2375
+ // Reformat via the service-side jsonc-parser format() — the
2376
+ // edits are whitespace-only, so `//` and `/* */` comments
2377
+ // survive intact (which JSON.stringify(parse(...)) does not).
2378
+ btn.disabled = true;
2379
+ const orig = btn.textContent;
2380
+ btn.textContent = "Formatting…";
2381
+ try {
2382
+ const r = await formatJsonc(textarea.value);
2383
+ if (r?.content !== undefined) {
2384
+ textarea.value = r.content;
2385
+ renderGutter();
2386
+ scheduleValidate();
2387
+ }
2388
+ } catch (e: any) {
2389
+ errorEl.textContent = `Format failed: ${e.message}`;
2390
+ errorEl.hidden = false;
2391
+ } finally {
2392
+ btn.disabled = false;
2393
+ btn.textContent = orig || "Format";
2394
+ }
2395
+ return;
2396
+ }
2397
+ if (action === "save") {
2398
+ // Final sync-check; refuse to save if it doesn't parse
2399
+ const err = validateJsonc(textarea.value);
2400
+ if (err) { showValidation(err); return; }
2401
+ errorEl.hidden = true;
2402
+ btn.disabled = true;
2403
+ btn.textContent = "Saving...";
2404
+ try {
2405
+ await writeJsoncFile(fileSelect.value, textarea.value);
2406
+ close();
2407
+ const statusSync = document.getElementById("status-sync");
2408
+ if (statusSync) statusSync.textContent = `Saved ${fileSelect.value} — restart mailx to apply`;
2409
+ } catch (e: any) {
2410
+ errorEl.textContent = `${e.message}`;
2411
+ errorEl.hidden = false;
2412
+ btn.disabled = false;
2413
+ btn.textContent = "Save";
2414
+ }
2415
+ }
2416
+ });
2417
+ });
2418
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop) close(); });
2419
+ }
2420
+
2421
+ // JSONC validator — strips comments + trailing commas (preserving source positions
2422
+ // by replacing stripped chars with spaces/newlines) and runs JSON.parse. Reports
2423
+ // only the *first* error; cascading errors are suppressed.
2424
+ function validateJsonc(src: string): { message: string; pos: number; line: number; col: number } | null {
2425
+ const stripped = stripJsoncPreservingPositions(src);
2426
+ if (stripped.error) {
2427
+ const { pos, line, col } = offsetToLineCol(src, stripped.error.pos);
2428
+ return { message: stripped.error.message, pos, line, col };
2429
+ }
2430
+ if (stripped.text.trim() === "") return null; // empty file: treat as valid (settings code handles)
2431
+ try {
2432
+ JSON.parse(stripped.text);
2433
+ return null;
2434
+ } catch (e: any) {
2435
+ const msg = String(e?.message || "parse error");
2436
+ const m = msg.match(/at position (\d+)/i);
2437
+ const pos = m ? Math.min(parseInt(m[1], 10), src.length - 1) : 0;
2438
+ const lc = offsetToLineCol(src, pos);
2439
+ return { message: msg.replace(/\s*at position \d+/i, ""), pos: lc.pos, line: lc.line, col: lc.col };
2440
+ }
2441
+ }
2442
+
2443
+ function stripJsoncPreservingPositions(src: string): { text: string; error?: { message: string; pos: number } } {
2444
+ const out: string[] = new Array(src.length);
2445
+ let i = 0;
2446
+ const n = src.length;
2447
+ while (i < n) {
2448
+ const c = src[i];
2449
+ const next = src[i + 1];
2450
+ if (c === '"') {
2451
+ out[i] = c; i++;
2452
+ while (i < n) {
2453
+ const ch = src[i];
2454
+ out[i] = ch; i++;
2455
+ if (ch === "\\" && i < n) { out[i] = src[i]; i++; continue; }
2456
+ if (ch === '"') break;
2457
+ if (ch === "\n") return { text: out.join(""), error: { message: "unterminated string", pos: i - 1 } };
2458
+ }
2459
+ } else if (c === "/" && next === "/") {
2460
+ while (i < n && src[i] !== "\n") { out[i] = " "; i++; }
2461
+ } else if (c === "/" && next === "*") {
2462
+ const start = i;
2463
+ out[i] = " "; out[i + 1] = " "; i += 2;
2464
+ let closed = false;
2465
+ while (i < n) {
2466
+ if (src[i] === "*" && src[i + 1] === "/") { out[i] = " "; out[i + 1] = " "; i += 2; closed = true; break; }
2467
+ out[i] = src[i] === "\n" ? "\n" : " "; i++;
2468
+ }
2469
+ if (!closed) return { text: out.join(""), error: { message: "unterminated block comment", pos: start } };
2470
+ } else if (c === ",") {
2471
+ // trailing comma before } or ] → replace with space
2472
+ let j = i + 1;
2473
+ while (j < n && /\s/.test(src[j])) j++;
2474
+ if (j < n && (src[j] === "}" || src[j] === "]")) { out[i] = " "; i++; }
2475
+ else { out[i] = c; i++; }
2476
+ } else {
2477
+ out[i] = c; i++;
2478
+ }
2479
+ }
2480
+ return { text: out.join("") };
2481
+ }
2482
+
2483
+ function offsetToLineCol(src: string, pos: number): { pos: number; line: number; col: number } {
2484
+ pos = Math.max(0, Math.min(pos, src.length));
2485
+ let line = 1, col = 1;
2486
+ for (let i = 0; i < pos; i++) {
2487
+ if (src[i] === "\n") { line++; col = 1; } else col++;
2488
+ }
2489
+ return { pos, line, col };
2490
+ }
2491
+
2492
+ // Minimal markdown renderer for the help panel. Supports: headings, fenced code blocks,
2493
+ // inline code, bold, italic, links, bullet lists, paragraphs. HTML is escaped first.
2494
+ function renderMarkdown(md: string): string {
2495
+ const esc = (s: string) => s.replace(/[&<>"']/g, c =>
2496
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
2497
+
2498
+ // Pull fenced code blocks out first so their contents aren't processed as markdown.
2499
+ const blocks: string[] = [];
2500
+ let src = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
2501
+ const i = blocks.length;
2502
+ blocks.push(`<pre class="mailx-help-code"><code>${esc(code)}</code></pre>`);
2503
+ return `\u0000BLOCK${i}\u0000`;
2504
+ });
2505
+
2506
+ const lines = src.split(/\r?\n/);
2507
+ const out: string[] = [];
2508
+ let inList = false;
2509
+ let para: string[] = [];
2510
+ const flushPara = () => {
2511
+ if (para.length) { out.push(`<p>${inline(para.join(" "))}</p>`); para = []; }
2512
+ };
2513
+ const closeList = () => { if (inList) { out.push("</ul>"); inList = false; } };
2514
+
2515
+ function inline(s: string): string {
2516
+ s = esc(s);
2517
+ s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
2518
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
2519
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
2520
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
2521
+ return s;
2522
+ }
2523
+
2524
+ for (const raw of lines) {
2525
+ const blockMatch = /^\u0000BLOCK(\d+)\u0000$/.exec(raw);
2526
+ if (blockMatch) { flushPara(); closeList(); out.push(blocks[parseInt(blockMatch[1], 10)]); continue; }
2527
+ const h = /^(#{1,6})\s+(.+)$/.exec(raw);
2528
+ if (h) { flushPara(); closeList(); const lvl = h[1].length; out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`); continue; }
2529
+ const bullet = /^\s*[-*]\s+(.+)$/.exec(raw);
2530
+ if (bullet) {
2531
+ flushPara();
2532
+ if (!inList) { out.push("<ul>"); inList = true; }
2533
+ out.push(`<li>${inline(bullet[1])}</li>`);
2534
+ continue;
2535
+ }
2536
+ if (raw.trim() === "") { flushPara(); closeList(); continue; }
2537
+ para.push(raw);
2538
+ }
2539
+ flushPara();
2540
+ closeList();
2541
+ return out.join("\n");
2542
+ }
2543
+
2544
+ // ── About dialog ──
2545
+ document.getElementById("btn-about")?.addEventListener("click", () => {
2546
+ const settingsDropdown = document.getElementById("settings-dropdown");
2547
+ if (settingsDropdown) settingsDropdown.hidden = true;
2548
+ openAboutDialog();
2549
+ });
2550
+ // Clicking the version string (toolbar in wide mode, status bar in narrow mode) also opens About
2551
+ document.querySelectorAll<HTMLElement>(".app-version").forEach(el => {
2552
+ el.style.cursor = "pointer";
2553
+ el.addEventListener("click", openAboutDialog);
2554
+ });
2555
+
2556
+ async function openAboutDialog(): Promise<void> {
2557
+ const backdrop = document.createElement("div");
2558
+ backdrop.className = "mailx-modal-backdrop";
2559
+ const panel = document.createElement("div");
2560
+ panel.className = "mailx-modal";
2561
+ panel.innerHTML = `
2562
+ <div class="mailx-modal-title">
2563
+ <span class="mailx-modal-title-text">About mailx</span>
2564
+ <button type="button" class="mailx-modal-close" id="about-x" title="Close (Esc)" aria-label="Close">&times;</button>
2565
+ </div>
2566
+ <div class="mailx-about" id="about-body">Loading...</div>
2567
+ <div class="mailx-modal-buttons">
2568
+ <span class="mailx-modal-spacer"></span>
2569
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="close">Close</button>
2570
+ </div>`;
2571
+ backdrop.appendChild(panel);
2572
+ document.body.appendChild(panel.parentElement!);
2573
+
2574
+ const body = panel.querySelector<HTMLElement>("#about-body")!;
2575
+ const close = () => {
2576
+ backdrop.remove();
2577
+ document.removeEventListener("keydown", onKey, true);
2578
+ };
2579
+ const onKey = (e: KeyboardEvent) => {
2580
+ if (e.key === "Escape") { e.stopPropagation(); e.preventDefault(); close(); }
2581
+ };
2582
+ document.addEventListener("keydown", onKey, true);
2583
+ panel.querySelector<HTMLButtonElement>('[data-action="close"]')!
2584
+ .addEventListener("click", close);
2585
+ panel.querySelector<HTMLButtonElement>("#about-x")!
2586
+ .addEventListener("click", close);
2587
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop) close(); });
2588
+
2589
+ try {
2590
+ const [v, accounts] = await Promise.all([
2591
+ getVersion().catch(() => ({} as any)),
2592
+ getAccounts().catch(() => [] as any[]),
2593
+ ]);
2594
+ const storage = v.storage || {};
2595
+ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
2596
+ const platform = isApp ? (mailxapi?.platform || "app") : "browser";
2597
+ const versionText = v.version ? `v${v.version}` : "unknown";
2598
+ const versionHtml = v.version
2599
+ ? `<a href="https://github.com/BobFrankston/mailx/releases/tag/v${v.version}" target="_blank" rel="noopener">${versionText}</a>`
2600
+ : versionText;
2601
+ const rows: [string, string][] = [
2602
+ ["Version", versionHtml],
2603
+ ["Platform", platform],
2604
+ ["Storage", storage.provider || "local"],
2605
+ ];
2606
+ if (storage.cloudPath) rows.push(["Cloud path", `My Drive/${storage.cloudPath}/`]);
2607
+ if (storage.mode) rows.push(["Storage mode", storage.mode]);
2608
+ rows.push(["Accounts", String((accounts || []).length)]);
2609
+ rows.push(["User agent", navigator.userAgent]);
2610
+ rows.push(["Screen", `${screen.width}×${screen.height}`]);
2611
+ rows.push(["Window", `${window.innerWidth}×${window.innerHeight}`]);
2612
+
2613
+ // Version row contains an anchor tag; all other rows are plain text
2614
+ // and must be escaped. Treat row[0]==="Version" as pre-formatted HTML.
2615
+ body.innerHTML = `
2616
+ <dl class="mailx-about-dl">
2617
+ ${rows.map(([k, val]) => `<dt>${k}</dt><dd>${k === "Version" ? val : escapeHtml(val)}</dd>`).join("")}
2618
+ </dl>
2619
+ ${(accounts || []).length ? `
2620
+ <div class="mailx-about-accounts">
2621
+ <div class="mailx-about-section">Accounts</div>
2622
+ <ul>
2623
+ ${(accounts as any[]).map(a => `<li>${escapeHtml(a.email || a.id)}${a.name ? ` — ${escapeHtml(a.name)}` : ""}</li>`).join("")}
2624
+ </ul>
2625
+ </div>` : ""}
2626
+ <div class="mailx-about-foot">mailx — local-first mail client</div>`;
2627
+ } catch (e: any) {
2628
+ body.textContent = `Failed to load: ${e.message}`;
2629
+ }
2630
+ }
2631
+
2632
+ function escapeHtml(s: string): string {
2633
+ return String(s).replace(/[&<>"']/g, c =>
2634
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
2635
+ }
2636
+
2637
+ // Threaded view toggle
2638
+ optThreaded?.addEventListener("change", () => {
2639
+ const body = document.getElementById("ml-body");
2640
+ if (optThreaded.checked) {
2641
+ body?.classList.add("threaded");
2642
+ } else {
2643
+ body?.classList.remove("threaded");
2644
+ }
2645
+ localStorage.setItem("mailx-threaded", String(optThreaded.checked));
2646
+ reloadCurrentFolder();
2647
+ });
2648
+
2649
+ // Flagged-only filter — keeps the CSS-level hiding for instant feedback on
2650
+ // the current page AND re-queries the folder so flagged messages that live
2651
+ // outside the currently-loaded page show up.
2652
+ optFlagged?.addEventListener("change", () => {
2653
+ const body = document.getElementById("ml-body");
2654
+ if (optFlagged.checked) body?.classList.add("flagged-only");
2655
+ else body?.classList.remove("flagged-only");
2656
+ localStorage.setItem("mailx-flagged", String(optFlagged.checked));
2657
+ reloadCurrentFolder();
2658
+ });
2659
+
2660
+ // Folder counts toggle
2661
+ optFolderCounts?.addEventListener("change", () => {
2662
+ const tree = document.getElementById("folder-tree");
2663
+ if (optFolderCounts.checked) {
2664
+ tree?.classList.add("show-folder-counts");
2665
+ } else {
2666
+ tree?.classList.remove("show-folder-counts");
2667
+ }
2668
+ localStorage.setItem("mailx-folder-counts", String(optFolderCounts.checked));
2669
+ });
2670
+
2671
+ // Q52: Reset column widths — clears persisted list/viewer splitter and
2672
+ // restores the default CSS-var value. Currently only the list/viewer split
2673
+ // is user-resizable; if per-column drag-resize lands later, add its keys to
2674
+ // the cleanup list below.
2675
+ document.getElementById("btn-reset-widths")?.addEventListener("click", () => {
2676
+ localStorage.removeItem("mailx-split");
2677
+ document.documentElement.style.removeProperty("--list-viewer-split");
2678
+ if (viewDropdown) viewDropdown.hidden = true;
2679
+ });
2680
+
2681
+ // ── Settings menu ──
2682
+
2683
+ const settingsBtn = document.getElementById("btn-settings");
2684
+ const settingsDropdown = document.getElementById("settings-dropdown");
2685
+ const optEditorQuill = document.getElementById("opt-editor-quill") as HTMLInputElement;
2686
+ const optEditorTiptap = document.getElementById("opt-editor-tiptap") as HTMLInputElement;
2687
+
2688
+ settingsBtn?.addEventListener("click", (e) => {
2689
+ e.stopPropagation();
2690
+ if (viewDropdown) viewDropdown.hidden = true;
2691
+ const restartDd = document.getElementById("restart-dropdown");
2692
+ if (restartDd) restartDd.hidden = true;
2693
+ if (settingsDropdown) settingsDropdown.hidden = !settingsDropdown.hidden;
2694
+ });
2695
+ // Close handled by the shared document click handler above
2696
+
2697
+ // Load current editor setting from server
2698
+ getSettings().then((s: any) => {
2699
+ const ed = s.ui?.editor || "quill";
2700
+ if (optEditorQuill) optEditorQuill.checked = ed === "quill";
2701
+ if (optEditorTiptap) optEditorTiptap.checked = ed === "tiptap";
2702
+ }).catch(() => {});
2703
+
2704
+ // Save editor choice to server settings
2705
+ function saveEditorSetting(editor: string): void {
2706
+ getSettings().then((settings: any) => {
2707
+ settings.ui = { ...settings.ui, editor };
2708
+ saveSettings(settings);
2709
+ }).catch(() => {});
2710
+ }
2711
+
2712
+ optEditorQuill?.addEventListener("change", () => {
2713
+ if (optEditorQuill.checked) saveEditorSetting("quill");
2714
+ });
2715
+ optEditorTiptap?.addEventListener("change", () => {
2716
+ if (optEditorTiptap.checked) saveEditorSetting("tiptap");
2717
+ });
2718
+
2719
+ // External editor preference (Edit-in-Word handoff target). Stored under
2720
+ // settings.externalEditor so the service can read it via loadSettings().
2721
+ // "auto" tries Word → LibreOffice → OS default; explicit values force
2722
+ // that editor (still falling back to OS default if it isn't installed).
2723
+ const optExtEditAuto = document.getElementById("opt-extedit-auto") as HTMLInputElement | null;
2724
+ const optExtEditWord = document.getElementById("opt-extedit-word") as HTMLInputElement | null;
2725
+ const optExtEditLibre = document.getElementById("opt-extedit-libre") as HTMLInputElement | null;
2726
+ getSettings().then((s: any) => {
2727
+ const v = s.externalEditor || "auto";
2728
+ if (optExtEditAuto) optExtEditAuto.checked = v === "auto";
2729
+ if (optExtEditWord) optExtEditWord.checked = v === "word";
2730
+ if (optExtEditLibre) optExtEditLibre.checked = v === "libreoffice";
2731
+ }).catch(() => {});
2732
+ function saveExtEditor(v: "auto" | "word" | "libreoffice"): void {
2733
+ getSettings().then((settings: any) => {
2734
+ settings.externalEditor = v;
2735
+ saveSettings(settings);
2736
+ }).catch(() => {});
2737
+ }
2738
+ optExtEditAuto?.addEventListener("change", () => { if (optExtEditAuto.checked) saveExtEditor("auto"); });
2739
+ optExtEditWord?.addEventListener("change", () => { if (optExtEditWord.checked) saveExtEditor("word"); });
2740
+ optExtEditLibre?.addEventListener("change", () => { if (optExtEditLibre.checked) saveExtEditor("libreoffice"); });
2741
+
2742
+ // ── AI feature toggles ──
2743
+ // One umbrella settings record (AutocompleteSettings) holds the provider config
2744
+ // + per-feature on/off flags. All features default OFF — user must opt into
2745
+ // each AI behavior individually. Per user preference (2026-04-21).
2746
+ const optAutocomplete = document.getElementById("opt-autocomplete") as HTMLInputElement | null;
2747
+ const optAiTranslate = document.getElementById("opt-ai-translate") as HTMLInputElement | null;
2748
+ const optAiProofread = document.getElementById("opt-ai-proofread") as HTMLInputElement | null;
2749
+
2750
+ getAutocompleteSettings().then((ac: any) => {
2751
+ if (optAutocomplete) optAutocomplete.checked = !!ac.enabled;
2752
+ if (optAiTranslate) optAiTranslate.checked = !!ac.translateEnabled;
2753
+ if (optAiProofread) optAiProofread.checked = !!ac.proofreadEnabled;
2754
+ }).catch(() => {});
2755
+
2756
+ function persistAi(mutator: (ac: any) => void): void {
2757
+ getAutocompleteSettings().then((ac: any) => {
2758
+ mutator(ac);
2759
+ saveAutocompleteSettings(ac);
2760
+ }).catch(() => {});
2761
+ }
2762
+ optAutocomplete?.addEventListener("change", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));
2763
+ optAiTranslate?.addEventListener("change", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));
2764
+ optAiProofread?.addEventListener("change", () => {
2765
+ persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; });
2766
+ // Mirror to localStorage so the compose editor (separate page/iframe with
2767
+ // its own getSettings cycle) can read it synchronously.
2768
+ try { localStorage.setItem("mailx-ai-proofread-enabled", String(optAiProofread.checked)); } catch { /* */ }
2769
+ });
2770
+
2771
+ // Sender reputation check (Spamhaus DBL). Stored at top-level settings so
2772
+ // the service can read it cheaply without going through autocomplete config.
2773
+ // Off by default — enabling it leaks read-recipient domains to Spamhaus's
2774
+ // DNS infra, which the user should opt into knowingly.
2775
+ const optCheckReputation = document.getElementById("opt-check-reputation") as HTMLInputElement | null;
2776
+ getSettings().then((s: any) => {
2777
+ if (optCheckReputation) optCheckReputation.checked = !!s.checkDomainReputation;
2778
+ }).catch(() => {});
2779
+ optCheckReputation?.addEventListener("change", () => {
2780
+ getSettings().then((settings: any) => {
2781
+ settings.checkDomainReputation = !!optCheckReputation.checked;
2782
+ saveSettings(settings);
2783
+ }).catch(() => {});
2784
+ });
2785
+
2786
+ // Auto mark-as-read settings (per-device localStorage; the viewer reads
2787
+ // these directly when showing a message). Default on with a 2s delay so
2788
+ // scrolling through a folder doesn't mark every glanced-at message as
2789
+ // read, but a deliberate read still gets recorded.
2790
+ const optAutomarkRead = document.getElementById("opt-automark-read") as HTMLInputElement | null;
2791
+ const optAutomarkDelay = document.getElementById("opt-automark-delay") as HTMLInputElement | null;
2792
+ try {
2793
+ if (optAutomarkRead) optAutomarkRead.checked = localStorage.getItem("mailx-automark-read") !== "false";
2794
+ if (optAutomarkDelay) optAutomarkDelay.value = localStorage.getItem("mailx-automark-delay") || "2";
2795
+ } catch { /* private mode */ }
2796
+ optAutomarkRead?.addEventListener("change", () => {
2797
+ try { localStorage.setItem("mailx-automark-read", String(optAutomarkRead.checked)); } catch { /* */ }
2798
+ });
2799
+ optAutomarkDelay?.addEventListener("change", () => {
2800
+ const v = parseFloat(optAutomarkDelay.value);
2801
+ if (Number.isFinite(v) && v >= 0) {
2802
+ try { localStorage.setItem("mailx-automark-delay", String(v)); } catch { /* */ }
2803
+ }
2804
+ });
2805
+
2806
+ // ── Version display ──
2807
+ declare const mailxapi: { isApp: boolean; platform: string; ensureServer: () => Promise<boolean>; getVersion: () => Promise<any> } | undefined;
2808
+ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
2809
+
2810
+ // Wait for server ready signal, then fetch version
2811
+ const versionPromise = getVersion();
2812
+ versionPromise.then((d: any) => {
2813
+ const els = document.querySelectorAll<HTMLElement>(".app-version");
2814
+ const storage = d.storage || {};
2815
+ const storageLabel = storage.provider && storage.provider !== "local"
2816
+ ? ` [${storage.provider}]`
2817
+ : "";
2818
+ const text = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
2819
+ const tip = storage.provider && storage.provider !== "local"
2820
+ ? (storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider)
2821
+ : "";
2822
+ for (const el of els) {
2823
+ el.textContent = text;
2824
+ if (tip) el.title = tip;
2825
+ }
2826
+ if (d.settingsError) {
2827
+ showAlert(d.settingsError, "settings-error");
2828
+ // Add repair button to the banner
2829
+ const banner = document.getElementById("alert-banner");
2830
+ if (banner && !banner.querySelector(".repair-btn")) {
2831
+ const btn = document.createElement("button");
2832
+ btn.className = "repair-btn status-action";
2833
+ btn.textContent = "Repair: restore accounts from cache";
2834
+ btn.style.cssText = "margin-left:1rem;padding:0.25rem 0.75rem;background:#a6e3a1;color:#1e1e2e;border:none;border-radius:4px;cursor:pointer;font-weight:bold";
2835
+ btn.onclick = async () => {
2836
+ btn.textContent = "Restoring...";
2837
+ btn.disabled = true;
2838
+ try {
2839
+ const data = await repairAccounts();
2840
+ if (data.ok) {
2841
+ hideAlert();
2842
+ setTimeout(() => location.reload(), 1000);
2843
+ } else {
2844
+ btn.textContent = `Failed: ${data.error}`;
2845
+ }
2846
+ } catch (e: any) {
2847
+ btn.textContent = `Error: ${e.message}`;
2848
+ }
2849
+ };
2850
+ banner.querySelector("#alert-text")?.after(btn);
2851
+ }
2852
+ } else if (storage.cloudError) {
2853
+ showAlert(`Cloud storage error: ${storage.cloudError}`, "cloud-error");
2854
+ }
2855
+ }).catch((e: any) => {
2856
+ // Version fetch failed
2857
+ const els = document.querySelectorAll<HTMLElement>(".app-version");
2858
+ const text = isApp ? `mailx [version error: ${e.message}]` : "mailx [server offline]";
2859
+ for (const el of els) el.textContent = text;
2860
+ });
2861
+
2862
+ // ── Sync pending indicator + server health check (HTTP mode only) ──
2863
+ let serverDown = false;
2864
+ if (isApp) {
2865
+ // IPC mode: events come via push, no polling needed
2866
+ } else
2867
+ setInterval(async () => {
2868
+ try {
2869
+ const data = await getSyncPending();
2870
+ const el = document.getElementById("status-pending");
2871
+ if (el) {
2872
+ el.textContent = data.pending > 0 ? `↻ ${data.pending} pending` : "";
2873
+ el.style.color = data.pending > 0 ? "oklch(0.75 0.15 60)" : "";
2874
+ }
2875
+ // Server is back — reload if it was down
2876
+ if (serverDown) {
2877
+ serverDown = false;
2878
+ const statusEl = document.getElementById("status-sync");
2879
+ if (statusEl) statusEl.textContent = "Server reconnected";
2880
+ location.reload();
2881
+ }
2882
+ } catch {
2883
+ if (!serverDown) {
2884
+ serverDown = true;
2885
+ const statusEl = document.getElementById("status-sync");
2886
+ if (statusEl) {
2887
+ statusEl.textContent = "SERVER OFFLINE";
2888
+ statusEl.style.color = "oklch(0.65 0.2 25)";
2889
+ }
2890
+ }
2891
+ }
2892
+ }, 5000);
2893
+
2894
+ // ── Outbox queue indicator (status-queue span) ──
2895
+ // Event-driven in IPC mode (service pushes outboxStatus on every mutation).
2896
+ // Plus a 15s poll safety net for both modes so a missed event doesn't leave
2897
+ // the user staring at stale numbers. Idempotent — renderOutboxStatus just
2898
+ // overwrites the text.
2899
+ function renderOutboxStatus(s: any): void {
2900
+ // Feed the folder-tree synthesized "Send-pending" row. Idempotent —
2901
+ // it no-ops when the presence state and count haven't changed.
2902
+ setOutboxTotal(s?.total || 0);
2903
+ const el = document.getElementById("status-queue");
2904
+ if (!el) return;
2905
+ if (!s || !s.total || s.total === 0) {
2906
+ el.textContent = "";
2907
+ el.title = "";
2908
+ el.style.color = "";
2909
+ return;
2910
+ }
2911
+ const parts: string[] = [`✉ ${s.total} queued`];
2912
+ if (s.claimed > 0) parts.push(`${s.claimed} sending`);
2913
+ if (s.retrying > 0) parts.push(`${s.retrying} retrying (×${s.maxAttempts})`);
2914
+ if (s.oldestAgeSec >= 60) {
2915
+ const age = s.oldestAgeSec >= 3600
2916
+ ? `${Math.floor(s.oldestAgeSec / 3600)}h`
2917
+ : `${Math.floor(s.oldestAgeSec / 60)}m`;
2918
+ parts.push(`oldest ${age}`);
2919
+ }
2920
+ el.textContent = parts.join(" · ");
2921
+ const perAcct = s.perAccount || {};
2922
+ const detail = Object.keys(perAcct).sort().map(a =>
2923
+ `${a}: ${perAcct[a].total} total, ${perAcct[a].claimed} sending, ${perAcct[a].retrying} retrying`
2924
+ ).join("\n");
2925
+ el.title = detail || "";
2926
+ // Orange when retrying, red when stuck >5min, else muted.
2927
+ el.style.color = s.oldestAgeSec > 300 ? "oklch(0.65 0.2 25)"
2928
+ : s.retrying > 0 ? "oklch(0.75 0.15 60)"
2929
+ : "";
2930
+ }
2931
+
2932
+ setInterval(async () => {
2933
+ try {
2934
+ const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
2935
+ renderOutboxStatus(await getOutboxStatus());
2936
+ renderDiagnosticsBadge(await getDiagnostics());
2937
+ } catch { /* service unreachable */ }
2938
+ }, 15000);
2939
+ // First read on startup so the bar isn't blank.
2940
+ (async () => {
2941
+ try {
2942
+ const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
2943
+ renderOutboxStatus(await getOutboxStatus());
2944
+ renderDiagnosticsBadge(await getDiagnostics());
2945
+ } catch { /* */ }
2946
+ })();
2947
+
2948
+ /** Render the ⚠ "something's wrong" badge next to status-sync. Shown when
2949
+ * any account has non-zero diagnostic counters (inactivity timeouts,
2950
+ * connection-cap hits, rate-limit waits). Tooltip breaks down per-account. */
2951
+ function renderDiagnosticsBadge(snapshot: any[]): void {
2952
+ const host = document.getElementById("status-diag");
2953
+ if (!host) return;
2954
+ const issues = (snapshot || []).filter(d => d.inactivityTimeouts > 0 || d.connCapHits > 0 || d.rateLimitWaits > 0);
2955
+ if (issues.length === 0) {
2956
+ host.hidden = true;
2957
+ host.textContent = "";
2958
+ host.title = "";
2959
+ return;
2960
+ }
2961
+ host.hidden = false;
2962
+ host.textContent = "⚠";
2963
+ const totalTimeouts = issues.reduce((a, d) => a + d.inactivityTimeouts, 0);
2964
+ const totalCapHits = issues.reduce((a, d) => a + d.connCapHits, 0);
2965
+ const totalRateLimits = issues.reduce((a, d) => a + d.rateLimitWaits, 0);
2966
+ const summary = [
2967
+ totalTimeouts > 0 ? `${totalTimeouts} IMAP inactivity timeout${totalTimeouts === 1 ? "" : "s"}` : null,
2968
+ totalCapHits > 0 ? `${totalCapHits} conn-cap rejection${totalCapHits === 1 ? "" : "s"}` : null,
2969
+ totalRateLimits > 0 ? `${totalRateLimits} rate-limit wait${totalRateLimits === 1 ? "" : "s"}` : null,
2970
+ ].filter(Boolean).join("; ");
2971
+ const detail = issues.map(d => {
2972
+ const parts = [
2973
+ d.inactivityTimeouts > 0 ? `${d.inactivityTimeouts} timeout${d.inactivityTimeouts === 1 ? "" : "s"}` : null,
2974
+ d.connCapHits > 0 ? `${d.connCapHits} conn-cap` : null,
2975
+ d.rateLimitWaits > 0 ? `${d.rateLimitWaits} rate-limit` : null,
2976
+ ].filter(Boolean).join(", ");
2977
+ const last = d.lastCommand ? `\n last: ${d.lastCommand}` : "";
2978
+ return `${d.accountId}: ${parts}${last}`;
2979
+ }).join("\n");
2980
+ host.title = `Connection issues — ${summary}\n\n${detail}`;
2981
+ }
2982
+ // Q64: pop-out a message into a floating overlay (real-OS-window pending C44).
2983
+ document.addEventListener("mailx-popout-message", (async (e: any) => {
2984
+ const { accountId, uid, folderId, subject } = e.detail || {};
2985
+ if (!accountId || !uid) return;
2986
+ const { getMessage } = await import("./lib/api-client.js");
2987
+ let msg: any;
2988
+ try {
2989
+ msg = await getMessage(accountId, uid, false, folderId);
2990
+ } catch (err: any) {
2991
+ alert(`Couldn't load message: ${err?.message || err}`);
2992
+ return;
2993
+ }
2994
+ const wrapper = document.createElement("div");
2995
+ wrapper.className = "popout-overlay";
2996
+ wrapper.style.cssText = "position:fixed;top:5vh;right:5vw;width:min(900px,60vw);height:min(800px,80vh);z-index:1500;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;";
2997
+ const header = document.createElement("div");
2998
+ header.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--color-bg-surface);border-bottom:1px solid var(--color-border);font-weight:600;cursor:move;";
2999
+ const title = document.createElement("span");
3000
+ title.textContent = subject || "(no subject)";
3001
+ title.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
3002
+ const closeBtn = document.createElement("button");
3003
+ closeBtn.textContent = "×";
3004
+ closeBtn.style.cssText = "background:none;border:none;font-size:1.4rem;cursor:pointer;padding:0 8px;";
3005
+ closeBtn.addEventListener("click", () => wrapper.remove());
3006
+ header.appendChild(title);
3007
+ header.appendChild(closeBtn);
3008
+ const meta = document.createElement("div");
3009
+ meta.style.cssText = "padding:8px 12px;border-bottom:1px solid var(--color-border);font-size:0.9rem;color:var(--color-text-muted);";
3010
+ meta.innerHTML = `<div><b>From:</b> ${escapeHtmlBasic(msg.from?.name || "")} &lt;${escapeHtmlBasic(msg.from?.address || "")}&gt;</div>
3011
+ <div><b>To:</b> ${(msg.to || []).map((a: any) => escapeHtmlBasic(`${a.name||""} <${a.address}>`)).join(", ")}</div>
3012
+ ${msg.cc?.length ? `<div><b>Cc:</b> ${msg.cc.map((a:any) => escapeHtmlBasic(`${a.name||""} <${a.address}>`)).join(", ")}</div>` : ""}
3013
+ <div><b>Date:</b> ${new Date(msg.date).toLocaleString()}</div>`;
3014
+ const body = document.createElement("iframe");
3015
+ body.style.cssText = "flex:1;border:none;width:100%;background:#fff;";
3016
+ body.sandbox.add("allow-same-origin");
3017
+ wrapper.appendChild(header);
3018
+ wrapper.appendChild(meta);
3019
+ wrapper.appendChild(body);
3020
+ document.body.appendChild(wrapper);
3021
+ body.srcdoc = msg.bodyHtml || `<pre style="white-space:pre-wrap;font-family:ui-sans-serif">${escapeHtmlBasic(msg.bodyText || "(no body)")}</pre>`;
3022
+ // Drag-to-move.
3023
+ let dragX = 0, dragY = 0, dragging = false;
3024
+ header.addEventListener("mousedown", (de: MouseEvent) => {
3025
+ if ((de.target as HTMLElement).tagName === "BUTTON") return;
3026
+ dragging = true;
3027
+ const rect = wrapper.getBoundingClientRect();
3028
+ dragX = de.clientX - rect.left;
3029
+ dragY = de.clientY - rect.top;
3030
+ de.preventDefault();
3031
+ });
3032
+ document.addEventListener("mousemove", (de) => {
3033
+ if (!dragging) return;
3034
+ wrapper.style.left = `${de.clientX - dragX}px`;
3035
+ wrapper.style.top = `${de.clientY - dragY}px`;
3036
+ wrapper.style.right = "auto";
3037
+ });
3038
+ document.addEventListener("mouseup", () => { dragging = false; });
3039
+ }) as EventListener);
3040
+ function escapeHtmlBasic(s: string): string {
3041
+ return (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]!));
3042
+ }
3043
+
3044
+ // Click the status-queue pill to open the outbox view (pink-row list).
3045
+ document.getElementById("status-queue")?.addEventListener("click", async () => {
3046
+ try {
3047
+ const { openOutboxView } = await import("./components/outbox-view.js");
3048
+ openOutboxView();
3049
+ } catch (e: any) {
3050
+ console.error("Outbox view failed:", e);
3051
+ }
3052
+ });
3053
+ // Make it look clickable.
3054
+ (() => {
3055
+ const el = document.getElementById("status-queue");
3056
+ if (el) { el.style.cursor = "pointer"; el.title = "Click to view queued messages"; }
3057
+ })();
3058
+
3059
+ console.log("mailx client initialized, location:", location.href);
3060
+ updateNewMessageCount();
3061
+
3062
+ // Offline indicator — show/hide based on navigator.onLine. Doesn't gate any
3063
+ // functionality (the store is local-first; edits queue and replay on
3064
+ // reconnect regardless) but tells the user their queued actions are stacking
3065
+ // up for a later push rather than hitting the server now.
3066
+ const offlineEl = document.getElementById("status-offline");
3067
+ function refreshOfflineIndicator(): void {
3068
+ if (!offlineEl) return;
3069
+ offlineEl.hidden = navigator.onLine;
3070
+ }
3071
+ window.addEventListener("online", refreshOfflineIndicator);
3072
+ window.addEventListener("offline", refreshOfflineIndicator);
3073
+ refreshOfflineIndicator();
3074
+
3075
+ // ── Midnight refresh — update date display when day changes ──
3076
+ function scheduleMiddnightRefresh(): void {
3077
+ const now = new Date();
3078
+ const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
3079
+ const ms = midnight.getTime() - now.getTime();
3080
+ setTimeout(() => {
3081
+ reloadCurrentFolder();
3082
+ scheduleMiddnightRefresh();
3083
+ }, ms + 1000); // 1s after midnight
3084
+ }
3085
+ scheduleMiddnightRefresh();
3086
+
3087
+ // ── Apply theme from settings ──
3088
+ versionPromise.then((d: any) => {
3089
+ if (d.theme === "dark") document.documentElement.classList.add("theme-dark");
3090
+ else if (d.theme === "light") document.documentElement.classList.add("theme-light");
3091
+ }).catch(() => {});
3092
+
3093
+ // ── Save window geometry on close (IPC mode only) ──
3094
+ // Sends window position and size so the next launch restores them.
3095
+ if (isApp) {
3096
+ const ipcApi = (window as unknown as Record<string, unknown>).mailxapi as
3097
+ { saveWindowGeometry?: (g: { x: number; y: number; width: number; height: number }) => Promise<unknown> } | undefined;
3098
+
3099
+ function sendGeometry(): void {
3100
+ if (!ipcApi?.saveWindowGeometry) return;
3101
+ ipcApi.saveWindowGeometry({
3102
+ x: window.screenX,
3103
+ y: window.screenY,
3104
+ width: window.outerWidth,
3105
+ height: window.outerHeight,
3106
+ }).catch(() => { /* fire-and-forget */ });
3107
+ }
3108
+
3109
+ // Save on unload (window close) and periodically as a safety net
3110
+ window.addEventListener("beforeunload", sendGeometry);
3111
+ setInterval(sendGeometry, 60_000);
3112
+ }