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