@bobfrankston/rmfmail 1.0.467

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 (330) hide show
  1. package/README.md +429 -0
  2. package/bin/build-icon-ico.js +283 -0
  3. package/bin/lean-accounts.js +172 -0
  4. package/bin/mailx.js +1580 -0
  5. package/bin/mailx.js.map +1 -0
  6. package/bin/mailx.ts +1549 -0
  7. package/bin/postinstall.js +39 -0
  8. package/bin/postinstall.js.map +1 -0
  9. package/bin/postinstall.ts +41 -0
  10. package/bin/tsconfig.json +10 -0
  11. package/client/.gitattributes +10 -0
  12. package/client/.msger-window.json +1 -0
  13. package/client/android.html +226 -0
  14. package/client/app.js +3400 -0
  15. package/client/app.js.map +1 -0
  16. package/client/app.ts +3190 -0
  17. package/client/components/address-book.js +199 -0
  18. package/client/components/address-book.js.map +1 -0
  19. package/client/components/address-book.ts +204 -0
  20. package/client/components/alarms.js +299 -0
  21. package/client/components/alarms.js.map +1 -0
  22. package/client/components/alarms.ts +276 -0
  23. package/client/components/calendar-sidebar.js +508 -0
  24. package/client/components/calendar-sidebar.js.map +1 -0
  25. package/client/components/calendar-sidebar.ts +474 -0
  26. package/client/components/calendar.js +217 -0
  27. package/client/components/calendar.js.map +1 -0
  28. package/client/components/calendar.ts +211 -0
  29. package/client/components/context-menu.js +80 -0
  30. package/client/components/context-menu.js.map +1 -0
  31. package/client/components/context-menu.ts +95 -0
  32. package/client/components/folder-picker.js +119 -0
  33. package/client/components/folder-picker.js.map +1 -0
  34. package/client/components/folder-picker.ts +127 -0
  35. package/client/components/folder-tree.js +1093 -0
  36. package/client/components/folder-tree.js.map +1 -0
  37. package/client/components/folder-tree.ts +1069 -0
  38. package/client/components/message-list.js +1140 -0
  39. package/client/components/message-list.js.map +1 -0
  40. package/client/components/message-list.ts +1129 -0
  41. package/client/components/message-viewer.js +1291 -0
  42. package/client/components/message-viewer.js.map +1 -0
  43. package/client/components/message-viewer.ts +1257 -0
  44. package/client/components/outbox-view.js +104 -0
  45. package/client/components/outbox-view.js.map +1 -0
  46. package/client/components/outbox-view.ts +102 -0
  47. package/client/components/tasks.js +261 -0
  48. package/client/components/tasks.js.map +1 -0
  49. package/client/components/tasks.ts +234 -0
  50. package/client/compose/compose.css +560 -0
  51. package/client/compose/compose.html +52 -0
  52. package/client/compose/compose.js +1289 -0
  53. package/client/compose/compose.js.map +1 -0
  54. package/client/compose/compose.ts +1231 -0
  55. package/client/compose/editor.js +616 -0
  56. package/client/compose/editor.js.map +1 -0
  57. package/client/compose/editor.ts +599 -0
  58. package/client/compose/ghost-text.js +122 -0
  59. package/client/compose/ghost-text.js.map +1 -0
  60. package/client/compose/ghost-text.ts +140 -0
  61. package/client/favicon.svg +11 -0
  62. package/client/icon.ico +0 -0
  63. package/client/icon.png +0 -0
  64. package/client/index.html +230 -0
  65. package/client/lib/android-bootstrap.js +9 -0
  66. package/client/lib/android-bootstrap.js.map +1 -0
  67. package/client/lib/android-bootstrap.ts +9 -0
  68. package/client/lib/api-client.js +378 -0
  69. package/client/lib/api-client.js.map +1 -0
  70. package/client/lib/api-client.ts +439 -0
  71. package/client/lib/local-service.js +603 -0
  72. package/client/lib/local-service.js.map +1 -0
  73. package/client/lib/local-service.ts +646 -0
  74. package/client/lib/local-store.js +214 -0
  75. package/client/lib/local-store.js.map +1 -0
  76. package/client/lib/local-store.ts +283 -0
  77. package/client/lib/mailxapi.js +304 -0
  78. package/client/lib/message-state.js +132 -0
  79. package/client/lib/message-state.js.map +1 -0
  80. package/client/lib/message-state.ts +160 -0
  81. package/client/package.json +16 -0
  82. package/client/styles/components.css +2049 -0
  83. package/client/styles/layout.css +332 -0
  84. package/client/styles/variables.css +85 -0
  85. package/client/tsconfig.json +19 -0
  86. package/package.json +124 -0
  87. package/packages/mailx-api/.gitattributes +10 -0
  88. package/packages/mailx-api/index.d.ts +10 -0
  89. package/packages/mailx-api/index.d.ts.map +1 -0
  90. package/packages/mailx-api/index.js +322 -0
  91. package/packages/mailx-api/index.js.map +1 -0
  92. package/packages/mailx-api/index.ts +283 -0
  93. package/packages/mailx-api/package.json +27 -0
  94. package/packages/mailx-api/tsconfig.json +9 -0
  95. package/packages/mailx-api/tsconfig.tsbuildinfo +1 -0
  96. package/packages/mailx-compose/.gitattributes +10 -0
  97. package/packages/mailx-compose/index.d.ts +21 -0
  98. package/packages/mailx-compose/index.d.ts.map +1 -0
  99. package/packages/mailx-compose/index.js +61 -0
  100. package/packages/mailx-compose/index.js.map +1 -0
  101. package/packages/mailx-compose/index.ts +85 -0
  102. package/packages/mailx-compose/package.json +18 -0
  103. package/packages/mailx-compose/tsconfig.json +9 -0
  104. package/packages/mailx-core/index.d.ts +139 -0
  105. package/packages/mailx-core/index.d.ts.map +1 -0
  106. package/packages/mailx-core/index.js +403 -0
  107. package/packages/mailx-core/index.js.map +1 -0
  108. package/packages/mailx-core/index.ts +424 -0
  109. package/packages/mailx-core/ipc.d.ts +13 -0
  110. package/packages/mailx-core/ipc.d.ts.map +1 -0
  111. package/packages/mailx-core/ipc.js +56 -0
  112. package/packages/mailx-core/ipc.js.map +1 -0
  113. package/packages/mailx-core/ipc.ts +62 -0
  114. package/packages/mailx-core/package.json +18 -0
  115. package/packages/mailx-core/tsconfig.json +9 -0
  116. package/packages/mailx-host/.gitattributes +10 -0
  117. package/packages/mailx-host/index.d.ts +21 -0
  118. package/packages/mailx-host/index.d.ts.map +1 -0
  119. package/packages/mailx-host/index.js +29 -0
  120. package/packages/mailx-host/index.js.map +1 -0
  121. package/packages/mailx-host/index.ts +38 -0
  122. package/packages/mailx-host/package.json +23 -0
  123. package/packages/mailx-host/tsconfig.json +9 -0
  124. package/packages/mailx-host/types-shim.d.ts +14 -0
  125. package/packages/mailx-imap/.gitattributes +10 -0
  126. package/packages/mailx-imap/index.d.ts +442 -0
  127. package/packages/mailx-imap/index.d.ts.map +1 -0
  128. package/packages/mailx-imap/index.js +3684 -0
  129. package/packages/mailx-imap/index.js.map +1 -0
  130. package/packages/mailx-imap/index.ts +3652 -0
  131. package/packages/mailx-imap/package-lock.json +131 -0
  132. package/packages/mailx-imap/package.json +28 -0
  133. package/packages/mailx-imap/providers/gmail-api.d.ts +8 -0
  134. package/packages/mailx-imap/providers/gmail-api.d.ts.map +1 -0
  135. package/packages/mailx-imap/providers/gmail-api.js +8 -0
  136. package/packages/mailx-imap/providers/gmail-api.js.map +1 -0
  137. package/packages/mailx-imap/providers/gmail-api.ts +8 -0
  138. package/packages/mailx-imap/providers/outlook-api.ts +7 -0
  139. package/packages/mailx-imap/providers/types.d.ts +9 -0
  140. package/packages/mailx-imap/providers/types.d.ts.map +1 -0
  141. package/packages/mailx-imap/providers/types.js +9 -0
  142. package/packages/mailx-imap/providers/types.js.map +1 -0
  143. package/packages/mailx-imap/providers/types.ts +9 -0
  144. package/packages/mailx-imap/tsconfig.json +9 -0
  145. package/packages/mailx-imap/tsconfig.tsbuildinfo +1 -0
  146. package/packages/mailx-send/.gitattributes +10 -0
  147. package/packages/mailx-send/README.md +217 -0
  148. package/packages/mailx-send/cli-queue.d.ts +11 -0
  149. package/packages/mailx-send/cli-queue.d.ts.map +1 -0
  150. package/packages/mailx-send/cli-queue.js +59 -0
  151. package/packages/mailx-send/cli-queue.js.map +1 -0
  152. package/packages/mailx-send/cli-queue.ts +62 -0
  153. package/packages/mailx-send/cli-send.d.ts +17 -0
  154. package/packages/mailx-send/cli-send.d.ts.map +1 -0
  155. package/packages/mailx-send/cli-send.js +75 -0
  156. package/packages/mailx-send/cli-send.js.map +1 -0
  157. package/packages/mailx-send/cli-send.ts +83 -0
  158. package/packages/mailx-send/cli.d.ts +22 -0
  159. package/packages/mailx-send/cli.d.ts.map +1 -0
  160. package/packages/mailx-send/cli.js +115 -0
  161. package/packages/mailx-send/cli.js.map +1 -0
  162. package/packages/mailx-send/cli.ts +126 -0
  163. package/packages/mailx-send/index.d.ts +77 -0
  164. package/packages/mailx-send/index.d.ts.map +1 -0
  165. package/packages/mailx-send/index.js +264 -0
  166. package/packages/mailx-send/index.js.map +1 -0
  167. package/packages/mailx-send/index.ts +333 -0
  168. package/packages/mailx-send/mailsend/README.md +133 -0
  169. package/packages/mailx-send/mailsend/cli.d.ts +12 -0
  170. package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
  171. package/packages/mailx-send/mailsend/cli.js +70 -0
  172. package/packages/mailx-send/mailsend/cli.js.map +1 -0
  173. package/packages/mailx-send/mailsend/cli.ts +81 -0
  174. package/packages/mailx-send/mailsend/index.d.ts +77 -0
  175. package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
  176. package/packages/mailx-send/mailsend/index.js +264 -0
  177. package/packages/mailx-send/mailsend/index.js.map +1 -0
  178. package/packages/mailx-send/mailsend/index.ts +333 -0
  179. package/packages/mailx-send/mailsend/package-lock.json +65 -0
  180. package/packages/mailx-send/mailsend/package.json +26 -0
  181. package/packages/mailx-send/mailsend/tsconfig.json +21 -0
  182. package/packages/mailx-send/package-lock.json +65 -0
  183. package/packages/mailx-send/package.json +37 -0
  184. package/packages/mailx-send/tsconfig.json +21 -0
  185. package/packages/mailx-server/.gitattributes +10 -0
  186. package/packages/mailx-server/index.d.ts +8 -0
  187. package/packages/mailx-server/index.d.ts.map +1 -0
  188. package/packages/mailx-server/index.js +397 -0
  189. package/packages/mailx-server/index.js.map +1 -0
  190. package/packages/mailx-server/index.ts +429 -0
  191. package/packages/mailx-server/package.json +28 -0
  192. package/packages/mailx-server/tsconfig.json +9 -0
  193. package/packages/mailx-server/tsconfig.tsbuildinfo +1 -0
  194. package/packages/mailx-service/google-sync.d.ts +96 -0
  195. package/packages/mailx-service/google-sync.d.ts.map +1 -0
  196. package/packages/mailx-service/google-sync.js +153 -0
  197. package/packages/mailx-service/google-sync.js.map +1 -0
  198. package/packages/mailx-service/google-sync.ts +238 -0
  199. package/packages/mailx-service/index.d.ts +366 -0
  200. package/packages/mailx-service/index.d.ts.map +1 -0
  201. package/packages/mailx-service/index.js +2589 -0
  202. package/packages/mailx-service/index.js.map +1 -0
  203. package/packages/mailx-service/index.ts +2512 -0
  204. package/packages/mailx-service/jsonrpc.d.ts +25 -0
  205. package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
  206. package/packages/mailx-service/jsonrpc.js +238 -0
  207. package/packages/mailx-service/jsonrpc.js.map +1 -0
  208. package/packages/mailx-service/jsonrpc.ts +268 -0
  209. package/packages/mailx-service/package.json +22 -0
  210. package/packages/mailx-service/tsconfig.json +9 -0
  211. package/packages/mailx-settings/.gitattributes +10 -0
  212. package/packages/mailx-settings/cloud.d.ts +47 -0
  213. package/packages/mailx-settings/cloud.d.ts.map +1 -0
  214. package/packages/mailx-settings/cloud.js +408 -0
  215. package/packages/mailx-settings/cloud.js.map +1 -0
  216. package/packages/mailx-settings/cloud.ts +393 -0
  217. package/packages/mailx-settings/index.d.ts +140 -0
  218. package/packages/mailx-settings/index.d.ts.map +1 -0
  219. package/packages/mailx-settings/index.js +872 -0
  220. package/packages/mailx-settings/index.js.map +1 -0
  221. package/packages/mailx-settings/index.ts +908 -0
  222. package/packages/mailx-settings/package.json +20 -0
  223. package/packages/mailx-settings/tsconfig.json +9 -0
  224. package/packages/mailx-settings/tsconfig.tsbuildinfo +1 -0
  225. package/packages/mailx-store/.gitattributes +10 -0
  226. package/packages/mailx-store/db.d.ts +346 -0
  227. package/packages/mailx-store/db.d.ts.map +1 -0
  228. package/packages/mailx-store/db.js +1796 -0
  229. package/packages/mailx-store/db.js.map +1 -0
  230. package/packages/mailx-store/db.ts +2007 -0
  231. package/packages/mailx-store/file-store.d.ts +36 -0
  232. package/packages/mailx-store/file-store.d.ts.map +1 -0
  233. package/packages/mailx-store/file-store.js +80 -0
  234. package/packages/mailx-store/file-store.js.map +1 -0
  235. package/packages/mailx-store/file-store.ts +82 -0
  236. package/packages/mailx-store/index.d.ts +7 -0
  237. package/packages/mailx-store/index.d.ts.map +1 -0
  238. package/packages/mailx-store/index.js +7 -0
  239. package/packages/mailx-store/index.js.map +1 -0
  240. package/packages/mailx-store/index.ts +7 -0
  241. package/packages/mailx-store/package.json +19 -0
  242. package/packages/mailx-store/tsconfig.json +9 -0
  243. package/packages/mailx-store/tsconfig.tsbuildinfo +1 -0
  244. package/packages/mailx-store-web/android-bootstrap.d.ts +16 -0
  245. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
  246. package/packages/mailx-store-web/android-bootstrap.js +1247 -0
  247. package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
  248. package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
  249. package/packages/mailx-store-web/db.d.ts +132 -0
  250. package/packages/mailx-store-web/db.d.ts.map +1 -0
  251. package/packages/mailx-store-web/db.js +642 -0
  252. package/packages/mailx-store-web/db.js.map +1 -0
  253. package/packages/mailx-store-web/db.ts +756 -0
  254. package/packages/mailx-store-web/gmail-api-web.d.ts +11 -0
  255. package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
  256. package/packages/mailx-store-web/gmail-api-web.js +11 -0
  257. package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
  258. package/packages/mailx-store-web/gmail-api-web.ts +11 -0
  259. package/packages/mailx-store-web/imap-web-provider.d.ts +33 -0
  260. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
  261. package/packages/mailx-store-web/imap-web-provider.js +140 -0
  262. package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
  263. package/packages/mailx-store-web/imap-web-provider.ts +156 -0
  264. package/packages/mailx-store-web/index.d.ts +10 -0
  265. package/packages/mailx-store-web/index.d.ts.map +1 -0
  266. package/packages/mailx-store-web/index.js +10 -0
  267. package/packages/mailx-store-web/index.js.map +1 -0
  268. package/packages/mailx-store-web/index.ts +10 -0
  269. package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
  270. package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
  271. package/packages/mailx-store-web/main-thread-host.js +292 -0
  272. package/packages/mailx-store-web/main-thread-host.js.map +1 -0
  273. package/packages/mailx-store-web/main-thread-host.ts +322 -0
  274. package/packages/mailx-store-web/package.json +23 -0
  275. package/packages/mailx-store-web/provider-types.d.ts +7 -0
  276. package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
  277. package/packages/mailx-store-web/provider-types.js +7 -0
  278. package/packages/mailx-store-web/provider-types.js.map +1 -0
  279. package/packages/mailx-store-web/provider-types.ts +7 -0
  280. package/packages/mailx-store-web/sql-wasm-esm.js +10 -0
  281. package/packages/mailx-store-web/sql.js.d.ts +29 -0
  282. package/packages/mailx-store-web/sync-manager.d.ts +68 -0
  283. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
  284. package/packages/mailx-store-web/sync-manager.js +506 -0
  285. package/packages/mailx-store-web/sync-manager.js.map +1 -0
  286. package/packages/mailx-store-web/sync-manager.ts +508 -0
  287. package/packages/mailx-store-web/tsconfig.json +10 -0
  288. package/packages/mailx-store-web/web-jsonrpc.d.ts +20 -0
  289. package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
  290. package/packages/mailx-store-web/web-jsonrpc.js +102 -0
  291. package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
  292. package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
  293. package/packages/mailx-store-web/web-message-store.d.ts +16 -0
  294. package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
  295. package/packages/mailx-store-web/web-message-store.js +89 -0
  296. package/packages/mailx-store-web/web-message-store.js.map +1 -0
  297. package/packages/mailx-store-web/web-message-store.ts +97 -0
  298. package/packages/mailx-store-web/web-service.d.ts +111 -0
  299. package/packages/mailx-store-web/web-service.d.ts.map +1 -0
  300. package/packages/mailx-store-web/web-service.js +542 -0
  301. package/packages/mailx-store-web/web-service.js.map +1 -0
  302. package/packages/mailx-store-web/web-service.ts +616 -0
  303. package/packages/mailx-store-web/web-settings.d.ts +83 -0
  304. package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
  305. package/packages/mailx-store-web/web-settings.js +491 -0
  306. package/packages/mailx-store-web/web-settings.js.map +1 -0
  307. package/packages/mailx-store-web/web-settings.ts +522 -0
  308. package/packages/mailx-store-web/worker-bundle.js +6838 -0
  309. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  310. package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
  311. package/packages/mailx-store-web/worker-entry.js +187 -0
  312. package/packages/mailx-store-web/worker-entry.js.map +1 -0
  313. package/packages/mailx-store-web/worker-entry.ts +215 -0
  314. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  315. package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
  316. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
  317. package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
  318. package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
  319. package/packages/mailx-types/.gitattributes +10 -0
  320. package/packages/mailx-types/index.d.ts +319 -0
  321. package/packages/mailx-types/index.d.ts.map +1 -0
  322. package/packages/mailx-types/index.js +243 -0
  323. package/packages/mailx-types/index.js.map +1 -0
  324. package/packages/mailx-types/index.ts +498 -0
  325. package/packages/mailx-types/package.json +16 -0
  326. package/packages/mailx-types/tsconfig.json +9 -0
  327. package/packages/mailx-types/tsconfig.tsbuildinfo +1 -0
  328. package/todo.json +13 -0
  329. package/tsconfig.base.json +31 -0
  330. package/tsconfig.json +9 -0
