@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,2007 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SQLite metadata index for mailx.
|
|
3
|
-
* Stores message headers, folder structure, sync state.
|
|
4
|
-
* Message bodies are NOT here -- they live in the MessageStore backend.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { DatabaseSync } from "node:sqlite";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
-
import * as path from "node:path";
|
|
10
|
-
import * as fs from "node:fs";
|
|
11
|
-
import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery, SpecialUse } from "@bobfrankston/mailx-types";
|
|
12
|
-
|
|
13
|
-
/** Addresses that have no business in autocomplete. Standard automated
|
|
14
|
-
* sender patterns plus name-side hints ("MAILER-DAEMON" etc.). The exact
|
|
15
|
-
* match list keeps surprise low; the regex catches the long tail of
|
|
16
|
-
* *-bounces@, no-reply variants, and listserv-style addresses. */
|
|
17
|
-
const JUNK_LOCAL_RE = /^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$/i;
|
|
18
|
-
const JUNK_LOCAL_SUFFIX_RE = /(-bounces|\+bounces|-noreply|-no-reply|-notifications?|-mailer)$/i;
|
|
19
|
-
function isJunkContact(email: string, name: string): boolean {
|
|
20
|
-
const local = email.split("@")[0] || "";
|
|
21
|
-
if (JUNK_LOCAL_RE.test(local)) return true;
|
|
22
|
-
if (JUNK_LOCAL_SUFFIX_RE.test(local)) return true;
|
|
23
|
-
// Bare numeric / hex addresses (rotating IDs from automated systems)
|
|
24
|
-
// — three or fewer chars is too short to be useful regardless.
|
|
25
|
-
if (local.length < 2) return true;
|
|
26
|
-
const lname = (name || "").trim().toLowerCase();
|
|
27
|
-
if (lname.includes("mailer-daemon") || lname.includes("postmaster")) return true;
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const SCHEMA = `
|
|
32
|
-
CREATE TABLE IF NOT EXISTS accounts (
|
|
33
|
-
id TEXT PRIMARY KEY,
|
|
34
|
-
name TEXT NOT NULL,
|
|
35
|
-
email TEXT NOT NULL,
|
|
36
|
-
config_json TEXT NOT NULL,
|
|
37
|
-
last_sync INTEGER DEFAULT 0
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
CREATE TABLE IF NOT EXISTS folders (
|
|
41
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
-
account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
43
|
-
path TEXT NOT NULL,
|
|
44
|
-
name TEXT NOT NULL,
|
|
45
|
-
special_use TEXT,
|
|
46
|
-
delimiter TEXT DEFAULT '/',
|
|
47
|
-
total_count INTEGER DEFAULT 0,
|
|
48
|
-
unread_count INTEGER DEFAULT 0,
|
|
49
|
-
uidvalidity INTEGER DEFAULT 0,
|
|
50
|
-
highest_modseq TEXT DEFAULT '0',
|
|
51
|
-
UNIQUE(account_id, path)
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
55
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
-
account_id TEXT NOT NULL,
|
|
57
|
-
folder_id INTEGER NOT NULL REFERENCES folders(id),
|
|
58
|
-
uid INTEGER NOT NULL,
|
|
59
|
-
message_id TEXT,
|
|
60
|
-
in_reply_to TEXT,
|
|
61
|
-
refs TEXT,
|
|
62
|
-
thread_id TEXT,
|
|
63
|
-
date INTEGER NOT NULL,
|
|
64
|
-
subject TEXT DEFAULT '',
|
|
65
|
-
from_address TEXT DEFAULT '',
|
|
66
|
-
from_name TEXT DEFAULT '',
|
|
67
|
-
to_json TEXT DEFAULT '[]',
|
|
68
|
-
cc_json TEXT DEFAULT '[]',
|
|
69
|
-
flags_json TEXT DEFAULT '[]',
|
|
70
|
-
size INTEGER DEFAULT 0,
|
|
71
|
-
has_attachments INTEGER DEFAULT 0,
|
|
72
|
-
preview TEXT DEFAULT '',
|
|
73
|
-
body_path TEXT,
|
|
74
|
-
cached_at INTEGER NOT NULL,
|
|
75
|
-
UNIQUE(account_id, folder_id, uid)
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
CREATE INDEX IF NOT EXISTS idx_messages_folder_date
|
|
79
|
-
ON messages(account_id, folder_id, date DESC);
|
|
80
|
-
|
|
81
|
-
CREATE INDEX IF NOT EXISTS idx_messages_message_id
|
|
82
|
-
ON messages(message_id);
|
|
83
|
-
|
|
84
|
-
-- Note: idx_messages_thread_id is created by the addColumnIfMissing migration
|
|
85
|
-
-- in the constructor, AFTER thread_id is guaranteed to exist. Including it
|
|
86
|
-
-- here would crash startup on any pre-thread_id DB because exec(SCHEMA) runs
|
|
87
|
-
-- before the column-add migration.
|
|
88
|
-
|
|
89
|
-
CREATE TABLE IF NOT EXISTS sent_log (
|
|
90
|
-
message_id TEXT PRIMARY KEY,
|
|
91
|
-
account_id TEXT NOT NULL,
|
|
92
|
-
subject TEXT DEFAULT '',
|
|
93
|
-
recipients TEXT DEFAULT '',
|
|
94
|
-
sent_at INTEGER NOT NULL
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
CREATE TABLE IF NOT EXISTS queue (
|
|
98
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
99
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
100
|
-
created_at INTEGER NOT NULL,
|
|
101
|
-
send_after INTEGER NOT NULL,
|
|
102
|
-
attempts INTEGER DEFAULT 0,
|
|
103
|
-
last_attempt INTEGER DEFAULT 0,
|
|
104
|
-
error TEXT,
|
|
105
|
-
from_account TEXT NOT NULL,
|
|
106
|
-
to_json TEXT NOT NULL,
|
|
107
|
-
cc_json TEXT DEFAULT '[]',
|
|
108
|
-
bcc_json TEXT DEFAULT '[]',
|
|
109
|
-
subject TEXT DEFAULT '',
|
|
110
|
-
body_html TEXT DEFAULT '',
|
|
111
|
-
body_text TEXT DEFAULT '',
|
|
112
|
-
in_reply_to TEXT,
|
|
113
|
-
refs TEXT
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
CREATE TABLE IF NOT EXISTS contacts (
|
|
117
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
-
source TEXT NOT NULL DEFAULT 'discovered',
|
|
119
|
-
google_id TEXT,
|
|
120
|
-
name TEXT DEFAULT '',
|
|
121
|
-
email TEXT NOT NULL,
|
|
122
|
-
organization TEXT DEFAULT '',
|
|
123
|
-
last_used INTEGER DEFAULT 0,
|
|
124
|
-
use_count INTEGER DEFAULT 0,
|
|
125
|
-
updated_at INTEGER NOT NULL,
|
|
126
|
-
UNIQUE(source, email, name)
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
|
|
130
|
-
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
|
131
|
-
|
|
132
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
133
|
-
subject, from_name, from_address, to_text, cc_text, body_text,
|
|
134
|
-
content=messages, content_rowid=id
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
CREATE TABLE IF NOT EXISTS sync_actions (
|
|
138
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
-
account_id TEXT NOT NULL,
|
|
140
|
-
action TEXT NOT NULL,
|
|
141
|
-
uid INTEGER,
|
|
142
|
-
folder_id INTEGER,
|
|
143
|
-
target_folder_id INTEGER,
|
|
144
|
-
flags_json TEXT,
|
|
145
|
-
raw_message TEXT,
|
|
146
|
-
created_at INTEGER NOT NULL,
|
|
147
|
-
attempts INTEGER DEFAULT 0,
|
|
148
|
-
last_error TEXT,
|
|
149
|
-
UNIQUE(account_id, action, uid, folder_id)
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
-- Tombstones: messages the user deleted locally. Sync checks this table
|
|
153
|
-
-- before inserting a new row so a server-side delete that hasn't yet
|
|
154
|
-
-- propagated (or a stale server listing during the EXPUNGE race) can't
|
|
155
|
-
-- resurrect a message the user already removed. Keyed by Message-ID
|
|
156
|
-
-- because that's the only identifier stable across UID renumbers,
|
|
157
|
-
-- UIDVALIDITY bumps, and cross-folder moves.
|
|
158
|
-
CREATE TABLE IF NOT EXISTS tombstones (
|
|
159
|
-
account_id TEXT NOT NULL,
|
|
160
|
-
message_id TEXT NOT NULL,
|
|
161
|
-
deleted_at INTEGER NOT NULL,
|
|
162
|
-
subject TEXT DEFAULT '',
|
|
163
|
-
PRIMARY KEY (account_id, message_id)
|
|
164
|
-
);
|
|
165
|
-
CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
|
|
166
|
-
|
|
167
|
-
-- Calendar events: two-way cache of Google Calendar / local events.
|
|
168
|
-
-- uuid = local stable identity (survives provider_id rebinds).
|
|
169
|
-
-- provider_id = Google Calendar event id when known (null for local-only
|
|
170
|
-
-- events that haven't been pushed yet).
|
|
171
|
-
-- deleted = tombstone marker; drainer removes row from server then deletes
|
|
172
|
-
-- the row locally.
|
|
173
|
-
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
174
|
-
uuid TEXT PRIMARY KEY,
|
|
175
|
-
account_id TEXT NOT NULL,
|
|
176
|
-
provider_id TEXT,
|
|
177
|
-
calendar_id TEXT DEFAULT 'primary',
|
|
178
|
-
title TEXT NOT NULL DEFAULT '',
|
|
179
|
-
start_ms INTEGER NOT NULL,
|
|
180
|
-
end_ms INTEGER NOT NULL,
|
|
181
|
-
all_day INTEGER DEFAULT 0,
|
|
182
|
-
location TEXT DEFAULT '',
|
|
183
|
-
notes TEXT DEFAULT '',
|
|
184
|
-
etag TEXT,
|
|
185
|
-
last_synced INTEGER DEFAULT 0,
|
|
186
|
-
dirty INTEGER DEFAULT 0,
|
|
187
|
-
deleted INTEGER DEFAULT 0,
|
|
188
|
-
updated_at INTEGER NOT NULL
|
|
189
|
-
);
|
|
190
|
-
CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
|
|
191
|
-
CREATE INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
|
|
192
|
-
-- getCalendarEventByProviderId runs once per event on every Google refresh;
|
|
193
|
-
-- without this index each lookup is a full table scan over calendar_events.
|
|
194
|
-
CREATE INDEX IF NOT EXISTS idx_calendar_events_provider ON calendar_events(account_id, provider_id);
|
|
195
|
-
|
|
196
|
-
-- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
|
|
197
|
-
-- calendar_events minus the time range.
|
|
198
|
-
CREATE TABLE IF NOT EXISTS tasks (
|
|
199
|
-
uuid TEXT PRIMARY KEY,
|
|
200
|
-
account_id TEXT NOT NULL,
|
|
201
|
-
provider_id TEXT,
|
|
202
|
-
list_id TEXT DEFAULT '@default',
|
|
203
|
-
title TEXT NOT NULL DEFAULT '',
|
|
204
|
-
notes TEXT DEFAULT '',
|
|
205
|
-
due_ms INTEGER,
|
|
206
|
-
completed_ms INTEGER,
|
|
207
|
-
etag TEXT,
|
|
208
|
-
last_synced INTEGER DEFAULT 0,
|
|
209
|
-
dirty INTEGER DEFAULT 0,
|
|
210
|
-
deleted INTEGER DEFAULT 0,
|
|
211
|
-
updated_at INTEGER NOT NULL
|
|
212
|
-
);
|
|
213
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
|
|
214
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
|
|
215
|
-
-- Mirror calendar_events: any provider_id lookup path needs a proper index,
|
|
216
|
-
-- even if today's reconcile does the dedup in memory — prevents a future
|
|
217
|
-
-- refactor from accidentally introducing an O(N) scan.
|
|
218
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_provider ON tasks(account_id, provider_id);
|
|
219
|
-
|
|
220
|
-
-- Generic store-sync queue for domains OTHER than messages. Messages
|
|
221
|
-
-- use sync_actions above. This table queues push-to-server actions
|
|
222
|
-
-- for calendar / tasks / contacts / allowlist. Kind identifies the
|
|
223
|
-
-- domain; op is "create" / "update" / "delete"; payload is JSON the
|
|
224
|
-
-- drainer posts to the provider. Target URL isn't stored — the
|
|
225
|
-
-- drainer knows the provider endpoint from kind + payload.
|
|
226
|
-
CREATE TABLE IF NOT EXISTS store_sync (
|
|
227
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
228
|
-
kind TEXT NOT NULL,
|
|
229
|
-
op TEXT NOT NULL,
|
|
230
|
-
account_id TEXT NOT NULL,
|
|
231
|
-
target_uuid TEXT NOT NULL,
|
|
232
|
-
payload TEXT,
|
|
233
|
-
attempts INTEGER DEFAULT 0,
|
|
234
|
-
last_error TEXT,
|
|
235
|
-
created_at INTEGER NOT NULL,
|
|
236
|
-
UNIQUE(kind, target_uuid, op)
|
|
237
|
-
);
|
|
238
|
-
CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
|
|
239
|
-
-- UNIQUE(kind, target_uuid, op) covers queries that start with kind; lookups
|
|
240
|
-
-- by target_uuid alone ("is this uuid queued for any op?") would otherwise
|
|
241
|
-
-- table-scan. Cheap; store_sync is tiny and write-heavy.
|
|
242
|
-
CREATE INDEX IF NOT EXISTS idx_store_sync_target_uuid ON store_sync(target_uuid);
|
|
243
|
-
-- Generic per-scope/per-key string store. Used for sync tokens (Google
|
|
244
|
-
-- People nextSyncToken per account, Gmail history-id, calendar sync token,
|
|
245
|
-
-- etc.) and any other small bits of state that need to outlive a process
|
|
246
|
-
-- restart but don't deserve their own table. Keyed by (scope, key).
|
|
247
|
-
CREATE TABLE IF NOT EXISTS kv (
|
|
248
|
-
scope TEXT NOT NULL,
|
|
249
|
-
key TEXT NOT NULL,
|
|
250
|
-
value TEXT,
|
|
251
|
-
updated_at INTEGER NOT NULL,
|
|
252
|
-
PRIMARY KEY(scope, key)
|
|
253
|
-
);
|
|
254
|
-
`;
|
|
255
|
-
|
|
256
|
-
export class MailxDB {
|
|
257
|
-
private db: DatabaseSync;
|
|
258
|
-
|
|
259
|
-
constructor(dbDir: string) {
|
|
260
|
-
fs.mkdirSync(dbDir, { recursive: true });
|
|
261
|
-
const dbPath = path.join(dbDir, "mailx.db");
|
|
262
|
-
this.db = new DatabaseSync(dbPath);
|
|
263
|
-
this.db.exec("PRAGMA journal_mode = WAL");
|
|
264
|
-
this.db.exec("PRAGMA foreign_keys = ON");
|
|
265
|
-
this.db.exec(SCHEMA);
|
|
266
|
-
// Idempotent migrations for older databases that predate new columns.
|
|
267
|
-
// SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so we just try the
|
|
268
|
-
// ALTER and catch the "duplicate column" error. Simpler and more robust
|
|
269
|
-
// than probing via PRAGMA table_info (which can behave differently
|
|
270
|
-
// across sqlite drivers).
|
|
271
|
-
this.addColumnIfMissing("messages", "thread_id", "TEXT");
|
|
272
|
-
try {
|
|
273
|
-
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
|
|
274
|
-
} catch { /* already exists */ }
|
|
275
|
-
// provider_id: native server-side id for API-backed providers (Gmail
|
|
276
|
-
// hex id, Outlook Graph id, etc.). Lets fetchOne look up the message
|
|
277
|
-
// directly instead of paginating listMessageIds for every body fetch
|
|
278
|
-
// — a UID-only path costs 2-3 rate-limited API calls per message.
|
|
279
|
-
this.addColumnIfMissing("messages", "provider_id", "TEXT");
|
|
280
|
-
// uuid: stable per-message local identity. Assigned the first time
|
|
281
|
-
// mailx sees the message and never changes — survives server UID
|
|
282
|
-
// renumbers, UIDVALIDITY bumps, and cross-folder moves (the sync
|
|
283
|
-
// rebinds the (folder_id, uid) tuple but keeps the UUID). All UI
|
|
284
|
-
// references SHOULD flow through uuid; (account_id, folder_id, uid)
|
|
285
|
-
// remains the server-binding metadata used only by sync.
|
|
286
|
-
this.addColumnIfMissing("messages", "uuid", "TEXT");
|
|
287
|
-
try {
|
|
288
|
-
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
|
|
289
|
-
} catch { /* already exists */ }
|
|
290
|
-
// bcc_json: pre-existing DBs predate this column. Without the migration
|
|
291
|
-
// every contacts seed pass throws "no such column: m.bcc_json" and the
|
|
292
|
-
// local autocomplete corpus stays empty.
|
|
293
|
-
this.addColumnIfMissing("messages", "bcc_json", "TEXT DEFAULT '[]'");
|
|
294
|
-
// calendar_events: recurring_event_id carries the Google Calendar
|
|
295
|
-
// series id when the event is an expanded instance of a recurrence.
|
|
296
|
-
// Filters like "hide recurring events" check this column.
|
|
297
|
-
this.addColumnIfMissing("calendar_events", "recurring_event_id", "TEXT");
|
|
298
|
-
this.addColumnIfMissing("calendar_events", "html_link", "TEXT");
|
|
299
|
-
// Backfill UUIDs for any pre-existing rows that were inserted before
|
|
300
|
-
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
301
|
-
// at our row counts, runs once per DB upgrade.
|
|
302
|
-
this.backfillUuids();
|
|
303
|
-
|
|
304
|
-
// One-shot contacts table reset: the contacts schema's UNIQUE constraint
|
|
305
|
-
// was widened from `(email)` to `(source, email, name)` so the same
|
|
306
|
-
// address can carry multiple distinct (name, source) entries — Bob's
|
|
307
|
-
// wife at bob@example.com and a separate `Bob Smith <bob@example.com>`
|
|
308
|
-
// for work, both legitimate. Old rows with the email-only unique key
|
|
309
|
-
// would block the new inserts. Per user "don't migrate, start fresh":
|
|
310
|
-
// drop the old table, recreate it via SCHEMA, reseed from messages on
|
|
311
|
-
// next sync. Gated by a kv flag so we run exactly once per machine.
|
|
312
|
-
const contactsResetFlag = this.getKv("schema", "contacts_v2");
|
|
313
|
-
if (!contactsResetFlag) {
|
|
314
|
-
try {
|
|
315
|
-
this.db.exec("DROP TABLE IF EXISTS contacts");
|
|
316
|
-
this.db.exec(`
|
|
317
|
-
CREATE TABLE contacts (
|
|
318
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
319
|
-
source TEXT NOT NULL DEFAULT 'discovered',
|
|
320
|
-
google_id TEXT,
|
|
321
|
-
name TEXT DEFAULT '',
|
|
322
|
-
email TEXT NOT NULL,
|
|
323
|
-
organization TEXT DEFAULT '',
|
|
324
|
-
last_used INTEGER DEFAULT 0,
|
|
325
|
-
use_count INTEGER DEFAULT 0,
|
|
326
|
-
updated_at INTEGER NOT NULL,
|
|
327
|
-
UNIQUE(source, email, name)
|
|
328
|
-
);
|
|
329
|
-
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
|
|
330
|
-
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
|
331
|
-
`);
|
|
332
|
-
this.setKv("schema", "contacts_v2", String(Date.now()));
|
|
333
|
-
console.log(" [db] contacts table reset to v2 schema (multi-name-per-email)");
|
|
334
|
-
} catch (e: any) {
|
|
335
|
-
console.error(` [db] contacts v2 reset failed: ${e.message}`);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Post-migration sanity check: verify the columns we actually read in
|
|
340
|
-
// SELECTs exist. If any migration silently failed (stale driver, DB
|
|
341
|
-
// file locked, permission error), later code would throw cryptic
|
|
342
|
-
// "no such column" errors buried deep in a sync run. Fail loud here
|
|
343
|
-
// with a clear "run mailx -rebuild" message. C32 on Linux was exactly
|
|
344
|
-
// this — old mailx-store that predated thread_id/uuid migrations.
|
|
345
|
-
this.verifySchema();
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
|
|
349
|
-
* runs at startup). The user-facing message names the recovery command. */
|
|
350
|
-
private verifySchema(): void {
|
|
351
|
-
const required: Record<string, string[]> = {
|
|
352
|
-
messages: ["thread_id", "provider_id", "uuid", "bcc_json"],
|
|
353
|
-
calendar_events: ["recurring_event_id", "html_link"],
|
|
354
|
-
};
|
|
355
|
-
for (const [table, cols] of Object.entries(required)) {
|
|
356
|
-
let actual: { name: string }[];
|
|
357
|
-
try {
|
|
358
|
-
actual = this.db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
|
359
|
-
} catch (e: any) {
|
|
360
|
-
throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
|
|
361
|
-
}
|
|
362
|
-
const names = new Set(actual.map(r => r.name));
|
|
363
|
-
const missing = cols.filter(c => !names.has(c));
|
|
364
|
-
if (missing.length > 0) {
|
|
365
|
-
throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'mailx -rebuild' to rebuild the local store.`);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Fetch a string from the kv table. Returns null when not set. */
|
|
371
|
-
getKv(scope: string, key: string): string | null {
|
|
372
|
-
const r = this.db.prepare("SELECT value FROM kv WHERE scope = ? AND key = ?").get(scope, key) as { value: string } | undefined;
|
|
373
|
-
return r?.value ?? null;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Upsert a kv row. Pass `null` to delete. */
|
|
377
|
-
setKv(scope: string, key: string, value: string | null): void {
|
|
378
|
-
if (value === null) {
|
|
379
|
-
this.db.prepare("DELETE FROM kv WHERE scope = ? AND key = ?").run(scope, key);
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
this.db.prepare(
|
|
383
|
-
"INSERT INTO kv (scope, key, value, updated_at) VALUES (?, ?, ?, ?) "
|
|
384
|
-
+ "ON CONFLICT(scope, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at"
|
|
385
|
-
).run(scope, key, value, Date.now());
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** One-time: assign UUIDs to every `messages` row that's missing one.
|
|
389
|
-
* Runs on every startup but the WHERE clause makes it a no-op after the
|
|
390
|
-
* first pass. */
|
|
391
|
-
private backfillUuids(): void {
|
|
392
|
-
try {
|
|
393
|
-
const rows = this.db.prepare("SELECT id FROM messages WHERE uuid IS NULL OR uuid = ''").all() as { id: number }[];
|
|
394
|
-
if (rows.length === 0) return;
|
|
395
|
-
console.log(` [db] backfilling ${rows.length} message UUIDs`);
|
|
396
|
-
const upd = this.db.prepare("UPDATE messages SET uuid = ? WHERE id = ?");
|
|
397
|
-
this.db.exec("BEGIN");
|
|
398
|
-
try {
|
|
399
|
-
for (const r of rows) upd.run(randomUUID().replace(/-/g, ""), r.id);
|
|
400
|
-
this.db.exec("COMMIT");
|
|
401
|
-
} catch (e) {
|
|
402
|
-
this.db.exec("ROLLBACK");
|
|
403
|
-
throw e;
|
|
404
|
-
}
|
|
405
|
-
} catch (e: any) {
|
|
406
|
-
console.error(` [db] backfillUuids failed: ${e.message}`);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// ── Sent-log (dedup) ──
|
|
411
|
-
|
|
412
|
-
/** Has this Message-ID already been sent? Used to prevent the outbox from
|
|
413
|
-
* re-sending the same raw file across crash/restart cycles. */
|
|
414
|
-
hasSentMessage(messageId: string): boolean {
|
|
415
|
-
if (!messageId) return false;
|
|
416
|
-
const row = this.db.prepare("SELECT 1 FROM sent_log WHERE message_id = ? LIMIT 1").get(messageId);
|
|
417
|
-
return !!row;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** Record a successfully sent message so future attempts are skipped. */
|
|
421
|
-
recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void {
|
|
422
|
-
if (!messageId) return;
|
|
423
|
-
try {
|
|
424
|
-
this.db.prepare(
|
|
425
|
-
"INSERT INTO sent_log (message_id, account_id, subject, recipients, sent_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(message_id) DO NOTHING"
|
|
426
|
-
).run(messageId, accountId, subject || "", recipients.join(", "), Date.now());
|
|
427
|
-
} catch (e: any) {
|
|
428
|
-
console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** Q49 heuristic: has the user ever sent a message to `recipientEmail`
|
|
433
|
-
* that had a non-empty Cc field? Used by compose to auto-expand the Cc
|
|
434
|
-
* input when replying to someone who customarily gets Cc'd with others.
|
|
435
|
-
* Query scans only Sent folders (special_use='sent') and matches the
|
|
436
|
-
* recipient's address inside `to_json` via LIKE. No special index — the
|
|
437
|
-
* Sent folder's row count is typically a few thousand at most; acceptable
|
|
438
|
-
* on the compose-open path. */
|
|
439
|
-
hasCcHistoryTo(recipientEmail: string): boolean {
|
|
440
|
-
const email = (recipientEmail || "").trim().toLowerCase();
|
|
441
|
-
if (!email) return false;
|
|
442
|
-
try {
|
|
443
|
-
const row = this.db.prepare(`
|
|
444
|
-
SELECT 1 FROM messages m
|
|
445
|
-
JOIN folders f ON m.folder_id = f.id
|
|
446
|
-
WHERE f.special_use = 'sent'
|
|
447
|
-
AND lower(m.to_json) LIKE ?
|
|
448
|
-
AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
|
|
449
|
-
LIMIT 1
|
|
450
|
-
`).get(`%"${email}"%`);
|
|
451
|
-
return !!row;
|
|
452
|
-
} catch {
|
|
453
|
-
return false;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** Same shape as hasCcHistoryTo for the Bcc field. Bcc only appears in the
|
|
458
|
-
* user's own Sent copy, so it's still a reliable signal that this user
|
|
459
|
-
* habitually Bccs when writing to this recipient. */
|
|
460
|
-
hasBccHistoryTo(recipientEmail: string): boolean {
|
|
461
|
-
const email = (recipientEmail || "").trim().toLowerCase();
|
|
462
|
-
if (!email) return false;
|
|
463
|
-
try {
|
|
464
|
-
const row = this.db.prepare(`
|
|
465
|
-
SELECT 1 FROM messages m
|
|
466
|
-
JOIN folders f ON m.folder_id = f.id
|
|
467
|
-
WHERE f.special_use = 'sent'
|
|
468
|
-
AND lower(m.to_json) LIKE ?
|
|
469
|
-
AND m.bcc_json IS NOT NULL AND m.bcc_json != '[]' AND m.bcc_json != ''
|
|
470
|
-
LIMIT 1
|
|
471
|
-
`).get(`%"${email}"%`);
|
|
472
|
-
return !!row;
|
|
473
|
-
} catch {
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// ── Tombstones (local-delete record so server echo can't resurrect) ──
|
|
479
|
-
|
|
480
|
-
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
481
|
-
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
482
|
-
* can't check against future sync results anyway. */
|
|
483
|
-
addTombstone(accountId: string, messageId: string, subject: string = ""): void {
|
|
484
|
-
if (!messageId) return;
|
|
485
|
-
try {
|
|
486
|
-
this.db.prepare(
|
|
487
|
-
"INSERT INTO tombstones (account_id, message_id, deleted_at, subject) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at"
|
|
488
|
-
).run(accountId, messageId, Date.now(), subject || "");
|
|
489
|
-
} catch (e: any) {
|
|
490
|
-
console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/** Is this Message-ID tombstoned for this account? */
|
|
495
|
-
hasTombstone(accountId: string, messageId: string): boolean {
|
|
496
|
-
if (!messageId) return false;
|
|
497
|
-
const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
|
|
498
|
-
return !!row;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
|
|
502
|
-
* re-imports the message as normal. Also lets the user recover from a
|
|
503
|
-
* mistaken local delete. */
|
|
504
|
-
removeTombstone(accountId: string, messageId: string): void {
|
|
505
|
-
if (!messageId) return;
|
|
506
|
-
try {
|
|
507
|
-
this.db.prepare("DELETE FROM tombstones WHERE account_id = ? AND message_id = ?").run(accountId, messageId);
|
|
508
|
-
} catch (e: any) {
|
|
509
|
-
console.error(` [tombstones] failed to remove ${messageId}: ${e.message}`);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/** Age-out tombstones older than the given cutoff. Keeps the table from
|
|
514
|
-
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
515
|
-
* actual cutoff in ms since epoch. */
|
|
516
|
-
pruneTombstones(olderThanMs: number): number {
|
|
517
|
-
try {
|
|
518
|
-
const res = this.db.prepare("DELETE FROM tombstones WHERE deleted_at < ?").run(olderThanMs);
|
|
519
|
-
return Number(res.changes || 0);
|
|
520
|
-
} catch (e: any) {
|
|
521
|
-
console.error(` [tombstones] prune failed: ${e.message}`);
|
|
522
|
-
return 0;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// ── Calendar events (two-way cache) ──
|
|
527
|
-
|
|
528
|
-
upsertCalendarEvent(ev: {
|
|
529
|
-
uuid?: string; accountId: string; providerId?: string; calendarId?: string;
|
|
530
|
-
title: string; startMs: number; endMs: number; allDay?: boolean;
|
|
531
|
-
location?: string; notes?: string; etag?: string; dirty?: boolean;
|
|
532
|
-
recurringEventId?: string; htmlLink?: string;
|
|
533
|
-
}): string {
|
|
534
|
-
const uuid = ev.uuid || randomUUID().replace(/-/g, "");
|
|
535
|
-
this.db.prepare(`
|
|
536
|
-
INSERT INTO calendar_events
|
|
537
|
-
(uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
|
|
538
|
-
all_day, location, notes, etag, last_synced, dirty, deleted, updated_at,
|
|
539
|
-
recurring_event_id, html_link)
|
|
540
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
|
|
541
|
-
ON CONFLICT(uuid) DO UPDATE SET
|
|
542
|
-
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
543
|
-
calendar_id=excluded.calendar_id, title=excluded.title,
|
|
544
|
-
start_ms=excluded.start_ms, end_ms=excluded.end_ms,
|
|
545
|
-
all_day=excluded.all_day, location=excluded.location,
|
|
546
|
-
notes=excluded.notes, etag=excluded.etag,
|
|
547
|
-
last_synced=excluded.last_synced, dirty=excluded.dirty,
|
|
548
|
-
updated_at=excluded.updated_at,
|
|
549
|
-
recurring_event_id=excluded.recurring_event_id,
|
|
550
|
-
html_link=excluded.html_link
|
|
551
|
-
`).run(
|
|
552
|
-
uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary",
|
|
553
|
-
ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0,
|
|
554
|
-
ev.location || "", ev.notes || "", ev.etag || null,
|
|
555
|
-
ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now(),
|
|
556
|
-
ev.recurringEventId || null, ev.htmlLink || null
|
|
557
|
-
);
|
|
558
|
-
return uuid;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[] {
|
|
562
|
-
const rows = this.db.prepare(`
|
|
563
|
-
SELECT * FROM calendar_events
|
|
564
|
-
WHERE account_id = ? AND deleted = 0 AND start_ms >= ? AND start_ms < ?
|
|
565
|
-
ORDER BY start_ms ASC
|
|
566
|
-
`).all(accountId, fromMs, toMs) as any[];
|
|
567
|
-
return rows.map(this.calendarRowToObject);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/** Lookup by uuid only — used by patch/delete paths that don't have an
|
|
571
|
-
* accountId context. Returns the row even when it's soft-deleted. */
|
|
572
|
-
getCalendarEventByUuid(uuid: string): any | null {
|
|
573
|
-
const r = this.db.prepare("SELECT * FROM calendar_events WHERE uuid = ?").get(uuid) as any;
|
|
574
|
-
return r ? this.calendarRowToObject(r) : null;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
getTaskByUuid(uuid: string): any | null {
|
|
578
|
-
const r = this.db.prepare("SELECT * FROM tasks WHERE uuid = ?").get(uuid) as any;
|
|
579
|
-
return r ? this.taskRowToObject(r) : null;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
getDirtyCalendarEvents(accountId: string): any[] {
|
|
583
|
-
const rows = this.db.prepare(`
|
|
584
|
-
SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
|
|
585
|
-
`).all(accountId) as any[];
|
|
586
|
-
return rows.map(this.calendarRowToObject);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
private calendarRowToObject(r: any): any {
|
|
590
|
-
return {
|
|
591
|
-
uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
|
|
592
|
-
calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
|
|
593
|
-
endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
|
|
594
|
-
etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
|
|
595
|
-
recurringEventId: r.recurring_event_id || null,
|
|
596
|
-
htmlLink: r.html_link || null,
|
|
597
|
-
};
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** Find a calendar event by its Google Calendar event id (provider_id).
|
|
601
|
-
* Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
|
|
602
|
-
getCalendarEventByProviderId(accountId: string, providerId: string): any | null {
|
|
603
|
-
const r = this.db.prepare(
|
|
604
|
-
"SELECT * FROM calendar_events WHERE account_id = ? AND provider_id = ?"
|
|
605
|
-
).get(accountId, providerId) as any;
|
|
606
|
-
return r ? this.calendarRowToObject(r) : null;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
markCalendarEventClean(uuid: string, providerId: string, etag: string): void {
|
|
610
|
-
this.db.prepare(`
|
|
611
|
-
UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
|
|
612
|
-
`).run(providerId, etag, Date.now(), uuid);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
deleteCalendarEventLocal(uuid: string): void {
|
|
616
|
-
this.db.prepare("UPDATE calendar_events SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
purgeCalendarEvent(uuid: string): void {
|
|
620
|
-
this.db.prepare("DELETE FROM calendar_events WHERE uuid=?").run(uuid);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// ── Tasks (two-way cache) ──
|
|
624
|
-
|
|
625
|
-
upsertTask(t: {
|
|
626
|
-
uuid?: string; accountId: string; providerId?: string; listId?: string;
|
|
627
|
-
title: string; notes?: string; dueMs?: number; completedMs?: number;
|
|
628
|
-
etag?: string; dirty?: boolean;
|
|
629
|
-
}): string {
|
|
630
|
-
const uuid = t.uuid || randomUUID().replace(/-/g, "");
|
|
631
|
-
this.db.prepare(`
|
|
632
|
-
INSERT INTO tasks
|
|
633
|
-
(uuid, account_id, provider_id, list_id, title, notes, due_ms, completed_ms,
|
|
634
|
-
etag, last_synced, dirty, deleted, updated_at)
|
|
635
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
|
|
636
|
-
ON CONFLICT(uuid) DO UPDATE SET
|
|
637
|
-
account_id=excluded.account_id, provider_id=excluded.provider_id,
|
|
638
|
-
list_id=excluded.list_id, title=excluded.title, notes=excluded.notes,
|
|
639
|
-
due_ms=excluded.due_ms, completed_ms=excluded.completed_ms,
|
|
640
|
-
etag=excluded.etag, last_synced=excluded.last_synced,
|
|
641
|
-
dirty=excluded.dirty, updated_at=excluded.updated_at
|
|
642
|
-
`).run(
|
|
643
|
-
uuid, t.accountId, t.providerId || null, t.listId || "@default",
|
|
644
|
-
t.title, t.notes || "", t.dueMs || null, t.completedMs || null,
|
|
645
|
-
t.etag || null, t.dirty ? 0 : Date.now(), t.dirty ? 1 : 0, Date.now()
|
|
646
|
-
);
|
|
647
|
-
return uuid;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
getTasks(accountId: string, includeCompleted = false): any[] {
|
|
651
|
-
const where = includeCompleted
|
|
652
|
-
? "account_id = ? AND deleted = 0"
|
|
653
|
-
: "account_id = ? AND deleted = 0 AND completed_ms IS NULL";
|
|
654
|
-
const rows = this.db.prepare(`SELECT * FROM tasks WHERE ${where} ORDER BY COALESCE(due_ms, updated_at) ASC`).all(accountId) as any[];
|
|
655
|
-
return rows.map(this.taskRowToObject);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
getDirtyTasks(accountId: string): any[] {
|
|
659
|
-
const rows = this.db.prepare(`SELECT * FROM tasks WHERE account_id = ? AND (dirty = 1 OR deleted = 1)`).all(accountId) as any[];
|
|
660
|
-
return rows.map(this.taskRowToObject);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
private taskRowToObject(r: any): any {
|
|
664
|
-
return {
|
|
665
|
-
uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
|
|
666
|
-
listId: r.list_id, title: r.title, notes: r.notes, dueMs: r.due_ms,
|
|
667
|
-
completedMs: r.completed_ms, etag: r.etag, lastSynced: r.last_synced,
|
|
668
|
-
dirty: !!r.dirty, deleted: !!r.deleted,
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
markTaskClean(uuid: string, providerId: string, etag: string): void {
|
|
673
|
-
this.db.prepare(`UPDATE tasks SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?`)
|
|
674
|
-
.run(providerId, etag, Date.now(), uuid);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
deleteTaskLocal(uuid: string): void {
|
|
678
|
-
this.db.prepare("UPDATE tasks SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
purgeTask(uuid: string): void {
|
|
682
|
-
this.db.prepare("DELETE FROM tasks WHERE uuid=?").run(uuid);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// ── Contacts two-way: existing upsertContact / deleteContact handle the
|
|
686
|
-
// local side; the service layer adds store_sync push-queue entries.
|
|
687
|
-
// (No extra methods needed here — upsertContact/deleteContact at the
|
|
688
|
-
// regular contact-management section are the two-way cache's local
|
|
689
|
-
// writers.)
|
|
690
|
-
|
|
691
|
-
/** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
|
|
692
|
-
deleteContactLocal(email: string): void { this.deleteContact(email); }
|
|
693
|
-
|
|
694
|
-
// ── Store-sync queue (calendar / tasks / contacts / allowlist) ──
|
|
695
|
-
|
|
696
|
-
enqueueStoreSync(kind: string, op: string, accountId: string, targetUuid: string, payload: any): void {
|
|
697
|
-
try {
|
|
698
|
-
this.db.prepare(`
|
|
699
|
-
INSERT OR REPLACE INTO store_sync (kind, op, account_id, target_uuid, payload, created_at)
|
|
700
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
701
|
-
`).run(kind, op, accountId, targetUuid, JSON.stringify(payload), Date.now());
|
|
702
|
-
} catch (e: any) {
|
|
703
|
-
console.error(` [store_sync] enqueue ${kind}/${op}/${targetUuid} failed: ${e.message}`);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
getStoreSyncQueue(kind?: string, accountId?: string): any[] {
|
|
708
|
-
let sql = "SELECT * FROM store_sync";
|
|
709
|
-
const params: any[] = [];
|
|
710
|
-
const wh: string[] = [];
|
|
711
|
-
if (kind) { wh.push("kind = ?"); params.push(kind); }
|
|
712
|
-
if (accountId) { wh.push("account_id = ?"); params.push(accountId); }
|
|
713
|
-
if (wh.length) sql += " WHERE " + wh.join(" AND ");
|
|
714
|
-
sql += " ORDER BY created_at ASC";
|
|
715
|
-
const rows = this.db.prepare(sql).all(...params) as any[];
|
|
716
|
-
return rows.map(r => ({
|
|
717
|
-
id: r.id, kind: r.kind, op: r.op, accountId: r.account_id,
|
|
718
|
-
targetUuid: r.target_uuid,
|
|
719
|
-
payload: r.payload ? JSON.parse(r.payload) : null,
|
|
720
|
-
attempts: r.attempts, lastError: r.last_error,
|
|
721
|
-
}));
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
completeStoreSync(id: number): void {
|
|
725
|
-
this.db.prepare("DELETE FROM store_sync WHERE id = ?").run(id);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
failStoreSync(id: number, error: string): void {
|
|
729
|
-
this.db.prepare("UPDATE store_sync SET attempts = attempts + 1, last_error = ? WHERE id = ?").run(error, id);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Idempotently add a column to a table if it's missing. */
|
|
733
|
-
private addColumnIfMissing(table: string, column: string, sqlType: string): void {
|
|
734
|
-
try {
|
|
735
|
-
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${sqlType}`);
|
|
736
|
-
console.log(` [db] added column ${table}.${column}`);
|
|
737
|
-
} catch (e: any) {
|
|
738
|
-
const msg = String(e?.message || e);
|
|
739
|
-
// "duplicate column name" is the expected case when column already exists
|
|
740
|
-
if (!/duplicate column/i.test(msg)) {
|
|
741
|
-
console.error(` [db] migration ${table}.${column} failed: ${msg}`);
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/** Compute a thread id for an incoming message. Strategy:
|
|
747
|
-
* 1. If any ancestor (in_reply_to or references) is already present in
|
|
748
|
-
* messages with a thread_id, reuse it — this handles the case where
|
|
749
|
-
* replies arrive before / after the root.
|
|
750
|
-
* 2. Otherwise use the oldest ref (first entry in References), or
|
|
751
|
-
* in_reply_to, or the message's own messageId as the thread root. */
|
|
752
|
-
private computeThreadId(accountId: string, messageId: string, inReplyTo: string, references: string[]): string {
|
|
753
|
-
const candidates: string[] = [];
|
|
754
|
-
if (references && references.length) candidates.push(...references);
|
|
755
|
-
if (inReplyTo && !candidates.includes(inReplyTo)) candidates.push(inReplyTo);
|
|
756
|
-
if (messageId && !candidates.includes(messageId)) candidates.push(messageId);
|
|
757
|
-
|
|
758
|
-
// Look for an existing thread anchored on any of the ancestors
|
|
759
|
-
for (const mid of candidates) {
|
|
760
|
-
if (!mid) continue;
|
|
761
|
-
const row = this.db.prepare(
|
|
762
|
-
"SELECT thread_id FROM messages WHERE account_id = ? AND message_id = ? AND thread_id IS NOT NULL LIMIT 1"
|
|
763
|
-
).get(accountId, mid) as { thread_id: string } | undefined;
|
|
764
|
-
if (row?.thread_id) return row.thread_id;
|
|
765
|
-
}
|
|
766
|
-
// No existing thread — seed from the oldest ref, falling back to
|
|
767
|
-
// in_reply_to, then messageId
|
|
768
|
-
return (references && references[0]) || inReplyTo || messageId || `orphan-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/** Get all messages in a thread (across folders) for a given account. */
|
|
772
|
-
getThreadMessages(accountId: string, threadId: string): MessageEnvelope[] {
|
|
773
|
-
if (!threadId) return [];
|
|
774
|
-
const rows = this.db.prepare(
|
|
775
|
-
`SELECT * FROM messages WHERE account_id = ? AND thread_id = ? ORDER BY date ASC`
|
|
776
|
-
).all(accountId, threadId) as any[];
|
|
777
|
-
return rows.map(r => ({
|
|
778
|
-
id: r.id,
|
|
779
|
-
accountId: r.account_id,
|
|
780
|
-
folderId: r.folder_id,
|
|
781
|
-
uid: r.uid,
|
|
782
|
-
messageId: r.message_id || "",
|
|
783
|
-
inReplyTo: r.in_reply_to || "",
|
|
784
|
-
references: JSON.parse(r.refs || "[]"),
|
|
785
|
-
threadId: r.thread_id || undefined,
|
|
786
|
-
date: r.date,
|
|
787
|
-
subject: r.subject,
|
|
788
|
-
from: { name: r.from_name, address: r.from_address },
|
|
789
|
-
to: JSON.parse(r.to_json),
|
|
790
|
-
cc: JSON.parse(r.cc_json),
|
|
791
|
-
flags: JSON.parse(r.flags_json),
|
|
792
|
-
size: r.size,
|
|
793
|
-
hasAttachments: !!r.has_attachments,
|
|
794
|
-
preview: r.preview,
|
|
795
|
-
bodyPath: r.body_path || undefined,
|
|
796
|
-
}));
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
close(): void {
|
|
800
|
-
this.db.close();
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// ── Accounts ──
|
|
804
|
-
|
|
805
|
-
upsertAccount(id: string, name: string, email: string, configJson: string): void {
|
|
806
|
-
this.db.prepare(`
|
|
807
|
-
INSERT INTO accounts (id, name, email, config_json)
|
|
808
|
-
VALUES (?, ?, ?, ?)
|
|
809
|
-
ON CONFLICT(id) DO UPDATE SET name=?, email=?, config_json=?
|
|
810
|
-
`).run(id, name, email, configJson, name, email, configJson);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
getAccounts(): { id: string; name: string; email: string; lastSync: number }[] {
|
|
814
|
-
return this.db.prepare("SELECT id, name, email, last_sync as lastSync FROM accounts").all() as any[];
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
getAccountConfigs(): { id: string; name: string; email: string; configJson: string }[] {
|
|
818
|
-
return this.db.prepare("SELECT id, name, email, config_json as configJson FROM accounts").all() as any[];
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
updateLastSync(accountId: string, timestamp: number): void {
|
|
822
|
-
this.db.prepare("UPDATE accounts SET last_sync = ? WHERE id = ?").run(timestamp, accountId);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// ── Folders ──
|
|
826
|
-
|
|
827
|
-
upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number {
|
|
828
|
-
const existing = this.db.prepare(
|
|
829
|
-
"SELECT id FROM folders WHERE account_id = ? AND path = ?"
|
|
830
|
-
).get(accountId, folderPath) as { id: number } | undefined;
|
|
831
|
-
|
|
832
|
-
if (existing) {
|
|
833
|
-
this.db.prepare(
|
|
834
|
-
"UPDATE folders SET name = ?, special_use = ?, delimiter = ? WHERE id = ?"
|
|
835
|
-
).run(name, specialUse, delimiter, existing.id);
|
|
836
|
-
return existing.id;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
const result = this.db.prepare(
|
|
840
|
-
"INSERT INTO folders (account_id, path, name, special_use, delimiter) VALUES (?, ?, ?, ?, ?)"
|
|
841
|
-
).run(accountId, folderPath, name, specialUse, delimiter);
|
|
842
|
-
return Number(result.lastInsertRowid);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
getFolders(accountId: string): Folder[] {
|
|
846
|
-
const rows = this.db.prepare(
|
|
847
|
-
"SELECT * FROM folders WHERE account_id = ? ORDER BY path"
|
|
848
|
-
).all(accountId) as any[];
|
|
849
|
-
|
|
850
|
-
const folders: Folder[] = rows.map(r => ({
|
|
851
|
-
id: r.id,
|
|
852
|
-
accountId: r.account_id,
|
|
853
|
-
path: r.path,
|
|
854
|
-
name: r.name,
|
|
855
|
-
specialUse: r.special_use,
|
|
856
|
-
delimiter: r.delimiter,
|
|
857
|
-
totalCount: r.total_count,
|
|
858
|
-
unreadCount: r.unread_count,
|
|
859
|
-
children: [] as Folder[]
|
|
860
|
-
}));
|
|
861
|
-
|
|
862
|
-
// Sub-folder inheritance: a folder under Drafts/Sent/Trash/Junk/Archive
|
|
863
|
-
// inherits the parent's special role for UI purposes (column layout,
|
|
864
|
-
// open-in-compose, etc.). INBOX is intentionally excluded — its sub-
|
|
865
|
-
// folders are typically filtered mail and inheriting "inbox" would
|
|
866
|
-
// inflate All Inboxes. findFolder() still resolves to the canonical
|
|
867
|
-
// folder because rows are sorted by path and the parent sorts before
|
|
868
|
-
// its children.
|
|
869
|
-
const INHERITABLE = new Set(["sent", "drafts", "trash", "junk", "archive"]);
|
|
870
|
-
const roleByPath = new Map<string, SpecialUse>();
|
|
871
|
-
for (const f of folders) {
|
|
872
|
-
if (f.specialUse && INHERITABLE.has(f.specialUse)) {
|
|
873
|
-
roleByPath.set(f.path, f.specialUse);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
for (const f of folders) {
|
|
877
|
-
if (f.specialUse) continue;
|
|
878
|
-
const delim = f.delimiter || "/";
|
|
879
|
-
const parts = f.path.split(delim);
|
|
880
|
-
while (parts.length > 1) {
|
|
881
|
-
parts.pop();
|
|
882
|
-
const role = roleByPath.get(parts.join(delim));
|
|
883
|
-
if (role) { f.specialUse = role; break; }
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
return folders;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
deleteFolder(folderId: number): void {
|
|
891
|
-
this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
|
|
892
|
-
this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
markFolderRead(folderId: number): void {
|
|
896
|
-
this.db.prepare(
|
|
897
|
-
`UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]') WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`
|
|
898
|
-
).run(folderId);
|
|
899
|
-
this.recalcFolderCounts(folderId);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
deleteAllMessages(accountId: string, folderId: number): void {
|
|
903
|
-
this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
|
|
904
|
-
this.recalcFolderCounts(folderId);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
updateFolderCounts(folderId: number, total: number, unread: number): void {
|
|
908
|
-
this.db.prepare(
|
|
909
|
-
"UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?"
|
|
910
|
-
).run(total, unread, folderId);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
updateFolderSync(folderId: number, uidvalidity: number, highestModseq: string): void {
|
|
914
|
-
this.db.prepare(
|
|
915
|
-
"UPDATE folders SET uidvalidity = ?, highest_modseq = ? WHERE id = ?"
|
|
916
|
-
).run(uidvalidity, highestModseq, folderId);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
getFolderSync(folderId: number): { uidvalidity: number; highestModseq: string } {
|
|
920
|
-
const row = this.db.prepare(
|
|
921
|
-
"SELECT uidvalidity, highest_modseq as highestModseq FROM folders WHERE id = ?"
|
|
922
|
-
).get(folderId) as any;
|
|
923
|
-
return row || { uidvalidity: 0, highestModseq: "0" };
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// ── Messages ──
|
|
927
|
-
|
|
928
|
-
upsertMessage(msg: {
|
|
929
|
-
accountId: string;
|
|
930
|
-
folderId: number;
|
|
931
|
-
uid: number;
|
|
932
|
-
messageId: string;
|
|
933
|
-
inReplyTo: string;
|
|
934
|
-
references: string[];
|
|
935
|
-
date: number;
|
|
936
|
-
subject: string;
|
|
937
|
-
from: EmailAddress;
|
|
938
|
-
to: EmailAddress[];
|
|
939
|
-
cc: EmailAddress[];
|
|
940
|
-
flags: string[];
|
|
941
|
-
size: number;
|
|
942
|
-
hasAttachments: boolean;
|
|
943
|
-
preview: string;
|
|
944
|
-
bodyPath: string;
|
|
945
|
-
providerId?: string;
|
|
946
|
-
}): number {
|
|
947
|
-
const existing = this.db.prepare(
|
|
948
|
-
"SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?"
|
|
949
|
-
).get(msg.accountId, msg.folderId, msg.uid) as { id: number; provider_id: string | null } | undefined;
|
|
950
|
-
|
|
951
|
-
if (existing) {
|
|
952
|
-
// Backfill provider_id on existing rows that predate this column —
|
|
953
|
-
// critical for body fetch to bypass listMessageIds pagination.
|
|
954
|
-
if (msg.providerId && !existing.provider_id) {
|
|
955
|
-
this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
|
|
956
|
-
}
|
|
957
|
-
// Only overwrite body_path / preview when the caller actually has a
|
|
958
|
-
// body. Metadata-only syncs (Gmail API storeApiMessages, IMAP
|
|
959
|
-
// header-only fetches) pass bodyPath: "" and would otherwise wipe
|
|
960
|
-
// the path that prefetch just wrote, causing prefetch to re-download
|
|
961
|
-
// every message every cycle.
|
|
962
|
-
if (msg.bodyPath) {
|
|
963
|
-
this.db.prepare(`
|
|
964
|
-
UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
|
|
965
|
-
WHERE id = ?
|
|
966
|
-
`).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
|
|
967
|
-
} else {
|
|
968
|
-
this.db.prepare(`
|
|
969
|
-
UPDATE messages SET flags_json = ?, cached_at = ?
|
|
970
|
-
WHERE id = ?
|
|
971
|
-
`).run(JSON.stringify(msg.flags), Date.now(), existing.id);
|
|
972
|
-
}
|
|
973
|
-
return existing.id;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// Move-detection: if this Message-ID already exists for this account
|
|
977
|
-
// in a DIFFERENT folder, treat it as a server-side move rather than a
|
|
978
|
-
// new arrival. Rebind that row to (folder_id, uid) — keep the UUID,
|
|
979
|
-
// body_path, flags, all the local state. Saves a body re-fetch and
|
|
980
|
-
// preserves any local references (in_reply_to, dally entries, undo
|
|
981
|
-
// stacks) that point at the UUID. Only kicks in when messageId is
|
|
982
|
-
// present (servers usually include it; if not we fall through to a
|
|
983
|
-
// fresh insert which mints a new UUID).
|
|
984
|
-
if (msg.messageId) {
|
|
985
|
-
const moved = this.db.prepare(
|
|
986
|
-
"SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1"
|
|
987
|
-
).get(msg.accountId, msg.messageId) as { id: number; folder_id: number; uid: number } | undefined;
|
|
988
|
-
if (moved) {
|
|
989
|
-
console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
|
|
990
|
-
// Update folder_id + uid; preserve uuid, body_path, flags
|
|
991
|
-
// (server flags will catch up on the next full sync).
|
|
992
|
-
this.db.prepare(
|
|
993
|
-
"UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?"
|
|
994
|
-
).run(msg.folderId, msg.uid, Date.now(), moved.id);
|
|
995
|
-
return moved.id;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
const toText = msg.to.map(a => `${a.name} ${a.address}`).join(" ");
|
|
1000
|
-
const ccText = msg.cc.map(a => `${a.name} ${a.address}`).join(" ");
|
|
1001
|
-
|
|
1002
|
-
// Thread id = oldest ancestor in the reference chain, or the in-reply-to
|
|
1003
|
-
// parent, or the message's own Message-ID as a fallback. We also check
|
|
1004
|
-
// whether an existing row already has a thread_id for any of the refs,
|
|
1005
|
-
// so late-arriving replies latch onto the same thread.
|
|
1006
|
-
const threadId = this.computeThreadId(msg.accountId, msg.messageId, msg.inReplyTo, msg.references);
|
|
1007
|
-
|
|
1008
|
-
// Mint a per-message local identity UUID at first-sight. Stable for
|
|
1009
|
-
// the life of the row — survives server UID renumbers, folder moves
|
|
1010
|
-
// (sync rebinds folder_id/uid but keeps uuid), UIDVALIDITY bumps.
|
|
1011
|
-
const uuid = randomUUID().replace(/-/g, "");
|
|
1012
|
-
|
|
1013
|
-
const result = this.db.prepare(`
|
|
1014
|
-
INSERT INTO messages (
|
|
1015
|
-
account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
|
|
1016
|
-
date, subject, from_address, from_name, to_json, cc_json,
|
|
1017
|
-
flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
|
|
1018
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1019
|
-
`).run(
|
|
1020
|
-
msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId,
|
|
1021
|
-
msg.inReplyTo, JSON.stringify(msg.references), threadId,
|
|
1022
|
-
msg.date, msg.subject, msg.from.address, msg.from.name,
|
|
1023
|
-
JSON.stringify(msg.to), JSON.stringify(msg.cc),
|
|
1024
|
-
JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0,
|
|
1025
|
-
msg.preview, msg.bodyPath, Date.now(), msg.providerId || null
|
|
1026
|
-
);
|
|
1027
|
-
|
|
1028
|
-
const rowId = Number(result.lastInsertRowid);
|
|
1029
|
-
|
|
1030
|
-
// Index for full-text search
|
|
1031
|
-
try {
|
|
1032
|
-
this.db.prepare(
|
|
1033
|
-
"INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
1034
|
-
).run(rowId, msg.subject, msg.from.name, msg.from.address, toText, ccText, msg.preview);
|
|
1035
|
-
} catch { /* FTS insert may fail on rebuild, non-fatal */ }
|
|
1036
|
-
|
|
1037
|
-
return rowId;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
|
|
1041
|
-
const page = query.page || 1;
|
|
1042
|
-
const pageSize = query.pageSize || 50;
|
|
1043
|
-
const offset = (page - 1) * pageSize;
|
|
1044
|
-
const sort = query.sort || "date";
|
|
1045
|
-
const sortDir = query.sortDir || "desc";
|
|
1046
|
-
|
|
1047
|
-
const sortCol = sort === "from" ? "from_name" : sort === "subject" ? "subject" : "date";
|
|
1048
|
-
|
|
1049
|
-
let where = "account_id = ? AND folder_id = ?";
|
|
1050
|
-
const params: (string | number)[] = [query.accountId, query.folderId];
|
|
1051
|
-
|
|
1052
|
-
if (query.search) {
|
|
1053
|
-
where += " AND (subject LIKE ? OR from_name LIKE ? OR from_address LIKE ?)";
|
|
1054
|
-
const term = `%${query.search}%`;
|
|
1055
|
-
params.push(term, term, term);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
if (query.flaggedOnly) {
|
|
1059
|
-
// flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
|
|
1060
|
-
// LIKE on the serialized form is sufficient to find rows with the
|
|
1061
|
-
// \Flagged flag without decoding every row.
|
|
1062
|
-
where += " AND flags_json LIKE '%\\\\Flagged%'";
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
const total = (this.db.prepare(
|
|
1066
|
-
`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`
|
|
1067
|
-
).get(...params) as any).cnt;
|
|
1068
|
-
|
|
1069
|
-
// LEFT JOIN sync_actions so each row carries a `pending` flag —
|
|
1070
|
-
// true when the user has a queued local action (move/flag/delete)
|
|
1071
|
-
// not yet acknowledged by the server. UI renders these in pink so
|
|
1072
|
-
// local-only state is visible (Slice C of S1). Negative UIDs also
|
|
1073
|
-
// count as pending: that's the convention for optimistic local
|
|
1074
|
-
// inserts (e.g. Sent rows written the moment the user hits Send,
|
|
1075
|
-
// before the real APPENDUID comes back from the server).
|
|
1076
|
-
const rows = this.db.prepare(
|
|
1077
|
-
`SELECT m.*, (
|
|
1078
|
-
EXISTS(
|
|
1079
|
-
SELECT 1 FROM sync_actions sa
|
|
1080
|
-
WHERE sa.account_id = m.account_id AND sa.uid = m.uid
|
|
1081
|
-
) OR m.uid < 0
|
|
1082
|
-
) AS pending
|
|
1083
|
-
FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
|
|
1084
|
-
ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`
|
|
1085
|
-
).all(...params, pageSize, offset) as any[];
|
|
1086
|
-
|
|
1087
|
-
const items: MessageEnvelope[] = rows.map(r => ({
|
|
1088
|
-
id: r.id,
|
|
1089
|
-
accountId: r.account_id,
|
|
1090
|
-
folderId: r.folder_id,
|
|
1091
|
-
uid: r.uid,
|
|
1092
|
-
uuid: r.uuid || "",
|
|
1093
|
-
messageId: r.message_id || "",
|
|
1094
|
-
inReplyTo: r.in_reply_to || "",
|
|
1095
|
-
references: JSON.parse(r.refs || "[]"),
|
|
1096
|
-
threadId: r.thread_id || undefined,
|
|
1097
|
-
date: r.date,
|
|
1098
|
-
subject: r.subject,
|
|
1099
|
-
from: { name: r.from_name, address: r.from_address },
|
|
1100
|
-
to: JSON.parse(r.to_json),
|
|
1101
|
-
cc: JSON.parse(r.cc_json),
|
|
1102
|
-
flags: JSON.parse(r.flags_json),
|
|
1103
|
-
size: r.size,
|
|
1104
|
-
hasAttachments: !!r.has_attachments,
|
|
1105
|
-
preview: r.preview,
|
|
1106
|
-
bodyPath: r.body_path || "",
|
|
1107
|
-
pending: !!r.pending,
|
|
1108
|
-
} as any));
|
|
1109
|
-
|
|
1110
|
-
return { items, total, page, pageSize };
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
|
|
1114
|
-
getUnifiedInbox(page = 1, pageSize = 50): PagedResult<MessageEnvelope> {
|
|
1115
|
-
const offset = (page - 1) * pageSize;
|
|
1116
|
-
// Find all inbox folder IDs
|
|
1117
|
-
const inboxRows = this.db.prepare(
|
|
1118
|
-
"SELECT id FROM folders WHERE special_use = 'inbox'"
|
|
1119
|
-
).all() as any[];
|
|
1120
|
-
if (inboxRows.length === 0) return { items: [], total: 0, page, pageSize };
|
|
1121
|
-
|
|
1122
|
-
const placeholders = inboxRows.map(() => "?").join(",");
|
|
1123
|
-
const folderIds = inboxRows.map((r: any) => r.id);
|
|
1124
|
-
|
|
1125
|
-
const total = (this.db.prepare(
|
|
1126
|
-
`SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`
|
|
1127
|
-
).get(...folderIds) as any).cnt;
|
|
1128
|
-
|
|
1129
|
-
const rows = this.db.prepare(
|
|
1130
|
-
`SELECT m.*, EXISTS(
|
|
1131
|
-
SELECT 1 FROM sync_actions sa
|
|
1132
|
-
WHERE sa.account_id = m.account_id AND sa.uid = m.uid
|
|
1133
|
-
) AS pending,
|
|
1134
|
-
(SELECT COUNT(DISTINCT account_id) FROM messages m2
|
|
1135
|
-
WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
|
|
1136
|
-
FROM messages m WHERE m.folder_id IN (${placeholders})
|
|
1137
|
-
ORDER BY m.date DESC LIMIT ? OFFSET ?`
|
|
1138
|
-
).all(...folderIds, pageSize, offset) as any[];
|
|
1139
|
-
|
|
1140
|
-
const items: MessageEnvelope[] = rows.map(r => ({
|
|
1141
|
-
id: r.id,
|
|
1142
|
-
accountId: r.account_id,
|
|
1143
|
-
folderId: r.folder_id,
|
|
1144
|
-
uid: r.uid,
|
|
1145
|
-
messageId: r.message_id || "",
|
|
1146
|
-
inReplyTo: r.in_reply_to || "",
|
|
1147
|
-
references: JSON.parse(r.refs || "[]"),
|
|
1148
|
-
threadId: r.thread_id || undefined,
|
|
1149
|
-
date: r.date,
|
|
1150
|
-
subject: r.subject,
|
|
1151
|
-
from: { name: r.from_name, address: r.from_address },
|
|
1152
|
-
to: JSON.parse(r.to_json),
|
|
1153
|
-
cc: JSON.parse(r.cc_json),
|
|
1154
|
-
flags: JSON.parse(r.flags_json),
|
|
1155
|
-
size: r.size,
|
|
1156
|
-
hasAttachments: !!r.has_attachments,
|
|
1157
|
-
preview: r.preview,
|
|
1158
|
-
bodyPath: r.body_path || "",
|
|
1159
|
-
pending: !!r.pending,
|
|
1160
|
-
// >=2 means the same message-id exists under another account in
|
|
1161
|
-
// the local DB (delivered to both accounts, or a mailing-list
|
|
1162
|
-
// Bcc). The unified-inbox UI shows a small ⇆ badge on these
|
|
1163
|
-
// rows so the user knows "this is a copy of the same message".
|
|
1164
|
-
dupeCount: (r.dupeCount as number) | 0,
|
|
1165
|
-
} as any));
|
|
1166
|
-
|
|
1167
|
-
return { items, total, page, pageSize };
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
/** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
|
|
1171
|
-
* identity) and `bodyPath` (authoritative on-disk location) in addition
|
|
1172
|
-
* to the server-binding metadata. */
|
|
1173
|
-
private rowToEnvelope(r: any): MessageEnvelope {
|
|
1174
|
-
return {
|
|
1175
|
-
id: r.id,
|
|
1176
|
-
accountId: r.account_id,
|
|
1177
|
-
folderId: r.folder_id,
|
|
1178
|
-
uid: r.uid,
|
|
1179
|
-
uuid: r.uuid || "",
|
|
1180
|
-
messageId: r.message_id || "",
|
|
1181
|
-
inReplyTo: r.in_reply_to || "",
|
|
1182
|
-
references: JSON.parse(r.refs || "[]"),
|
|
1183
|
-
threadId: r.thread_id || undefined,
|
|
1184
|
-
date: r.date,
|
|
1185
|
-
subject: r.subject,
|
|
1186
|
-
from: { name: r.from_name, address: r.from_address },
|
|
1187
|
-
to: JSON.parse(r.to_json),
|
|
1188
|
-
cc: JSON.parse(r.cc_json),
|
|
1189
|
-
flags: JSON.parse(r.flags_json),
|
|
1190
|
-
size: r.size,
|
|
1191
|
-
hasAttachments: !!r.has_attachments,
|
|
1192
|
-
preview: r.preview,
|
|
1193
|
-
bodyPath: r.body_path || "",
|
|
1194
|
-
providerId: r.provider_id || undefined,
|
|
1195
|
-
} as MessageEnvelope;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope {
|
|
1199
|
-
const sql = folderId != null
|
|
1200
|
-
? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
|
|
1201
|
-
: "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
|
|
1202
|
-
const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
|
|
1203
|
-
const r = this.db.prepare(sql).get(...params) as any;
|
|
1204
|
-
if (!r) return null as any;
|
|
1205
|
-
return this.rowToEnvelope(r);
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/** Look up a message by its stable local UUID. Returned envelope includes
|
|
1209
|
-
* the current (folder_id, uid) — these may have changed since the UUID
|
|
1210
|
-
* was minted (folder move or server UID renumber) but the UUID itself
|
|
1211
|
-
* is stable. Use this as the identity in any long-lived reference
|
|
1212
|
-
* (compose in-reply-to, dally, undo stacks). */
|
|
1213
|
-
getMessageByUuid(uuid: string): MessageEnvelope {
|
|
1214
|
-
if (!uuid) return null as any;
|
|
1215
|
-
const r = this.db.prepare("SELECT * FROM messages WHERE uuid = ?").get(uuid) as any;
|
|
1216
|
-
if (!r) return null as any;
|
|
1217
|
-
return this.rowToEnvelope(r);
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
getMessageBodyPath(accountId: string, uid: number): string {
|
|
1221
|
-
const r = this.db.prepare(
|
|
1222
|
-
"SELECT body_path FROM messages WHERE account_id = ? AND uid = ?"
|
|
1223
|
-
).get(accountId, uid) as any;
|
|
1224
|
-
return r?.body_path || "";
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
updateMessageFlags(accountId: string, uid: number, flags: string[]): void {
|
|
1228
|
-
this.db.prepare(
|
|
1229
|
-
"UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?"
|
|
1230
|
-
).run(JSON.stringify(flags), accountId, uid);
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
updateMessageFolder(accountId: string, uid: number, targetFolderId: number): void {
|
|
1234
|
-
// Idempotency: if a row already exists at (account, target_folder, uid)
|
|
1235
|
-
// — common with Gmail's hash-synthesized UIDs across labels, or any
|
|
1236
|
-
// case where the move was already partially applied — the UPDATE
|
|
1237
|
-
// would fail the (acct, folder, uid) unique constraint. Treat it as
|
|
1238
|
-
// a no-op: drop the source row, the message is already where the
|
|
1239
|
-
// user wants it. Previously surfaced as "Mark-as-spam failed: UNIQUE
|
|
1240
|
-
// constraint failed" — bad UX for what is logically already done.
|
|
1241
|
-
const existingTarget = this.db.prepare(
|
|
1242
|
-
"SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?"
|
|
1243
|
-
).get(accountId, targetFolderId, uid);
|
|
1244
|
-
if (existingTarget) {
|
|
1245
|
-
this.db.prepare(
|
|
1246
|
-
"DELETE FROM messages WHERE account_id = ? AND uid = ? AND folder_id != ?"
|
|
1247
|
-
).run(accountId, uid, targetFolderId);
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
this.db.prepare(
|
|
1251
|
-
"UPDATE messages SET folder_id = ? WHERE account_id = ? AND uid = ?"
|
|
1252
|
-
).run(targetFolderId, accountId, uid);
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
updateBodyPath(accountId: string, uid: number, bodyPath: string): void {
|
|
1256
|
-
this.db.prepare(
|
|
1257
|
-
"UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?"
|
|
1258
|
-
).run(bodyPath, accountId, uid);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
/** Get messages without cached bodies (for background prefetch) */
|
|
1262
|
-
getMessagesWithoutBody(accountId: string, limit = 50): { uid: number; folderId: number }[] {
|
|
1263
|
-
return this.db.prepare(
|
|
1264
|
-
"SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY date DESC LIMIT ?"
|
|
1265
|
-
).all(accountId, limit) as any[];
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
getHighestUid(accountId: string, folderId: number): number {
|
|
1269
|
-
const r = this.db.prepare(
|
|
1270
|
-
"SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?"
|
|
1271
|
-
).get(accountId, folderId) as any;
|
|
1272
|
-
return r?.maxUid || 0;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
getOldestDate(accountId: string, folderId: number): number {
|
|
1276
|
-
const r = this.db.prepare(
|
|
1277
|
-
"SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?"
|
|
1278
|
-
).get(accountId, folderId) as any;
|
|
1279
|
-
return r?.minDate || 0;
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
getMessageCount(accountId: string, folderId: number): number {
|
|
1283
|
-
const r = this.db.prepare(
|
|
1284
|
-
"SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?"
|
|
1285
|
-
).get(accountId, folderId) as any;
|
|
1286
|
-
return r?.cnt || 0;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
/** Get all UIDs for a folder */
|
|
1290
|
-
getUidsForFolder(accountId: string, folderId: number): number[] {
|
|
1291
|
-
const rows = this.db.prepare(
|
|
1292
|
-
"SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?"
|
|
1293
|
-
).all(accountId, folderId) as any[];
|
|
1294
|
-
return rows.map(r => r.uid);
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/** Delete a message by account + UID */
|
|
1298
|
-
deleteMessage(accountId: string, uid: number): void {
|
|
1299
|
-
// Get folderId before deleting so we can update counts
|
|
1300
|
-
const msg = this.db.prepare(
|
|
1301
|
-
"SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?"
|
|
1302
|
-
).get(accountId, uid) as any;
|
|
1303
|
-
this.db.prepare(
|
|
1304
|
-
"DELETE FROM messages WHERE account_id = ? AND uid = ?"
|
|
1305
|
-
).run(accountId, uid);
|
|
1306
|
-
// Refresh folder counts
|
|
1307
|
-
if (msg) this.recalcFolderCounts(msg.folder_id);
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
/** Recalculate folder total/unread counts from actual messages */
|
|
1311
|
-
recalcFolderCounts(folderId: number): void {
|
|
1312
|
-
const counts = this.db.prepare(
|
|
1313
|
-
`SELECT COUNT(*) as total,
|
|
1314
|
-
SUM(CASE WHEN flags_json NOT LIKE '%\\\\Seen%' THEN 1 ELSE 0 END) as unread
|
|
1315
|
-
FROM messages WHERE folder_id = ?`
|
|
1316
|
-
).get(folderId) as any;
|
|
1317
|
-
this.updateFolderCounts(folderId, counts?.total || 0, counts?.unread || 0);
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
/** Bulk insert within a transaction for sync performance */
|
|
1321
|
-
beginTransaction(): void { this.db.exec("BEGIN"); }
|
|
1322
|
-
commitTransaction(): void { this.db.exec("COMMIT"); }
|
|
1323
|
-
rollbackTransaction(): void { this.db.exec("ROLLBACK"); }
|
|
1324
|
-
|
|
1325
|
-
// ── Contacts ──
|
|
1326
|
-
|
|
1327
|
-
/** Record an address used in sent mail */
|
|
1328
|
-
recordSentAddress(name: string, email: string): void {
|
|
1329
|
-
// Don't pollute the contacts table with non-addresses.
|
|
1330
|
-
if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) return;
|
|
1331
|
-
const lower = email.toLowerCase();
|
|
1332
|
-
if (this.isAddressDenylisted(lower)) return;
|
|
1333
|
-
const now = Date.now();
|
|
1334
|
-
// discovered tier holds one row per email — bump if present, else
|
|
1335
|
-
// insert. Doesn't touch preferred or google rows for the same email;
|
|
1336
|
-
// those are independent address-book entries the user/Google curates.
|
|
1337
|
-
const existing = this.db.prepare(
|
|
1338
|
-
"SELECT id, name FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
|
|
1339
|
-
).get(lower) as { id: number; name: string } | undefined;
|
|
1340
|
-
if (existing) {
|
|
1341
|
-
this.db.prepare(
|
|
1342
|
-
"UPDATE contacts SET name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE id = ?"
|
|
1343
|
-
).run(name, name, now, now, existing.id);
|
|
1344
|
-
} else {
|
|
1345
|
-
this.db.prepare(
|
|
1346
|
-
"INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, 1, ?)"
|
|
1347
|
-
).run(name || "", email, now, now);
|
|
1348
|
-
}
|
|
1349
|
-
this.notifyContactsChanged();
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
/** True if `email` (lowercased) appears in the active denylist. Cached
|
|
1353
|
-
* in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
|
|
1354
|
-
private _denylist: Set<string> = new Set();
|
|
1355
|
-
isAddressDenylisted(emailLower: string): boolean {
|
|
1356
|
-
return this._denylist.has(emailLower);
|
|
1357
|
-
}
|
|
1358
|
-
setContactsDenylist(emails: string[]): void {
|
|
1359
|
-
this._denylist = new Set(emails.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
/** Callback fired when local-DB contacts mutations happen (sends adding
|
|
1363
|
-
* to discovered, corpus seeder finding new addresses). The service
|
|
1364
|
-
* registers a debounced cloud flush here so the GDrive copy stays in
|
|
1365
|
-
* sync. NOT fired from applyContactsConfig — that's the inbound path
|
|
1366
|
-
* and would create a write loop. */
|
|
1367
|
-
private _onContactsChanged?: () => void;
|
|
1368
|
-
setOnContactsChanged(cb: () => void): void {
|
|
1369
|
-
this._onContactsChanged = cb;
|
|
1370
|
-
}
|
|
1371
|
-
private notifyContactsChanged(): void {
|
|
1372
|
-
try { this._onContactsChanged?.(); } catch { /* ignore */ }
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
/** Seed `discovered`-tier contacts from every address that appears in
|
|
1376
|
-
* any cached message — From / To / Cc / Bcc across all folders. One row
|
|
1377
|
-
* per email; first non-empty name observed wins. Sent-folder rows skip
|
|
1378
|
-
* the From (it's us). Junk addresses (noreply, mailer-daemon, *-bounces)
|
|
1379
|
-
* and denylisted addresses are dropped at seed time so they never enter
|
|
1380
|
-
* autocomplete.
|
|
1381
|
-
*
|
|
1382
|
-
* Discovered is a single tier; sub-distinctions like sent-vs-received
|
|
1383
|
-
* collapse here because the user-facing UI shows them as one "discovered"
|
|
1384
|
-
* source. Recency-weighted use_count differentiates within the tier. */
|
|
1385
|
-
seedContactsFromMessages(): number {
|
|
1386
|
-
const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
|
|
1387
|
-
const now = Date.now();
|
|
1388
|
-
const agg = new Map<string, { name: string; cnt: number; last: number }>();
|
|
1389
|
-
const bump = (name: string, address: string, date: number) => {
|
|
1390
|
-
const email = (address || "").trim().toLowerCase();
|
|
1391
|
-
if (!email || !VALID.test(email)) return;
|
|
1392
|
-
if (isJunkContact(email, name)) return;
|
|
1393
|
-
if (this.isAddressDenylisted(email)) return;
|
|
1394
|
-
const e = agg.get(email);
|
|
1395
|
-
if (e) {
|
|
1396
|
-
e.cnt++;
|
|
1397
|
-
if (date > e.last) e.last = date;
|
|
1398
|
-
if (!e.name && name) e.name = name;
|
|
1399
|
-
} else {
|
|
1400
|
-
agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
|
|
1401
|
-
}
|
|
1402
|
-
};
|
|
1403
|
-
|
|
1404
|
-
// Sent folder: recipients only (skip the user's own From address).
|
|
1405
|
-
const sentRows = this.db.prepare(
|
|
1406
|
-
`SELECT m.to_json, m.cc_json, m.bcc_json, m.date
|
|
1407
|
-
FROM messages m
|
|
1408
|
-
JOIN folders f ON m.folder_id = f.id
|
|
1409
|
-
WHERE f.special_use = 'sent'`
|
|
1410
|
-
).all() as { to_json: string; cc_json: string; bcc_json: string; date: number }[];
|
|
1411
|
-
for (const r of sentRows) {
|
|
1412
|
-
const date = r.date || 0;
|
|
1413
|
-
for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
|
|
1414
|
-
if (!field) continue;
|
|
1415
|
-
let parsed: any[];
|
|
1416
|
-
try { parsed = JSON.parse(field); } catch { continue; }
|
|
1417
|
-
if (!Array.isArray(parsed)) continue;
|
|
1418
|
-
for (const a of parsed) {
|
|
1419
|
-
if (!a) continue;
|
|
1420
|
-
bump(a.name || "", a.address || a.email || "", date);
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
// Other folders: From + recipients.
|
|
1426
|
-
const recvRows = this.db.prepare(
|
|
1427
|
-
`SELECT m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
|
|
1428
|
-
FROM messages m
|
|
1429
|
-
LEFT JOIN folders f ON m.folder_id = f.id
|
|
1430
|
-
WHERE f.special_use IS NULL OR f.special_use != 'sent'`
|
|
1431
|
-
).all() as { from_name: string; from_address: string; to_json: string; cc_json: string; bcc_json: string; date: number }[];
|
|
1432
|
-
for (const r of recvRows) {
|
|
1433
|
-
const date = r.date || 0;
|
|
1434
|
-
bump(r.from_name, r.from_address, date);
|
|
1435
|
-
for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
|
|
1436
|
-
if (!field) continue;
|
|
1437
|
-
let parsed: any[];
|
|
1438
|
-
try { parsed = JSON.parse(field); } catch { continue; }
|
|
1439
|
-
if (!Array.isArray(parsed)) continue;
|
|
1440
|
-
for (const a of parsed) {
|
|
1441
|
-
if (!a) continue;
|
|
1442
|
-
bump(a.name || "", a.address || a.email || "", date);
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
let added = 0;
|
|
1448
|
-
let bumped = 0;
|
|
1449
|
-
const insStmt = this.db.prepare(
|
|
1450
|
-
"INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)"
|
|
1451
|
-
);
|
|
1452
|
-
const updStmt = this.db.prepare(
|
|
1453
|
-
`UPDATE contacts SET use_count = ?,
|
|
1454
|
-
last_used = max(last_used, ?),
|
|
1455
|
-
name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
|
|
1456
|
-
updated_at = ?
|
|
1457
|
-
WHERE id = ?`
|
|
1458
|
-
);
|
|
1459
|
-
for (const [email, info] of agg) {
|
|
1460
|
-
const existing = this.db.prepare(
|
|
1461
|
-
"SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
|
|
1462
|
-
).get(email) as { id: number } | undefined;
|
|
1463
|
-
if (!existing) {
|
|
1464
|
-
insStmt.run(info.name, email, info.last, info.cnt, now);
|
|
1465
|
-
added++;
|
|
1466
|
-
} else {
|
|
1467
|
-
updStmt.run(info.cnt, info.last, info.name, info.name, now, existing.id);
|
|
1468
|
-
bumped++;
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
if (added > 0 || bumped > 0) {
|
|
1472
|
-
console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
|
|
1473
|
-
this.notifyContactsChanged();
|
|
1474
|
-
}
|
|
1475
|
-
return added;
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
/** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
|
|
1479
|
-
* with the entries in `preferred[]`, merges `discovered[]` into the local
|
|
1480
|
-
* cache, sets the in-memory denylist, and purges any discovered rows
|
|
1481
|
-
* whose email is now denylisted. Preferred rows are *not* auto-purged on
|
|
1482
|
-
* denylist hit — if the user explicitly added them they win that
|
|
1483
|
-
* conflict; we just log a warning.
|
|
1484
|
-
*
|
|
1485
|
-
* Discovered rows from the file are MERGED with whatever the local
|
|
1486
|
-
* message-corpus seeder has produced. Each device contributes its
|
|
1487
|
-
* observed addresses; over time GDrive accumulates the union. */
|
|
1488
|
-
applyContactsConfig(cfg: {
|
|
1489
|
-
preferred?: { name?: string; email: string; source?: string; organization?: string; org?: string }[];
|
|
1490
|
-
denylist?: string[];
|
|
1491
|
-
discovered?: { name?: string; email: string; useCount?: number; lastUsed?: number }[];
|
|
1492
|
-
}): { preferred: number; discovered: number; purged: number; conflicts: string[] } {
|
|
1493
|
-
const preferred = Array.isArray(cfg.preferred) ? cfg.preferred : [];
|
|
1494
|
-
const denylist = Array.isArray(cfg.denylist) ? cfg.denylist : [];
|
|
1495
|
-
const discovered = Array.isArray(cfg.discovered) ? cfg.discovered : [];
|
|
1496
|
-
this.setContactsDenylist(denylist);
|
|
1497
|
-
|
|
1498
|
-
// Wipe and rewrite preferred-tier rows owned by contacts.jsonc.
|
|
1499
|
-
// The address-book UI's legacy `upsertContact` still writes
|
|
1500
|
-
// source='manual' rows; those are owned by the address-book code
|
|
1501
|
-
// path, not contacts.jsonc, so we leave them alone here.
|
|
1502
|
-
this.db.exec("DELETE FROM contacts WHERE source NOT IN ('google', 'discovered', 'manual')");
|
|
1503
|
-
|
|
1504
|
-
const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
|
|
1505
|
-
const now = Date.now();
|
|
1506
|
-
const ins = this.db.prepare(
|
|
1507
|
-
`INSERT OR IGNORE INTO contacts (source, name, email, organization, last_used, use_count, updated_at)
|
|
1508
|
-
VALUES (?, ?, ?, ?, 0, 0, ?)`
|
|
1509
|
-
);
|
|
1510
|
-
const denySet = new Set(denylist.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
|
|
1511
|
-
const conflicts: string[] = [];
|
|
1512
|
-
let inserted = 0;
|
|
1513
|
-
for (const entry of preferred) {
|
|
1514
|
-
if (!entry) continue;
|
|
1515
|
-
const email = (entry.email || "").trim();
|
|
1516
|
-
if (!email || !VALID.test(email)) continue;
|
|
1517
|
-
if (denySet.has(email.toLowerCase())) {
|
|
1518
|
-
conflicts.push(email);
|
|
1519
|
-
continue;
|
|
1520
|
-
}
|
|
1521
|
-
const source = (entry.source || "preferred").trim() || "preferred";
|
|
1522
|
-
const name = (entry.name || "").trim();
|
|
1523
|
-
const org = (entry.organization || entry.org || "").trim();
|
|
1524
|
-
try {
|
|
1525
|
-
const r = ins.run(source, name, email, org, now);
|
|
1526
|
-
if ((r as any).changes) inserted++;
|
|
1527
|
-
} catch { /* dup row, skip */ }
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
// Merge discovered[] from cloud into local cache. For each entry:
|
|
1531
|
-
// existing row wins on use_count (max), name fills if empty, lastUsed
|
|
1532
|
-
// is max. Missing rows are inserted. Denylisted entries skipped.
|
|
1533
|
-
const insDiscovered = this.db.prepare(
|
|
1534
|
-
"INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)"
|
|
1535
|
-
);
|
|
1536
|
-
const updDiscovered = this.db.prepare(
|
|
1537
|
-
`UPDATE contacts SET use_count = max(use_count, ?),
|
|
1538
|
-
last_used = max(last_used, ?),
|
|
1539
|
-
name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
|
|
1540
|
-
updated_at = ?
|
|
1541
|
-
WHERE id = ?`
|
|
1542
|
-
);
|
|
1543
|
-
let discoveredAdded = 0;
|
|
1544
|
-
for (const entry of discovered) {
|
|
1545
|
-
if (!entry) continue;
|
|
1546
|
-
const email = (entry.email || "").trim();
|
|
1547
|
-
if (!email || !VALID.test(email)) continue;
|
|
1548
|
-
const lower = email.toLowerCase();
|
|
1549
|
-
if (denySet.has(lower)) continue;
|
|
1550
|
-
if (isJunkContact(lower, entry.name || "")) continue;
|
|
1551
|
-
const name = (entry.name || "").trim();
|
|
1552
|
-
const useCount = Math.max(0, entry.useCount || 0);
|
|
1553
|
-
const lastUsed = Math.max(0, entry.lastUsed || 0);
|
|
1554
|
-
const existing = this.db.prepare(
|
|
1555
|
-
"SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
|
|
1556
|
-
).get(lower) as { id: number } | undefined;
|
|
1557
|
-
if (!existing) {
|
|
1558
|
-
insDiscovered.run(name, email, lastUsed, useCount, now);
|
|
1559
|
-
discoveredAdded++;
|
|
1560
|
-
} else {
|
|
1561
|
-
updDiscovered.run(useCount, lastUsed, name, name, now, existing.id);
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// Purge discovered rows for any denylisted email.
|
|
1566
|
-
const purge = this.db.prepare("DELETE FROM contacts WHERE source = 'discovered' AND lower(email) = ?");
|
|
1567
|
-
let purged = 0;
|
|
1568
|
-
for (const e of denySet) {
|
|
1569
|
-
const r = purge.run(e);
|
|
1570
|
-
purged += Number((r as any).changes || 0);
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
if (conflicts.length > 0) {
|
|
1574
|
-
console.warn(` [contacts] config: ${conflicts.length} preferred entries also appear in denylist — denylist wins, entries skipped: ${conflicts.join(", ")}`);
|
|
1575
|
-
}
|
|
1576
|
-
console.log(` [contacts] config applied: ${inserted} preferred + ${discoveredAdded} discovered row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
|
|
1577
|
-
return { preferred: inserted, discovered: discoveredAdded, purged, conflicts };
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
/** Build the contacts.jsonc shape from current DB state — for round-trip
|
|
1581
|
-
* to GDrive. Preferred-tier rows come from anything not in the reserved
|
|
1582
|
-
* system sources; discovered comes from `source='discovered'` rows;
|
|
1583
|
-
* denylist comes from the in-memory set (set by applyContactsConfig).
|
|
1584
|
-
* Caller is responsible for actually writing the cloud copy. */
|
|
1585
|
-
exportContactsConfig(): {
|
|
1586
|
-
preferred: { name: string; email: string; source: string; organization?: string }[];
|
|
1587
|
-
denylist: string[];
|
|
1588
|
-
discovered: { name: string; email: string; useCount: number; lastUsed: number }[];
|
|
1589
|
-
} {
|
|
1590
|
-
const preferredRows = this.db.prepare(
|
|
1591
|
-
`SELECT name, email, source, organization
|
|
1592
|
-
FROM contacts
|
|
1593
|
-
WHERE source NOT IN ('google', 'discovered', 'manual')
|
|
1594
|
-
ORDER BY source, lower(email), lower(name)`
|
|
1595
|
-
).all() as { name: string; email: string; source: string; organization: string }[];
|
|
1596
|
-
const discoveredRows = this.db.prepare(
|
|
1597
|
-
`SELECT name, email, use_count, last_used
|
|
1598
|
-
FROM contacts
|
|
1599
|
-
WHERE source = 'discovered'
|
|
1600
|
-
ORDER BY use_count DESC, last_used DESC, lower(email)`
|
|
1601
|
-
).all() as { name: string; email: string; use_count: number; last_used: number }[];
|
|
1602
|
-
return {
|
|
1603
|
-
preferred: preferredRows.map(r => {
|
|
1604
|
-
const out: any = { name: r.name || "", email: r.email, source: r.source };
|
|
1605
|
-
if (r.organization) out.organization = r.organization;
|
|
1606
|
-
return out;
|
|
1607
|
-
}),
|
|
1608
|
-
denylist: Array.from(this._denylist),
|
|
1609
|
-
discovered: discoveredRows.map(r => ({
|
|
1610
|
-
name: r.name || "",
|
|
1611
|
-
email: r.email,
|
|
1612
|
-
useCount: r.use_count,
|
|
1613
|
-
lastUsed: r.last_used,
|
|
1614
|
-
})),
|
|
1615
|
-
};
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
/** Search contacts by name or email prefix.
|
|
1619
|
-
*
|
|
1620
|
-
* Source-tier bonus is what makes the curated address book win against
|
|
1621
|
-
* passive corpus harvest. Anything in `contacts.jsonc#preferred[]` (any
|
|
1622
|
-
* source value other than the two reserved system sources) gets the
|
|
1623
|
-
* highest tier — that's the user's explicit address book and overrides
|
|
1624
|
-
* Google. Google sits in the middle (the auto-synced address book).
|
|
1625
|
-
* `discovered` is the corpus-harvested floor.
|
|
1626
|
-
*
|
|
1627
|
-
* Multi-name-per-email is supported: the same email can carry distinct
|
|
1628
|
-
* (source, name) rows — Bob's wife and Bob Smith both at bob@example.com
|
|
1629
|
-
* surface as two rows, each typing-completable by their own name. */
|
|
1630
|
-
searchContacts(query: string, limit = 10): { name: string; email: string; source: string; useCount: number }[] {
|
|
1631
|
-
query = (query || "").trim();
|
|
1632
|
-
if (!query) return [];
|
|
1633
|
-
// Split into whitespace-separated tokens. Each token must appear in
|
|
1634
|
-
// name or email — order- and adjacency-independent. So "eleanor elkin"
|
|
1635
|
-
// matches "Eleanor Elkin", "Elkin, Eleanor", "Eleanor M Elkin", and
|
|
1636
|
-
// "elkin@eleanor.example". The first token gets the prefix bonus for
|
|
1637
|
-
// ranking; remaining tokens just have to be present.
|
|
1638
|
-
const tokens = query.split(/\s+/).filter(Boolean);
|
|
1639
|
-
const firstSubstr = `%${tokens[0]}%`;
|
|
1640
|
-
const firstPrefix = `${tokens[0]}%`;
|
|
1641
|
-
const tokenWhere = tokens.map(() => "(name LIKE ? OR email LIKE ?)").join(" AND ");
|
|
1642
|
-
const tokenParams: string[] = [];
|
|
1643
|
-
for (const t of tokens) { tokenParams.push(`%${t}%`, `%${t}%`); }
|
|
1644
|
-
let rows: any[];
|
|
1645
|
-
try {
|
|
1646
|
-
// Source tier: anything not in the two reserved system sources
|
|
1647
|
-
// ('google', 'discovered') is preferred-tier — i.e. came out of
|
|
1648
|
-
// contacts.jsonc#preferred[]. The user's `source: "work"` /
|
|
1649
|
-
// `source: "family"` tags all rank +40 alongside the default
|
|
1650
|
-
// `preferred` label.
|
|
1651
|
-
rows = this.db.prepare(
|
|
1652
|
-
`SELECT name, email, source, use_count, last_used,
|
|
1653
|
-
(CASE
|
|
1654
|
-
WHEN lower(name) LIKE lower(?) THEN 3
|
|
1655
|
-
WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
|
|
1656
|
-
WHEN email LIKE ? OR name LIKE ? THEN 1
|
|
1657
|
-
ELSE 0
|
|
1658
|
-
END) +
|
|
1659
|
-
(CASE
|
|
1660
|
-
WHEN source = 'google' THEN 30
|
|
1661
|
-
WHEN source = 'discovered' THEN 0
|
|
1662
|
-
ELSE 40
|
|
1663
|
-
END) AS match_rank
|
|
1664
|
-
FROM contacts
|
|
1665
|
-
WHERE ${tokenWhere}
|
|
1666
|
-
ORDER BY match_rank DESC, use_count DESC, last_used DESC
|
|
1667
|
-
LIMIT ?`
|
|
1668
|
-
).all(firstPrefix, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2) as any[];
|
|
1669
|
-
} catch (e: any) {
|
|
1670
|
-
console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
|
|
1671
|
-
rows = this.db.prepare(
|
|
1672
|
-
`SELECT name, email, source, use_count, last_used, 0 AS match_rank
|
|
1673
|
-
FROM contacts
|
|
1674
|
-
WHERE ${tokenWhere}
|
|
1675
|
-
ORDER BY use_count DESC, last_used DESC
|
|
1676
|
-
LIMIT ?`
|
|
1677
|
-
).all(...tokenParams, limit * 2) as any[];
|
|
1678
|
-
}
|
|
1679
|
-
// Filter out denylisted emails as a defense-in-depth — applyContactsConfig
|
|
1680
|
-
// already purges discovered rows on denylist, but a Google sync that
|
|
1681
|
-
// reintroduced a denylisted address would otherwise leak through.
|
|
1682
|
-
rows = rows.filter(r => !this.isAddressDenylisted((r.email || "").toLowerCase()));
|
|
1683
|
-
const now = Date.now();
|
|
1684
|
-
const HALF_LIFE_MS = 30 * 86400_000;
|
|
1685
|
-
const score = (r: any) => (r.match_rank || 0) * 10_000
|
|
1686
|
-
+ (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
|
|
1687
|
-
rows.sort((a, b) => score(b) - score(a));
|
|
1688
|
-
rows = rows.slice(0, limit);
|
|
1689
|
-
return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
/** List all contacts (address-book view) with pagination + optional filter. */
|
|
1693
|
-
listContacts(query: string, page = 1, pageSize = 100): { items: { name: string; email: string; source: string; googleId: string | null; useCount: number; lastUsed: number }[]; total: number; page: number; pageSize: number } {
|
|
1694
|
-
query = (query || "").trim();
|
|
1695
|
-
const hasQuery = !!query;
|
|
1696
|
-
const q = `%${query}%`;
|
|
1697
|
-
const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
|
|
1698
|
-
const params = hasQuery ? [q, q] : [];
|
|
1699
|
-
const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params) as any;
|
|
1700
|
-
const offset = (page - 1) * pageSize;
|
|
1701
|
-
const rows = this.db.prepare(
|
|
1702
|
-
`SELECT name, email, source, google_id, use_count, last_used FROM contacts
|
|
1703
|
-
${whereClause}
|
|
1704
|
-
ORDER BY use_count DESC, last_used DESC
|
|
1705
|
-
LIMIT ? OFFSET ?`
|
|
1706
|
-
).all(...params, pageSize, offset) as any[];
|
|
1707
|
-
return {
|
|
1708
|
-
items: rows.map(r => ({
|
|
1709
|
-
name: r.name, email: r.email, source: r.source,
|
|
1710
|
-
googleId: r.google_id || null,
|
|
1711
|
-
useCount: r.use_count, lastUsed: r.last_used,
|
|
1712
|
-
})),
|
|
1713
|
-
total: totalRow?.c || 0,
|
|
1714
|
-
page, pageSize,
|
|
1715
|
-
};
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
/** Update or insert a contact manually (from the address book UI). */
|
|
1719
|
-
upsertContact(name: string, email: string): void {
|
|
1720
|
-
if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
|
|
1721
|
-
throw new Error(`Invalid email: ${email}`);
|
|
1722
|
-
}
|
|
1723
|
-
const now = Date.now();
|
|
1724
|
-
const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email) as any;
|
|
1725
|
-
if (existing) {
|
|
1726
|
-
this.db.prepare(
|
|
1727
|
-
"UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?"
|
|
1728
|
-
).run(name, now, email);
|
|
1729
|
-
} else {
|
|
1730
|
-
this.db.prepare(
|
|
1731
|
-
"INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, ?, 0, ?)"
|
|
1732
|
-
).run(name, email, now, now);
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
/** Delete a contact by email (address book UI). */
|
|
1737
|
-
deleteContact(email: string): void {
|
|
1738
|
-
this.db.prepare("DELETE FROM contacts WHERE email = ?").run(email);
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
/** Delete contact rows by Google People resourceName. Used by the
|
|
1742
|
-
* incremental People sync when a person comes back with `metadata.deleted = true`
|
|
1743
|
-
* — the email may have already changed/disappeared, but the resourceName
|
|
1744
|
-
* is stable. Removes all rows tied to that Google identity (a single
|
|
1745
|
-
* contact can have multiple email addresses, each is its own row). */
|
|
1746
|
-
deleteContactByGoogleId(googleId: string): number {
|
|
1747
|
-
if (!googleId) return 0;
|
|
1748
|
-
const r = this.db.prepare("DELETE FROM contacts WHERE google_id = ?").run(googleId);
|
|
1749
|
-
return Number((r as any).changes || 0);
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
// ── Search ──
|
|
1753
|
-
|
|
1754
|
-
/** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
|
|
1755
|
-
searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number): PagedResult<MessageEnvelope> {
|
|
1756
|
-
query = (query || "").trim();
|
|
1757
|
-
// Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
|
|
1758
|
-
let ftsQuery = "";
|
|
1759
|
-
const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
1760
|
-
|
|
1761
|
-
// Extra SQL where-clauses for qualifiers that don't map to FTS columns.
|
|
1762
|
-
const extraWhere: string[] = [];
|
|
1763
|
-
const extraParams: any[] = [];
|
|
1764
|
-
// Parse a "1d", "1w", "2024-01-15", "yesterday", "today" etc. into ms epoch.
|
|
1765
|
-
const parseRel = (s: string): number | null => {
|
|
1766
|
-
const lower = s.toLowerCase().trim();
|
|
1767
|
-
if (lower === "today") { const d = new Date(); d.setHours(0,0,0,0); return d.getTime(); }
|
|
1768
|
-
if (lower === "yesterday") { const d = new Date(); d.setHours(0,0,0,0); return d.getTime() - 86400_000; }
|
|
1769
|
-
const rel = lower.match(/^(\d+)([dwmy])$/);
|
|
1770
|
-
if (rel) {
|
|
1771
|
-
const n = parseInt(rel[1]);
|
|
1772
|
-
const unit = rel[2];
|
|
1773
|
-
const ms = unit === "d" ? n * 86400_000
|
|
1774
|
-
: unit === "w" ? n * 7 * 86400_000
|
|
1775
|
-
: unit === "m" ? n * 30 * 86400_000
|
|
1776
|
-
: n * 365 * 86400_000;
|
|
1777
|
-
return Date.now() - ms;
|
|
1778
|
-
}
|
|
1779
|
-
const ts = Date.parse(s);
|
|
1780
|
-
return isNaN(ts) ? null : ts;
|
|
1781
|
-
};
|
|
1782
|
-
|
|
1783
|
-
for (const part of parts) {
|
|
1784
|
-
const fromMatch = part.match(/^from:(.+)$/i);
|
|
1785
|
-
const toMatch = part.match(/^to:(.+)$/i);
|
|
1786
|
-
const subjectMatch = part.match(/^subject:(.+)$/i);
|
|
1787
|
-
const dateMatch = part.match(/^date:([><]?=?)(.+)$/i);
|
|
1788
|
-
const afterMatch = part.match(/^after:(.+)$/i);
|
|
1789
|
-
const beforeMatch = part.match(/^before:(.+)$/i);
|
|
1790
|
-
const hasMatch = part.match(/^has:(.+)$/i);
|
|
1791
|
-
const isMatch = part.match(/^is:(.+)$/i);
|
|
1792
|
-
const folderMatch = part.match(/^folder:(.+)$/i);
|
|
1793
|
-
|
|
1794
|
-
if (fromMatch) {
|
|
1795
|
-
const term = fromMatch[1].replace(/"/g, "");
|
|
1796
|
-
ftsQuery += `(from_name:${term} OR from_address:${term}) `;
|
|
1797
|
-
} else if (toMatch) {
|
|
1798
|
-
const term = toMatch[1].replace(/"/g, "");
|
|
1799
|
-
ftsQuery += `(to_text:${term} OR cc_text:${term}) `;
|
|
1800
|
-
} else if (subjectMatch) {
|
|
1801
|
-
const term = subjectMatch[1].replace(/"/g, "");
|
|
1802
|
-
ftsQuery += `subject:${term} `;
|
|
1803
|
-
} else if (dateMatch || afterMatch || beforeMatch) {
|
|
1804
|
-
const op = dateMatch ? (dateMatch[1] || "=") : (afterMatch ? ">" : "<");
|
|
1805
|
-
const valStr = dateMatch ? dateMatch[2] : (afterMatch ? afterMatch[1] : beforeMatch![1]);
|
|
1806
|
-
const ts = parseRel(valStr.replace(/"/g, ""));
|
|
1807
|
-
if (ts !== null) {
|
|
1808
|
-
if (op === ">" || op === ">=") { extraWhere.push("m.date >= ?"); extraParams.push(ts); }
|
|
1809
|
-
else if (op === "<" || op === "<=") { extraWhere.push("m.date <= ?"); extraParams.push(ts); }
|
|
1810
|
-
else { extraWhere.push("m.date >= ? AND m.date < ?"); extraParams.push(ts, ts + 86400_000); }
|
|
1811
|
-
}
|
|
1812
|
-
} else if (hasMatch) {
|
|
1813
|
-
const v = hasMatch[1].toLowerCase().replace(/"/g, "");
|
|
1814
|
-
if (v === "attachment" || v === "attachments") { extraWhere.push("m.has_attachments = 1"); }
|
|
1815
|
-
} else if (isMatch) {
|
|
1816
|
-
const v = isMatch[1].toLowerCase().replace(/"/g, "");
|
|
1817
|
-
if (v === "flagged" || v === "starred") extraWhere.push("m.flags_json LIKE '%\\\\Flagged%'");
|
|
1818
|
-
else if (v === "unread") extraWhere.push("m.flags_json NOT LIKE '%\\\\Seen%'");
|
|
1819
|
-
else if (v === "read" || v === "seen") extraWhere.push("m.flags_json LIKE '%\\\\Seen%'");
|
|
1820
|
-
else if (v === "answered") extraWhere.push("m.flags_json LIKE '%\\\\Answered%'");
|
|
1821
|
-
else if (v === "draft") extraWhere.push("m.flags_json LIKE '%\\\\Draft%'");
|
|
1822
|
-
} else if (folderMatch) {
|
|
1823
|
-
const v = folderMatch[1].replace(/"/g, "");
|
|
1824
|
-
extraWhere.push("LOWER(f.name) LIKE ?");
|
|
1825
|
-
extraParams.push(`%${v.toLowerCase()}%`);
|
|
1826
|
-
} else {
|
|
1827
|
-
// Unqualified — search everything.
|
|
1828
|
-
let term = part.replace(/^\/|\/$/g, "");
|
|
1829
|
-
if (term.includes("|")) {
|
|
1830
|
-
const alts = term.split("|").filter(Boolean).map(t => `${t}*`).join(" OR ");
|
|
1831
|
-
ftsQuery += `(${alts}) `;
|
|
1832
|
-
} else {
|
|
1833
|
-
ftsQuery += `${term}* `;
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
ftsQuery = ftsQuery.trim();
|
|
1839
|
-
// If the user typed only qualifier-only terms (e.g. "is:flagged after:1w"),
|
|
1840
|
-
// FTS query is empty — match-all surrogate.
|
|
1841
|
-
if (!ftsQuery) ftsQuery = "*";
|
|
1842
|
-
|
|
1843
|
-
const offset = (page - 1) * pageSize;
|
|
1844
|
-
|
|
1845
|
-
try {
|
|
1846
|
-
let scopeWhere = "";
|
|
1847
|
-
const scopeParams: any[] = [];
|
|
1848
|
-
if (accountId && folderId) {
|
|
1849
|
-
scopeWhere = " AND m.account_id = ? AND m.folder_id = ?";
|
|
1850
|
-
scopeParams.push(accountId, folderId);
|
|
1851
|
-
} else if (accountId) {
|
|
1852
|
-
scopeWhere = " AND m.account_id = ?";
|
|
1853
|
-
scopeParams.push(accountId);
|
|
1854
|
-
}
|
|
1855
|
-
if (extraWhere.length > 0) {
|
|
1856
|
-
scopeWhere += " AND " + extraWhere.join(" AND ");
|
|
1857
|
-
scopeParams.push(...extraParams);
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
const countRow = this.db.prepare(
|
|
1861
|
-
`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`
|
|
1862
|
-
).get(ftsQuery, ...scopeParams) as any;
|
|
1863
|
-
const total = countRow?.cnt || 0;
|
|
1864
|
-
|
|
1865
|
-
const rows = this.db.prepare(
|
|
1866
|
-
`SELECT m.*, f.name AS folder_name FROM messages m
|
|
1867
|
-
JOIN messages_fts fts ON m.id = fts.rowid
|
|
1868
|
-
LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
|
|
1869
|
-
WHERE messages_fts MATCH ?${scopeWhere}
|
|
1870
|
-
ORDER BY m.date DESC
|
|
1871
|
-
LIMIT ? OFFSET ?`
|
|
1872
|
-
).all(ftsQuery, ...scopeParams, pageSize, offset) as any[];
|
|
1873
|
-
|
|
1874
|
-
const items: MessageEnvelope[] = rows.map(r => ({
|
|
1875
|
-
id: r.id,
|
|
1876
|
-
accountId: r.account_id,
|
|
1877
|
-
folderId: r.folder_id,
|
|
1878
|
-
folderName: r.folder_name || "",
|
|
1879
|
-
uid: r.uid,
|
|
1880
|
-
messageId: r.message_id || "",
|
|
1881
|
-
inReplyTo: r.in_reply_to || "",
|
|
1882
|
-
references: JSON.parse(r.refs || "[]"),
|
|
1883
|
-
date: r.date,
|
|
1884
|
-
subject: r.subject,
|
|
1885
|
-
from: { name: r.from_name, address: r.from_address },
|
|
1886
|
-
to: JSON.parse(r.to_json),
|
|
1887
|
-
cc: JSON.parse(r.cc_json),
|
|
1888
|
-
flags: JSON.parse(r.flags_json),
|
|
1889
|
-
size: r.size,
|
|
1890
|
-
hasAttachments: !!r.has_attachments,
|
|
1891
|
-
preview: r.preview
|
|
1892
|
-
}));
|
|
1893
|
-
|
|
1894
|
-
return { items, total, page, pageSize };
|
|
1895
|
-
} catch (e: any) {
|
|
1896
|
-
console.error(`Search error: ${e.message}`);
|
|
1897
|
-
return { items: [], total: 0, page, pageSize };
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
/** Rebuild FTS index from existing messages */
|
|
1902
|
-
rebuildSearchIndex(): number {
|
|
1903
|
-
// Drop and recreate in case schema changed
|
|
1904
|
-
try { this.db.exec("DROP TABLE IF EXISTS messages_fts"); } catch { /* ignore */ }
|
|
1905
|
-
this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
1906
|
-
subject, from_name, from_address, to_text, cc_text, body_text,
|
|
1907
|
-
content=messages, content_rowid=id
|
|
1908
|
-
)`);
|
|
1909
|
-
|
|
1910
|
-
// Use a single transaction + prepared statement for speed (~50x faster than individual inserts)
|
|
1911
|
-
const insert = this.db.prepare(
|
|
1912
|
-
"INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
1913
|
-
);
|
|
1914
|
-
const rows = this.db.prepare(
|
|
1915
|
-
"SELECT id, subject, from_name, from_address, to_json, cc_json, preview FROM messages"
|
|
1916
|
-
).all() as any[];
|
|
1917
|
-
|
|
1918
|
-
let count = 0;
|
|
1919
|
-
this.db.exec("BEGIN");
|
|
1920
|
-
try {
|
|
1921
|
-
for (const r of rows) {
|
|
1922
|
-
const to = JSON.parse(r.to_json || "[]");
|
|
1923
|
-
const cc = JSON.parse(r.cc_json || "[]");
|
|
1924
|
-
const toText = to.map((a: any) => `${a.name} ${a.address}`).join(" ");
|
|
1925
|
-
const ccText = cc.map((a: any) => `${a.name} ${a.address}`).join(" ");
|
|
1926
|
-
try {
|
|
1927
|
-
insert.run(r.id, r.subject, r.from_name, r.from_address, toText, ccText, r.preview);
|
|
1928
|
-
count++;
|
|
1929
|
-
} catch { /* skip duplicates */ }
|
|
1930
|
-
}
|
|
1931
|
-
this.db.exec("COMMIT");
|
|
1932
|
-
} catch (e) {
|
|
1933
|
-
this.db.exec("ROLLBACK");
|
|
1934
|
-
throw e;
|
|
1935
|
-
}
|
|
1936
|
-
return count;
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
// ── Sync Actions ──
|
|
1940
|
-
|
|
1941
|
-
/** Queue a local action for later sync to IMAP */
|
|
1942
|
-
queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
|
|
1943
|
-
targetFolderId?: number;
|
|
1944
|
-
flags?: string[];
|
|
1945
|
-
rawMessage?: string;
|
|
1946
|
-
}): void {
|
|
1947
|
-
try {
|
|
1948
|
-
this.db.prepare(
|
|
1949
|
-
`INSERT OR REPLACE INTO sync_actions (account_id, action, uid, folder_id, target_folder_id, flags_json, raw_message, created_at)
|
|
1950
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1951
|
-
).run(
|
|
1952
|
-
accountId, action, uid, folderId,
|
|
1953
|
-
extra?.targetFolderId || null,
|
|
1954
|
-
extra?.flags ? JSON.stringify(extra.flags) : null,
|
|
1955
|
-
extra?.rawMessage || null,
|
|
1956
|
-
Date.now()
|
|
1957
|
-
);
|
|
1958
|
-
} catch { /* UNIQUE constraint — already queued */ }
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
/** Get all pending sync actions for an account */
|
|
1962
|
-
getPendingSyncActions(accountId: string): {
|
|
1963
|
-
id: number; action: string; uid: number; folderId: number;
|
|
1964
|
-
targetFolderId: number; flags: string[]; rawMessage: string;
|
|
1965
|
-
attempts: number;
|
|
1966
|
-
}[] {
|
|
1967
|
-
const rows = this.db.prepare(
|
|
1968
|
-
"SELECT * FROM sync_actions WHERE account_id = ? ORDER BY created_at"
|
|
1969
|
-
).all(accountId) as any[];
|
|
1970
|
-
return rows.map(r => ({
|
|
1971
|
-
id: r.id,
|
|
1972
|
-
action: r.action,
|
|
1973
|
-
uid: r.uid,
|
|
1974
|
-
folderId: r.folder_id,
|
|
1975
|
-
targetFolderId: r.target_folder_id,
|
|
1976
|
-
flags: r.flags_json ? JSON.parse(r.flags_json) : [],
|
|
1977
|
-
rawMessage: r.raw_message,
|
|
1978
|
-
attempts: r.attempts,
|
|
1979
|
-
}));
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
/** Remove a completed sync action */
|
|
1983
|
-
completeSyncAction(id: number): void {
|
|
1984
|
-
this.db.prepare("DELETE FROM sync_actions WHERE id = ?").run(id);
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
/** Mark a sync action as failed */
|
|
1988
|
-
failSyncAction(id: number, error: string): void {
|
|
1989
|
-
this.db.prepare(
|
|
1990
|
-
"UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE id = ?"
|
|
1991
|
-
).run(error, id);
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
|
-
/** Count pending sync actions for an account */
|
|
1995
|
-
getPendingSyncCount(accountId: string): number {
|
|
1996
|
-
const r = this.db.prepare(
|
|
1997
|
-
"SELECT COUNT(*) as cnt FROM sync_actions WHERE account_id = ?"
|
|
1998
|
-
).get(accountId) as any;
|
|
1999
|
-
return r?.cnt || 0;
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
/** Count total pending sync actions across all accounts */
|
|
2003
|
-
getTotalPendingSyncCount(): number {
|
|
2004
|
-
const r = this.db.prepare("SELECT COUNT(*) as cnt FROM sync_actions").get() as any;
|
|
2005
|
-
return r?.cnt || 0;
|
|
2006
|
-
}
|
|
2007
|
-
}
|