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