@@ -0,0 +1,1289 @@
1
+ /**
2
+ * Compose window entry point.
3
+ * Opened as a popup from the main mailx window.
4
+ * Receives init data via window.opener.postMessage or URL params.
5
+ */
6
+ import { createEditor } from "./editor.js";
7
+ import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist, openInWord, closeWordEdit, onEvent } from "../lib/api-client.js";
8
+ import { showContextMenu } from "../components/context-menu.js";
9
+ // Very first line the iframe runs — if this doesn't reach Node, the iframe
10
+ // itself isn't loading or the bridge is completely broken.
11
+ logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
12
+ /** Close compose window */
13
+ function closeCompose() {
14
+ logClientEvent("compose-close");
15
+ // S61: Android WebView's window.close() override is unreliable inside
16
+ // iframes — compose overlay sometimes stays visible after Send. Primary
17
+ // path is a parent postMessage; window.close() is a fallback that also
18
+ // works on desktop/msger where the override DOES fire reliably.
19
+ try {
20
+ parent.postMessage({ type: "mailx-compose-close" }, "*");
21
+ }
22
+ catch { /* */ }
23
+ try {
24
+ window.close();
25
+ }
26
+ catch { /* */ }
27
+ }
28
+ // ── Load editor scripts dynamically ──
29
+ function loadScript(src) {
30
+ return new Promise((resolve, reject) => {
31
+ const s = document.createElement("script");
32
+ s.src = src;
33
+ s.onload = () => resolve();
34
+ s.onerror = () => reject(new Error(`Failed to load ${src}`));
35
+ document.head.appendChild(s);
36
+ });
37
+ }
38
+ function loadCSS(href) {
39
+ const link = document.createElement("link");
40
+ link.rel = "stylesheet";
41
+ link.href = href;
42
+ document.head.appendChild(link);
43
+ }
44
+ async function loadEditorAssets(type) {
45
+ if (type === "tiptap") {
46
+ // tiptap UMD bundles from CDN
47
+ const cdn = "https://cdn.jsdelivr.net/npm";
48
+ await loadScript(`${cdn}/@tiptap/core@2/dist/index.umd.js`);
49
+ await Promise.all([
50
+ loadScript(`${cdn}/@tiptap/starter-kit@2/dist/index.umd.js`),
51
+ loadScript(`${cdn}/@tiptap/extension-link@2/dist/index.umd.js`),
52
+ loadScript(`${cdn}/@tiptap/extension-image@2/dist/index.umd.js`),
53
+ loadScript(`${cdn}/@tiptap/extension-underline@2/dist/index.umd.js`),
54
+ loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`),
55
+ ]);
56
+ }
57
+ else {
58
+ // Quill
59
+ loadCSS("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css");
60
+ await loadScript("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js");
61
+ }
62
+ }
63
+ // ── Determine editor type from settings ──
64
+ //
65
+ // Compose must open fast. The previous flow awaited getVersion() then
66
+ // getSettings() sequentially before the editor was even loaded — any
67
+ // service-side stall (busy sync, slow IMAP, hung OAuth refresh) turned
68
+ // "click Reply" into a multi-second / multi-minute wait with a blank
69
+ // compose window. Local-first: read the editor-type preference from a
70
+ // tiny localStorage cache that we update whenever getSettings succeeds
71
+ // in the background. Default to quill on first run / cache miss.
72
+ let editorType = "quill";
73
+ let appSettings = null;
74
+ try {
75
+ const cached = localStorage.getItem("mailx-editor-type");
76
+ if (cached === "tiptap" || cached === "quill")
77
+ editorType = cached;
78
+ }
79
+ catch { /* private-mode / SecurityError — default quill */ }
80
+ // Refresh the cache asynchronously — doesn't block compose open.
81
+ (async () => {
82
+ try {
83
+ appSettings = await getSettings();
84
+ const next = appSettings?.ui?.editor === "tiptap" ? "tiptap" : "quill";
85
+ try {
86
+ localStorage.setItem("mailx-editor-type", next);
87
+ }
88
+ catch { /* */ }
89
+ // Note: we don't hot-swap the editor if the preference changed while
90
+ // compose was opening — the old type is already instantiated. Next
91
+ // compose open will pick up the new preference.
92
+ }
93
+ catch { /* non-fatal */ }
94
+ })();
95
+ // Whatever happens in editor init, surface failures to the mailx log
96
+ // AND fall through to a plain-contenteditable fallback so the rest of
97
+ // the compose script (Discard, X, Send, save-draft) still runs. Earlier
98
+ // versions re-threw asset-load failures, which left compose with dead
99
+ // buttons and the user with no recourse — exactly the "Reply window
100
+ // won't close" symptom we hit when Quill's CDN was unreachable.
101
+ let editor;
102
+ let editorAssetError = null;
103
+ try {
104
+ await loadEditorAssets(editorType);
105
+ }
106
+ catch (e) {
107
+ editorAssetError = e;
108
+ logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
109
+ }
110
+ const container = document.getElementById("compose-editor");
111
+ container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
112
+ try {
113
+ if (editorAssetError)
114
+ throw editorAssetError;
115
+ editor = await createEditor(container, editorType);
116
+ }
117
+ catch (e) {
118
+ logClientEvent("compose-editor-create-failed", { type: editorType, error: String(e?.message || e) });
119
+ // Render a minimal contenteditable fallback so the user can still type
120
+ // SOMETHING. Without this, an editor failure leaves the compose form
121
+ // half-functional (To/Cc/Bcc work, body doesn't) and the user doesn't
122
+ // know why. The fallback is a plain div — no toolbar, no rich text.
123
+ container.innerHTML = `<div class="compose-fallback-editor" contenteditable="true" style="border:1px solid #c00;padding:8px;min-height:200px;background:#fff" data-fallback="true"></div>`;
124
+ const fallback = container.querySelector(".compose-fallback-editor");
125
+ editor = {
126
+ root: fallback,
127
+ setHtml: (html) => { fallback.innerHTML = html; },
128
+ getHtml: () => fallback.innerHTML,
129
+ getText: () => fallback.innerText,
130
+ focus: () => fallback.focus(),
131
+ setCursor: () => { },
132
+ getScrollContainer: () => fallback,
133
+ onContentChange: (handler) => { fallback.addEventListener("input", handler); },
134
+ onKeyDown: (handler) => { fallback.addEventListener("keydown", handler); },
135
+ insertTextAtCursor: (text) => {
136
+ const sel = window.getSelection();
137
+ if (sel && sel.rangeCount > 0) {
138
+ const range = sel.getRangeAt(0);
139
+ range.deleteContents();
140
+ range.insertNode(document.createTextNode(text));
141
+ }
142
+ else {
143
+ fallback.append(document.createTextNode(text));
144
+ }
145
+ },
146
+ };
147
+ // Surface the failure to the user in the status bar so they know
148
+ // why the toolbar is missing and the editor is plain.
149
+ setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
150
+ }
151
+ // Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
152
+ // Persists per-session in localStorage so zoom survives window pop/close cycles.
153
+ (() => {
154
+ const STORAGE_KEY = "mailx.compose.zoom";
155
+ const MIN = 0.5, MAX = 3, STEP = 0.1;
156
+ let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
157
+ const applyZoom = () => {
158
+ container.style.fontSize = `${zoom}em`;
159
+ localStorage.setItem(STORAGE_KEY, String(zoom));
160
+ };
161
+ applyZoom();
162
+ container.addEventListener("wheel", (e) => {
163
+ if (!e.ctrlKey)
164
+ return;
165
+ e.preventDefault();
166
+ const delta = e.deltaY < 0 ? STEP : -STEP;
167
+ zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
168
+ applyZoom();
169
+ }, { passive: false });
170
+ document.addEventListener("keydown", (e) => {
171
+ if (!(e.ctrlKey || e.metaKey))
172
+ return;
173
+ if (e.key === "=" || e.key === "+") {
174
+ zoom = Math.min(MAX, zoom + STEP);
175
+ applyZoom();
176
+ e.preventDefault();
177
+ }
178
+ else if (e.key === "-") {
179
+ zoom = Math.max(MIN, zoom - STEP);
180
+ applyZoom();
181
+ e.preventDefault();
182
+ }
183
+ else if (e.key === "0") {
184
+ zoom = 1;
185
+ applyZoom();
186
+ e.preventDefault();
187
+ }
188
+ });
189
+ })();
190
+ // ── Populate from init data ──
191
+ // From field is a free-text input with a <datalist> of known accounts. The
192
+ // user can pick a preset or type an arbitrary "Name <addr@domain>" — no
193
+ // separate "Other..." escape hatch, no hidden custom input toggle.
194
+ const fromInput = document.getElementById("compose-from-input");
195
+ const fromOptions = document.getElementById("compose-from-options");
196
+ const toInput = document.getElementById("compose-to");
197
+ const ccInput = document.getElementById("compose-cc");
198
+ const bccInput = document.getElementById("compose-bcc");
199
+ const subjectInput = document.getElementById("compose-subject");
200
+ let knownAccounts = [];
201
+ // ── AI ghost text autocomplete ──
202
+ if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
203
+ import("./ghost-text.js").then(({ initGhostText }) => {
204
+ initGhostText(editor, {
205
+ getSubject: () => subjectInput.value,
206
+ getTo: () => toInput.value,
207
+ }, { debounceMs: appSettings.autocomplete.debounceMs || 600 });
208
+ }).catch(() => { });
209
+ }
210
+ /** Format an account for the From field: "Name <email>". */
211
+ function formatAccountFrom(acct) {
212
+ return `${acct.name} <${acct.email}>`;
213
+ }
214
+ const FROM_HISTORY_KEY = "mailx-from-history"; // up to 20 recent manual From entries
215
+ const FROM_HISTORY_MAX = 20;
216
+ function loadFromHistory() {
217
+ try {
218
+ return JSON.parse(localStorage.getItem(FROM_HISTORY_KEY) || "[]");
219
+ }
220
+ catch {
221
+ return [];
222
+ }
223
+ }
224
+ function recordFromHistory(value) {
225
+ const v = (value || "").trim();
226
+ if (!v)
227
+ return;
228
+ try {
229
+ const list = loadFromHistory().filter(x => x !== v);
230
+ list.unshift(v);
231
+ localStorage.setItem(FROM_HISTORY_KEY, JSON.stringify(list.slice(0, FROM_HISTORY_MAX)));
232
+ }
233
+ catch { /* private mode */ }
234
+ }
235
+ /** Populate the From <datalist> with one entry per known account plus any
236
+ * manually-typed addresses from localStorage history. Account entries rank
237
+ * first; history entries get an "(used before)" label so the user can tell
238
+ * which ones are real accounts vs free-form aliases. */
239
+ function populateFromOptions(accounts, selectedId) {
240
+ knownAccounts = accounts;
241
+ fromOptions.innerHTML = "";
242
+ const seenValues = new Set();
243
+ for (const acct of accounts) {
244
+ const opt = document.createElement("option");
245
+ opt.value = formatAccountFrom(acct);
246
+ const tag = acct.label || acct.name;
247
+ opt.label = tag;
248
+ fromOptions.appendChild(opt);
249
+ seenValues.add(opt.value);
250
+ }
251
+ // Custom From history — addresses the user has typed before that don't
252
+ // match any known account (aliases, +tag addresses, one-off identities).
253
+ // Stored in localStorage because they're inherently per-device preferences;
254
+ // moving them to an account profile would be a different feature.
255
+ for (const value of loadFromHistory()) {
256
+ if (seenValues.has(value))
257
+ continue;
258
+ const opt = document.createElement("option");
259
+ opt.value = value;
260
+ opt.label = "(used before)";
261
+ fromOptions.appendChild(opt);
262
+ }
263
+ if (!fromInput.value) {
264
+ const selected = (selectedId && accounts.find(a => a.id === selectedId)) ||
265
+ accounts.find(a => a.defaultSend) ||
266
+ accounts[0];
267
+ if (selected)
268
+ fromInput.value = formatAccountFrom(selected);
269
+ }
270
+ }
271
+ /** Parse the current From input into { name, address } for header building. */
272
+ function parseFromInput() {
273
+ const raw = fromInput.value.trim();
274
+ const match = raw.match(/^(.+?)\s*<(.+?)>$/);
275
+ if (match)
276
+ return { name: match[1].trim(), address: match[2].trim() };
277
+ return { name: "", address: raw };
278
+ }
279
+ /** Match the From input's address against the known accounts table and
280
+ * return that account's id. Used by send() / saveDraft() to decide which
281
+ * account to send through. Falls back to defaultSend, then first account. */
282
+ function getFromAccountId() {
283
+ const { address } = parseFromInput();
284
+ const lower = address.toLowerCase();
285
+ // Exact match wins
286
+ const exact = knownAccounts.find(a => a.email.toLowerCase() === lower);
287
+ if (exact)
288
+ return exact.id;
289
+ // Same-domain match — handles +tag aliases and identity addresses
290
+ const domain = lower.split("@")[1] || "";
291
+ if (domain) {
292
+ const sameDomain = knownAccounts.find(a => a.email.toLowerCase().endsWith("@" + domain));
293
+ if (sameDomain)
294
+ return sameDomain.id;
295
+ }
296
+ // Give up — use default send account or the first account
297
+ const def = knownAccounts.find(a => a.defaultSend) || knownAccounts[0];
298
+ return def?.id || "";
299
+ }
300
+ /** Get the raw From header string ("Name <addr>"). */
301
+ function getFromAddress() {
302
+ return fromInput.value.trim();
303
+ }
304
+ /** Smart tab — skip to next empty field, ending at body */
305
+ function smartTab(current) {
306
+ const fields = [toInput, ccInput, bccInput, subjectInput];
307
+ const currentIdx = fields.indexOf(current);
308
+ // Look for next empty field after current
309
+ for (let i = currentIdx + 1; i < fields.length; i++) {
310
+ if (!fields[i].value.trim()) {
311
+ fields[i].focus();
312
+ return;
313
+ }
314
+ }
315
+ // All fields filled or past the end — go to editor body
316
+ editor.focus();
317
+ }
318
+ // ── Autocomplete ──
319
+ /** Right-click on an autocomplete row → contextual actions. Two paths:
320
+ * - Add to preferred (small modal: name / email / source-tag / org → write to
321
+ * contacts.jsonc#preferred[])
322
+ * - Never suggest this address (write to contacts.jsonc#denylist[]; the
323
+ * service-side handler purges any matching discovered rows on apply) */
324
+ function showAutocompleteContextMenu(e, row) {
325
+ showContextMenu(e.clientX, e.clientY, [
326
+ {
327
+ label: "Add to preferred…",
328
+ action: () => openAddToPreferredModal(row),
329
+ },
330
+ {
331
+ label: "Never suggest this address",
332
+ action: async () => {
333
+ try {
334
+ await addToDenylist(row.email);
335
+ }
336
+ catch (err) {
337
+ alert(`Failed to add to denylist: ${err?.message || err}`);
338
+ }
339
+ },
340
+ },
341
+ ]);
342
+ }
343
+ function openAddToPreferredModal(prefill) {
344
+ const overlay = document.createElement("div");
345
+ overlay.className = "modal-overlay";
346
+ overlay.innerHTML = `
347
+ <div class="modal" role="dialog" aria-label="Add to preferred contacts">
348
+ <h3>Add to preferred</h3>
349
+ <p class="muted">Saved to <code>contacts.jsonc</code> on your shared drive.</p>
350
+ <label>Name <input type="text" id="pf-name" /></label>
351
+ <label>Email <input type="email" id="pf-email" /></label>
352
+ <label>Source tag <input type="text" id="pf-source" placeholder="(optional — e.g. work, family)" /></label>
353
+ <label>Organization <input type="text" id="pf-org" placeholder="(optional)" /></label>
354
+ <div class="modal-actions">
355
+ <button id="pf-cancel">Cancel</button>
356
+ <button id="pf-save" class="primary">Save</button>
357
+ </div>
358
+ </div>
359
+ `;
360
+ document.body.appendChild(overlay);
361
+ overlay.querySelector("#pf-name").value = prefill.name || "";
362
+ overlay.querySelector("#pf-email").value = prefill.email || "";
363
+ // Pre-fill source from existing tag if it's already a custom one (not a system source).
364
+ const sysSources = new Set(["google", "discovered", "preferred", ""]);
365
+ const initSource = sysSources.has(prefill.source || "") ? "" : prefill.source;
366
+ overlay.querySelector("#pf-source").value = initSource;
367
+ const close = () => overlay.remove();
368
+ overlay.querySelector("#pf-cancel").addEventListener("click", close);
369
+ overlay.addEventListener("click", (ev) => { if (ev.target === overlay)
370
+ close(); });
371
+ overlay.querySelector("#pf-save").addEventListener("click", async () => {
372
+ const name = overlay.querySelector("#pf-name").value.trim();
373
+ const email = overlay.querySelector("#pf-email").value.trim();
374
+ const source = overlay.querySelector("#pf-source").value.trim();
375
+ const org = overlay.querySelector("#pf-org").value.trim();
376
+ if (!email) {
377
+ alert("Email is required.");
378
+ return;
379
+ }
380
+ try {
381
+ await addPreferredContact({ name, email, source, organization: org });
382
+ close();
383
+ }
384
+ catch (err) {
385
+ alert(`Failed to save: ${err?.message || err}`);
386
+ }
387
+ });
388
+ overlay.querySelector("#pf-name").focus();
389
+ }
390
+ function setupAutocomplete(input) {
391
+ let dropdown = null;
392
+ let activeIndex = -1;
393
+ let debounce;
394
+ function closeDropdown() {
395
+ if (dropdown) {
396
+ dropdown.remove();
397
+ dropdown = null;
398
+ }
399
+ activeIndex = -1;
400
+ }
401
+ function getLastToken() {
402
+ const val = input.value;
403
+ const lastComma = val.lastIndexOf(",");
404
+ return val.substring(lastComma + 1).trim();
405
+ }
406
+ function replaceLastToken(replacement) {
407
+ const val = input.value;
408
+ const lastComma = val.lastIndexOf(",");
409
+ const prefix = lastComma >= 0 ? val.substring(0, lastComma + 1) + " " : "";
410
+ input.value = prefix + replacement + ", ";
411
+ closeDropdown();
412
+ input.focus();
413
+ }
414
+ input.addEventListener("input", () => {
415
+ clearTimeout(debounce);
416
+ const token = getLastToken();
417
+ if (token.length < 1) {
418
+ closeDropdown();
419
+ return;
420
+ }
421
+ debounce = setTimeout(() => {
422
+ // rAF yield before hitting the DB — S60 mitigation, same reason
423
+ // as the draft-save path. The 200 ms timer already deferred past
424
+ // the input burst; this extra frame lets the last keystroke paint.
425
+ requestAnimationFrame(async () => {
426
+ try {
427
+ const results = await searchContacts(token);
428
+ if (results.length === 0) {
429
+ closeDropdown();
430
+ return;
431
+ }
432
+ closeDropdown();
433
+ dropdown = document.createElement("div");
434
+ dropdown.className = "ac-dropdown";
435
+ activeIndex = 0; // first item highlighted by default
436
+ for (let i = 0; i < results.length; i++) {
437
+ const item = document.createElement("div");
438
+ item.className = `ac-item${i === 0 ? " ac-active" : ""}`;
439
+ const nameEl = document.createElement("span");
440
+ nameEl.className = "ac-item-name";
441
+ nameEl.textContent = results[i].name || results[i].email;
442
+ const emailEl = document.createElement("span");
443
+ emailEl.className = "ac-item-email";
444
+ emailEl.textContent = results[i].email;
445
+ // Source badge — shows where this row came from. Custom
446
+ // user tags from contacts.jsonc preferred entries
447
+ // (`source: "work"`, `source: "family"`) flow through
448
+ // verbatim; system sources show as 'google' / 'discovered'
449
+ // / 'preferred'. Helps disambiguate two rows with the
450
+ // same email but different names (Bob's wife vs Bob Smith).
451
+ const sourceEl = document.createElement("span");
452
+ sourceEl.className = "ac-item-source";
453
+ sourceEl.textContent = results[i].source || "";
454
+ item.appendChild(nameEl);
455
+ if (results[i].name)
456
+ item.appendChild(emailEl);
457
+ if (results[i].source)
458
+ item.appendChild(sourceEl);
459
+ item.addEventListener("mousedown", (e) => {
460
+ e.preventDefault();
461
+ const display = results[i].name
462
+ ? `${results[i].name} <${results[i].email}>`
463
+ : results[i].email;
464
+ replaceLastToken(display);
465
+ });
466
+ // Right-click → contextual actions on this autocomplete
467
+ // row. Two paths: promote to preferred (writes to
468
+ // contacts.jsonc#preferred[]) or denylist (writes to
469
+ // contacts.jsonc#denylist[] and purges any matching
470
+ // discovered rows). Both round-trip through cloudWrite.
471
+ item.addEventListener("contextmenu", (e) => {
472
+ e.preventDefault();
473
+ e.stopPropagation();
474
+ showAutocompleteContextMenu(e, results[i]);
475
+ });
476
+ dropdown.appendChild(item);
477
+ }
478
+ input.parentElement.appendChild(dropdown);
479
+ }
480
+ catch { /* ignore */ }
481
+ });
482
+ }, 200);
483
+ });
484
+ input.addEventListener("keydown", (e) => {
485
+ if (!dropdown)
486
+ return;
487
+ const items = dropdown.querySelectorAll(".ac-item");
488
+ if (e.key === "ArrowDown") {
489
+ e.preventDefault();
490
+ activeIndex = Math.min(activeIndex + 1, items.length - 1);
491
+ items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
492
+ }
493
+ else if (e.key === "ArrowUp") {
494
+ e.preventDefault();
495
+ activeIndex = Math.max(activeIndex - 1, 0);
496
+ items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
497
+ }
498
+ else if (e.key === "Tab" || e.key === "Enter") {
499
+ if (items.length > 0) {
500
+ e.preventDefault();
501
+ const idx = activeIndex >= 0 ? activeIndex : 0;
502
+ items[idx].dispatchEvent(new MouseEvent("mousedown"));
503
+ // Stay in field — user may want to add more addresses
504
+ return;
505
+ }
506
+ }
507
+ else if (e.key === "Escape") {
508
+ closeDropdown();
509
+ }
510
+ });
511
+ input.addEventListener("blur", () => {
512
+ setTimeout(closeDropdown, 150);
513
+ });
514
+ }
515
+ setupAutocomplete(toInput);
516
+ setupAutocomplete(ccInput);
517
+ setupAutocomplete(bccInput);
518
+ function formatAddrs(addrs) {
519
+ return addrs.map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
520
+ }
521
+ function parseAddrs(s) {
522
+ if (!s.trim())
523
+ return [];
524
+ // Split on commas and drop empty segments. This handles trailing commas
525
+ // ("foo@x.com,") and stray whitespace ("foo@x.com, ,bar@y.com") without
526
+ // producing phantom empty addresses that fail validation on send.
527
+ return s.split(",")
528
+ .map(p => p.trim())
529
+ .filter(p => p.length > 0)
530
+ .map(part => {
531
+ const match = part.match(/^(.+?)\s*<(.+?)>$/);
532
+ if (match)
533
+ return { name: match[1].trim(), address: match[2].trim() };
534
+ return { name: "", address: part };
535
+ });
536
+ }
537
+ function applyInit(init) {
538
+ // Populate the From datalist with known accounts
539
+ populateFromOptions(init.accounts, init.accountId);
540
+ // If the reply has a specific identity address (alias / +tag), set it
541
+ // as the From value directly — overrides the account default.
542
+ if (init.fromAddress) {
543
+ const account = init.accounts.find(a => a.id === init.accountId);
544
+ const displayName = account?.name || "";
545
+ fromInput.value = displayName ? `${displayName} <${init.fromAddress}>` : init.fromAddress;
546
+ }
547
+ toInput.value = formatAddrs(init.to);
548
+ ccInput.value = formatAddrs(init.cc);
549
+ subjectInput.value = init.subject;
550
+ // Auto-expand Cc row if the init already has Cc content (reply-all, draft-with-cc)
551
+ if (ccInput.value.trim()) {
552
+ const ccRowEl = document.getElementById("compose-cc-row");
553
+ const ccBtn = document.getElementById("btn-toggle-cc");
554
+ if (ccRowEl)
555
+ ccRowEl.hidden = false;
556
+ if (ccBtn)
557
+ ccBtn.classList.add("active");
558
+ }
559
+ else if (init.to && init.to.length === 1) {
560
+ // Q49: heuristic auto-expand — when replying/composing to a single
561
+ // recipient, check sent-history. If the user has previously Cc'd or
562
+ // Bcc'd anyone on a message to this recipient, expand the matching
563
+ // row (empty, just visible) so they're prompted to fill it.
564
+ // Fire-and-forget; if the service call fails or the user starts
565
+ // typing manually before it resolves, the answer doesn't matter.
566
+ const firstEmail = init.to[0]?.address || "";
567
+ if (firstEmail) {
568
+ import("../lib/api-client.js").then(({ hasCcHistoryTo, hasBccHistoryTo }) => {
569
+ hasCcHistoryTo(firstEmail)
570
+ .then(res => {
571
+ if (!res?.hasCc)
572
+ return;
573
+ const ccRowEl = document.getElementById("compose-cc-row");
574
+ const ccBtn = document.getElementById("btn-toggle-cc");
575
+ if (ccRowEl?.hidden && !ccInput.value) {
576
+ ccRowEl.hidden = false;
577
+ ccBtn?.classList.add("active");
578
+ }
579
+ })
580
+ .catch(() => { });
581
+ hasBccHistoryTo(firstEmail)
582
+ .then(res => {
583
+ if (!res?.hasBcc)
584
+ return;
585
+ const bccRowEl = document.getElementById("compose-bcc-row");
586
+ const bccBtn = document.getElementById("btn-toggle-bcc");
587
+ if (bccRowEl?.hidden && !bccInput.value) {
588
+ bccRowEl.hidden = false;
589
+ bccBtn?.classList.add("active");
590
+ }
591
+ })
592
+ .catch(() => { });
593
+ });
594
+ }
595
+ }
596
+ // C42: append the account's signature (if configured) BEFORE rendering
597
+ // the body. Two sources, in priority order:
598
+ // 1. New `sig: { text, html? }` object — applied to NEW messages only.
599
+ // `text` is HTML-escaped (newlines → <br>) unless `html: true` is set
600
+ // (reserved for future use; currently `html` is ignored and text is
601
+ // always escaped).
602
+ // 2. Legacy `signature: string` — HTML, applied to new + reply + forward.
603
+ //
604
+ // Drafts are skipped — the signature is already baked into the saved body.
605
+ // Editing an existing draft also skipped.
606
+ let bodyToRender = init.bodyHtml || "";
607
+ const acct = init.accounts.find(a => a.id === init.accountId);
608
+ const isNew = init.mode !== "reply" && init.mode !== "replyAll"
609
+ && init.mode !== "forward" && init.mode !== "draft" && !init.draftUid;
610
+ const isReplyForward = init.mode === "reply" || init.mode === "replyAll"
611
+ || init.mode === "forward";
612
+ if (isNew && acct?.sig?.text) {
613
+ const sigText = acct.sig.html
614
+ ? acct.sig.text // future: trust as raw HTML
615
+ : escapeHtml(acct.sig.text).replace(/\n/g, "<br>");
616
+ bodyToRender = `${bodyToRender}<br><br>-- <br>${sigText}`;
617
+ }
618
+ else if (acct?.signature && init.mode !== "draft" && !init.draftUid) {
619
+ const sigBlock = `<br><br>--<br>${acct.signature}`;
620
+ bodyToRender = isReplyForward
621
+ ? `<br>${sigBlock}<br>${bodyToRender}` // sig above the quote
622
+ : `${bodyToRender}${sigBlock}`; // sig at the end for new
623
+ }
624
+ if (bodyToRender) {
625
+ editor.setHtml(bodyToRender);
626
+ editor.setCursor(0);
627
+ }
628
+ // If resuming a draft, track its UID for deletion after send
629
+ if (init.draftUid) {
630
+ draftUid = init.draftUid;
631
+ }
632
+ setComposeTitle(init.subject || "");
633
+ // Focus first empty field: To → Subject → body
634
+ if (!toInput.value)
635
+ toInput.focus();
636
+ else if (!subjectInput.value)
637
+ subjectInput.focus();
638
+ else
639
+ editor.focus();
640
+ }
641
+ // Q68: dirty marker (•) in the window title until the next successful save.
642
+ let composeDirty = false;
643
+ function setComposeTitle(subject) {
644
+ const base = subject ? `${subject} - Compose` : "Compose - mailx";
645
+ document.title = composeDirty ? `• ${base}` : base;
646
+ }
647
+ function markComposeDirty() {
648
+ if (composeDirty)
649
+ return;
650
+ composeDirty = true;
651
+ setComposeTitle(subjectInput?.value || "");
652
+ }
653
+ function markComposeClean() {
654
+ if (!composeDirty)
655
+ return;
656
+ composeDirty = false;
657
+ setComposeTitle(subjectInput?.value || "");
658
+ }
659
+ // ── Compose state (declared before init so the async IIFE can reference them) ──
660
+ const DRAFT_INPUT_DEBOUNCE_MS = 1500; // save ~1.5s after the last keystroke
661
+ const DRAFT_INTERVAL_MS = 5000; // safety-net interval save
662
+ let draftUid = null;
663
+ let draftId = null; // stable ID for dedup when APPENDUID unavailable
664
+ let draftTimer = null;
665
+ let draftDebounceTimer = null;
666
+ let lastDraftContent = "";
667
+ let draftSaving = false; // prevent concurrent saves
668
+ let draftSaveFailed = false; // surfaced in the compose status tag
669
+ const attachments = [];
670
+ function showDraftStatus(text, isError) {
671
+ const status = document.getElementById("compose-status");
672
+ if (!status)
673
+ return;
674
+ status.textContent = text;
675
+ status.classList.toggle("compose-status-error", isError);
676
+ }
677
+ async function saveDraft() {
678
+ if (draftSaving)
679
+ return; // previous save still in flight
680
+ const content = editor.getHtml() + subjectInput.value + toInput.value;
681
+ if (content === lastDraftContent)
682
+ return; // no changes
683
+ if (!editor.getText().trim() && !subjectInput.value && !toInput.value)
684
+ return; // empty
685
+ // Expose to window for blur-handler.
686
+ window.__mailxSaveDraft = saveDraft;
687
+ lastDraftContent = content;
688
+ draftSaving = true;
689
+ try {
690
+ const data = await apiSaveDraft({
691
+ accountId: getFromAccountId(),
692
+ subject: subjectInput.value,
693
+ bodyHtml: editor.getHtml(),
694
+ bodyText: editor.getText(),
695
+ to: toInput.value,
696
+ cc: ccInput.value,
697
+ previousDraftUid: draftUid,
698
+ draftId: draftId,
699
+ });
700
+ if (data?.draftUid)
701
+ draftUid = data.draftUid;
702
+ if (data?.draftId)
703
+ draftId = data.draftId;
704
+ if (draftSaveFailed) {
705
+ draftSaveFailed = false;
706
+ showDraftStatus("Draft saved", false);
707
+ }
708
+ else
709
+ showDraftStatus(`Draft saved ${new Date().toLocaleTimeString()}`, false);
710
+ markComposeClean();
711
+ }
712
+ catch (e) {
713
+ // Surface the error — silent failures are how drafts get lost on IMAP hiccups.
714
+ // The local editing/ checkpoint already exists server-side regardless.
715
+ console.error("[draft] save failed:", e);
716
+ draftSaveFailed = true;
717
+ showDraftStatus(`Draft save failed: ${e?.message || e}`, true);
718
+ // Clear lastDraftContent so the next tick retries the same content
719
+ lastDraftContent = "";
720
+ }
721
+ finally {
722
+ draftSaving = false;
723
+ }
724
+ }
725
+ /** Schedule a debounced save on user input — fires ~1.5s after the last
726
+ * keystroke, then yields one animation frame before actually writing so
727
+ * the browser can paint any keystroke in the mean time. S60 mitigation:
728
+ * wa-sqlite writes are synchronous on Android; this keeps the typing
729
+ * experience responsive by never running the write in the same task as
730
+ * an input event. */
731
+ function scheduleDraftSave() {
732
+ markComposeDirty();
733
+ if (draftDebounceTimer)
734
+ clearTimeout(draftDebounceTimer);
735
+ draftDebounceTimer = setTimeout(() => {
736
+ draftDebounceTimer = null;
737
+ // rAF yield — lets any pending keystroke render before we block on
738
+ // the DB write. A no-op when the tab is hidden (rAF is throttled),
739
+ // which is fine because the user isn't typing then either.
740
+ requestAnimationFrame(() => { saveDraft(); });
741
+ }, DRAFT_INPUT_DEBOUNCE_MS);
742
+ }
743
+ // ── Initialize: local-first population.
744
+ //
745
+ // Reply / Reply-All / Forward callers pre-populate `init.accounts` with the
746
+ // full account list (app.ts:openCompose). In that common case we do NOT need
747
+ // to call getAccounts() — everything required to fill the compose form is
748
+ // already in sessionStorage and reads synchronously. That turns "click Reply"
749
+ // into an instant-open instead of "wait for getAccounts IPC to respond,
750
+ // which can take >120s when the service is busy syncing / hung on IMAP".
751
+ //
752
+ // getAccounts is still called (non-blocking) to refresh the dropdown with
753
+ // the freshest data — and it IS awaited only in the fallback path where
754
+ // init doesn't have an account list (message-viewer's Edit Draft passes
755
+ // init.accounts=[]).
756
+ (async () => {
757
+ const stored = sessionStorage.getItem("composeInit");
758
+ if (stored) {
759
+ sessionStorage.removeItem("composeInit");
760
+ const init = JSON.parse(stored);
761
+ if (init.accounts && init.accounts.length > 0) {
762
+ // Happy path — init is complete. Apply immediately. Kick
763
+ // getAccounts in the background to refresh the dropdown if the
764
+ // user keeps compose open long enough for the result.
765
+ applyInit(init);
766
+ getAccounts().then((fresh) => {
767
+ if (Array.isArray(fresh) && fresh.length > 0) {
768
+ init.accounts = fresh;
769
+ // Re-populate the From dropdown only — don't clobber
770
+ // anything the user may have already typed.
771
+ try {
772
+ populateFromOptions(fresh);
773
+ }
774
+ catch { /* */ }
775
+ }
776
+ }).catch(() => { });
777
+ }
778
+ else {
779
+ // Edit Draft / other callers that didn't pre-fill accounts.
780
+ // Have to wait on getAccounts here — the From dropdown needs it.
781
+ let fresh = [];
782
+ try {
783
+ fresh = await getAccounts();
784
+ }
785
+ catch (e) {
786
+ console.error("Failed to load accounts:", e);
787
+ }
788
+ init.accounts = fresh;
789
+ applyInit(init);
790
+ }
791
+ }
792
+ else {
793
+ let accounts = [];
794
+ try {
795
+ accounts = await getAccounts();
796
+ }
797
+ catch (e) {
798
+ console.error("Failed to load accounts:", e);
799
+ }
800
+ populateFromOptions(accounts);
801
+ toInput.focus();
802
+ }
803
+ // Wire debounced saves to input events — checkpoint ~1.5s after the last
804
+ // keystroke instead of waiting up to 5s for the interval tick.
805
+ toInput.addEventListener("input", scheduleDraftSave);
806
+ ccInput.addEventListener("input", scheduleDraftSave);
807
+ bccInput.addEventListener("input", scheduleDraftSave);
808
+ subjectInput.addEventListener("input", scheduleDraftSave);
809
+ editor.onContentChange(scheduleDraftSave);
810
+ // Safety-net interval: even with no user input, catch any edge cases.
811
+ draftTimer = setInterval(saveDraft, DRAFT_INTERVAL_MS);
812
+ // Flush the draft on window close so the last-typed content lands in
813
+ // editing/ even if the interval tick hasn't fired yet. navigator.sendBeacon
814
+ // is synchronous enough to survive unload; callNode IPC would be dropped.
815
+ window.addEventListener("beforeunload", () => {
816
+ if (draftDebounceTimer) {
817
+ clearTimeout(draftDebounceTimer);
818
+ draftDebounceTimer = null;
819
+ }
820
+ // fire-and-forget — can't await during unload
821
+ saveDraft();
822
+ });
823
+ })();
824
+ // ── Send ──
825
+ // Q55: Ctrl+Enter (or Cmd+Enter on macOS) anywhere in compose triggers send.
826
+ document.addEventListener("keydown", (e) => {
827
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
828
+ e.preventDefault();
829
+ document.getElementById("btn-send")?.click();
830
+ }
831
+ });
832
+ // Q59: autosave when the window loses focus (in addition to debounce + interval).
833
+ window.addEventListener("blur", () => {
834
+ // Use the same saveDraft path as the 5s interval.
835
+ try {
836
+ window.__mailxSaveDraft?.();
837
+ }
838
+ catch { /* */ }
839
+ });
840
+ document.getElementById("btn-send")?.addEventListener("click", () => {
841
+ // Loud tracing through the whole send pipeline. Every step ships a
842
+ // `[client] compose-send-*` event to the Node log so a "vanished message"
843
+ // report can be traced end-to-end without devtools. If the log stops
844
+ // at any step, that's where the pipeline broke.
845
+ logClientEvent("compose-send-click");
846
+ const body = {
847
+ from: getFromAccountId(),
848
+ fromAddress: getFromAddress(),
849
+ to: parseAddrs(toInput.value),
850
+ cc: parseAddrs(ccInput.value),
851
+ bcc: parseAddrs(bccInput.value),
852
+ subject: subjectInput.value,
853
+ bodyHtml: editor.getHtml(),
854
+ bodyText: editor.getText(),
855
+ attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
856
+ };
857
+ logClientEvent("compose-send-body-built", { from: body.from, toCount: body.to.length, subjectLen: (body.subject || "").length, bodyHtmlLen: (body.bodyHtml || "").length, atts: body.attachments.length });
858
+ // Local validity (one missing-To check) — must run before close so the
859
+ // user gets an inline error instead of silent loss. Anything else (real
860
+ // address validation, MIME assembly, disk write) happens server-side.
861
+ if (!body.to.length) {
862
+ logClientEvent("compose-send-rejected-no-to");
863
+ alert("Please add at least one To recipient.");
864
+ return;
865
+ }
866
+ console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
867
+ // Wait for the IPC round-trip before closing compose. The IPC is supposed
868
+ // to be <100ms (Node validates + disk-writes synchronously inside send()
869
+ // then returns). If it throws OR takes too long, the user keeps their
870
+ // typed message and sees the error inline instead of losing it to a
871
+ // fire-and-forget that never landed. Earlier fire-and-forget version lost
872
+ // messages silently when anything upstream of disk write broke.
873
+ const sendBtn = document.getElementById("btn-send");
874
+ if (sendBtn) {
875
+ sendBtn.disabled = true;
876
+ sendBtn.textContent = "Sending…";
877
+ }
878
+ const statusEl = document.getElementById("compose-status");
879
+ if (statusEl)
880
+ statusEl.textContent = "";
881
+ const sendStart = Date.now();
882
+ logClientEvent("compose-send-pre-ipc");
883
+ // Parent-window relay for send. Empirical observation: Android (direct
884
+ // in-process SMTP) sends reliably; desktop (iframe → parent.mailxapi →
885
+ // msger → Node → service) has sendMessage IPCs failing to reach Node
886
+ // for reasons still unknown (iframe bridge behaves differently from the
887
+ // top frame in msger's WebView2 for this specific call). Meanwhile the
888
+ // parent window's bridge is proven — getAccounts / getOutboxStatus run
889
+ // through it every few seconds with no failures.
890
+ //
891
+ // Fix: the iframe doesn't touch the bridge at all for send. It posts
892
+ // a request to the parent, and the parent calls the real sendMessage
893
+ // from its own frame. Parent posts the result back. This bypasses
894
+ // whatever is wrong with iframe-scoped IPC.
895
+ const ipcPromise = new Promise((resolve, reject) => {
896
+ const reqId = `send-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
897
+ const timer = setTimeout(() => {
898
+ window.removeEventListener("message", onMsg);
899
+ reject(new Error("parent-relay send timeout (120s)"));
900
+ }, 120000);
901
+ const onMsg = (ev) => {
902
+ if (!ev.data || ev.data.type !== "mailx-compose-send-result" || ev.data.id !== reqId)
903
+ return;
904
+ clearTimeout(timer);
905
+ window.removeEventListener("message", onMsg);
906
+ if (ev.data.ok)
907
+ resolve();
908
+ else
909
+ reject(new Error(ev.data.error || "unknown"));
910
+ };
911
+ window.addEventListener("message", onMsg);
912
+ try {
913
+ parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
914
+ logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
915
+ }
916
+ catch (e) {
917
+ clearTimeout(timer);
918
+ window.removeEventListener("message", onMsg);
919
+ reject(e);
920
+ }
921
+ });
922
+ Promise.resolve(ipcPromise)
923
+ .then(() => {
924
+ logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
925
+ console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
926
+ // Record From-address history on successful send. Only manual
927
+ // values worth keeping — skip anything that exactly matches a
928
+ // known account (already in the dropdown), and skip obviously
929
+ // invalid inputs. Populated dropdown surfaces this next time.
930
+ try {
931
+ const raw = fromInput.value.trim();
932
+ const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
933
+ if (raw && !known && /@.+\./.test(raw))
934
+ recordFromHistory(raw);
935
+ }
936
+ catch { /* */ }
937
+ // Stop autosave only after ACK — if send threw we want the draft
938
+ // autosave to keep the message safe.
939
+ if (draftTimer) {
940
+ clearInterval(draftTimer);
941
+ draftTimer = null;
942
+ }
943
+ if (draftUid || draftId) {
944
+ deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
945
+ }
946
+ closeCompose();
947
+ })
948
+ .catch((e) => {
949
+ const msg = e?.message || String(e);
950
+ logClientEvent("compose-send-ipc-rejected", { error: msg, ms: Date.now() - sendStart });
951
+ console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
952
+ if (sendBtn) {
953
+ sendBtn.disabled = false;
954
+ sendBtn.textContent = "Send";
955
+ }
956
+ if (statusEl)
957
+ statusEl.textContent = `Send failed: ${msg}`;
958
+ try {
959
+ parent.postMessage({ type: "mailx-send-error", message: msg, accountId: body.from }, "*");
960
+ }
961
+ catch { /* */ }
962
+ });
963
+ });
964
+ // ── Close handling ──
965
+ /** True if the compose has anything worth asking about. */
966
+ function composeHasContent() {
967
+ return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
968
+ }
969
+ /** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel".
970
+ * Uses an in-page modal so all three choices are presented at once — the
971
+ * native confirm() flow forced the user through two sequential dialogs and
972
+ * hid Discard behind a Cancel click, which was confusing. */
973
+ function promptSaveOrDiscard() {
974
+ return new Promise(resolve => {
975
+ const overlay = document.createElement("div");
976
+ overlay.className = "compose-modal-overlay";
977
+ const box = document.createElement("div");
978
+ box.className = "compose-modal";
979
+ const msg = document.createElement("div");
980
+ msg.className = "compose-modal-msg";
981
+ msg.textContent = "Save this message as a draft?";
982
+ const btnRow = document.createElement("div");
983
+ btnRow.className = "compose-modal-buttons";
984
+ const mkBtn = (label, choice, primary) => {
985
+ const b = document.createElement("button");
986
+ b.type = "button";
987
+ b.textContent = label;
988
+ b.className = primary ? "compose-modal-btn primary" : "compose-modal-btn";
989
+ b.addEventListener("click", () => { cleanup(); resolve(choice); });
990
+ return b;
991
+ };
992
+ const cleanup = () => {
993
+ document.removeEventListener("keydown", onKey);
994
+ overlay.remove();
995
+ };
996
+ const onKey = (e) => {
997
+ if (e.key === "Escape") {
998
+ e.preventDefault();
999
+ cleanup();
1000
+ resolve("cancel");
1001
+ }
1002
+ else if (e.key === "Enter") {
1003
+ e.preventDefault();
1004
+ cleanup();
1005
+ resolve("save");
1006
+ }
1007
+ };
1008
+ document.addEventListener("keydown", onKey);
1009
+ btnRow.appendChild(mkBtn("Save draft", "save", true));
1010
+ btnRow.appendChild(mkBtn("Discard", "discard", false));
1011
+ btnRow.appendChild(mkBtn("Cancel", "cancel", false));
1012
+ box.appendChild(msg);
1013
+ box.appendChild(btnRow);
1014
+ overlay.appendChild(box);
1015
+ document.body.appendChild(overlay);
1016
+ btnRow.firstChild.focus();
1017
+ });
1018
+ }
1019
+ /** Handle any "close the compose" action (Discard button, Escape, X, window close). */
1020
+ async function handleCloseRequest() {
1021
+ if (!composeHasContent()) {
1022
+ closeCompose();
1023
+ return true;
1024
+ }
1025
+ const choice = await promptSaveOrDiscard();
1026
+ if (choice === "cancel")
1027
+ return false;
1028
+ // Stop auto-save so it can't race with our explicit save/discard.
1029
+ if (draftDebounceTimer) {
1030
+ clearTimeout(draftDebounceTimer);
1031
+ draftDebounceTimer = null;
1032
+ }
1033
+ if (draftTimer) {
1034
+ clearInterval(draftTimer);
1035
+ draftTimer = null;
1036
+ }
1037
+ if (choice === "save") {
1038
+ try {
1039
+ await saveDraft();
1040
+ }
1041
+ catch { /* already logged */ }
1042
+ }
1043
+ else {
1044
+ // Discard: if we have a tracked draft, delete it so the orphan doesn't stick around.
1045
+ if (draftUid || draftId) {
1046
+ try {
1047
+ await deleteDraft(getFromAccountId(), draftUid || 0, draftId || "");
1048
+ }
1049
+ catch { /* ignore */ }
1050
+ }
1051
+ }
1052
+ closeCompose();
1053
+ return true;
1054
+ }
1055
+ document.getElementById("btn-discard")?.addEventListener("click", () => {
1056
+ handleCloseRequest();
1057
+ });
1058
+ // ── Cc / Bcc toggle ──
1059
+ const ccRow = document.getElementById("compose-cc-row");
1060
+ const bccRow = document.getElementById("compose-bcc-row");
1061
+ const toggleCcBtn = document.getElementById("btn-toggle-cc");
1062
+ const toggleBccBtn = document.getElementById("btn-toggle-bcc");
1063
+ function setCcVisible(visible) {
1064
+ ccRow.hidden = !visible;
1065
+ toggleCcBtn.classList.toggle("active", visible);
1066
+ if (visible)
1067
+ ccInput.focus();
1068
+ else
1069
+ ccInput.value = "";
1070
+ }
1071
+ function setBccVisible(visible) {
1072
+ bccRow.hidden = !visible;
1073
+ toggleBccBtn.classList.toggle("active", visible);
1074
+ if (visible)
1075
+ bccInput.focus();
1076
+ else
1077
+ bccInput.value = "";
1078
+ }
1079
+ toggleCcBtn?.addEventListener("click", () => setCcVisible(ccRow.hidden));
1080
+ toggleBccBtn?.addEventListener("click", () => setBccVisible(bccRow.hidden));
1081
+ // Q49 deferred: should be derived from the address book / sent-history DB,
1082
+ // not a parallel localStorage store. Pending: extend contacts schema or
1083
+ // query messages table on To-input change (debounced); auto-expand Cc/Bcc
1084
+ // when this recipient's history shows ≥N past uses.
1085
+ // ── Attachments ──
1086
+ const fileInput = document.getElementById("compose-file");
1087
+ const attEl = document.getElementById("compose-attachments");
1088
+ function renderAttachmentChips() {
1089
+ attEl.innerHTML = "";
1090
+ if (attachments.length === 0) {
1091
+ attEl.hidden = true;
1092
+ return;
1093
+ }
1094
+ attEl.hidden = false;
1095
+ for (let i = 0; i < attachments.length; i++) {
1096
+ const a = attachments[i];
1097
+ const chip = document.createElement("span");
1098
+ chip.className = "compose-att-chip";
1099
+ chip.innerHTML = `\uD83D\uDCCE ${escapeHtml(a.filename)} (${formatSize(a.size)}) `;
1100
+ const rm = document.createElement("button");
1101
+ rm.type = "button";
1102
+ rm.title = "Remove attachment";
1103
+ rm.textContent = "\u2715";
1104
+ rm.addEventListener("click", () => {
1105
+ attachments.splice(i, 1);
1106
+ renderAttachmentChips();
1107
+ });
1108
+ chip.appendChild(rm);
1109
+ attEl.appendChild(chip);
1110
+ }
1111
+ }
1112
+ function escapeHtml(s) {
1113
+ return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
1114
+ }
1115
+ function formatSize(n) {
1116
+ if (n < 1024)
1117
+ return `${n} B`;
1118
+ if (n < 1024 * 1024)
1119
+ return `${(n / 1024).toFixed(1)} KB`;
1120
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1121
+ }
1122
+ /** Set when the user clicks Attach — the native file picker eats the Esc
1123
+ * press, but on Windows WebView2 the keydown can still spill to the page
1124
+ * and trip the document-level Esc-closes-compose handler. While this flag
1125
+ * is set, that handler short-circuits. Cleared shortly after click. */
1126
+ let attachJustClicked = 0;
1127
+ document.getElementById("btn-attach")?.addEventListener("click", () => {
1128
+ attachJustClicked = Date.now();
1129
+ fileInput?.click();
1130
+ });
1131
+ // ── Edit in Word (external editor handoff) ──
1132
+ //
1133
+ // Click writes the current body to a temp file, opens it in Word (or the
1134
+ // platform fallback), and watches the file. When Word saves, the service
1135
+ // emits `wordEditUpdated` and we replace the editor's HTML with the new
1136
+ // content. The editId is per-compose-window — closeWordEdit cleans up the
1137
+ // temp file when the window closes or the message is sent.
1138
+ let wordEditId = null;
1139
+ document.getElementById("btn-edit-in-word")?.addEventListener("click", async () => {
1140
+ if (!wordEditId)
1141
+ wordEditId = `compose-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1142
+ showDraftStatus("Opening in Word…", false);
1143
+ try {
1144
+ const result = await openInWord(wordEditId, editor.getHtml());
1145
+ if (!result.ok || result.opener === "none") {
1146
+ showDraftStatus("Couldn't launch an editor. Install Word, LibreOffice, or set a default for .html.", true);
1147
+ return;
1148
+ }
1149
+ const label = result.opener === "word" ? "Word" :
1150
+ result.opener === "libreoffice" ? "LibreOffice" :
1151
+ "your default editor";
1152
+ showDraftStatus(`Editing in ${label} — saves there will reload here.`, false);
1153
+ }
1154
+ catch (e) {
1155
+ showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
1156
+ }
1157
+ });
1158
+ // Listen for external-editor saves. Only react to events for this compose's
1159
+ // editId — multiple compose windows can be open and should not stomp each
1160
+ // other's bodies.
1161
+ onEvent((ev) => {
1162
+ if (ev?.type !== "wordEditUpdated")
1163
+ return;
1164
+ if (!wordEditId || ev.editId !== wordEditId)
1165
+ return;
1166
+ try {
1167
+ editor.setHtml(ev.html || "");
1168
+ showDraftStatus("Reloaded edits from external editor.", false);
1169
+ scheduleDraftSave();
1170
+ }
1171
+ catch (e) {
1172
+ showDraftStatus(`Reload failed: ${e?.message || e}`, true);
1173
+ }
1174
+ });
1175
+ window.addEventListener("beforeunload", () => {
1176
+ if (wordEditId)
1177
+ closeWordEdit(wordEditId).catch(() => { });
1178
+ });
1179
+ async function ingestFiles(files) {
1180
+ for (const file of Array.from(files)) {
1181
+ const buf = await file.arrayBuffer();
1182
+ // base64 the whole thing — mailx-service builds the multipart/mixed
1183
+ let binary = "";
1184
+ const bytes = new Uint8Array(buf);
1185
+ for (let i = 0; i < bytes.length; i++)
1186
+ binary += String.fromCharCode(bytes[i]);
1187
+ const dataBase64 = btoa(binary);
1188
+ attachments.push({
1189
+ filename: file.name,
1190
+ mimeType: file.type || "application/octet-stream",
1191
+ size: file.size,
1192
+ dataBase64,
1193
+ });
1194
+ }
1195
+ renderAttachmentChips();
1196
+ scheduleDraftSave();
1197
+ }
1198
+ fileInput?.addEventListener("change", async () => {
1199
+ if (!fileInput.files)
1200
+ return;
1201
+ await ingestFiles(fileInput.files);
1202
+ fileInput.value = "";
1203
+ });
1204
+ // Drag-and-drop: dropping files anywhere on the compose window attaches them.
1205
+ // Highlights a subtle overlay while dragging so the target is obvious. The
1206
+ // editor iframe swallows drag events internally so we attach to the compose
1207
+ // document root; Quill's own paste/drop handling doesn't fight us because
1208
+ // files-with-no-HTML-or-text dragover never hits Quill's clipboard module.
1209
+ (() => {
1210
+ let dragDepth = 0;
1211
+ const root = document.body;
1212
+ const overlay = document.createElement("div");
1213
+ overlay.id = "compose-drop-overlay";
1214
+ // Toggle `display` directly — can't use the `hidden` attribute here
1215
+ // because the inline `display` property in cssText outranks it, which is
1216
+ // why the overlay showed permanently when I used `overlay.hidden = true`
1217
+ // (user-reported 2026-04-24 with screenshot — blue tint + dashed border
1218
+ // were visible before any drag started).
1219
+ const baseStyle = "position:fixed;inset:0;background:oklch(0.6 0.18 250 / 0.15);border:3px dashed oklch(0.55 0.2 250);z-index:9999;pointer-events:none;align-items:center;justify-content:center;font-size:1.5rem;font-weight:500;color:oklch(0.35 0.2 250)";
1220
+ overlay.style.cssText = baseStyle + ";display:none";
1221
+ overlay.textContent = "Drop files to attach";
1222
+ root.appendChild(overlay);
1223
+ const show = () => { overlay.style.display = "flex"; };
1224
+ const hide = () => { overlay.style.display = "none"; };
1225
+ const hasFiles = (e) => Array.from(e.dataTransfer?.types || []).includes("Files");
1226
+ root.addEventListener("dragenter", (e) => {
1227
+ if (!hasFiles(e))
1228
+ return;
1229
+ dragDepth++;
1230
+ show();
1231
+ });
1232
+ root.addEventListener("dragleave", (e) => {
1233
+ if (!hasFiles(e))
1234
+ return;
1235
+ dragDepth = Math.max(0, dragDepth - 1);
1236
+ if (dragDepth === 0)
1237
+ hide();
1238
+ });
1239
+ root.addEventListener("dragover", (e) => {
1240
+ if (!hasFiles(e))
1241
+ return;
1242
+ e.preventDefault(); // required so drop fires
1243
+ if (e.dataTransfer)
1244
+ e.dataTransfer.dropEffect = "copy";
1245
+ });
1246
+ root.addEventListener("drop", async (e) => {
1247
+ if (!hasFiles(e))
1248
+ return;
1249
+ e.preventDefault();
1250
+ dragDepth = 0;
1251
+ hide();
1252
+ const files = e.dataTransfer?.files;
1253
+ if (files && files.length > 0)
1254
+ await ingestFiles(files);
1255
+ });
1256
+ })();
1257
+ // ── Save and close (X button from parent) ──
1258
+ window.addEventListener("compose-save-and-close", () => {
1259
+ handleCloseRequest();
1260
+ });
1261
+ // ── Keyboard shortcuts ──
1262
+ document.addEventListener("keydown", (e) => {
1263
+ if (e.ctrlKey && e.key === "Enter") {
1264
+ document.getElementById("btn-send")?.click();
1265
+ }
1266
+ if (e.key === "Escape") {
1267
+ // If the user just clicked Attach, the native file picker is up.
1268
+ // The picker swallows the Esc that dismissed it, but the keydown can
1269
+ // still bubble here on WebView2 — closing the whole compose. Suppress
1270
+ // for a short window after the attach click.
1271
+ if (Date.now() - attachJustClicked < 1500)
1272
+ return;
1273
+ e.preventDefault();
1274
+ handleCloseRequest();
1275
+ }
1276
+ // Ctrl+K in an address field = trigger address completion.
1277
+ // NOTE: Ctrl+K is ALSO the editor's "insert link" shortcut. Scope this handler
1278
+ // strictly to the to/cc/bcc inputs so it doesn't shadow the editor binding when
1279
+ // focus is in the body.
1280
+ if (e.ctrlKey && (e.key === "k" || e.key === "K")) {
1281
+ const active = document.activeElement;
1282
+ const addressFields = [toInput, ccInput, bccInput];
1283
+ if (addressFields.includes(active)) {
1284
+ e.preventDefault();
1285
+ active.dispatchEvent(new Event("input"));
1286
+ }
1287
+ }
1288
+ });
1289
+ //# sourceMappingURL=compose.js.map