@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,1093 @@
1
+ /**
2
+ * Folder tree component -- renders account folders with hierarchy,
3
+ * expand/collapse, and optional unified inbox.
4
+ */
5
+ import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder, setupAccount, getDeviceAccounts, getVersion, syncAccount } from "../lib/api-client.js";
6
+ import { showContextMenu } from "./context-menu.js";
7
+ let onFolderSelect;
8
+ let onUnifiedInbox = null;
9
+ let selectedElement;
10
+ let selectedAccountId = null;
11
+ let selectedFolderId = null;
12
+ let isFirstLoad = true; // only auto-select on first load
13
+ let hasAutoSelected = false; // track whether we've ever managed to auto-select
14
+ // Debounce timer for refreshFolderTree
15
+ let refreshDebounceTimer = null;
16
+ // Persist expand/collapse state in localStorage
17
+ const expandState = JSON.parse(localStorage.getItem("mailx-folders-expanded") || "{}");
18
+ function saveExpandState() {
19
+ localStorage.setItem("mailx-folders-expanded", JSON.stringify(expandState));
20
+ }
21
+ // Last-sync tracking: populated by folderSynced events. Memory-only; fresh
22
+ // restarts start with an empty map and fill in as syncs happen.
23
+ const folderLastSync = new Map(); // key = `${accountId}:${folderId}`
24
+ function syncKey(accountId, folderId) {
25
+ return `${accountId}:${folderId}`;
26
+ }
27
+ export function setFolderSynced(accountId, folderId, syncedAt) {
28
+ folderLastSync.set(syncKey(accountId, folderId), syncedAt);
29
+ // Update the row in place if rendered — avoids a full re-render.
30
+ const el = document.querySelector(`.ft-folder[data-account-id="${CSS.escape(accountId)}"][data-folder-id="${folderId}"]`);
31
+ if (el)
32
+ applyFreshness(el, syncedAt);
33
+ }
34
+ export function getFolderSynced(accountId, folderId) {
35
+ return folderLastSync.get(syncKey(accountId, folderId));
36
+ }
37
+ function formatAge(ms) {
38
+ const secs = Math.round(ms / 1000);
39
+ if (secs < 60)
40
+ return `${secs}s ago`;
41
+ const mins = Math.round(secs / 60);
42
+ if (mins < 60)
43
+ return `${mins}m ago`;
44
+ const hours = Math.round(mins / 60);
45
+ if (hours < 24)
46
+ return `${hours}h ago`;
47
+ return `${Math.round(hours / 24)}d ago`;
48
+ }
49
+ function freshnessClass(ageMs) {
50
+ if (ageMs < 5 * 60_000)
51
+ return "fresh"; // green
52
+ if (ageMs < 30 * 60_000)
53
+ return "stale-soft"; // yellow
54
+ return "stale"; // red
55
+ }
56
+ function applyFreshness(el, syncedAt) {
57
+ const age = Date.now() - syncedAt;
58
+ el.classList.remove("fresh", "stale-soft", "stale");
59
+ el.classList.add(freshnessClass(age));
60
+ el.title = `Last synced: ${formatAge(age)} (${new Date(syncedAt).toLocaleTimeString()})`;
61
+ }
62
+ /** Build a tree from flat folder list using delimiter */
63
+ function buildTree(folders, delimiter, accountId) {
64
+ const root = [];
65
+ const byPath = {};
66
+ // Sort by path so parents come before children
67
+ const sorted = [...folders].sort((a, b) => a.path.localeCompare(b.path));
68
+ for (const f of sorted) {
69
+ const node = {
70
+ id: f.id,
71
+ accountId: f.accountId,
72
+ path: f.path,
73
+ name: f.name,
74
+ specialUse: f.specialUse,
75
+ delimiter: f.delimiter || delimiter,
76
+ unreadCount: f.unreadCount || 0,
77
+ totalCount: f.totalCount || 0,
78
+ children: [],
79
+ };
80
+ byPath[f.path] = node;
81
+ // Find parent by stripping the last segment
82
+ const lastDelim = f.path.lastIndexOf(delimiter);
83
+ if (lastDelim > 0) {
84
+ const parentPath = f.path.substring(0, lastDelim);
85
+ let parent = byPath[parentPath];
86
+ if (!parent) {
87
+ // Create virtual parent for non-selectable folders (e.g., "Added2")
88
+ const parentName = parentPath.split(delimiter).pop() || parentPath;
89
+ parent = {
90
+ id: -1, // virtual, not selectable
91
+ accountId,
92
+ path: parentPath,
93
+ name: parentName,
94
+ specialUse: "",
95
+ delimiter,
96
+ unreadCount: 0,
97
+ totalCount: 0,
98
+ children: [],
99
+ };
100
+ byPath[parentPath] = parent;
101
+ // Insert the virtual parent into the tree
102
+ const grandParentDelim = parentPath.lastIndexOf(delimiter);
103
+ if (grandParentDelim > 0) {
104
+ const grandParent = byPath[parentPath.substring(0, grandParentDelim)];
105
+ if (grandParent) {
106
+ grandParent.children.push(parent);
107
+ }
108
+ else {
109
+ root.push(parent);
110
+ }
111
+ }
112
+ else {
113
+ root.push(parent);
114
+ }
115
+ }
116
+ parent.children.push(node);
117
+ }
118
+ else {
119
+ root.push(node);
120
+ }
121
+ }
122
+ // Aggregate counts from children to parents (so collapsed parents show totals)
123
+ function aggregateCounts(nodes) {
124
+ let unread = 0, total = 0;
125
+ for (const n of nodes) {
126
+ const child = aggregateCounts(n.children);
127
+ n.unreadCount += child.unread;
128
+ n.totalCount += child.total;
129
+ unread += n.unreadCount;
130
+ total += n.totalCount;
131
+ }
132
+ return { unread, total };
133
+ }
134
+ aggregateCounts(root);
135
+ return root;
136
+ }
137
+ /** Sort: INBOX first, then special folders, then alphabetical */
138
+ function sortFolders(nodes) {
139
+ const specialOrder = { inbox: 0, sent: 1, outbox: 2, drafts: 3, trash: 4, junk: 5, archive: 6 };
140
+ const nameOrder = { inbox: 0, sent: 1, "sent items": 1, outbox: 2, drafts: 3, trash: 4, "deleted items": 4, junk: 5, "junk email": 5, "junk e-mail": 5, spam: 5, archive: 6 };
141
+ nodes.sort((a, b) => {
142
+ const aOrder = specialOrder[a.specialUse] ?? nameOrder[a.name.toLowerCase()] ?? 99;
143
+ const bOrder = specialOrder[b.specialUse] ?? nameOrder[b.name.toLowerCase()] ?? 99;
144
+ if (aOrder !== bOrder)
145
+ return aOrder - bOrder;
146
+ return a.name.localeCompare(b.name);
147
+ });
148
+ for (const n of nodes) {
149
+ if (n.children.length > 0)
150
+ sortFolders(n.children);
151
+ }
152
+ }
153
+ /** Render a folder node and its children recursively */
154
+ function renderNode(node, container, depth) {
155
+ const hasChildren = node.children.length > 0;
156
+ const expandKey = `${node.accountId}:${node.path}`;
157
+ const isExpanded = expandState[expandKey] === true; // default collapsed
158
+ const folderEl = document.createElement("div");
159
+ folderEl.className = "ft-folder";
160
+ folderEl.dataset.accountId = node.accountId;
161
+ folderEl.dataset.folderId = String(node.id);
162
+ folderEl.dataset.folderPath = node.path;
163
+ folderEl.dataset.specialUse = node.specialUse || "";
164
+ folderEl.style.paddingLeft = `${depth * 16 + 8}px`;
165
+ // Expand/collapse toggle
166
+ const toggle = document.createElement("span");
167
+ toggle.className = "ft-toggle";
168
+ if (hasChildren) {
169
+ toggle.textContent = isExpanded ? "▾" : "▸";
170
+ toggle.addEventListener("click", (e) => {
171
+ e.stopPropagation();
172
+ expandState[expandKey] = !isExpanded;
173
+ saveExpandState();
174
+ // Re-render the tree
175
+ const treeContainer = document.getElementById("folder-tree");
176
+ if (treeContainer)
177
+ loadFolderTree(treeContainer);
178
+ });
179
+ }
180
+ else {
181
+ toggle.textContent = " ";
182
+ }
183
+ folderEl.appendChild(toggle);
184
+ const freshnessDot = document.createElement("span");
185
+ freshnessDot.className = "ft-freshness";
186
+ freshnessDot.setAttribute("aria-hidden", "true");
187
+ folderEl.appendChild(freshnessDot);
188
+ const nameSpan = document.createElement("span");
189
+ nameSpan.className = "ft-folder-name";
190
+ nameSpan.textContent = node.name;
191
+ folderEl.appendChild(nameSpan);
192
+ const syncedAt = getFolderSynced(node.accountId, node.id);
193
+ if (syncedAt)
194
+ applyFreshness(folderEl, syncedAt);
195
+ const isOutbox = node.specialUse === "outbox" || node.path.toLowerCase() === "outbox";
196
+ if (isOutbox && node.totalCount > 0) {
197
+ // Outbox: show total (pending) count with warning style
198
+ const badge = document.createElement("span");
199
+ badge.className = "ft-badge ft-badge-outbox";
200
+ badge.textContent = String(node.totalCount);
201
+ badge.title = `${node.totalCount} pending`;
202
+ folderEl.appendChild(badge);
203
+ }
204
+ else if (node.unreadCount > 0) {
205
+ const badge = document.createElement("span");
206
+ badge.className = "ft-badge";
207
+ badge.textContent = String(node.unreadCount);
208
+ badge.title = `${node.unreadCount} unread`;
209
+ folderEl.appendChild(badge);
210
+ }
211
+ // Total count (shown when View > Folder counts is checked)
212
+ if (node.totalCount > 0) {
213
+ const total = document.createElement("span");
214
+ total.className = "ft-total-count";
215
+ total.textContent = String(node.totalCount);
216
+ total.title = `${node.totalCount} total messages`;
217
+ folderEl.appendChild(total);
218
+ }
219
+ folderEl.addEventListener("click", () => {
220
+ if (node.id === -1) {
221
+ // Virtual parent — toggle expand instead of selecting
222
+ expandState[expandKey] = !isExpanded;
223
+ saveExpandState();
224
+ const treeContainer = document.getElementById("folder-tree");
225
+ if (treeContainer)
226
+ loadFolderTree(treeContainer);
227
+ return;
228
+ }
229
+ if (selectedElement)
230
+ selectedElement.classList.remove("selected");
231
+ folderEl.classList.add("selected");
232
+ selectedElement = folderEl;
233
+ selectedAccountId = node.accountId;
234
+ selectedFolderId = node.id;
235
+ onFolderSelect(node.accountId, node.id, node.name, node.specialUse || node.path.toLowerCase());
236
+ });
237
+ // ── Right-click context menu ──
238
+ folderEl.addEventListener("contextmenu", (e) => {
239
+ e.preventDefault();
240
+ e.stopPropagation();
241
+ const isTrash = node.specialUse === "trash" || node.path.toLowerCase().includes("trash");
242
+ const isJunk = node.specialUse === "junk" || node.path.toLowerCase().includes("spam") || node.path.toLowerCase().includes("junk");
243
+ const items = [
244
+ { label: "Mark all read", action: async () => {
245
+ try {
246
+ await markFolderRead(node.accountId, node.id);
247
+ const treeContainer = document.getElementById("folder-tree");
248
+ if (treeContainer)
249
+ loadFolderTree(treeContainer);
250
+ }
251
+ catch { /* ignore */ }
252
+ } },
253
+ { label: "", action: () => { }, separator: true },
254
+ { label: "New subfolder...", action: async () => {
255
+ const name = prompt("New folder name:");
256
+ if (!name)
257
+ return;
258
+ try {
259
+ await createFolder(node.accountId, node.path, name);
260
+ const treeContainer = document.getElementById("folder-tree");
261
+ if (treeContainer)
262
+ loadFolderTree(treeContainer);
263
+ }
264
+ catch (err) {
265
+ alert(`Failed: ${err.message}`);
266
+ }
267
+ } },
268
+ { label: "Rename...", action: async () => {
269
+ const newName = prompt("Rename folder:", node.name);
270
+ if (!newName || newName === node.name)
271
+ return;
272
+ try {
273
+ await renameFolder(node.accountId, node.id, newName);
274
+ const treeContainer = document.getElementById("folder-tree");
275
+ if (treeContainer)
276
+ loadFolderTree(treeContainer);
277
+ }
278
+ catch (err) {
279
+ alert(`Failed: ${err.message}`);
280
+ }
281
+ }, disabled: !!node.specialUse },
282
+ { label: "Delete folder", action: async () => {
283
+ if (!confirm(`Delete folder "${node.name}"? Messages will be moved to Trash.`))
284
+ return;
285
+ try {
286
+ await deleteFolder(node.accountId, node.id);
287
+ const treeContainer = document.getElementById("folder-tree");
288
+ if (treeContainer)
289
+ loadFolderTree(treeContainer);
290
+ }
291
+ catch (err) {
292
+ alert(`Failed: ${err.message}`);
293
+ }
294
+ }, disabled: !!node.specialUse },
295
+ { label: "", action: () => { }, separator: true },
296
+ // Q57: copy IMAP path so user can paste into accounts.jsonc as
297
+ // a spam/sent/drafts/trash hint without retyping case-sensitively.
298
+ { label: "Copy folder path", action: async () => {
299
+ try {
300
+ await navigator.clipboard.writeText(node.path);
301
+ const status = document.getElementById("status-sync");
302
+ if (status)
303
+ status.textContent = `Copied: ${node.path}`;
304
+ }
305
+ catch {
306
+ prompt("Folder path:", node.path);
307
+ }
308
+ } },
309
+ ];
310
+ if (isTrash || isJunk) {
311
+ items.push({ label: "", action: () => { }, separator: true });
312
+ items.push({ label: `Empty ${node.name}`, action: async () => {
313
+ if (!confirm(`Permanently delete all messages in "${node.name}"?`))
314
+ return;
315
+ try {
316
+ await emptyFolder(node.accountId, node.id);
317
+ const { setMessages } = await import("../lib/message-state.js");
318
+ setMessages([]); // Folder emptied — clear list and viewer
319
+ const treeContainer = document.getElementById("folder-tree");
320
+ if (treeContainer)
321
+ loadFolderTree(treeContainer);
322
+ }
323
+ catch (err) {
324
+ alert(`Failed: ${err.message}`);
325
+ }
326
+ } });
327
+ }
328
+ showContextMenu(e.clientX, e.clientY, items);
329
+ });
330
+ // ── Drop target for message drag-and-drop ──
331
+ if (node.id !== -1) {
332
+ let dragExpandTimer = null;
333
+ folderEl.addEventListener("dragover", (e) => {
334
+ e.preventDefault();
335
+ e.dataTransfer.dropEffect = e.ctrlKey ? "copy" : "move";
336
+ folderEl.classList.add("drop-target");
337
+ });
338
+ folderEl.addEventListener("dragenter", () => {
339
+ if (hasChildren && !isExpanded && !dragExpandTimer) {
340
+ dragExpandTimer = setTimeout(() => {
341
+ dragExpandTimer = null;
342
+ expandState[expandKey] = true;
343
+ saveExpandState();
344
+ const treeContainer = document.getElementById("folder-tree");
345
+ if (treeContainer)
346
+ loadFolderTree(treeContainer);
347
+ }, 500);
348
+ }
349
+ });
350
+ folderEl.addEventListener("dragleave", () => {
351
+ folderEl.classList.remove("drop-target");
352
+ if (dragExpandTimer) {
353
+ clearTimeout(dragExpandTimer);
354
+ dragExpandTimer = null;
355
+ }
356
+ });
357
+ folderEl.addEventListener("drop", async (e) => {
358
+ e.preventDefault();
359
+ folderEl.classList.remove("drop-target");
360
+ if (dragExpandTimer) {
361
+ clearTimeout(dragExpandTimer);
362
+ dragExpandTimer = null;
363
+ }
364
+ // Multi-message or single-message drop
365
+ const multiData = e.dataTransfer.getData("application/x-mailx-messages");
366
+ const singleData = e.dataTransfer.getData("application/x-mailx-message");
367
+ const messages = multiData ? JSON.parse(multiData) : singleData ? [JSON.parse(singleData)] : [];
368
+ // Filter: not already in target folder
369
+ const toMove = messages.filter(m => m.folderId !== node.id || m.accountId !== node.accountId);
370
+ if (toMove.length === 0)
371
+ return;
372
+ const statusEl = document.getElementById("status-sync");
373
+ const crossAccount = toMove.some(m => m.accountId !== node.accountId);
374
+ try {
375
+ if (crossAccount) {
376
+ // Cross-account: must do one at a time
377
+ for (const msg of toMove) {
378
+ const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;
379
+ await moveMessage(msg.accountId, msg.uid, node.id, targetAccountId);
380
+ }
381
+ }
382
+ else {
383
+ // Same account: bulk move
384
+ const accountId = toMove[0].accountId;
385
+ const uids = toMove.map(m => m.uid);
386
+ await moveMessages(accountId, uids, node.id);
387
+ }
388
+ const moved = toMove.length;
389
+ if (statusEl)
390
+ statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name} — Ctrl+Z to undo`;
391
+ // Remove from shared state — list and viewer update automatically
392
+ const { removeMessages } = await import("../lib/message-state.js");
393
+ removeMessages(toMove);
394
+ const treeContainer = document.getElementById("folder-tree");
395
+ if (treeContainer)
396
+ loadFolderTree(treeContainer);
397
+ // Notify app.ts so Ctrl+Z can undo this move. Each entry carries
398
+ // its ORIGINAL folderId/accountId so we know where to move back to.
399
+ document.dispatchEvent(new CustomEvent("mailx-moved", {
400
+ detail: {
401
+ messages: toMove.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),
402
+ targetAccountId: node.accountId,
403
+ targetFolderId: node.id,
404
+ },
405
+ }));
406
+ }
407
+ catch (err) {
408
+ console.error(`Move failed: ${err.message}`);
409
+ if (statusEl)
410
+ statusEl.textContent = `Move failed: ${err.message}`;
411
+ }
412
+ });
413
+ }
414
+ container.appendChild(folderEl);
415
+ // Render children if expanded
416
+ if (hasChildren && isExpanded) {
417
+ for (const child of node.children) {
418
+ renderNode(child, container, depth + 1);
419
+ }
420
+ }
421
+ }
422
+ export function initFolderTree(container, handler, unifiedHandler) {
423
+ onFolderSelect = handler;
424
+ onUnifiedInbox = unifiedHandler || null;
425
+ loadFolderTree(container);
426
+ }
427
+ // Item 12: outbox total drives a synthesized "Send-pending" row at the top
428
+ // of the folder tree. The server pushes outboxStatus on every mutation; when
429
+ // the total flips between zero and non-zero we re-render so the row appears
430
+ // / disappears without waiting for the next full refresh.
431
+ let lastOutboxTotal = 0;
432
+ export function setOutboxTotal(total) {
433
+ const prev = lastOutboxTotal;
434
+ lastOutboxTotal = total | 0;
435
+ // Zero → zero: nothing to render, nothing to clear.
436
+ if (prev === 0 && lastOutboxTotal === 0)
437
+ return;
438
+ const existing = document.getElementById("ft-send-pending");
439
+ // Non-zero in both → just update the badge text; avoid a full re-render.
440
+ if (prev > 0 && lastOutboxTotal > 0 && existing) {
441
+ const badge = existing.querySelector(".ft-badge");
442
+ if (badge)
443
+ badge.textContent = String(lastOutboxTotal);
444
+ existing.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? "" : "s"} queued for send`;
445
+ return;
446
+ }
447
+ // Presence flipped (0→N or N→0) — re-render to insert / remove the row.
448
+ const container = document.getElementById("folder-tree");
449
+ if (container)
450
+ loadFolderTree(container);
451
+ }
452
+ async function loadFolderTree(container) {
453
+ // Show loading state while preserving existing tree (if any) on refresh
454
+ const hadContent = container.children.length > 0 && !container.querySelector(".folder-loading");
455
+ if (!hadContent) {
456
+ container.innerHTML = `<div class="folder-loading">Loading accounts...</div>`;
457
+ }
458
+ try {
459
+ let accounts = await getAccounts();
460
+ // Accounts may still be registering (OAuth in progress) — retry a few times
461
+ if (accounts.length === 0) {
462
+ for (let retry = 0; retry < 5 && accounts.length === 0; retry++) {
463
+ await new Promise(r => setTimeout(r, 2000));
464
+ accounts = await getAccounts();
465
+ }
466
+ }
467
+ if (accounts.length === 0) {
468
+ container.innerHTML = `<div class="folder-loading">No accounts</div>`;
469
+ // Hide the message list and show setup in the viewer pane (full width)
470
+ const mlSection = document.querySelector(".message-list");
471
+ if (mlSection)
472
+ mlSection.style.display = "none";
473
+ const splitter = document.getElementById("splitter-h");
474
+ if (splitter)
475
+ splitter.style.display = "none";
476
+ const mvHeader = document.getElementById("mv-header");
477
+ if (mvHeader)
478
+ mvHeader.style.display = "none";
479
+ const mainBody = document.getElementById("mv-body");
480
+ if (mainBody) {
481
+ const isAndroid = window.mailxapi?.platform === "android";
482
+ const formDisplay = isAndroid ? "display:none;" : "";
483
+ const introText = isAndroid ? "" : "Add your email account to get started.";
484
+ const checkingHtml = isAndroid ? '<div style="padding:0.5rem;color:var(--color-text-muted)">Checking for accounts...</div>' : "";
485
+ mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:500px">
486
+ <h2 style="margin-bottom:1rem">Welcome to mailx</h2>
487
+ <div id="setup-device-accounts">${checkingHtml}</div>
488
+ <div id="setup-cloud-status"></div>
489
+ <p id="setup-form-intro">${introText}</p>
490
+ <form id="setup-form" style="margin-top:1rem;${formDisplay}">
491
+ <label style="display:block;margin-bottom:0.5rem">
492
+ Email address
493
+ <input id="setup-email" type="email" placeholder="you@gmail.com" required style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
494
+ </label>
495
+ <div id="setup-provider-preview" style="display:none;margin-bottom:0.5rem;padding:0.4rem 0.6rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem">
496
+ <span id="setup-provider-icon" style="display:inline-block;width:1.2em;text-align:center;margin-right:0.4em"></span><span id="setup-provider-label"></span>
497
+ </div>
498
+ <label id="setup-name-row" style="display:none;margin-bottom:0.5rem">
499
+ Your name <span style="color:var(--color-text-muted);font-size:0.85rem">(optional — auto-detected from Google)</span>
500
+ <input id="setup-name" type="text" placeholder="Your Name (leave blank to use Google profile)" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
501
+ </label>
502
+ <label id="setup-password-row" style="display:none;margin-bottom:0.5rem">
503
+ Password
504
+ <input id="setup-password" type="password" placeholder="password" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
505
+ <div id="setup-app-password-help" style="display:none;margin-top:0.25rem;font-size:0.8rem;color:var(--color-text-muted)"></div>
506
+ </label>
507
+ <button id="setup-submit" type="submit" style="display:none;margin-top:1rem;padding:0.5rem 2rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:1rem">Add Account</button>
508
+ <div id="setup-status" style="margin-top:1rem;color:var(--color-text-muted)"></div>
509
+ </form>
510
+ <details style="margin-top:2rem;color:var(--color-text-muted)">
511
+ <summary>Manual setup (advanced)</summary>
512
+ <p style="margin-top:0.5rem">Create <code>~/.rmfmail/config.jsonc</code> with a cloud provider:</p>
513
+ <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": { "provider": "gdrive", "path": "rmfmail" } }</code>
514
+ <p style="margin-top:0.5rem;font-size:0.85rem">Settings sync via Google Drive API (auto-configured for Gmail accounts).</p>
515
+ </details>
516
+ </div>`;
517
+ // Wire up the setup form
518
+ const form = document.getElementById("setup-form");
519
+ const emailInput = document.getElementById("setup-email");
520
+ const statusEl = document.getElementById("setup-status");
521
+ // Hide password for OAuth providers, show app-password help for others
522
+ const APP_PASSWORD_HELP = {
523
+ "yahoo.com": "Use an app password: Yahoo Settings → Account Security → Generate app password",
524
+ "aol.com": "Use an app password: AOL Settings → Account Security → Generate app password",
525
+ "icloud.com": "Use an app-specific password: appleid.apple.com → Sign-In and Security → App-Specific Passwords",
526
+ };
527
+ // Q67: describe the detected provider so the user knows which
528
+ // auto-config path we're about to take BEFORE they hit Next.
529
+ // Gmail / Google Workspace domains auto-detect via MX in the
530
+ // service; here we can only name the known ones up front and
531
+ // say "will auto-detect" for everything else.
532
+ const PROVIDER_PREVIEW = {
533
+ "gmail.com": { icon: "✉", label: "Gmail — OAuth (no password needed)" },
534
+ "googlemail.com": { icon: "✉", label: "Gmail — OAuth (no password needed)" },
535
+ "outlook.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
536
+ "hotmail.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
537
+ "live.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
538
+ "yahoo.com": { icon: "✉", label: "Yahoo Mail — IMAP (needs app password)" },
539
+ "aol.com": { icon: "✉", label: "AOL Mail — IMAP (needs app password)" },
540
+ "icloud.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
541
+ "me.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
542
+ "mac.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
543
+ };
544
+ let oauthAutoFired = false;
545
+ emailInput?.addEventListener("input", () => {
546
+ const email = emailInput.value.trim();
547
+ const domain = email.split("@")[1]?.toLowerCase() || "";
548
+ const hasAt = email.includes("@") && domain.length > 0;
549
+ const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
550
+ const isGmailLike = ["gmail.com", "googlemail.com"].includes(domain);
551
+ // Provider preview row
552
+ const preview = document.getElementById("setup-provider-preview");
553
+ const icon = document.getElementById("setup-provider-icon");
554
+ const label = document.getElementById("setup-provider-label");
555
+ if (preview && icon && label) {
556
+ if (hasAt) {
557
+ const hit = PROVIDER_PREVIEW[domain];
558
+ icon.textContent = hit ? hit.icon : "❓";
559
+ label.textContent = hit ? hit.label : `${domain} — will auto-detect via MX records`;
560
+ preview.style.display = "block";
561
+ }
562
+ else {
563
+ preview.style.display = "none";
564
+ }
565
+ }
566
+ // OAuth providers: auto-fire setup immediately once domain
567
+ // is recognized — don't show name/password (name is auto-
568
+ // detected from Google profile, no password needed). This
569
+ // eliminates the "form flash" where fields briefly appear
570
+ // before the page reloads.
571
+ if (hasAt && isOAuth && !oauthAutoFired && !setupTriggered) {
572
+ oauthAutoFired = true;
573
+ statusEl.textContent = `Connecting to ${isGmailLike ? "Gmail" : "Outlook"}...`;
574
+ trySetup();
575
+ return;
576
+ }
577
+ // Non-OAuth: progressive reveal of name + password + submit.
578
+ const nameRow = document.getElementById("setup-name-row");
579
+ const pwRow = document.getElementById("setup-password-row");
580
+ const submitBtn = document.getElementById("setup-submit");
581
+ if (nameRow)
582
+ nameRow.style.display = hasAt && !isOAuth ? "block" : "none";
583
+ if (pwRow)
584
+ pwRow.style.display = hasAt && !isOAuth ? "block" : "none";
585
+ if (submitBtn)
586
+ submitBtn.style.display = hasAt && !isOAuth ? "block" : "none";
587
+ const helpEl = document.getElementById("setup-app-password-help");
588
+ if (helpEl) {
589
+ const help = APP_PASSWORD_HELP[domain];
590
+ helpEl.style.display = help ? "block" : "none";
591
+ helpEl.textContent = help || "";
592
+ }
593
+ });
594
+ // When a valid email is entered, try setup immediately — if cloud has
595
+ // existing accounts, loads them without needing name/password
596
+ let setupTriggered = false;
597
+ async function trySetup() {
598
+ const email = emailInput.value.trim();
599
+ if (!email || !email.includes("@") || setupTriggered)
600
+ return;
601
+ const name = document.getElementById("setup-name").value.trim();
602
+ const password = document.getElementById("setup-password").value;
603
+ setupTriggered = true;
604
+ statusEl.textContent = "Checking for existing accounts...";
605
+ try {
606
+ const data = await setupAccount(name, email, password);
607
+ if (data.ok) {
608
+ statusEl.textContent = data.message || "Accounts loaded! Syncing...";
609
+ setTimeout(() => location.reload(), 5000);
610
+ }
611
+ else {
612
+ setupTriggered = false;
613
+ statusEl.textContent = `Error: ${data.error || "Setup failed"}`;
614
+ }
615
+ }
616
+ catch (err) {
617
+ setupTriggered = false;
618
+ statusEl.textContent = `Error: ${err.message}`;
619
+ }
620
+ }
621
+ form?.addEventListener("submit", async (e) => {
622
+ e.preventDefault();
623
+ await trySetup();
624
+ });
625
+ // Auto-trigger for OAuth providers when email looks complete
626
+ emailInput?.addEventListener("change", async () => {
627
+ const domain = emailInput.value.split("@")[1]?.toLowerCase() || "";
628
+ const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
629
+ if (isOAuth)
630
+ await trySetup();
631
+ });
632
+ // Show cloud storage status in setup form
633
+ getVersion().then((d) => {
634
+ const cloudEl = document.getElementById("setup-cloud-status");
635
+ if (!cloudEl)
636
+ return;
637
+ const s = d.storage || {};
638
+ if (s.cloudError) {
639
+ cloudEl.innerHTML = `<div style="padding:0.75rem;margin-bottom:1rem;background:#5c1a1a;color:#fca;border:1px solid #a33;border-radius:4px">
640
+ <strong>Cloud storage unavailable:</strong> ${s.cloudError}<br>
641
+ <span style="font-size:0.85rem">Settings on ${s.provider || "cloud"} cannot be read. Add an account below to initialize cloud storage.</span>
642
+ </div>`;
643
+ }
644
+ else if (s.mode === "api") {
645
+ cloudEl.innerHTML = `<div style="padding:0.5rem;margin-bottom:1rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;color:var(--color-text-muted)">
646
+ Using ${s.provider} API (no local mount)
647
+ </div>`;
648
+ }
649
+ }).catch(() => { });
650
+ // On Android, check for device Google accounts
651
+ getDeviceAccounts().then(async (deviceAccounts) => {
652
+ const pickerEl = document.getElementById("setup-device-accounts");
653
+ if (!pickerEl)
654
+ return;
655
+ if (deviceAccounts.length === 0) {
656
+ // No device accounts — show the form
657
+ pickerEl.innerHTML = "";
658
+ const f = document.getElementById("setup-form");
659
+ const i = document.getElementById("setup-form-intro");
660
+ if (f)
661
+ f.style.display = "block";
662
+ if (i)
663
+ i.textContent = "Add your email account to get started.";
664
+ return;
665
+ }
666
+ const formEl = document.getElementById("setup-form");
667
+ const introEl = document.getElementById("setup-form-intro");
668
+ // Auto-setup helper
669
+ async function autoSetup(email, name) {
670
+ if (introEl)
671
+ introEl.textContent = "";
672
+ if (formEl)
673
+ formEl.style.display = "none";
674
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-text-muted)">Setting up ${email}...</div>`;
675
+ const result = await setupAccount(name, email, "");
676
+ if (result?.ok) {
677
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-accent)">${result.message || "Account added!"}</div>`;
678
+ setTimeout(() => location.reload(), 2000);
679
+ }
680
+ else {
681
+ pickerEl.innerHTML = `<div style="padding:0.5rem;color:#f55">${result?.error || "Setup failed"}</div>`;
682
+ if (formEl)
683
+ formEl.style.display = "block";
684
+ }
685
+ }
686
+ // One account — auto-select it
687
+ if (deviceAccounts.length === 1) {
688
+ await autoSetup(deviceAccounts[0].email, deviceAccounts[0].name);
689
+ return;
690
+ }
691
+ // Multiple accounts — show picker
692
+ if (introEl)
693
+ introEl.textContent = "Select an account:";
694
+ if (formEl)
695
+ formEl.style.display = "none";
696
+ pickerEl.innerHTML = deviceAccounts.map((a) => `<button class="device-account-btn" data-email="${a.email}" data-name="${a.name}" style="display:block;width:100%;padding:0.75rem 1rem;margin-bottom:0.5rem;background:var(--color-accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:1rem;text-align:left">Use ${a.email}</button>`).join("") + `<button id="setup-show-form" style="margin-top:0.5rem;padding:0.5rem 1rem;background:none;color:var(--color-text-muted);border:none;cursor:pointer;font-size:0.9rem">Use a different account...</button>`;
697
+ pickerEl.querySelectorAll(".device-account-btn").forEach((btn) => {
698
+ btn.addEventListener("click", async () => {
699
+ await autoSetup(btn.dataset.email || "", btn.dataset.name || "");
700
+ });
701
+ });
702
+ document.getElementById("setup-show-form")?.addEventListener("click", () => {
703
+ pickerEl.style.display = "none";
704
+ if (formEl)
705
+ formEl.style.display = "block";
706
+ if (introEl)
707
+ introEl.textContent = "Add your email account to get started.";
708
+ });
709
+ }).catch(() => {
710
+ // Bridge failed — show the form
711
+ const p = document.getElementById("setup-device-accounts");
712
+ if (p)
713
+ p.innerHTML = "";
714
+ const f = document.getElementById("setup-form");
715
+ const i = document.getElementById("setup-form-intro");
716
+ if (f)
717
+ f.style.display = "block";
718
+ if (i)
719
+ i.textContent = "Add your email account to get started.";
720
+ });
721
+ }
722
+ // Dismiss startup overlay
723
+ const overlay = document.getElementById("startup-overlay");
724
+ if (overlay) {
725
+ overlay.classList.add("hidden");
726
+ setTimeout(() => overlay.remove(), 400);
727
+ }
728
+ return;
729
+ }
730
+ // Fetch ALL account folder data in parallel BEFORE touching the DOM
731
+ const accountFolderData = await Promise.all(accounts.map(async (account) => {
732
+ const accountKey = `account:${account.id}`;
733
+ const accountExpanded = expandState[accountKey] !== false;
734
+ const folders = accountExpanded ? await getFolders(account.id) : [];
735
+ return { account, folders };
736
+ }));
737
+ // Save scroll position before rebuild
738
+ const savedScroll = container.scrollTop;
739
+ // Build entire new tree into a DocumentFragment (off-screen, no reflows)
740
+ const fragment = document.createDocumentFragment();
741
+ // Unified Inbox — always shown so startup auto-selects it consistently
742
+ // (with one account it's effectively that account's INBOX, but the UI
743
+ // stays uniform so the auto-select path doesn't fork on account count)
744
+ if (accounts.length >= 1) {
745
+ const unifiedEl = document.createElement("div");
746
+ unifiedEl.className = "ft-folder ft-unified";
747
+ unifiedEl.title = accounts.length > 1
748
+ ? "Merged inbox view of all accounts — click to see messages from every account's INBOX sorted by date"
749
+ : "Inbox view across all your accounts";
750
+ unifiedEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">All Inboxes</span>`;
751
+ unifiedEl.addEventListener("click", () => {
752
+ if (selectedElement)
753
+ selectedElement.classList.remove("selected");
754
+ unifiedEl.classList.add("selected");
755
+ selectedElement = unifiedEl;
756
+ selectedAccountId = null;
757
+ selectedFolderId = -1;
758
+ if (onUnifiedInbox)
759
+ onUnifiedInbox();
760
+ });
761
+ fragment.appendChild(unifiedEl);
762
+ }
763
+ // Item 12: Send-pending virtual row — synthesized from the outbox
764
+ // queue, only shown when something is actually queued. Clicking
765
+ // opens the outbox-view modal (pink rows, cancellable). Lives at
766
+ // the top of the tree so a stuck send is impossible to miss.
767
+ if (lastOutboxTotal > 0) {
768
+ const pendingEl = document.createElement("div");
769
+ pendingEl.className = "ft-folder ft-unified ft-send-pending";
770
+ pendingEl.id = "ft-send-pending";
771
+ pendingEl.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? "" : "s"} queued for send`;
772
+ pendingEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">Send-pending</span><span class="ft-badge ft-badge-outbox">${lastOutboxTotal}</span>`;
773
+ pendingEl.addEventListener("click", async () => {
774
+ try {
775
+ const { openOutboxView } = await import("./outbox-view.js");
776
+ openOutboxView();
777
+ }
778
+ catch { /* outbox-view load failed — silent is OK, status pill still works */ }
779
+ });
780
+ fragment.appendChild(pendingEl);
781
+ }
782
+ for (const { account, folders } of accountFolderData) {
783
+ const accountEl = document.createElement("div");
784
+ accountEl.className = "ft-account";
785
+ const accountKey = `account:${account.id}`;
786
+ const accountExpanded = expandState[accountKey] !== false; // accounts default expanded
787
+ const header = document.createElement("div");
788
+ header.className = "ft-account-header";
789
+ header.textContent = `${accountExpanded ? "▾" : "▸"} ${account.label || account.name}`;
790
+ header.addEventListener("click", () => {
791
+ expandState[accountKey] = !accountExpanded;
792
+ saveExpandState();
793
+ const treeContainer = document.getElementById("folder-tree");
794
+ if (treeContainer)
795
+ loadFolderTree(treeContainer);
796
+ });
797
+ // Right-click: full menu instead of plain toggle (Q54).
798
+ header.addEventListener("contextmenu", (e) => {
799
+ e.preventDefault();
800
+ e.stopPropagation();
801
+ const items = [
802
+ { label: "Mark all read (account)", action: async () => {
803
+ const folderRows = folders.slice();
804
+ for (const f of folderRows) {
805
+ try {
806
+ await markFolderRead(account.id, f.id);
807
+ }
808
+ catch { /* keep going */ }
809
+ }
810
+ const tc = document.getElementById("folder-tree");
811
+ if (tc)
812
+ loadFolderTree(tc);
813
+ } },
814
+ { label: "", action: () => { }, separator: true },
815
+ { label: "Expand all folders", action: () => {
816
+ const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
817
+ for (const k of keys)
818
+ expandState[k] = true;
819
+ expandState[accountKey] = true;
820
+ saveExpandState();
821
+ const tc = document.getElementById("folder-tree");
822
+ if (tc)
823
+ loadFolderTree(tc);
824
+ } },
825
+ { label: "Collapse all folders", action: () => {
826
+ const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
827
+ for (const k of keys)
828
+ expandState[k] = false;
829
+ expandState[accountKey] = false;
830
+ saveExpandState();
831
+ const tc = document.getElementById("folder-tree");
832
+ if (tc)
833
+ loadFolderTree(tc);
834
+ } },
835
+ { label: "", action: () => { }, separator: true },
836
+ { label: "Sync this account now", action: async () => {
837
+ try {
838
+ await syncAccount(account.id);
839
+ }
840
+ catch (err) {
841
+ alert(`Sync failed: ${err?.message || err}`);
842
+ }
843
+ } },
844
+ ];
845
+ showContextMenu(e.clientX, e.clientY, items);
846
+ });
847
+ accountEl.appendChild(header);
848
+ if (accountExpanded && folders.length > 0) {
849
+ const delimiter = folders[0]?.delimiter || ".";
850
+ const tree = buildTree(folders, delimiter, account.id);
851
+ sortFolders(tree);
852
+ // Case-duplicate detection: fold folder paths to lowercase and
853
+ // flag any whose form matches another. Common with servers that
854
+ // let users create `Archive` and `archive` as distinct folders,
855
+ // or `Sent Items` alongside a `Sent items` rename gone sideways.
856
+ // A ⚠ glyph on the affected rows lets the user notice before
857
+ // losing mail to the wrong one.
858
+ const lowerCounts = new Map();
859
+ for (const f of folders) {
860
+ const key = (f.path || "").toLowerCase();
861
+ lowerCounts.set(key, (lowerCounts.get(key) || 0) + 1);
862
+ }
863
+ const duplicatePaths = new Set();
864
+ for (const [k, c] of lowerCounts)
865
+ if (c > 1)
866
+ duplicatePaths.add(k);
867
+ for (const node of tree) {
868
+ renderNode(node, accountEl, 1);
869
+ }
870
+ if (duplicatePaths.size > 0) {
871
+ accountEl.querySelectorAll(".ft-folder").forEach(el => {
872
+ const p = (el.dataset.folderPath || "").toLowerCase();
873
+ if (duplicatePaths.has(p)) {
874
+ el.classList.add("ft-folder-duplicate");
875
+ el.title = (el.title ? el.title + " — " : "") +
876
+ "Case-duplicate folder name on the server (another folder with the same name in different case exists)";
877
+ }
878
+ });
879
+ }
880
+ }
881
+ fragment.appendChild(accountEl);
882
+ }
883
+ // Atomic swap — single reflow, no intermediate empty state
884
+ container.replaceChildren(fragment);
885
+ // Restore scroll position
886
+ container.scrollTop = savedScroll;
887
+ // Re-select previous folder, or auto-select on first load
888
+ const allFolderEls = container.querySelectorAll('.ft-folder');
889
+ let target = null;
890
+ if (selectedFolderId === -1) {
891
+ // Unified inbox was selected — just re-highlight it, don't click
892
+ const unified = container.querySelector('.ft-unified');
893
+ if (unified) {
894
+ unified.classList.add("selected");
895
+ selectedElement = unified;
896
+ target = unified;
897
+ }
898
+ }
899
+ else if (selectedAccountId && selectedFolderId !== null && selectedFolderId >= 0) {
900
+ for (const f of allFolderEls) {
901
+ const el = f;
902
+ if (el.dataset.accountId === selectedAccountId && el.dataset.folderId === String(selectedFolderId)) {
903
+ el.classList.add("selected");
904
+ selectedElement = el;
905
+ target = el;
906
+ break;
907
+ }
908
+ }
909
+ }
910
+ // Auto-select on first load OR until we successfully auto-selected at least once
911
+ // (handles Android where folders don't exist on first load — they arrive after sync)
912
+ if (!target && (isFirstLoad || !hasAutoSelected)) {
913
+ // Auto-select only on first load — not on refresh (prevents jumping)
914
+ const unified = container.querySelector('.ft-unified');
915
+ if (unified) {
916
+ target = unified;
917
+ }
918
+ else {
919
+ let bestInbox = null;
920
+ let bestCount = -1;
921
+ for (const f of allFolderEls) {
922
+ const name = f.querySelector('.ft-folder-name')?.textContent ?? "";
923
+ if (name.toLowerCase() === "inbox") {
924
+ const badge = f.querySelector('.ft-badge');
925
+ const count = badge ? parseInt(badge.textContent || "0") : 0;
926
+ if (count > bestCount) {
927
+ bestCount = count;
928
+ bestInbox = f;
929
+ }
930
+ }
931
+ }
932
+ target = bestInbox;
933
+ }
934
+ if (!target && allFolderEls.length > 0)
935
+ target = allFolderEls[0];
936
+ if (target) {
937
+ target.click();
938
+ hasAutoSelected = true;
939
+ }
940
+ }
941
+ isFirstLoad = false;
942
+ // Dismiss startup overlay once tree is loaded
943
+ const overlay = document.getElementById("startup-overlay");
944
+ if (overlay)
945
+ overlay.classList.add("hidden");
946
+ // Remove from DOM after transition
947
+ setTimeout(() => overlay?.remove(), 400);
948
+ }
949
+ catch (e) {
950
+ // Don't destroy existing folder tree on error — just log it
951
+ console.error(`Folder tree error: ${e.message}`);
952
+ // Only show error if tree is completely empty (first load failure)
953
+ if (container.children.length === 0 || container.querySelector(".folder-loading")) {
954
+ const errEl = document.createElement("div");
955
+ errEl.className = "folder-loading";
956
+ errEl.textContent = `Error loading folders: ${e.message}`;
957
+ container.replaceChildren(errEl);
958
+ }
959
+ // Dismiss overlay on error too
960
+ const overlay = document.getElementById("startup-overlay");
961
+ if (overlay) {
962
+ const status = document.getElementById("startup-status");
963
+ if (status)
964
+ status.textContent = `Error: ${e.message}`;
965
+ setTimeout(() => { overlay.classList.add("hidden"); setTimeout(() => overlay.remove(), 400); }, 2000);
966
+ }
967
+ }
968
+ }
969
+ /** Refresh folder tree (e.g., after sync) — debounced to prevent rapid rebuilds */
970
+ export function refreshFolderTree() {
971
+ if (refreshDebounceTimer)
972
+ clearTimeout(refreshDebounceTimer);
973
+ refreshDebounceTimer = setTimeout(() => {
974
+ refreshDebounceTimer = null;
975
+ const container = document.getElementById("folder-tree");
976
+ if (container)
977
+ loadFolderTree(container);
978
+ }, 300);
979
+ }
980
+ /**
981
+ * Incremental count update — patches badge counts in-place without rebuilding the DOM.
982
+ * Used for folderCountsChanged events to avoid jitter. Falls back to full rebuild
983
+ * if the folder structure has changed.
984
+ */
985
+ export async function updateFolderCounts() {
986
+ const container = document.getElementById("folder-tree");
987
+ if (!container)
988
+ return;
989
+ // If tree hasn't loaded yet, do a full load
990
+ if (container.children.length === 0 || container.querySelector(".folder-loading")) {
991
+ refreshFolderTree();
992
+ return;
993
+ }
994
+ try {
995
+ const accounts = await getAccounts();
996
+ // Fetch all folder data in parallel
997
+ const allFolderData = await Promise.all(accounts.map(async (account) => {
998
+ const folders = await getFolders(account.id);
999
+ return { accountId: account.id, folders };
1000
+ }));
1001
+ // Build a lookup: accountId+folderId → { unreadCount, totalCount }
1002
+ // Also rebuild trees to get aggregated counts
1003
+ const countMap = new Map();
1004
+ for (const { accountId, folders } of allFolderData) {
1005
+ const delimiter = folders[0]?.delimiter || ".";
1006
+ const tree = buildTree(folders, delimiter, accountId);
1007
+ // Walk the tree and collect counts (buildTree already aggregates)
1008
+ function collectCounts(nodes) {
1009
+ for (const n of nodes) {
1010
+ countMap.set(`${n.accountId}:${n.id}`, { unread: n.unreadCount, total: n.totalCount });
1011
+ collectCounts(n.children);
1012
+ }
1013
+ }
1014
+ collectCounts(tree);
1015
+ }
1016
+ // Patch existing DOM elements in-place
1017
+ const folderEls = container.querySelectorAll(".ft-folder[data-account-id][data-folder-id]");
1018
+ let structureChanged = false;
1019
+ for (const el of folderEls) {
1020
+ const htmlEl = el;
1021
+ const key = `${htmlEl.dataset.accountId}:${htmlEl.dataset.folderId}`;
1022
+ const counts = countMap.get(key);
1023
+ if (!counts)
1024
+ continue; // folder not found — structure may have changed
1025
+ // Update unread badge
1026
+ const isOutbox = htmlEl.dataset.specialUse === "outbox" || htmlEl.dataset.folderPath?.toLowerCase() === "outbox";
1027
+ let badge = htmlEl.querySelector(".ft-badge");
1028
+ const outboxBadge = htmlEl.querySelector(".ft-badge-outbox");
1029
+ if (isOutbox) {
1030
+ if (counts.total > 0) {
1031
+ if (outboxBadge) {
1032
+ outboxBadge.textContent = String(counts.total);
1033
+ }
1034
+ else {
1035
+ const b = document.createElement("span");
1036
+ b.className = "ft-badge ft-badge-outbox";
1037
+ b.textContent = String(counts.total);
1038
+ htmlEl.querySelector(".ft-folder-name")?.after(b);
1039
+ }
1040
+ }
1041
+ else if (outboxBadge) {
1042
+ outboxBadge.remove();
1043
+ }
1044
+ }
1045
+ else {
1046
+ if (counts.unread > 0) {
1047
+ if (badge) {
1048
+ badge.textContent = String(counts.unread);
1049
+ }
1050
+ else {
1051
+ const b = document.createElement("span");
1052
+ b.className = "ft-badge";
1053
+ b.textContent = String(counts.unread);
1054
+ htmlEl.querySelector(".ft-folder-name")?.after(b);
1055
+ }
1056
+ }
1057
+ else if (badge && !badge.classList.contains("ft-badge-outbox")) {
1058
+ badge.remove();
1059
+ }
1060
+ }
1061
+ // Update total count
1062
+ let totalEl = htmlEl.querySelector(".ft-total-count");
1063
+ if (counts.total > 0) {
1064
+ if (totalEl) {
1065
+ totalEl.textContent = String(counts.total);
1066
+ }
1067
+ else {
1068
+ const t = document.createElement("span");
1069
+ t.className = "ft-total-count";
1070
+ t.textContent = String(counts.total);
1071
+ htmlEl.appendChild(t);
1072
+ }
1073
+ }
1074
+ else if (totalEl) {
1075
+ totalEl.remove();
1076
+ }
1077
+ }
1078
+ // Check if folder count changed (new folders added or removed)
1079
+ const existingCount = folderEls.length;
1080
+ let serverCount = 0;
1081
+ for (const { folders } of allFolderData)
1082
+ serverCount += folders.length;
1083
+ if (Math.abs(existingCount - serverCount) > 2) {
1084
+ // Structure changed significantly — do a full rebuild
1085
+ refreshFolderTree();
1086
+ }
1087
+ }
1088
+ catch {
1089
+ // If count update fails, fall back to full rebuild
1090
+ refreshFolderTree();
1091
+ }
1092
+ }
1093
+ //# sourceMappingURL=folder-tree.js.map