@bobfrankston/mailx 1.0.466 → 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,3652 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @bobfrankston/mailx-imap
|
|
3
|
-
* Multi-account IMAP management wrapping iflow.
|
|
4
|
-
* Syncs messages to local store, emits events for new mail.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { createAutoImapConfig, CompatImapClient } from "@bobfrankston/iflow-direct";
|
|
8
|
-
import type { TransportFactory } from "@bobfrankston/tcp-transport";
|
|
9
|
-
import { authenticateOAuth } from "@bobfrankston/oauthsupport";
|
|
10
|
-
import { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
|
|
11
|
-
import { loadSettings, getStorePath, getConfigDir, getHistoryDays, getPrefetch } from "@bobfrankston/mailx-settings";
|
|
12
|
-
import type { AccountConfig, MessageEnvelope, EmailAddress, Folder } from "@bobfrankston/mailx-types";
|
|
13
|
-
import { EventEmitter } from "node:events";
|
|
14
|
-
import * as fs from "node:fs";
|
|
15
|
-
import * as path from "node:path";
|
|
16
|
-
import { simpleParser } from "mailparser";
|
|
17
|
-
import { GmailApiProvider } from "./providers/gmail-api.js";
|
|
18
|
-
import type { MailProvider, ProviderMessage } from "./providers/types.js";
|
|
19
|
-
import { SmtpClient } from "@bobfrankston/smtp-direct";
|
|
20
|
-
import * as os from "node:os";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
22
|
-
|
|
23
|
-
// Well-known ports — no magic numbers
|
|
24
|
-
const SMTP_PORT_STARTTLS = 587;
|
|
25
|
-
const SMTP_PORT_IMPLICIT_TLS = 465;
|
|
26
|
-
|
|
27
|
-
type OpsTask = () => Promise<void>;
|
|
28
|
-
interface OpsQueue {
|
|
29
|
-
fast: OpsTask[];
|
|
30
|
-
slow: OpsTask[];
|
|
31
|
-
running: boolean;
|
|
32
|
-
}
|
|
33
|
-
interface HostSemaphore {
|
|
34
|
-
permits: number;
|
|
35
|
-
waiters: Array<() => void>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Per-message SMTP retry delay: if a send attempt fails, wait this long before
|
|
39
|
-
* the same file is retried. Gives the server time to settle so a retry after a
|
|
40
|
-
* lost-ack doesn't arrive while the first copy is still being processed. */
|
|
41
|
-
const OUTBOX_RETRY_DELAY_MS = 60000;
|
|
42
|
-
|
|
43
|
-
/** Parse X-Mailx-Retry* tracking headers from a raw RFC822 message. */
|
|
44
|
-
function parseRetryInfo(raw: string): { attemptCount: number; nextAttemptAt: number } {
|
|
45
|
-
const headerEnd = raw.search(/\r?\n\r?\n/);
|
|
46
|
-
const headers = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw;
|
|
47
|
-
const attemptCount = (headers.match(/^X-Mailx-Retry:/gmi) || []).length;
|
|
48
|
-
const nextMatch = headers.match(/^X-Mailx-Retry-After:\s*(.+)$/mi);
|
|
49
|
-
const parsed = nextMatch ? Date.parse(nextMatch[1].trim()) : NaN;
|
|
50
|
-
return { attemptCount, nextAttemptAt: Number.isFinite(parsed) ? parsed : 0 };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Remove every occurrence of a header field from a raw RFC822 message. */
|
|
54
|
-
function stripHeaderField(raw: string, name: string): string {
|
|
55
|
-
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}:.*\\r?\\n`, "gmi");
|
|
56
|
-
return raw.replace(re, "");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Insert a header line just before the header/body blank line. Preserves CRLF vs LF. */
|
|
60
|
-
function insertHeaderBeforeBody(raw: string, line: string): string {
|
|
61
|
-
const m = raw.match(/\r?\n\r?\n/);
|
|
62
|
-
if (!m) return raw + "\r\n" + line + "\r\n";
|
|
63
|
-
const nl = m[0].startsWith("\r\n") ? "\r\n" : "\n";
|
|
64
|
-
return raw.slice(0, m.index!) + nl + line + raw.slice(m.index!);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Error thrown when a message body can't be fetched because the server says
|
|
68
|
-
* the message is gone (deleted from another device, expunged, etc.). The
|
|
69
|
-
* caller uses this to remove the stale local row instead of showing a
|
|
70
|
-
* generic "fetch failed" error to the user. */
|
|
71
|
-
function makeNotFoundError(accountId: string, folderId: number, uid: number): Error {
|
|
72
|
-
const err = new Error(`Message ${accountId}/${folderId}/${uid} not found on server`);
|
|
73
|
-
(err as any).isNotFound = true;
|
|
74
|
-
return err;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Extract full error detail with provenance */
|
|
78
|
-
function imapError(err: any): string {
|
|
79
|
-
const msg = err.message || err.reason || err.code || (typeof err === "string" ? err : "");
|
|
80
|
-
const parts: string[] = [];
|
|
81
|
-
if (msg) parts.push(msg);
|
|
82
|
-
if (err.responseText) parts.push(err.responseText);
|
|
83
|
-
if (err.responseStatus) parts.push(`[${err.responseStatus}]`);
|
|
84
|
-
if (err.code && err.code !== msg) parts.push(`[${err.code}]`);
|
|
85
|
-
if (parts.length === 0) parts.push(`Unexpected error: ${JSON.stringify(err).slice(0, 200)}`);
|
|
86
|
-
// Add source location if available
|
|
87
|
-
if (err.stack) {
|
|
88
|
-
const frame = err.stack.split("\n").find((l: string) => l.includes("imap") || l.includes("transport") || l.includes("compat"));
|
|
89
|
-
if (frame) parts.push(`(${frame.trim().replace(/^at\s+/, "")})`);
|
|
90
|
-
}
|
|
91
|
-
return parts.join(" — ");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Events emitted by the IMAP manager */
|
|
95
|
-
export interface ImapManagerEvents {
|
|
96
|
-
newMessage: (accountId: string, folderId: number, envelope: MessageEnvelope) => void;
|
|
97
|
-
syncProgress: (accountId: string, phase: string, progress: number) => void;
|
|
98
|
-
syncComplete: (accountId: string) => void;
|
|
99
|
-
syncError: (accountId: string, error: string) => void;
|
|
100
|
-
folderCountsChanged: (accountId: string, counts: Record<number, { total: number; unread: number }>) => void;
|
|
101
|
-
accountError: (accountId: string, error: string, hint: string, isOAuth: boolean) => void;
|
|
102
|
-
configChanged: (filename: string) => void;
|
|
103
|
-
/** Fired after a message body has been written to the local store — lets
|
|
104
|
-
* the UI flip a row's "not-downloaded" indicator without re-rendering. */
|
|
105
|
-
bodyCached: (accountId: string, uid: number) => void;
|
|
106
|
-
syncActionFailed: (accountId: string, action: string, uid: number, error: string) => void;
|
|
107
|
-
/** Fired whenever the outbox queue depth or state changes (file added,
|
|
108
|
-
* file sent and removed, retry attempted). Lets the UI show a persistent
|
|
109
|
-
* queue-status indicator without polling. Aggregate status across all
|
|
110
|
-
* accounts is included so the listener doesn't have to reassemble it. */
|
|
111
|
-
outboxStatus: (status: OutboxStatus) => void;
|
|
112
|
-
/** Per-account health counter update. Fires when an inactivity timeout,
|
|
113
|
-
* connection-cap hit, or rate-limit wait happens. */
|
|
114
|
-
diagnostics: (accountId: string, snapshot: AccountDiagnostics) => void;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Per-account diagnostic counters — tracks "something's wrong" events the
|
|
118
|
-
* user should be able to see without opening the log. */
|
|
119
|
-
export interface AccountDiagnostics {
|
|
120
|
-
accountId: string;
|
|
121
|
-
inactivityTimeouts: number; // IMAP server dropped the socket mid-command
|
|
122
|
-
connCapHits: number; // "Maximum number of connections" rejections
|
|
123
|
-
rateLimitWaits: number; // Gmail API 429 cooldowns
|
|
124
|
-
lastTimeoutAt: number; // epoch ms of most recent inactivity timeout
|
|
125
|
-
lastCommand: string; // IMAP command that last timed out (e.g. "A471 UID STORE 4956119 +FLAGS.SILENT")
|
|
126
|
-
lastError: string; // full error message (truncated)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Per-account outbox queue breakdown, plus totals for the UI. */
|
|
130
|
-
export interface OutboxStatus {
|
|
131
|
-
total: number; // messages waiting to send across all accounts
|
|
132
|
-
retrying: number; // subset of total that have at least one X-Mailx-Retry attempt
|
|
133
|
-
claimed: number; // subset of total currently in flight (*.sending-host-pid)
|
|
134
|
-
oldestAgeSec: number; // age of the oldest pending file, 0 if none
|
|
135
|
-
maxAttempts: number; // highest attempt count across all pending files
|
|
136
|
-
perAccount: Record<string, { total: number; retrying: number; claimed: number }>;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Convert iflow address objects to our EmailAddress */
|
|
140
|
-
function toEmailAddress(addr: { name?: string; address?: string }): EmailAddress {
|
|
141
|
-
return {
|
|
142
|
-
name: addr?.name || "",
|
|
143
|
-
address: addr?.address || ""
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Convert array of iflow addresses */
|
|
148
|
-
function toEmailAddresses(addrs: { name?: string; address?: string }[]): EmailAddress[] {
|
|
149
|
-
if (!addrs) return [];
|
|
150
|
-
return addrs.map(toEmailAddress);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Decode HTML entities (  & etc.) to plain characters */
|
|
154
|
-
function decodeEntities(text: string): string {
|
|
155
|
-
return text
|
|
156
|
-
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
157
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
158
|
-
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
159
|
-
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Extract a plain-text preview from message source */
|
|
163
|
-
async function extractPreview(source: string): Promise<{ bodyHtml: string; bodyText: string; preview: string; hasAttachments: boolean }> {
|
|
164
|
-
try {
|
|
165
|
-
const parsed = await simpleParser(source);
|
|
166
|
-
const bodyText = parsed.text || "";
|
|
167
|
-
const bodyHtml = parsed.html || "";
|
|
168
|
-
// Use text part; fall back to stripping HTML tags if text is empty
|
|
169
|
-
let raw = bodyText || bodyHtml.replace(/<[^>]+>/g, " ");
|
|
170
|
-
const preview = decodeEntities(raw).replace(/\s+/g, " ").trim().slice(0, 200);
|
|
171
|
-
const hasAttachments = (parsed.attachments?.length || 0) > 0;
|
|
172
|
-
return { bodyHtml, bodyText, preview, hasAttachments };
|
|
173
|
-
} catch {
|
|
174
|
-
return { bodyHtml: "", bodyText: "", preview: "", hasAttachments: false };
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Race a promise against a timeout. On timeout, forcibly logout the client to prevent hanging. */
|
|
179
|
-
async function withTimeout<T>(promise: Promise<T>, ms: number, client: any, label: string): Promise<T> {
|
|
180
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
181
|
-
const timeout = new Promise<never>((_, reject) => {
|
|
182
|
-
timer = setTimeout(() => {
|
|
183
|
-
// Force-close the client to unblock the hanging promise
|
|
184
|
-
try { client.logout?.(); } catch { /* ignore */ }
|
|
185
|
-
reject(new Error(`${label} timeout (${ms / 1000}s)`));
|
|
186
|
-
}, ms);
|
|
187
|
-
});
|
|
188
|
-
try {
|
|
189
|
-
return await Promise.race([promise, timeout]);
|
|
190
|
-
} finally {
|
|
191
|
-
clearTimeout(timer!);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export class ImapManager extends EventEmitter {
|
|
196
|
-
private configs: Map<string, ReturnType<typeof createAutoImapConfig>> = new Map();
|
|
197
|
-
private watchers: Map<string, () => void> = new Map();
|
|
198
|
-
private fetchClients: Map<string, any> = new Map();
|
|
199
|
-
private db: MailxDB;
|
|
200
|
-
private bodyStore: FileMessageStore;
|
|
201
|
-
private syncIntervals: Map<string, ReturnType<typeof setInterval>> = new Map();
|
|
202
|
-
/** Track which accounts have already shown an error banner — only emit once per session */
|
|
203
|
-
private accountErrorShown = new Set<string>();
|
|
204
|
-
private syncing = false;
|
|
205
|
-
private inboxSyncing = false;
|
|
206
|
-
/** Use native IMAP client instead of imapflow. Set to true to enable. */
|
|
207
|
-
useNativeClient = false;
|
|
208
|
-
// Connection management: see withConnection() below.
|
|
209
|
-
// Cap-hit backoff machinery removed — bounded per-account concurrency
|
|
210
|
-
// (one ops socket + one IDLE socket) keeps mailx well under any
|
|
211
|
-
// reasonable server cap, so the recovery timer was dead weight that
|
|
212
|
-
// mostly served to lock the UI for minutes after a transient failure.
|
|
213
|
-
|
|
214
|
-
/** Per-account health counters. Incremented when the server misbehaves
|
|
215
|
-
* in ways that suggest a problem the user should know about (inactivity
|
|
216
|
-
* timeouts, connection-cap hits, rate-limit waits). Surfaced via a
|
|
217
|
-
* `diagnostics` event + `getDiagnostics` IPC so the UI can show a ⚠
|
|
218
|
-
* badge instead of burying the issue in the log. */
|
|
219
|
-
private diagnostics = new Map<string, AccountDiagnostics>();
|
|
220
|
-
|
|
221
|
-
private getDiagnosticsEntry(accountId: string): AccountDiagnostics {
|
|
222
|
-
let d = this.diagnostics.get(accountId);
|
|
223
|
-
if (!d) {
|
|
224
|
-
d = { accountId, inactivityTimeouts: 0, connCapHits: 0, rateLimitWaits: 0, lastTimeoutAt: 0, lastCommand: "", lastError: "" };
|
|
225
|
-
this.diagnostics.set(accountId, d);
|
|
226
|
-
}
|
|
227
|
-
return d;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Classify an error message and bump the relevant counter; emit the
|
|
231
|
-
* updated diagnostics snapshot. Call this from every catch in the sync
|
|
232
|
-
* paths so the UI can count "something's wrong" in real time. */
|
|
233
|
-
private recordError(accountId: string, errMsg: string): void {
|
|
234
|
-
const d = this.getDiagnosticsEntry(accountId);
|
|
235
|
-
if (/inactivity timeout/i.test(errMsg)) {
|
|
236
|
-
d.inactivityTimeouts++;
|
|
237
|
-
d.lastTimeoutAt = Date.now();
|
|
238
|
-
const m = errMsg.match(/A\d+ [A-Z ]+.*$/);
|
|
239
|
-
if (m) d.lastCommand = m[0].slice(0, 120);
|
|
240
|
-
} else if (/UNAVAILABLE|Maximum number of connections|too many connections/i.test(errMsg)) {
|
|
241
|
-
d.connCapHits++;
|
|
242
|
-
} else if (/429|rate limit/i.test(errMsg)) {
|
|
243
|
-
d.rateLimitWaits++;
|
|
244
|
-
} else {
|
|
245
|
-
return; // not a known diagnostic class — don't emit
|
|
246
|
-
}
|
|
247
|
-
d.lastError = errMsg.slice(0, 200);
|
|
248
|
-
this.emit("diagnostics", accountId, { ...d });
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Public read for the IPC surface: snapshot of all account diagnostics. */
|
|
252
|
-
getDiagnosticsSnapshot(): AccountDiagnostics[] {
|
|
253
|
-
return Array.from(this.diagnostics.values()).map(d => ({ ...d }));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private transportFactory: TransportFactory;
|
|
257
|
-
|
|
258
|
-
constructor(db: MailxDB, transportFactory: TransportFactory) {
|
|
259
|
-
super();
|
|
260
|
-
this.db = db;
|
|
261
|
-
this.transportFactory = transportFactory;
|
|
262
|
-
const storePath = getStorePath();
|
|
263
|
-
this.bodyStore = new FileMessageStore(storePath);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Get OAuth access token for an account (for SMTP auth) */
|
|
267
|
-
async getOAuthToken(accountId: string): Promise<string | null> {
|
|
268
|
-
const config = this.configs.get(accountId);
|
|
269
|
-
if (!config || !config.tokenProvider) return null;
|
|
270
|
-
return config.tokenProvider();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Accounts currently re-authenticating — all operations skip these */
|
|
274
|
-
private reauthenticating = new Set<string>();
|
|
275
|
-
/** Last reauth attempt timestamp per account — prevents reauth loops (5 min cooldown) */
|
|
276
|
-
private lastReauthAttempt = new Map<string, number>();
|
|
277
|
-
|
|
278
|
-
/** Force re-authentication for an OAuth account — deletes cached IMAP token, triggers browser consent */
|
|
279
|
-
async reauthenticate(accountId: string): Promise<boolean> {
|
|
280
|
-
if (this.reauthenticating.has(accountId)) return false; // already in progress
|
|
281
|
-
this.reauthenticating.add(accountId);
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
const settings = loadSettings();
|
|
285
|
-
const account = settings.accounts.find(a => a.id === accountId);
|
|
286
|
-
if (!account) return false;
|
|
287
|
-
|
|
288
|
-
// Stop IDLE watcher for this account
|
|
289
|
-
const stopWatcher = this.watchers.get(accountId);
|
|
290
|
-
if (stopWatcher) { try { await stopWatcher(); } catch { /* */ } this.watchers.delete(accountId); }
|
|
291
|
-
|
|
292
|
-
// Delete only the IMAP token (not contacts — separate scope, separate consent)
|
|
293
|
-
const accountDir = account.imap.user.replace(/[@.]/g, "_");
|
|
294
|
-
const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
|
|
295
|
-
const tokenPath = path.join(tokenDir, "token.json");
|
|
296
|
-
if (fs.existsSync(tokenPath)) { fs.unlinkSync(tokenPath); console.log(` [reauth] Deleted ${tokenPath}`); }
|
|
297
|
-
|
|
298
|
-
// Re-register the account to get a fresh config with new tokenProvider
|
|
299
|
-
this.configs.delete(accountId);
|
|
300
|
-
await this.addAccount(account);
|
|
301
|
-
|
|
302
|
-
// addAccount already pre-validates the token (opens browser if needed)
|
|
303
|
-
const config = this.configs.get(accountId);
|
|
304
|
-
if (config?.tokenProvider) {
|
|
305
|
-
console.log(` [reauth] ${accountId}: success`);
|
|
306
|
-
this.accountErrorShown.delete(accountId);
|
|
307
|
-
this.syncInbox().catch(() => {});
|
|
308
|
-
return true;
|
|
309
|
-
}
|
|
310
|
-
} catch (e: any) {
|
|
311
|
-
console.error(` [reauth] ${accountId}: ${e.message}`);
|
|
312
|
-
} finally {
|
|
313
|
-
this.reauthenticating.delete(accountId);
|
|
314
|
-
}
|
|
315
|
-
return false;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
|
|
319
|
-
async deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void> {
|
|
320
|
-
return this.withConnection(accountId, async (client) => {
|
|
321
|
-
await client.deleteMessageByUid(folderPath, uid);
|
|
322
|
-
console.log(` Deleted UID ${uid} from ${folderPath} on server`);
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** Search messages on the IMAP server — returns matching UIDs */
|
|
327
|
-
async searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]> {
|
|
328
|
-
return this.withConnection(accountId, async (client) => {
|
|
329
|
-
return await client.searchMessages(mailboxPath, criteria);
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Server-side search that also materializes any UIDs we don't yet have
|
|
334
|
-
* locally. Returns the full result after upsert, so the caller can
|
|
335
|
-
* render hits that fall outside the history window. The fetch loop
|
|
336
|
-
* can be long for big hit-sets, so this runs on the slow lane and
|
|
337
|
-
* yields between chunks (each chunk is a separate withConnection)
|
|
338
|
-
* so an interactive body fetch can interleave. */
|
|
339
|
-
async searchAndFetchOnServer(accountId: string, folderId: number, mailboxPath: string, criteria: any): Promise<number[]> {
|
|
340
|
-
const uids = await this.withConnection(accountId, async (client) => {
|
|
341
|
-
return await client.searchMessages(mailboxPath, criteria) as number[];
|
|
342
|
-
});
|
|
343
|
-
if (uids.length === 0) return [];
|
|
344
|
-
const have = new Set(this.db.getUidsForFolder(accountId, folderId));
|
|
345
|
-
const missing = uids.filter(u => !have.has(u));
|
|
346
|
-
if (missing.length > 0) {
|
|
347
|
-
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
348
|
-
if (folder) {
|
|
349
|
-
const CHUNK = 500;
|
|
350
|
-
for (let i = 0; i < missing.length; i += CHUNK) {
|
|
351
|
-
const range = missing.slice(i, i + CHUNK).join(",");
|
|
352
|
-
await this.withConnection(accountId, async (client) => {
|
|
353
|
-
const fetched = await (client as any).fetchMessages(mailboxPath, range, { source: false });
|
|
354
|
-
if (fetched?.length) {
|
|
355
|
-
await this.storeMessages(accountId, folderId, folder, fetched, 0);
|
|
356
|
-
}
|
|
357
|
-
}, { slow: true });
|
|
358
|
-
}
|
|
359
|
-
this.db.recalcFolderCounts(folderId);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return uids;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/** Create a fresh IMAP client for an account (public access for API endpoints) */
|
|
366
|
-
async createPublicClient(accountId: string): Promise<any> {
|
|
367
|
-
return this.createClient(accountId);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Legacy fallback disabled — was doubling connections without helping.
|
|
371
|
-
// To re-enable: uncomment legacyFallbacks logic in createClient and _syncAll.
|
|
372
|
-
// private legacyFallbacks = new Set<string>();
|
|
373
|
-
|
|
374
|
-
// ── Connection management: one persistent connection per account ──
|
|
375
|
-
// All operations on an account are serialized through an operation queue.
|
|
376
|
-
// No semaphore, no pool, no per-operation connect/disconnect.
|
|
377
|
-
// IDLE uses a separate connection (see startWatching).
|
|
378
|
-
|
|
379
|
-
/** Persistent operational connections — one per account, reused for all operations.
|
|
380
|
-
* Body fetch, sync, prefetch, outbox-append, flag/move all serialize through
|
|
381
|
-
* this single client per account via withConnection(). The priority lane
|
|
382
|
-
* in the queue lets interactive clicks jump ahead of background prefetch. */
|
|
383
|
-
private opsClients = new Map<string, any>();
|
|
384
|
-
/** Two-lane operation queue per account — interactive ops (body fetch on
|
|
385
|
-
* click, flag toggle) drain before background ops (sync, prefetch). FIFO
|
|
386
|
-
* within each lane. The single ops connection means there's never a race
|
|
387
|
-
* on which folder is SELECTed; commands run strictly sequentially. */
|
|
388
|
-
private opsQueues = new Map<string, OpsQueue>();
|
|
389
|
-
/** Per-host semaphore — caps simultaneous IMAP socket opens to one server.
|
|
390
|
-
* Defensive guardrail: with the single-ops-per-account model an individual
|
|
391
|
-
* user's mailx never hits more than (#accounts × 2) sockets per host, well
|
|
392
|
-
* under any reasonable server cap. Exists for the multi-account-on-one-host
|
|
393
|
-
* case (e.g. bobma + bobma2 both on imap.iecc.com). */
|
|
394
|
-
private hostSemaphores = new Map<string, HostSemaphore>();
|
|
395
|
-
private static readonly HOST_PERMITS = 4;
|
|
396
|
-
|
|
397
|
-
/** Get (or create) the persistent operational connection for an account.
|
|
398
|
-
* logout() is wrapped as a no-op so legacy callers don't close it. */
|
|
399
|
-
private async getOpsClient(accountId: string): Promise<any> {
|
|
400
|
-
let client = this.opsClients.get(accountId);
|
|
401
|
-
if (client) {
|
|
402
|
-
// C38: health-check the cached client before returning. If the
|
|
403
|
-
// underlying socket is dead (Dovecot silently dropped IDLE after
|
|
404
|
-
// the inactivity timeout, or we lost connectivity), the next
|
|
405
|
-
// command would fail with "Not connected" — and nothing would
|
|
406
|
-
// recover it until an explicit reconnectOps was called from the
|
|
407
|
-
// catch handler. Cheap pre-check here catches it earlier.
|
|
408
|
-
const sock = client?.native?.transport?.socket;
|
|
409
|
-
const dead = sock?.destroyed || sock?.readyState === "closed" || client?._dead;
|
|
410
|
-
if (!dead) return client;
|
|
411
|
-
try { await (client._realLogout || client.logout)(); } catch { /* */ }
|
|
412
|
-
this.opsClients.delete(accountId);
|
|
413
|
-
console.log(` [conn] ${accountId}: stale ops client detected in getOpsClient — reconnecting`);
|
|
414
|
-
client = undefined as any;
|
|
415
|
-
}
|
|
416
|
-
client = await this.newClient(accountId, "ops");
|
|
417
|
-
// Wrap logout as no-op — this is a persistent connection. The
|
|
418
|
-
// newClient wrapper's close-counter runs on `_realLogout`.
|
|
419
|
-
const realLogout = client.logout.bind(client);
|
|
420
|
-
client.logout = async () => { /* no-op — persistent connection */ };
|
|
421
|
-
client._realLogout = realLogout;
|
|
422
|
-
this.opsClients.set(accountId, client);
|
|
423
|
-
return client;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/** Run an operation on the account's connection — queued, sequential, no concurrency */
|
|
427
|
-
/** Run an operation against the account's single ops connection. Tasks
|
|
428
|
-
* queue strictly sequentially per account — only one IMAP command in
|
|
429
|
-
* flight at a time. This eliminates the SELECT-races and "stale client
|
|
430
|
-
* recovery" paths the old multi-client design needed.
|
|
431
|
-
*
|
|
432
|
-
* Default lane is `fast` — covers virtually everything (body fetch,
|
|
433
|
-
* flag toggle, move, incremental sync). Pass `slow: true` only for
|
|
434
|
-
* operations the caller knows will take a long time and shouldn't
|
|
435
|
-
* block the user (multi-folder prefetch batches, large backfills).
|
|
436
|
-
* When both lanes have tasks, fast drains first.
|
|
437
|
-
*
|
|
438
|
-
* Within a lane, FIFO. The running task always finishes — IMAP can't
|
|
439
|
-
* preempt a command mid-flight. */
|
|
440
|
-
async withConnection<T>(
|
|
441
|
-
accountId: string,
|
|
442
|
-
fn: (client: any) => Promise<T>,
|
|
443
|
-
opts: { slow?: boolean; timeoutMs?: number } = {},
|
|
444
|
-
): Promise<T> {
|
|
445
|
-
let queue = this.opsQueues.get(accountId);
|
|
446
|
-
if (!queue) {
|
|
447
|
-
queue = { fast: [], slow: [], running: false };
|
|
448
|
-
this.opsQueues.set(accountId, queue);
|
|
449
|
-
}
|
|
450
|
-
// Per-task wall-clock cap. Without one, a wedged IMAP command (TCP
|
|
451
|
-
// half-open, server stalled mid-FETCH) keeps the queue's running flag
|
|
452
|
-
// set forever and every subsequent fast-lane task — including the
|
|
453
|
-
// retry button the user just hit — waits behind it. Default is
|
|
454
|
-
// generous; callers driving user-visible reads pass a tighter value.
|
|
455
|
-
const timeoutMs = opts.timeoutMs ?? 90_000;
|
|
456
|
-
return new Promise<T>((resolve, reject) => {
|
|
457
|
-
const task: OpsTask = async () => {
|
|
458
|
-
let timer: any;
|
|
459
|
-
try {
|
|
460
|
-
const client = await this.getOpsClient(accountId);
|
|
461
|
-
const result = await Promise.race<T>([
|
|
462
|
-
fn(client),
|
|
463
|
-
new Promise<T>((_, rej) => {
|
|
464
|
-
timer = setTimeout(() => rej(new Error(`ops timeout after ${Math.round(timeoutMs / 1000)}s — discarding client`)), timeoutMs);
|
|
465
|
-
}),
|
|
466
|
-
]);
|
|
467
|
-
clearTimeout(timer);
|
|
468
|
-
resolve(result);
|
|
469
|
-
} catch (e: any) {
|
|
470
|
-
clearTimeout(timer);
|
|
471
|
-
// Discard client on any error — keeping a half-broken
|
|
472
|
-
// socket poisoned every subsequent request. Destroy
|
|
473
|
-
// synchronously kills the in-flight command's socket so
|
|
474
|
-
// the underlying promise rejects and stops holding state.
|
|
475
|
-
const stale = this.opsClients.get(accountId);
|
|
476
|
-
this.opsClients.delete(accountId);
|
|
477
|
-
if (stale) {
|
|
478
|
-
try { await (stale._realLogout || stale.logout)(); } catch { /* */ }
|
|
479
|
-
try { stale.destroy?.(); } catch { /* */ }
|
|
480
|
-
}
|
|
481
|
-
reject(e);
|
|
482
|
-
}
|
|
483
|
-
};
|
|
484
|
-
(opts.slow ? queue!.slow : queue!.fast).push(task);
|
|
485
|
-
this.drainOpsQueue(accountId);
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** Run the next queued task. Fast lane drains before slow.
|
|
490
|
-
* Idempotent — safe to call after each task completes; the running
|
|
491
|
-
* flag prevents reentrant draining. */
|
|
492
|
-
private drainOpsQueue(accountId: string): void {
|
|
493
|
-
const queue = this.opsQueues.get(accountId);
|
|
494
|
-
if (!queue || queue.running) return;
|
|
495
|
-
const next = queue.fast.shift() || queue.slow.shift();
|
|
496
|
-
if (!next) return;
|
|
497
|
-
queue.running = true;
|
|
498
|
-
next().finally(() => {
|
|
499
|
-
queue.running = false;
|
|
500
|
-
this.drainOpsQueue(accountId);
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** Acquire one slot of the per-host connection semaphore. Returns a release
|
|
505
|
-
* function — call exactly once when the socket is closed. Used by
|
|
506
|
-
* newClient to cap simultaneous IMAP connections to a single server
|
|
507
|
-
* across all mailx accounts. */
|
|
508
|
-
private acquireHostSlot(host: string): Promise<() => void> {
|
|
509
|
-
let sem = this.hostSemaphores.get(host);
|
|
510
|
-
if (!sem) {
|
|
511
|
-
sem = { permits: ImapManager.HOST_PERMITS, waiters: [] };
|
|
512
|
-
this.hostSemaphores.set(host, sem);
|
|
513
|
-
}
|
|
514
|
-
const semRef = sem;
|
|
515
|
-
return new Promise<() => void>(resolve => {
|
|
516
|
-
const grant = () => {
|
|
517
|
-
semRef.permits--;
|
|
518
|
-
let released = false;
|
|
519
|
-
resolve(() => {
|
|
520
|
-
if (released) return;
|
|
521
|
-
released = true;
|
|
522
|
-
semRef.permits++;
|
|
523
|
-
const next = semRef.waiters.shift();
|
|
524
|
-
if (next) next();
|
|
525
|
-
});
|
|
526
|
-
};
|
|
527
|
-
if (semRef.permits > 0) grant();
|
|
528
|
-
else semRef.waiters.push(grant);
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/** Open IMAP clients per account, used to trace who's opening sockets
|
|
533
|
-
* when we hit the Dovecot per-user+IP connection cap. */
|
|
534
|
-
private openClients: Map<string, Set<any>> = new Map();
|
|
535
|
-
|
|
536
|
-
/** Create a new IMAP client (internal — callers use getOpsClient or withConnection).
|
|
537
|
-
* Acquires one slot of the per-host semaphore before constructing the
|
|
538
|
-
* client; the slot is released when logout() or destroy() runs.
|
|
539
|
-
* `purpose` is a short tag printed alongside the `[conn+]` log so we can
|
|
540
|
-
* tell which code path (ops/idle/etc.) opened each connection. */
|
|
541
|
-
private async newClient(accountId: string, purpose = "?"): Promise<any> {
|
|
542
|
-
if (this.reauthenticating.has(accountId)) throw new Error(`Account ${accountId} is re-authenticating`);
|
|
543
|
-
const config = this.configs.get(accountId);
|
|
544
|
-
if (!config) throw new Error(`No config for account ${accountId}`);
|
|
545
|
-
const host = config.server || accountId;
|
|
546
|
-
const releaseHostSlot = await this.acquireHostSlot(host);
|
|
547
|
-
let client: any;
|
|
548
|
-
try {
|
|
549
|
-
client = new CompatImapClient(config, this.transportFactory);
|
|
550
|
-
} catch (e) {
|
|
551
|
-
releaseHostSlot();
|
|
552
|
-
throw e;
|
|
553
|
-
}
|
|
554
|
-
let open = this.openClients.get(accountId);
|
|
555
|
-
if (!open) { open = new Set(); this.openClients.set(accountId, open); }
|
|
556
|
-
open.add(client);
|
|
557
|
-
console.log(` [conn+] ${accountId} (${purpose}) — ${open.size} open`);
|
|
558
|
-
let closed = false;
|
|
559
|
-
const markClosed = (how: string) => {
|
|
560
|
-
if (closed) return;
|
|
561
|
-
closed = true;
|
|
562
|
-
open!.delete(client);
|
|
563
|
-
releaseHostSlot();
|
|
564
|
-
console.log(` [conn-] ${accountId} (${purpose}/${how}) — ${open!.size} open`);
|
|
565
|
-
};
|
|
566
|
-
const origLogout = client.logout?.bind(client);
|
|
567
|
-
if (origLogout) {
|
|
568
|
-
client.logout = async () => {
|
|
569
|
-
try { await origLogout(); }
|
|
570
|
-
finally { markClosed("logout"); }
|
|
571
|
-
};
|
|
572
|
-
}
|
|
573
|
-
const origDestroy = client.destroy?.bind(client);
|
|
574
|
-
if (origDestroy) {
|
|
575
|
-
client.destroy = () => {
|
|
576
|
-
try { origDestroy(); }
|
|
577
|
-
finally { markClosed("destroy"); }
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
return client;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/** Force-close every IMAP socket for an account — ops + any lingering
|
|
584
|
-
* ones in openClients (e.g. an IDLE watcher in flight). Used during
|
|
585
|
-
* account removal and disconnectOps so the server's connection slots
|
|
586
|
-
* free immediately rather than waiting for socket idle timeouts. */
|
|
587
|
-
async closeAllClients(accountId: string): Promise<void> {
|
|
588
|
-
const ops = this.opsClients.get(accountId);
|
|
589
|
-
this.opsClients.delete(accountId);
|
|
590
|
-
if (ops) { try { await (ops._realLogout || ops.logout)(); } catch { /* */ } try { ops.destroy?.(); } catch { /* */ } }
|
|
591
|
-
const open = this.openClients.get(accountId);
|
|
592
|
-
if (open) {
|
|
593
|
-
for (const c of Array.from(open)) {
|
|
594
|
-
try { await (c._realLogout || c.logout)?.(); } catch { /* */ }
|
|
595
|
-
try { c.destroy?.(); } catch { /* */ }
|
|
596
|
-
}
|
|
597
|
-
open.clear();
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/** Disconnect the persistent operational connection for an account */
|
|
602
|
-
async disconnectOps(accountId: string): Promise<void> {
|
|
603
|
-
const client = this.opsClients.get(accountId);
|
|
604
|
-
this.opsClients.delete(accountId);
|
|
605
|
-
if (client) {
|
|
606
|
-
// Force-close: don't wait for LOGOUT on a possibly dead socket
|
|
607
|
-
try {
|
|
608
|
-
const timeout = new Promise(r => setTimeout(r, 2000));
|
|
609
|
-
await Promise.race([(client._realLogout || client.logout)(), timeout]);
|
|
610
|
-
} catch { /* */ }
|
|
611
|
-
// Destroy underlying socket if still open
|
|
612
|
-
try { client.destroy?.(); } catch { /* */ }
|
|
613
|
-
console.log(` [conn] ${accountId}: disconnected`);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/** Legacy entry: returns the shared persistent ops client. Most callers
|
|
618
|
-
* should be using `withConnection()` instead — that gives proper
|
|
619
|
-
* queueing and lets fast operations jump ahead of slow ones. */
|
|
620
|
-
async createClientWithLimit(accountId: string): Promise<any> {
|
|
621
|
-
return this.getOpsClient(accountId);
|
|
622
|
-
}
|
|
623
|
-
/** Disposable fresh client — only used by the IDLE watcher, which holds
|
|
624
|
-
* its own socket so the fast/slow ops queue isn't blocked by IDLE
|
|
625
|
-
* parking the connection in a wait-for-server state. */
|
|
626
|
-
private async createClient(accountId: string, purpose = "misc"): Promise<any> {
|
|
627
|
-
return this.newClient(accountId, purpose);
|
|
628
|
-
}
|
|
629
|
-
private trackLogout(_accountId: string): void { /* no-op — connection stays alive */ }
|
|
630
|
-
|
|
631
|
-
/** Number of registered IMAP accounts */
|
|
632
|
-
getAccountCount(): number { return this.configs.size; }
|
|
633
|
-
|
|
634
|
-
/** Register an account */
|
|
635
|
-
async addAccount(account: AccountConfig): Promise<void> {
|
|
636
|
-
if (this.configs.has(account.id)) return;
|
|
637
|
-
|
|
638
|
-
// createAutoImapConfig auto-detects Gmail from server/username and sets up OAuth
|
|
639
|
-
// For OAuth accounts, provide a tokenProvider using oauthsupport
|
|
640
|
-
let tokenProvider: (() => Promise<string>) | undefined;
|
|
641
|
-
if (account.imap.auth === "oauth2" || (!account.imap.password && account.imap.host?.includes("gmail"))) {
|
|
642
|
-
// Find Google OAuth credentials — check ~/.mailx first, then iflow-direct package
|
|
643
|
-
let credPath = path.join(getConfigDir(), "google-credentials.json");
|
|
644
|
-
if (!fs.existsSync(credPath)) {
|
|
645
|
-
try {
|
|
646
|
-
// Use fileURLToPath, NOT string-replace on "file://" — on Linux,
|
|
647
|
-
// file:///usr/local/... loses its leading slash via .replace("file:///",
|
|
648
|
-
// "") and becomes relative, so fs.existsSync silently fails.
|
|
649
|
-
const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
|
|
650
|
-
for (const name of ["iflow-credentials.json"]) {
|
|
651
|
-
const p = path.join(pkgDir, name);
|
|
652
|
-
if (fs.existsSync(p)) { credPath = p; break; }
|
|
653
|
-
}
|
|
654
|
-
} catch { /* iflow-direct not resolvable */ }
|
|
655
|
-
}
|
|
656
|
-
const tokenDir = path.join(getConfigDir(), "tokens", account.imap.user.replace(/[@.]/g, "_"));
|
|
657
|
-
tokenProvider = async () => {
|
|
658
|
-
// Wall-clock timeout on OAuth. Use a longer timeout when no
|
|
659
|
-
// cached token exists (first auth needs user to click through
|
|
660
|
-
// the browser consent screen — 30s is too tight).
|
|
661
|
-
const hasToken = fs.existsSync(path.join(tokenDir, "oauth-token.json"));
|
|
662
|
-
const TOKEN_FETCH_TIMEOUT_MS = hasToken ? 30_000 : 120_000;
|
|
663
|
-
const authPromise = authenticateOAuth(credPath, {
|
|
664
|
-
// Scope set covers two-way sync of all mailx-managed local
|
|
665
|
-
// stores: mail (mail.google.com), contacts (full, not
|
|
666
|
-
// readonly — we write edits back), calendar (full), tasks
|
|
667
|
-
// (full), drive (for shared accounts.jsonc).
|
|
668
|
-
scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/drive",
|
|
669
|
-
tokenDirectory: tokenDir,
|
|
670
|
-
credentialsKey: "installed",
|
|
671
|
-
loginHint: account.imap.user,
|
|
672
|
-
});
|
|
673
|
-
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
674
|
-
setTimeout(() => reject(new Error(`OAuth token fetch timeout (${TOKEN_FETCH_TIMEOUT_MS / 1000}s)`)), TOKEN_FETCH_TIMEOUT_MS)
|
|
675
|
-
);
|
|
676
|
-
const result = await Promise.race([authPromise, timeoutPromise]);
|
|
677
|
-
return result?.access_token || "";
|
|
678
|
-
};
|
|
679
|
-
}
|
|
680
|
-
// Non-Gmail accounts (typically Dovecot / generic IMAP) get a smaller
|
|
681
|
-
// fetch chunk size (10 vs 25) and longer inactivity timeout (300s) so
|
|
682
|
-
// multi-body FETCH batches don't trip the connection-dead detector on
|
|
683
|
-
// slow servers. Gmail stays at the defaults since it's fast and has
|
|
684
|
-
// its own rate limits to respect.
|
|
685
|
-
const isGmail = account.imap.host?.includes("gmail") || account.email.endsWith("@gmail.com");
|
|
686
|
-
const config = createAutoImapConfig({
|
|
687
|
-
server: account.imap.host,
|
|
688
|
-
port: account.imap.port,
|
|
689
|
-
username: account.imap.user,
|
|
690
|
-
password: account.imap.password,
|
|
691
|
-
tokenProvider,
|
|
692
|
-
inactivityTimeout: isGmail ? 60000 : 300000,
|
|
693
|
-
fetchChunkSize: isGmail ? 25 : 10,
|
|
694
|
-
fetchChunkSizeMax: isGmail ? 500 : 100,
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
this.configs.set(account.id, config);
|
|
698
|
-
|
|
699
|
-
// Register account in DB
|
|
700
|
-
this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
701
|
-
|
|
702
|
-
// Pre-validate OAuth token (so browser consent happens now, not during a timed sync)
|
|
703
|
-
if (config.tokenProvider) {
|
|
704
|
-
try {
|
|
705
|
-
await config.tokenProvider();
|
|
706
|
-
console.log(` [auth] ${account.id}: token valid`);
|
|
707
|
-
} catch (e: any) {
|
|
708
|
-
const errMsg = imapError(e);
|
|
709
|
-
console.error(` [auth] ${account.id}: ${errMsg}`);
|
|
710
|
-
const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH/i.test(errMsg);
|
|
711
|
-
if (isTransient) {
|
|
712
|
-
console.log(` [transient] ${account.id}: ${errMsg} — will retry on first sync`);
|
|
713
|
-
} else if (!this.accountErrorShown.has(account.id)) {
|
|
714
|
-
this.accountErrorShown.add(account.id);
|
|
715
|
-
const config = this.configs.get(account.id);
|
|
716
|
-
this.emit("accountError", account.id, errMsg, errMsg, !!config?.tokenProvider);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/** Check if an account uses Gmail (should use API instead of IMAP) */
|
|
723
|
-
private isGmailAccount(accountId: string): boolean {
|
|
724
|
-
const settings = loadSettings();
|
|
725
|
-
const account = settings.accounts.find(a => a.id === accountId);
|
|
726
|
-
if (!account) return false;
|
|
727
|
-
return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/** Get a Gmail API provider for an account (reuses tokenProvider from IMAP config) */
|
|
731
|
-
private getGmailProvider(accountId: string): GmailApiProvider {
|
|
732
|
-
const config = this.configs.get(accountId);
|
|
733
|
-
if (!config?.tokenProvider) throw new Error(`No tokenProvider for ${accountId}`);
|
|
734
|
-
return new GmailApiProvider(config.tokenProvider);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** Convert ProviderMessage to the shape expected by storeMessages/upsertMessage */
|
|
738
|
-
private providerMsgToLocal(msg: ProviderMessage): any {
|
|
739
|
-
return {
|
|
740
|
-
uid: msg.uid,
|
|
741
|
-
messageId: msg.messageId,
|
|
742
|
-
date: msg.date || new Date(),
|
|
743
|
-
subject: msg.subject,
|
|
744
|
-
from: msg.from,
|
|
745
|
-
to: msg.to,
|
|
746
|
-
cc: msg.cc,
|
|
747
|
-
seen: msg.seen,
|
|
748
|
-
flagged: msg.flagged,
|
|
749
|
-
answered: msg.answered,
|
|
750
|
-
draft: msg.draft,
|
|
751
|
-
size: msg.size,
|
|
752
|
-
source: msg.source,
|
|
753
|
-
providerId: msg.providerId,
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
/** Sync folder list for an account */
|
|
758
|
-
async syncFolders(accountId: string, client?: any): Promise<Folder[]> {
|
|
759
|
-
if (!client) client = await this.getOpsClient(accountId);
|
|
760
|
-
|
|
761
|
-
this.emit("syncProgress", accountId, "folders", 0);
|
|
762
|
-
|
|
763
|
-
const t0 = Date.now();
|
|
764
|
-
console.log(` [diag] ${accountId}: getFolderList starting...`);
|
|
765
|
-
const folders = await client.getFolderList();
|
|
766
|
-
console.log(` [diag] ${accountId}: getFolderList done in ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
767
|
-
const specialFolders = client.getSpecialFolders(folders);
|
|
768
|
-
|
|
769
|
-
// Collect server paths so we can prune anything the server no longer
|
|
770
|
-
// has (user-renamed / -deleted / case-flipped a folder from another
|
|
771
|
-
// client). IMAP paths are case-sensitive, so "Foo" → "foo" is a real
|
|
772
|
-
// delete+create of two distinct mailboxes.
|
|
773
|
-
const serverPaths = new Set<string>();
|
|
774
|
-
|
|
775
|
-
for (const folder of folders) {
|
|
776
|
-
// Skip non-selectable folders (virtual parents like "Added", "Added2")
|
|
777
|
-
const flags = (folder as any).flags as Set<string> | string[] | undefined;
|
|
778
|
-
const flagArr = flags instanceof Set ? [...flags] : (flags || []);
|
|
779
|
-
if (flagArr.some((f: string) => f.toLowerCase() === "\\noselect" || f.toLowerCase() === "\\nonexistent")) continue;
|
|
780
|
-
|
|
781
|
-
let specialUse: string = null as any;
|
|
782
|
-
if (specialFolders.inbox === folder.path) specialUse = "inbox";
|
|
783
|
-
else if (specialFolders.sent === folder.path) specialUse = "sent";
|
|
784
|
-
else if (specialFolders.trash === folder.path) specialUse = "trash";
|
|
785
|
-
else if (specialFolders.drafts === folder.path) specialUse = "drafts";
|
|
786
|
-
else if (specialFolders.spam === folder.path || specialFolders.junk === folder.path) specialUse = "junk";
|
|
787
|
-
else if (specialFolders.archive === folder.path) specialUse = "archive";
|
|
788
|
-
|
|
789
|
-
this.db.upsertFolder(
|
|
790
|
-
accountId,
|
|
791
|
-
folder.path,
|
|
792
|
-
folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path,
|
|
793
|
-
specialUse,
|
|
794
|
-
folder.delimiter || "/"
|
|
795
|
-
);
|
|
796
|
-
serverPaths.add(folder.path);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Prune: any local folder whose exact path (case-sensitive) isn't in
|
|
800
|
-
// the server's list has been deleted or renamed server-side. Safety
|
|
801
|
-
// rails: only prune when the server returned a non-empty list (empty
|
|
802
|
-
// result is more likely a transient protocol / auth error than "all
|
|
803
|
-
// your folders were deleted"). Never prune INBOX under any
|
|
804
|
-
// circumstances — even a broken server response shouldn't make us
|
|
805
|
-
// drop the account's primary mailbox. All other special-use folders
|
|
806
|
-
// ARE prunable: if the user actually deleted Sent on the server,
|
|
807
|
-
// we should reflect that locally, and the next sync will re-detect
|
|
808
|
-
// the server's real Sent folder and re-upsert.
|
|
809
|
-
if (folders.length > 0) {
|
|
810
|
-
const localFolders = this.db.getFolders(accountId);
|
|
811
|
-
const stale = localFolders.filter(f =>
|
|
812
|
-
!serverPaths.has(f.path) &&
|
|
813
|
-
f.specialUse !== "inbox"
|
|
814
|
-
);
|
|
815
|
-
for (const f of stale) {
|
|
816
|
-
console.log(` [sync] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — no longer on server`);
|
|
817
|
-
try { this.db.deleteFolder(f.id); }
|
|
818
|
-
catch (e: any) { console.error(` [sync] ${accountId}: prune failed for "${f.path}": ${e.message}`); }
|
|
819
|
-
}
|
|
820
|
-
if (stale.length > 0) {
|
|
821
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
this.emit("syncProgress", accountId, "folders", 100);
|
|
826
|
-
// Notify UI that folder structure changed — triggers tree re-render
|
|
827
|
-
const dbFolders = this.db.getFolders(accountId);
|
|
828
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
829
|
-
return dbFolders;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
/** Store a batch of messages to DB immediately — used by onChunk for incremental sync */
|
|
833
|
-
private async storeMessages(accountId: string, folderId: number, folder: any, msgs: any[], highestUid: number): Promise<number> {
|
|
834
|
-
let stored = 0;
|
|
835
|
-
this.db.beginTransaction();
|
|
836
|
-
try {
|
|
837
|
-
for (const msg of msgs) {
|
|
838
|
-
// Debug: log subjects with non-ASCII to trace encoding issues
|
|
839
|
-
if (msg.subject && /[^\x00-\x7F]/.test(msg.subject)) {
|
|
840
|
-
const hex = Buffer.from(msg.subject, "utf-8").subarray(0, 40).toString("hex");
|
|
841
|
-
console.log(` [encoding] subject: "${msg.subject.substring(0, 60)}" hex: ${hex}`);
|
|
842
|
-
}
|
|
843
|
-
if (msg.uid <= highestUid) continue; // already have it
|
|
844
|
-
// Tombstone check: if the user locally deleted this Message-ID,
|
|
845
|
-
// don't re-import it. Server-side EXPUNGE may lag, or reconcile
|
|
846
|
-
// may find the message in an old list snapshot. Without this,
|
|
847
|
-
// deleted messages reappear on the next sync pass.
|
|
848
|
-
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
849
|
-
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
const source = msg.source || "";
|
|
853
|
-
let bodyPath = "";
|
|
854
|
-
let preview = "";
|
|
855
|
-
let hasAttachments = false;
|
|
856
|
-
if (source) {
|
|
857
|
-
bodyPath = await this.bodyStore.putMessage(
|
|
858
|
-
accountId, folderId, msg.uid,
|
|
859
|
-
Buffer.from(source, "utf-8")
|
|
860
|
-
);
|
|
861
|
-
const parsed = await extractPreview(source);
|
|
862
|
-
preview = parsed.preview;
|
|
863
|
-
hasAttachments = parsed.hasAttachments;
|
|
864
|
-
}
|
|
865
|
-
const flags: string[] = [];
|
|
866
|
-
if (msg.seen) flags.push("\\Seen");
|
|
867
|
-
if (msg.flagged) flags.push("\\Flagged");
|
|
868
|
-
if (msg.answered) flags.push("\\Answered");
|
|
869
|
-
if (msg.draft) flags.push("\\Draft");
|
|
870
|
-
this.db.upsertMessage({
|
|
871
|
-
accountId, folderId, uid: msg.uid,
|
|
872
|
-
messageId: msg.messageId || "",
|
|
873
|
-
inReplyTo: (msg as any).inReplyTo || "",
|
|
874
|
-
references: [],
|
|
875
|
-
date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
|
|
876
|
-
subject: msg.subject || "",
|
|
877
|
-
from: toEmailAddress(msg.from?.[0] || {}),
|
|
878
|
-
to: toEmailAddresses(msg.to || []),
|
|
879
|
-
cc: toEmailAddresses(msg.cc || []),
|
|
880
|
-
flags, size: msg.size || 0, hasAttachments, preview, bodyPath
|
|
881
|
-
});
|
|
882
|
-
stored++;
|
|
883
|
-
}
|
|
884
|
-
this.db.commitTransaction();
|
|
885
|
-
} catch (e: any) {
|
|
886
|
-
this.db.rollbackTransaction();
|
|
887
|
-
console.error(` storeMessages error: ${e.message}`);
|
|
888
|
-
}
|
|
889
|
-
return stored;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
/** Sync messages for a specific folder */
|
|
893
|
-
async syncFolder(accountId: string, folderId: number, client?: any): Promise<number> {
|
|
894
|
-
if (!client) client = await this.getOpsClient(accountId);
|
|
895
|
-
const prefetch = getPrefetch();
|
|
896
|
-
|
|
897
|
-
const folders = this.db.getFolders(accountId);
|
|
898
|
-
const folder = folders.find(f => f.id === folderId);
|
|
899
|
-
if (!folder) throw new Error(`Folder ${folderId} not found`);
|
|
900
|
-
|
|
901
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
|
|
902
|
-
|
|
903
|
-
// Get the highest UID we already have for this folder
|
|
904
|
-
const highestUid = this.db.getHighestUid(accountId, folderId);
|
|
905
|
-
console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
|
|
906
|
-
|
|
907
|
-
let messages: any[];
|
|
908
|
-
const firstSync = highestUid === 0;
|
|
909
|
-
const historyDays = getHistoryDays(accountId);
|
|
910
|
-
// historyDays=0 means "all". On first sync we still cap at 30 days
|
|
911
|
-
// so the UI isn't empty for minutes while SEARCH SINCE 1970 runs
|
|
912
|
-
// through a years-old mailbox. Once we have any local messages, the
|
|
913
|
-
// backfill below extends the window in 90-day chunks per sync cycle.
|
|
914
|
-
let effectiveDays = historyDays;
|
|
915
|
-
if (effectiveDays === 0 && firstSync) effectiveDays = 30;
|
|
916
|
-
const startDate = effectiveDays > 0
|
|
917
|
-
? new Date(Date.now() - effectiveDays * 86400000)
|
|
918
|
-
: new Date(0);
|
|
919
|
-
|
|
920
|
-
if (highestUid > 0) {
|
|
921
|
-
// Incremental: fetch new messages — prefetch bodies for offline access
|
|
922
|
-
const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
|
|
923
|
-
// Filter out the last known message (IMAP * always returns at least one)
|
|
924
|
-
messages = fetched.filter((m: any) => m.uid > highestUid);
|
|
925
|
-
|
|
926
|
-
// Gap detection: check for missing UIDs within the range we've already synced
|
|
927
|
-
// Only reconcile between our lowest and highest UID — don't try to fetch the entire folder history
|
|
928
|
-
const existingUids = this.db.getUidsForFolder(accountId, folderId);
|
|
929
|
-
if (existingUids.length > 0) {
|
|
930
|
-
try {
|
|
931
|
-
const lowestUid = Math.min(...existingUids);
|
|
932
|
-
// Fetch UIDs in our known range from IMAP
|
|
933
|
-
const rangeUids = await (client as any).getUids(folder.path);
|
|
934
|
-
const rangeInScope = rangeUids.filter((uid: number) => uid >= lowestUid && uid <= highestUid);
|
|
935
|
-
const existingSet = new Set(existingUids);
|
|
936
|
-
const newSet = new Set(messages.map(m => m.uid));
|
|
937
|
-
const missingUids = rangeInScope.filter((uid: number) => !existingSet.has(uid) && !newSet.has(uid));
|
|
938
|
-
if (missingUids.length > 0 && missingUids.length <= 5000) {
|
|
939
|
-
console.log(` ${folder.path}: gap detected — ${missingUids.length} missing UIDs in range ${lowestUid}..${highestUid}`);
|
|
940
|
-
const chunkSize = 500;
|
|
941
|
-
let recoveredTotal = 0;
|
|
942
|
-
for (let i = 0; i < missingUids.length; i += chunkSize) {
|
|
943
|
-
const chunk = missingUids.slice(i, i + chunkSize);
|
|
944
|
-
const range = chunk.join(",");
|
|
945
|
-
const recovered = await (client as any).fetchMessages(folder.path, range, { source: false });
|
|
946
|
-
messages.push(...recovered);
|
|
947
|
-
recoveredTotal += recovered.length;
|
|
948
|
-
console.log(` ${folder.path}: gap-fill ${recoveredTotal}/${missingUids.length}`);
|
|
949
|
-
}
|
|
950
|
-
} else if (missingUids.length > 5000) {
|
|
951
|
-
console.log(` ${folder.path}: ${missingUids.length} missing UIDs — too many, skipping reconciliation (delete DB to force full re-sync)`);
|
|
952
|
-
}
|
|
953
|
-
} catch (e: any) {
|
|
954
|
-
console.error(` ${folder.path}: gap detection failed: ${e.message}`);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
// Backfill: if the history window reaches further back than our
|
|
959
|
-
// oldest local message, fetch the gap. Chunk 90 days per sync
|
|
960
|
-
// cycle so historyDays=0 catches up incrementally instead of
|
|
961
|
-
// asking Dovecot for SEARCH SINCE 1970 in one go.
|
|
962
|
-
const oldestDate = this.db.getOldestDate(accountId, folderId);
|
|
963
|
-
if (oldestDate > 0 && startDate.getTime() < oldestDate) {
|
|
964
|
-
try {
|
|
965
|
-
const CHUNK_MS = 90 * 86400000;
|
|
966
|
-
const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
|
|
967
|
-
const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
|
|
968
|
-
const backfill = await client.fetchMessageByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
|
|
969
|
-
const newBackfill = backfill.filter((m: any) => !existingUids.has(m.uid));
|
|
970
|
-
if (newBackfill.length > 0) {
|
|
971
|
-
console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0,10)} → ${new Date(oldestDate).toISOString().slice(0,10)})`);
|
|
972
|
-
messages.push(...newBackfill);
|
|
973
|
-
}
|
|
974
|
-
} catch (e: any) {
|
|
975
|
-
console.error(` ${folder.path}: backfill failed: ${e.message}`);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
} else {
|
|
979
|
-
// First sync: fetch in chunks, store each chunk immediately for instant UI
|
|
980
|
-
let totalStored = 0;
|
|
981
|
-
const onChunk = async (chunk: any[]) => {
|
|
982
|
-
const stored = await this.storeMessages(accountId, folderId, folder, chunk, highestUid);
|
|
983
|
-
totalStored += stored;
|
|
984
|
-
if (stored > 0) {
|
|
985
|
-
this.db.recalcFolderCounts(folderId);
|
|
986
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
987
|
-
}
|
|
988
|
-
};
|
|
989
|
-
const tomorrow = new Date(Date.now() + 86400000); // IMAP BEFORE is exclusive
|
|
990
|
-
// First sync: metadata only for fast UI — bodies prefetched in background after
|
|
991
|
-
messages = await (client as any).fetchMessageByDate(folder.path, startDate, tomorrow, { source: false }, onChunk);
|
|
992
|
-
if (totalStored > 0) {
|
|
993
|
-
console.log(` ${folder.path}: ${totalStored} messages (streamed)`);
|
|
994
|
-
this.db.recalcFolderCounts(folderId);
|
|
995
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
996
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
997
|
-
return totalStored;
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
if (messages.length > 0) console.log(` ${folder.path}: ${messages.length} new messages`);
|
|
1001
|
-
|
|
1002
|
-
let newCount = 0;
|
|
1003
|
-
const batchSize = 50;
|
|
1004
|
-
|
|
1005
|
-
for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
|
|
1006
|
-
const batchEnd = Math.min(batchStart + batchSize, messages.length);
|
|
1007
|
-
this.db.beginTransaction();
|
|
1008
|
-
try {
|
|
1009
|
-
for (let i = batchStart; i < batchEnd; i++) {
|
|
1010
|
-
const msg = messages[i];
|
|
1011
|
-
|
|
1012
|
-
// Skip if we already have this UID
|
|
1013
|
-
if (msg.uid <= highestUid) {
|
|
1014
|
-
// But update flags in case they changed
|
|
1015
|
-
const flags: string[] = [];
|
|
1016
|
-
if (msg.seen) flags.push("\\Seen");
|
|
1017
|
-
if (msg.flagged) flags.push("\\Flagged");
|
|
1018
|
-
if (msg.answered) flags.push("\\Answered");
|
|
1019
|
-
if (msg.draft) flags.push("\\Draft");
|
|
1020
|
-
this.db.updateMessageFlags(accountId, msg.uid, flags);
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
// Tombstone check — same reason as the streamy onChunk path
|
|
1025
|
-
// at storeMessages: a locally-deleted message that the server
|
|
1026
|
-
// hasn't EXPUNGEd yet would otherwise reappear on next sync.
|
|
1027
|
-
// User-visible symptom: "I deleted it but it came back."
|
|
1028
|
-
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1029
|
-
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
|
|
1030
|
-
continue;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// Store body
|
|
1034
|
-
const source = msg.source || "";
|
|
1035
|
-
let bodyPath = "";
|
|
1036
|
-
if (source) {
|
|
1037
|
-
bodyPath = await this.bodyStore.putMessage(
|
|
1038
|
-
accountId, folderId, msg.uid,
|
|
1039
|
-
Buffer.from(source, "utf-8")
|
|
1040
|
-
);
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// Parse for preview and attachment info
|
|
1044
|
-
const parsed = await extractPreview(source);
|
|
1045
|
-
|
|
1046
|
-
// Build flags array
|
|
1047
|
-
const flags: string[] = [];
|
|
1048
|
-
if (msg.seen) flags.push("\\Seen");
|
|
1049
|
-
if (msg.flagged) flags.push("\\Flagged");
|
|
1050
|
-
if (msg.answered) flags.push("\\Answered");
|
|
1051
|
-
if (msg.draft) flags.push("\\Draft");
|
|
1052
|
-
|
|
1053
|
-
// Store metadata
|
|
1054
|
-
this.db.upsertMessage({
|
|
1055
|
-
accountId,
|
|
1056
|
-
folderId,
|
|
1057
|
-
uid: msg.uid,
|
|
1058
|
-
messageId: msg.messageId || "",
|
|
1059
|
-
inReplyTo: (msg as any).inReplyTo || "",
|
|
1060
|
-
references: [],
|
|
1061
|
-
date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
|
|
1062
|
-
subject: msg.subject || "",
|
|
1063
|
-
from: toEmailAddress(msg.from?.[0] || {}),
|
|
1064
|
-
to: toEmailAddresses(msg.to || []),
|
|
1065
|
-
cc: toEmailAddresses(msg.cc || []),
|
|
1066
|
-
flags,
|
|
1067
|
-
size: msg.size || 0,
|
|
1068
|
-
hasAttachments: parsed.hasAttachments,
|
|
1069
|
-
preview: parsed.preview,
|
|
1070
|
-
bodyPath
|
|
1071
|
-
});
|
|
1072
|
-
|
|
1073
|
-
newCount++;
|
|
1074
|
-
}
|
|
1075
|
-
this.db.commitTransaction();
|
|
1076
|
-
} catch (e: any) {
|
|
1077
|
-
console.error(` transaction error: ${e.message}`);
|
|
1078
|
-
this.db.rollbackTransaction();
|
|
1079
|
-
throw e;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// Emit progress and notify client after each batch
|
|
1083
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`,
|
|
1084
|
-
Math.round((batchEnd / messages.length) * 100));
|
|
1085
|
-
|
|
1086
|
-
// On first sync, emit folderCountsChanged per batch so newest messages appear immediately
|
|
1087
|
-
if (firstSync && newCount > 0) {
|
|
1088
|
-
this.db.recalcFolderCounts(folderId);
|
|
1089
|
-
const folderInfo = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
1090
|
-
this.emit("folderCountsChanged", accountId, {
|
|
1091
|
-
[folderId]: { total: folderInfo?.totalCount || 0, unread: folderInfo?.unreadCount || 0 }
|
|
1092
|
-
});
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
if (newCount > 0) console.log(` stored ${newCount} new messages`);
|
|
1096
|
-
|
|
1097
|
-
// Remove messages deleted on the server (skip on first sync — nothing to reconcile).
|
|
1098
|
-
//
|
|
1099
|
-
// SAFETY (same three guards the Gmail API path uses, see ~line 1388):
|
|
1100
|
-
// 1. Skip if server returned an empty list but we have local messages
|
|
1101
|
-
// (transient Dovecot error / connection hiccup returning empty UID SEARCH
|
|
1102
|
-
// must not wipe the folder).
|
|
1103
|
-
// 2. Refuse to delete more than 50% in one pass — indicates a sync bug,
|
|
1104
|
-
// never a real user action. User can fix with `mailx -rebuild` if real.
|
|
1105
|
-
// 3. Log every deletion with Message-ID + subject so future reports have
|
|
1106
|
-
// data (the "ubiquiti letter disappeared after reply" case had no trace).
|
|
1107
|
-
let deletedCount = 0;
|
|
1108
|
-
if (!firstSync) {
|
|
1109
|
-
try {
|
|
1110
|
-
const serverUidsArr = await client.getUids(folder.path);
|
|
1111
|
-
const serverUids = new Set(serverUidsArr);
|
|
1112
|
-
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
1113
|
-
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
1114
|
-
if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
1115
|
-
console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUids.length} (treating as transient)`);
|
|
1116
|
-
} else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
|
|
1117
|
-
console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
|
|
1118
|
-
} else {
|
|
1119
|
-
for (const uid of toDelete) {
|
|
1120
|
-
const env = this.db.getMessageByUid(accountId, uid);
|
|
1121
|
-
const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
|
|
1122
|
-
console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
|
|
1123
|
-
this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
|
|
1124
|
-
this.db.deleteMessage(accountId, uid);
|
|
1125
|
-
deletedCount++;
|
|
1126
|
-
}
|
|
1127
|
-
if (deletedCount > 0) console.log(` removed ${deletedCount} deleted messages`);
|
|
1128
|
-
}
|
|
1129
|
-
} catch (e: any) {
|
|
1130
|
-
console.error(` deletion sync error: ${e.message}`);
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Update folder counts from local DB (after deletions + additions)
|
|
1135
|
-
// Use recalcFolderCounts — single SQL query instead of fetching all messages
|
|
1136
|
-
this.db.recalcFolderCounts(folderId);
|
|
1137
|
-
|
|
1138
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
1139
|
-
|
|
1140
|
-
const syncedAt = Date.now();
|
|
1141
|
-
// Notify client to refresh if anything changed
|
|
1142
|
-
if (newCount > 0 || deletedCount > 0) {
|
|
1143
|
-
const updatedFolder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
1144
|
-
this.emit("folderCountsChanged", accountId, {
|
|
1145
|
-
[folderId]: { total: updatedFolder?.totalCount || 0, unread: updatedFolder?.unreadCount || 0 }
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
this.emit("folderSynced", accountId, folderId, syncedAt);
|
|
1149
|
-
this.db.updateLastSync(accountId, syncedAt);
|
|
1150
|
-
|
|
1151
|
-
return newCount;
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/** Sync all folders for all accounts */
|
|
1155
|
-
async syncAll(): Promise<void> {
|
|
1156
|
-
if (this.syncing) return; // Prevent concurrent syncs
|
|
1157
|
-
this.syncing = true;
|
|
1158
|
-
try {
|
|
1159
|
-
await Promise.race([
|
|
1160
|
-
this._syncAll(),
|
|
1161
|
-
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("Global sync timeout (10min)")), 600000))
|
|
1162
|
-
]);
|
|
1163
|
-
} catch (e: any) {
|
|
1164
|
-
console.error(` syncAll error: ${e.message}`);
|
|
1165
|
-
} finally { this.syncing = false; }
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
private async _syncAll(): Promise<void> {
|
|
1169
|
-
const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
|
|
1170
|
-
|
|
1171
|
-
// Sync all accounts in parallel — each manages its own connection.
|
|
1172
|
-
// Prefetch runs per-account immediately after that account's sync
|
|
1173
|
-
// completes, NOT after all accounts finish. This way a slow account
|
|
1174
|
-
// (bobma with 300s timeouts) doesn't block prefetch for a fast account
|
|
1175
|
-
// (Gmail). The old code put prefetch after `allSettled`, but syncAll
|
|
1176
|
-
// has a 10-minute wall-clock timeout that killed it first — so
|
|
1177
|
-
// prefetch never ran.
|
|
1178
|
-
const syncAndPrefetch = async (accountId: string) => {
|
|
1179
|
-
try {
|
|
1180
|
-
await this.syncAccount(accountId, priorityOrder);
|
|
1181
|
-
} catch {
|
|
1182
|
-
// syncAccount already logs + emits syncError. Don't follow a
|
|
1183
|
-
// failed sync with a body-prefetch storm into the same API:
|
|
1184
|
-
// a 429 on listLabels means Gmail is in cooldown, so firing
|
|
1185
|
-
// prefetch next would just re-trigger the same limit.
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
if (getPrefetch()) {
|
|
1189
|
-
this.prefetchBodies(accountId).catch(e =>
|
|
1190
|
-
console.error(` [prefetch] ${accountId}: ${e.message}`)
|
|
1191
|
-
);
|
|
1192
|
-
}
|
|
1193
|
-
};
|
|
1194
|
-
const syncPromises = [...this.configs.keys()].map(syncAndPrefetch);
|
|
1195
|
-
await Promise.allSettled(syncPromises);
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
/** Sync a single account — manages its own connection lifecycle */
|
|
1199
|
-
private async syncAccount(accountId: string, priorityOrder: string[]): Promise<void> {
|
|
1200
|
-
// Gmail: use REST API instead of IMAP
|
|
1201
|
-
if (this.isGmailAccount(accountId)) {
|
|
1202
|
-
return this.syncAccountViaApi(accountId, priorityOrder);
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
try {
|
|
1206
|
-
// Step 1: Get folder list (fast — <1s typically)
|
|
1207
|
-
let client = await this.getOpsClient(accountId);
|
|
1208
|
-
const t0 = Date.now();
|
|
1209
|
-
const folders = await this.syncFolders(accountId, client);
|
|
1210
|
-
console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
1211
|
-
|
|
1212
|
-
// Step 2: Sync INBOX first — keep retrying on failure (most important folder)
|
|
1213
|
-
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
1214
|
-
if (inbox) {
|
|
1215
|
-
console.log(` [sync] ${accountId}: starting INBOX sync (folder ${inbox.id})`);
|
|
1216
|
-
const maxAttempts = 5;
|
|
1217
|
-
let inboxDone = false;
|
|
1218
|
-
for (let attempt = 1; attempt <= maxAttempts && !inboxDone; attempt++) {
|
|
1219
|
-
try {
|
|
1220
|
-
client = await this.getOpsClient(accountId);
|
|
1221
|
-
if (attempt > 1) console.log(` [sync] ${accountId}: INBOX retry #${attempt}`);
|
|
1222
|
-
await this.syncFolder(accountId, inbox.id, client);
|
|
1223
|
-
console.log(` [sync] ${accountId}: INBOX sync complete`);
|
|
1224
|
-
inboxDone = true;
|
|
1225
|
-
// Kick off prefetch as soon as INBOX is fresh, not
|
|
1226
|
-
// after all 105 folders finish — bobma's full sync
|
|
1227
|
-
// can take 30+ minutes on a wide folder tree, and
|
|
1228
|
-
// INBOX is the only folder the user is staring at.
|
|
1229
|
-
// Uses the body client (separate connection from
|
|
1230
|
-
// ops), so it runs concurrently with the rest of the
|
|
1231
|
-
// folder sync without contending for the same socket.
|
|
1232
|
-
if (getPrefetch()) {
|
|
1233
|
-
this.prefetchBodies(accountId).catch(e =>
|
|
1234
|
-
console.error(` [prefetch] ${accountId}: ${e.message}`)
|
|
1235
|
-
);
|
|
1236
|
-
}
|
|
1237
|
-
} catch (e: any) {
|
|
1238
|
-
console.error(` Inbox sync error for ${accountId} (attempt ${attempt}/${maxAttempts}): ${e.message}`);
|
|
1239
|
-
await this.reconnectOps(accountId);
|
|
1240
|
-
if (attempt < maxAttempts) {
|
|
1241
|
-
const delay = Math.min(attempt * 5000, 15000);
|
|
1242
|
-
console.log(` [sync] ${accountId}: waiting ${delay / 1000}s before INBOX retry`);
|
|
1243
|
-
await new Promise(r => setTimeout(r, delay));
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
if (!inboxDone) {
|
|
1248
|
-
console.error(` [sync] ${accountId}: INBOX failed after ${maxAttempts} attempts — will retry next sync cycle`);
|
|
1249
|
-
this.emit("syncError", accountId, `INBOX sync failed after ${maxAttempts} attempts`);
|
|
1250
|
-
// Even when sync failed, try to prefetch bodies for messages
|
|
1251
|
-
// already in the local DB. Prefetch uses a separate body
|
|
1252
|
-
// client (not the ops client that just timed out), so a
|
|
1253
|
-
// sync timeout on SELECT/SEARCH doesn't necessarily mean
|
|
1254
|
-
// body fetches will also fail. Without this, a server
|
|
1255
|
-
// having a slow patch would leave every message with a
|
|
1256
|
-
// white "not-downloaded" dot indefinitely until sync
|
|
1257
|
-
// recovers — even though prior syncs already populated
|
|
1258
|
-
// headers that prefetch can flesh out independently.
|
|
1259
|
-
if (getPrefetch()) {
|
|
1260
|
-
this.prefetchBodies(accountId).catch(e =>
|
|
1261
|
-
console.error(` [prefetch] ${accountId}: ${e.message}`)
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
} else {
|
|
1266
|
-
console.log(` [sync] ${accountId}: no INBOX folder found`);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
// Step 3: Sync remaining folders.
|
|
1270
|
-
//
|
|
1271
|
-
// Parallel pool (concurrency 2) with a per-folder wall-clock cap.
|
|
1272
|
-
// Previous serial loop meant one slow Dovecot UID FETCH could park
|
|
1273
|
-
// every other folder behind it for minutes — user observed "mailx
|
|
1274
|
-
// says synced but 90 folders are empty" because the loop never
|
|
1275
|
-
// progressed past the stalled FETCH before the next sync tick.
|
|
1276
|
-
//
|
|
1277
|
-
// Parallelism uses independent IMAP sockets from the ops-client
|
|
1278
|
-
// pool, so one stalled socket doesn't block the others. The 60s
|
|
1279
|
-
// timeout abandons a stalled command instead of waiting out
|
|
1280
|
-
// Dovecot's 300s server-side inactivity timer; the next sync tick
|
|
1281
|
-
// retries on a fresh socket.
|
|
1282
|
-
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
1283
|
-
remaining.sort((a, b) => {
|
|
1284
|
-
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
1285
|
-
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
1286
|
-
return pa - pb;
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
|
-
const CONCURRENCY = 2;
|
|
1290
|
-
const PER_FOLDER_TIMEOUT_MS = 60_000;
|
|
1291
|
-
const total = remaining.length;
|
|
1292
|
-
let done = 0;
|
|
1293
|
-
let idx = 0;
|
|
1294
|
-
|
|
1295
|
-
const syncOne = async (folder: any): Promise<void> => {
|
|
1296
|
-
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
1297
|
-
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
1298
|
-
if (isTrashChild && highestUid === 0) return;
|
|
1299
|
-
try {
|
|
1300
|
-
const fresh = await this.getOpsClient(accountId);
|
|
1301
|
-
await Promise.race([
|
|
1302
|
-
this.syncFolder(accountId, folder.id, fresh),
|
|
1303
|
-
new Promise((_, reject) => setTimeout(
|
|
1304
|
-
() => reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}`)),
|
|
1305
|
-
PER_FOLDER_TIMEOUT_MS
|
|
1306
|
-
)),
|
|
1307
|
-
]);
|
|
1308
|
-
} catch (e: any) {
|
|
1309
|
-
if (e.responseText?.includes("doesn't exist")) {
|
|
1310
|
-
this.db.deleteFolder(folder.id);
|
|
1311
|
-
} else {
|
|
1312
|
-
console.error(` Skipping ${folder.path}: ${e.message}`);
|
|
1313
|
-
this.recordError(accountId, e.message || String(e));
|
|
1314
|
-
// A timeout or stale-socket failure — drop the ops
|
|
1315
|
-
// client so the next iteration reconnects rather than
|
|
1316
|
-
// inheriting the doomed socket.
|
|
1317
|
-
await this.reconnectOps(accountId).catch(() => {});
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
};
|
|
1321
|
-
|
|
1322
|
-
const worker = async (): Promise<void> => {
|
|
1323
|
-
while (true) {
|
|
1324
|
-
const myIdx = idx++;
|
|
1325
|
-
if (myIdx >= remaining.length) return;
|
|
1326
|
-
const folder = remaining[myIdx];
|
|
1327
|
-
this.emit("syncProgress", accountId, `folders:${folder.path}`, Math.round((done / Math.max(total, 1)) * 100));
|
|
1328
|
-
await syncOne(folder);
|
|
1329
|
-
done++;
|
|
1330
|
-
this.emit("syncProgress", accountId, `folders-done`, Math.round((done / Math.max(total, 1)) * 100));
|
|
1331
|
-
console.log(` [sync] ${accountId}: folder ${done}/${total} done (${folder.path})`);
|
|
1332
|
-
}
|
|
1333
|
-
};
|
|
1334
|
-
|
|
1335
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, remaining.length) }, () => worker()));
|
|
1336
|
-
|
|
1337
|
-
this.accountErrorShown.delete(accountId);
|
|
1338
|
-
this.emit("syncComplete", accountId);
|
|
1339
|
-
} catch (e: any) {
|
|
1340
|
-
const errMsg = imapError(e);
|
|
1341
|
-
this.emit("syncError", accountId, errMsg);
|
|
1342
|
-
this.recordError(accountId, errMsg);
|
|
1343
|
-
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
1344
|
-
this.handleSyncError(accountId, errMsg);
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
/** Sync a Gmail account via REST API — no IMAP connections */
|
|
1349
|
-
private async syncAccountViaApi(accountId: string, priorityOrder: string[]): Promise<void> {
|
|
1350
|
-
try {
|
|
1351
|
-
const api = this.getGmailProvider(accountId);
|
|
1352
|
-
const t0 = Date.now();
|
|
1353
|
-
|
|
1354
|
-
// Step 1: Sync folder list via API
|
|
1355
|
-
console.log(` [api] ${accountId}: listing labels...`);
|
|
1356
|
-
const apiFolders = await api.listFolders();
|
|
1357
|
-
console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
|
|
1358
|
-
|
|
1359
|
-
// Store folders in DB (same as IMAP path)
|
|
1360
|
-
for (const f of apiFolders) {
|
|
1361
|
-
const specialUse = f.specialUse || "";
|
|
1362
|
-
this.db.upsertFolder(accountId, f.path, f.name, specialUse, f.delimiter);
|
|
1363
|
-
}
|
|
1364
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
1365
|
-
const dbFolders = this.db.getFolders(accountId);
|
|
1366
|
-
|
|
1367
|
-
// Step 2: Sync folders — INBOX first, then by priority
|
|
1368
|
-
const inbox = dbFolders.find(f => f.specialUse === "inbox");
|
|
1369
|
-
const remaining = dbFolders.filter(f => f.specialUse !== "inbox");
|
|
1370
|
-
remaining.sort((a, b) => {
|
|
1371
|
-
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
1372
|
-
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
1373
|
-
return pa - pb;
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
const foldersToSync = inbox ? [inbox, ...remaining] : remaining;
|
|
1377
|
-
|
|
1378
|
-
for (const folder of foldersToSync) {
|
|
1379
|
-
try {
|
|
1380
|
-
await this.syncFolderViaApi(accountId, folder, api);
|
|
1381
|
-
} catch (e: any) {
|
|
1382
|
-
console.error(` [api] ${accountId}/${folder.path}: ${e.message}`);
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
await api.close();
|
|
1387
|
-
this.accountErrorShown.delete(accountId);
|
|
1388
|
-
this.emit("syncComplete", accountId);
|
|
1389
|
-
} catch (e: any) {
|
|
1390
|
-
const errMsg = e.message || String(e);
|
|
1391
|
-
this.emit("syncError", accountId, errMsg);
|
|
1392
|
-
console.error(` [api] Sync error for ${accountId}: ${errMsg}`);
|
|
1393
|
-
this.handleSyncError(accountId, errMsg);
|
|
1394
|
-
// Propagate so the caller skips the prefetch that would otherwise
|
|
1395
|
-
// fire straight into the same 429/cooldown and make it worse.
|
|
1396
|
-
throw e;
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
/** Sync a single folder via Gmail/Outlook API */
|
|
1401
|
-
private async syncFolderViaApi(accountId: string, folder: Folder, api: MailProvider): Promise<void> {
|
|
1402
|
-
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
1403
|
-
const historyDays = getHistoryDays(accountId);
|
|
1404
|
-
const effectiveDays = (historyDays === 0 && highestUid === 0) ? 30 : historyDays;
|
|
1405
|
-
const startDate = effectiveDays > 0 ? new Date(Date.now() - effectiveDays * 86400000) : new Date(0);
|
|
1406
|
-
const tomorrow = new Date(Date.now() + 86400000);
|
|
1407
|
-
|
|
1408
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
|
|
1409
|
-
console.log(` [api] ${accountId}/${folder.path}: syncing (highestUid=${highestUid})...`);
|
|
1410
|
-
|
|
1411
|
-
let messages: ProviderMessage[];
|
|
1412
|
-
if (highestUid > 0) {
|
|
1413
|
-
// Incremental: fetch messages since last known UID.
|
|
1414
|
-
// Gmail "UIDs" are hashed (not chronological), so fetchSince
|
|
1415
|
-
// returns messages in hash order — they can be from ANY date.
|
|
1416
|
-
// Pass the date window so the provider can page the whole range
|
|
1417
|
-
// (otherwise Gmail's default 200-id cap truncates high-volume
|
|
1418
|
-
// inboxes to ~10 days regardless of historyDays).
|
|
1419
|
-
const fetchOpts: any = { source: false };
|
|
1420
|
-
if (effectiveDays > 0) fetchOpts.since = startDate;
|
|
1421
|
-
messages = await api.fetchSince(folder.path, highestUid, fetchOpts);
|
|
1422
|
-
if (effectiveDays > 0) {
|
|
1423
|
-
const cutoff = startDate.getTime();
|
|
1424
|
-
const before = messages.length;
|
|
1425
|
-
messages = messages.filter(m => !m.date || m.date.getTime() >= cutoff);
|
|
1426
|
-
if (messages.length < before) {
|
|
1427
|
-
console.log(` [api] ${accountId}/${folder.path}: filtered ${before - messages.length} messages older than ${effectiveDays}d`);
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
// Backfill: if the history window reaches further back than our
|
|
1432
|
-
// oldest local message, fetch the gap. Mirrors the IMAP path —
|
|
1433
|
-
// otherwise a user who started with historyDays=30 and later
|
|
1434
|
-
// sets it to 0 (or 365) never actually sees older mail. Cap
|
|
1435
|
-
// each sync cycle at 90 days so unlimited-history accounts
|
|
1436
|
-
// catch up incrementally instead of paging the whole mailbox.
|
|
1437
|
-
const oldestDate = this.db.getOldestDate(accountId, folder.id);
|
|
1438
|
-
if (oldestDate > 0 && startDate.getTime() < oldestDate) {
|
|
1439
|
-
try {
|
|
1440
|
-
const CHUNK_MS = 90 * 86400000;
|
|
1441
|
-
const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
|
|
1442
|
-
const existingUids = new Set(this.db.getUidsForFolder(accountId, folder.id));
|
|
1443
|
-
const backfill = await api.fetchByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
|
|
1444
|
-
const newBackfill = backfill.filter(m => !existingUids.has(m.uid));
|
|
1445
|
-
if (newBackfill.length > 0) {
|
|
1446
|
-
console.log(` [api] ${accountId}/${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0,10)} → ${new Date(oldestDate).toISOString().slice(0,10)})`);
|
|
1447
|
-
messages.push(...newBackfill);
|
|
1448
|
-
}
|
|
1449
|
-
} catch (e: any) {
|
|
1450
|
-
console.error(` [api] ${accountId}/${folder.path}: backfill failed: ${e.message}`);
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
} else {
|
|
1454
|
-
// First sync: fetch by date range
|
|
1455
|
-
messages = await api.fetchByDate(folder.path, startDate, tomorrow, { source: false },
|
|
1456
|
-
(chunk) => {
|
|
1457
|
-
// Stream chunks to DB for instant UI
|
|
1458
|
-
const stored = this.storeApiMessages(accountId, folder.id, chunk, highestUid);
|
|
1459
|
-
if (stored > 0) {
|
|
1460
|
-
this.db.recalcFolderCounts(folder.id);
|
|
1461
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
1462
|
-
}
|
|
1463
|
-
});
|
|
1464
|
-
// First sync chunks already stored via onChunk — just update counts
|
|
1465
|
-
this.db.recalcFolderCounts(folder.id);
|
|
1466
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
1467
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
1468
|
-
if (messages.length > 0) console.log(` [api] ${accountId}/${folder.path}: ${messages.length} messages`);
|
|
1469
|
-
return;
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
if (messages.length > 0) {
|
|
1473
|
-
console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
|
|
1474
|
-
this.storeApiMessages(accountId, folder.id, messages, highestUid);
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
// Reconcile deletions — messages present locally but not on the server.
|
|
1478
|
-
// SAFETY: this used to silently wipe entire folders when getUids()
|
|
1479
|
-
// returned a partial list (e.g. paginated fetch hit a rate limit and
|
|
1480
|
-
// bailed). Multiple guards now:
|
|
1481
|
-
// 1. getUids() flags partial results via _truncated — refuse to delete
|
|
1482
|
-
// 2. If server list is empty but local isn't, assume a transient error
|
|
1483
|
-
// 3. If reconcile would delete more than RECONCILE_DELETE_THRESHOLD of
|
|
1484
|
-
// local messages, log and skip — safer to keep phantoms than to lose
|
|
1485
|
-
// real messages. User can fix with `mailx -rebuild` if needed.
|
|
1486
|
-
try {
|
|
1487
|
-
const serverUidsArr = await api.getUids(folder.path);
|
|
1488
|
-
const serverUids = new Set(serverUidsArr);
|
|
1489
|
-
const localUids = this.db.getUidsForFolder(accountId, folder.id);
|
|
1490
|
-
if ((serverUidsArr as any)._truncated) {
|
|
1491
|
-
console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
|
|
1492
|
-
} else if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
1493
|
-
console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
|
|
1494
|
-
} else {
|
|
1495
|
-
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
1496
|
-
const RECONCILE_DELETE_THRESHOLD = 0.5; // refuse to delete >50% in one pass
|
|
1497
|
-
if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
|
|
1498
|
-
console.log(` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
|
|
1499
|
-
} else {
|
|
1500
|
-
for (const uid of toDelete) {
|
|
1501
|
-
const env = this.db.getMessageByUid(accountId, uid);
|
|
1502
|
-
const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
|
|
1503
|
-
console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
|
|
1504
|
-
this.unlinkBodyFile(accountId, uid, folder.id).catch(() => {});
|
|
1505
|
-
this.db.deleteMessage(accountId, uid);
|
|
1506
|
-
}
|
|
1507
|
-
if (toDelete.length > 0) console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
} catch (e: any) {
|
|
1511
|
-
console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
this.db.recalcFolderCounts(folder.id);
|
|
1515
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
1516
|
-
this.emit("folderSynced", accountId, folder.id, Date.now());
|
|
1517
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
/** Store API-fetched messages to DB */
|
|
1521
|
-
private storeApiMessages(accountId: string, folderId: number, msgs: ProviderMessage[], highestUid: number): number {
|
|
1522
|
-
// highestUid kept for signature compatibility but no longer used to
|
|
1523
|
-
// filter — Gmail message IDs aren't monotonic, so `msg.uid <= highestUid`
|
|
1524
|
-
// would drop brand-new messages whose hash happens to be smaller than
|
|
1525
|
-
// the previous high. upsertMessage's primary-key dedup handles it.
|
|
1526
|
-
void highestUid;
|
|
1527
|
-
let stored = 0;
|
|
1528
|
-
let errors = 0;
|
|
1529
|
-
// Don't wrap the whole batch in one transaction: a single bad row
|
|
1530
|
-
// would roll back the entire batch. E.g. a message with a malformed
|
|
1531
|
-
// Date header gave `new Date(rawStr).getTime() === NaN`, SQLite
|
|
1532
|
-
// coerced that to NULL, the NOT NULL constraint failed, and the
|
|
1533
|
-
// whole Gmail sync lost 200 messages per tick. Now each row runs
|
|
1534
|
-
// standalone — bad rows are logged and skipped.
|
|
1535
|
-
for (const msg of msgs) {
|
|
1536
|
-
try {
|
|
1537
|
-
// Tombstone check — Gmail API sync was missing this so a
|
|
1538
|
-
// locally-trashed Gmail message would reappear on the next
|
|
1539
|
-
// listMessages tick if Gmail's eventual-consistency hadn't
|
|
1540
|
-
// promoted the trash yet. Symmetric with the IMAP paths.
|
|
1541
|
-
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
1542
|
-
continue;
|
|
1543
|
-
}
|
|
1544
|
-
const flags: string[] = [];
|
|
1545
|
-
if (msg.seen) flags.push("\\Seen");
|
|
1546
|
-
if (msg.flagged) flags.push("\\Flagged");
|
|
1547
|
-
if (msg.answered) flags.push("\\Answered");
|
|
1548
|
-
if (msg.draft) flags.push("\\Draft");
|
|
1549
|
-
// Sanitize date: reject NaN (from malformed RFC 822 Date headers)
|
|
1550
|
-
// and fall back to "now" so the message still lands in the DB.
|
|
1551
|
-
let dateMs = Date.now();
|
|
1552
|
-
if (msg.date instanceof Date) {
|
|
1553
|
-
const t = msg.date.getTime();
|
|
1554
|
-
if (Number.isFinite(t)) dateMs = t;
|
|
1555
|
-
}
|
|
1556
|
-
this.db.upsertMessage({
|
|
1557
|
-
accountId, folderId, uid: msg.uid,
|
|
1558
|
-
messageId: msg.messageId || "",
|
|
1559
|
-
inReplyTo: msg.inReplyTo || "",
|
|
1560
|
-
references: msg.references || [],
|
|
1561
|
-
date: dateMs,
|
|
1562
|
-
subject: msg.subject || "",
|
|
1563
|
-
from: toEmailAddress(msg.from?.[0] || {}),
|
|
1564
|
-
to: toEmailAddresses(msg.to || []),
|
|
1565
|
-
cc: toEmailAddresses(msg.cc || []),
|
|
1566
|
-
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: "",
|
|
1567
|
-
providerId: msg.providerId || "",
|
|
1568
|
-
});
|
|
1569
|
-
stored++;
|
|
1570
|
-
} catch (e: any) {
|
|
1571
|
-
errors++;
|
|
1572
|
-
if (errors <= 3) {
|
|
1573
|
-
console.error(` [api] upsert ${accountId}/${folderId}/${msg.uid} (${msg.messageId}): ${e.message}`);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
if (errors > 0) console.error(` [api] storeApiMessages: ${errors} of ${msgs.length} rows failed (${stored} stored)`);
|
|
1578
|
-
return stored;
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
/** Kill and recreate the persistent ops connection */
|
|
1582
|
-
private async reconnectOps(accountId: string): Promise<void> {
|
|
1583
|
-
const old = this.opsClients.get(accountId);
|
|
1584
|
-
this.opsClients.delete(accountId);
|
|
1585
|
-
if (old) { try { await (old._realLogout || old.logout)(); } catch { /* */ } }
|
|
1586
|
-
console.log(` [conn] ${accountId}: reconnecting`);
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
/** Handle sync errors — classify and emit appropriate UI events.
|
|
1590
|
-
* The connection-cap branch was removed: with the unified ops queue +
|
|
1591
|
-
* per-host semaphore, mailx alone can't exceed the server cap. If the
|
|
1592
|
-
* cap *is* hit, that means another client (Thunderbird, phone, sibling
|
|
1593
|
-
* process) is holding slots — punishing mailx with a multi-minute
|
|
1594
|
-
* blackout doesn't help the user, the next sync tick will retry. */
|
|
1595
|
-
private handleSyncError(accountId: string, errMsg: string): void {
|
|
1596
|
-
const config = this.configs.get(accountId);
|
|
1597
|
-
const isOAuth = !!config?.tokenProvider;
|
|
1598
|
-
const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
|
|
1599
|
-
const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
|
|
1600
|
-
|
|
1601
|
-
if (isTransient) {
|
|
1602
|
-
console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
|
|
1603
|
-
} else if (isAuth && isOAuth) {
|
|
1604
|
-
const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
|
|
1605
|
-
if (Date.now() - lastReauth > 300000 && !this.reauthenticating.has(accountId)) {
|
|
1606
|
-
this.lastReauthAttempt.set(accountId, Date.now());
|
|
1607
|
-
this.reauthenticate(accountId).catch(() => {});
|
|
1608
|
-
}
|
|
1609
|
-
if (!this.accountErrorShown.has(accountId)) {
|
|
1610
|
-
this.accountErrorShown.add(accountId);
|
|
1611
|
-
this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
|
|
1612
|
-
}
|
|
1613
|
-
} else if (!this.accountErrorShown.has(accountId)) {
|
|
1614
|
-
this.accountErrorShown.add(accountId);
|
|
1615
|
-
this.emit("accountError", accountId, errMsg, errMsg, isOAuth);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
/** Fetch ONLY new messages above highestUid for one account's INBOX —
|
|
1620
|
-
* the IDLE callback's hot path. Skips gap detection, backfill, and the
|
|
1621
|
-
* server reconcile (each of which fetches a full UID list — multi-second
|
|
1622
|
-
* on a large mailbox). The 5-minute STATUS poll path still runs full
|
|
1623
|
-
* `syncFolder` so deletions and gaps eventually reconcile. */
|
|
1624
|
-
async syncInboxNewOnly(accountId: string): Promise<void> {
|
|
1625
|
-
if (this.isGmailAccount(accountId)) return; // IDLE is IMAP-only
|
|
1626
|
-
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
1627
|
-
if (!inbox) return;
|
|
1628
|
-
try {
|
|
1629
|
-
await this.withConnection(accountId, async (client: any) => {
|
|
1630
|
-
const highestUid = this.db.getHighestUid(accountId, inbox.id);
|
|
1631
|
-
if (highestUid === 0) {
|
|
1632
|
-
// First sync — fall through to full path so the date-windowed
|
|
1633
|
-
// backfill runs. `syncFolder` handles the no-highestUid case.
|
|
1634
|
-
await this.syncFolder(accountId, inbox.id, client);
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
const fetched = await client.fetchMessagesSinceUid(inbox.path, highestUid, { source: false });
|
|
1638
|
-
const fresh = fetched.filter((m: any) => m.uid > highestUid);
|
|
1639
|
-
if (fresh.length === 0) return;
|
|
1640
|
-
const stored = await this.storeMessages(accountId, inbox.id, inbox, fresh, highestUid);
|
|
1641
|
-
if (stored > 0) {
|
|
1642
|
-
this.db.recalcFolderCounts(inbox.id);
|
|
1643
|
-
const updated = this.db.getFolders(accountId).find(f => f.id === inbox.id);
|
|
1644
|
-
this.emit("folderCountsChanged", accountId, {
|
|
1645
|
-
[inbox.id]: { total: updated?.totalCount || 0, unread: updated?.unreadCount || 0 }
|
|
1646
|
-
});
|
|
1647
|
-
this.emit("folderSynced", accountId, inbox.id, Date.now());
|
|
1648
|
-
console.log(` [idle-fast] ${accountId}: stored ${stored} new message(s)`);
|
|
1649
|
-
}
|
|
1650
|
-
});
|
|
1651
|
-
} catch (e: any) {
|
|
1652
|
-
console.error(` [idle-fast] ${accountId}: ${e.message}`);
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
/** Sync just INBOX for each account (fast check for new mail) */
|
|
1657
|
-
async syncInbox(): Promise<void> {
|
|
1658
|
-
if (this.inboxSyncing) return;
|
|
1659
|
-
this.inboxSyncing = true;
|
|
1660
|
-
try {
|
|
1661
|
-
for (const [accountId] of this.configs) {
|
|
1662
|
-
try {
|
|
1663
|
-
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
1664
|
-
if (!inbox) continue;
|
|
1665
|
-
// Gmail: use REST API, NOT IMAP. Mixing paths causes UID
|
|
1666
|
-
// mismatch — API uses hashed IDs, IMAP uses server-assigned
|
|
1667
|
-
// UIDs. The IMAP reconcile then deletes every API-synced
|
|
1668
|
-
// message because their UIDs don't appear in the IMAP list.
|
|
1669
|
-
if (this.isGmailAccount(accountId)) {
|
|
1670
|
-
const api = this.getGmailProvider(accountId);
|
|
1671
|
-
try {
|
|
1672
|
-
await this.syncFolderViaApi(accountId, inbox, api);
|
|
1673
|
-
} finally {
|
|
1674
|
-
try { await api.close(); } catch { /* ignore */ }
|
|
1675
|
-
}
|
|
1676
|
-
} else {
|
|
1677
|
-
await this.withConnection(accountId, async (client) => {
|
|
1678
|
-
await this.syncFolder(accountId, inbox.id, client);
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
} catch (e: any) {
|
|
1682
|
-
console.error(` [inbox] Sync error for ${accountId}: ${e.message}`);
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
} finally {
|
|
1686
|
-
this.inboxSyncing = false;
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
/** Quick inbox check — per-account lightweight probe.
|
|
1691
|
-
* If the probe value changed since last time, triggers an inbox sync.
|
|
1692
|
-
* The marker is only advanced after a successful sync so that a failed
|
|
1693
|
-
* sync doesn't eat the "new mail" signal and make us stop retrying. */
|
|
1694
|
-
private lastInboxMarker = new Map<string, string | number>();
|
|
1695
|
-
private quickCheckRunning = new Set<string>(); // per-account guard
|
|
1696
|
-
|
|
1697
|
-
/** Shared quick-check skeleton: probe → compare → sync-if-changed → advance marker.
|
|
1698
|
-
* `probe` returns the current marker value; `sync` runs only when it differs
|
|
1699
|
-
* from the previously stored value. Marker is advanced only after sync resolves. */
|
|
1700
|
-
private async quickCheck<T extends string | number>(
|
|
1701
|
-
accountId: string,
|
|
1702
|
-
probe: () => Promise<T | null>,
|
|
1703
|
-
sync: (current: T, prev: T | undefined) => Promise<void>,
|
|
1704
|
-
): Promise<void> {
|
|
1705
|
-
if (this.quickCheckRunning.has(accountId)) return;
|
|
1706
|
-
if (this.reauthenticating.has(accountId)) return;
|
|
1707
|
-
this.quickCheckRunning.add(accountId);
|
|
1708
|
-
try {
|
|
1709
|
-
const current = await probe();
|
|
1710
|
-
if (current === null || current === "" as any) return;
|
|
1711
|
-
const prev = this.lastInboxMarker.get(accountId) as T | undefined;
|
|
1712
|
-
if (prev === undefined || current !== prev) {
|
|
1713
|
-
await sync(current, prev);
|
|
1714
|
-
}
|
|
1715
|
-
// Only advance after sync succeeds — a thrown error skips this line
|
|
1716
|
-
// and the next tick will see the same delta and retry.
|
|
1717
|
-
this.lastInboxMarker.set(accountId, current);
|
|
1718
|
-
} catch {
|
|
1719
|
-
// Lightweight check — silently ignore errors
|
|
1720
|
-
} finally {
|
|
1721
|
-
this.quickCheckRunning.delete(accountId);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
/** Check a single account's inbox — uses its own connection, never blocked by sync */
|
|
1726
|
-
async quickInboxCheckAccount(accountId: string): Promise<void> {
|
|
1727
|
-
if (this.isGmailAccount(accountId)) return this.quickGmailCheck(accountId);
|
|
1728
|
-
return this.quickImapCheck(accountId);
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
private async quickImapCheck(accountId: string): Promise<void> {
|
|
1732
|
-
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
1733
|
-
if (!inbox) return;
|
|
1734
|
-
let client: any = null;
|
|
1735
|
-
try {
|
|
1736
|
-
await this.quickCheck<number>(
|
|
1737
|
-
accountId,
|
|
1738
|
-
async () => {
|
|
1739
|
-
client = await this.newClient(accountId, "quickCheck");
|
|
1740
|
-
return await client.getMessagesCount("INBOX");
|
|
1741
|
-
},
|
|
1742
|
-
async (count, prev) => {
|
|
1743
|
-
if (prev !== undefined) console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
|
|
1744
|
-
await this.syncFolder(accountId, inbox.id, client);
|
|
1745
|
-
},
|
|
1746
|
-
);
|
|
1747
|
-
} finally {
|
|
1748
|
-
if (client) { try { await client.logout(); } catch { /* */ } }
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
private async quickGmailCheck(accountId: string): Promise<void> {
|
|
1753
|
-
const config = this.configs.get(accountId);
|
|
1754
|
-
if (!config?.tokenProvider) return;
|
|
1755
|
-
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
1756
|
-
if (!inbox) return;
|
|
1757
|
-
await this.quickCheck<string>(
|
|
1758
|
-
accountId,
|
|
1759
|
-
async () => {
|
|
1760
|
-
const token = await config.tokenProvider!();
|
|
1761
|
-
const res = await globalThis.fetch(
|
|
1762
|
-
`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=in:inbox&maxResults=1`,
|
|
1763
|
-
{ headers: { "Authorization": `Bearer ${token}` } }
|
|
1764
|
-
);
|
|
1765
|
-
if (!res.ok) return null;
|
|
1766
|
-
const data = await res.json();
|
|
1767
|
-
return data.messages?.[0]?.id || null;
|
|
1768
|
-
},
|
|
1769
|
-
async (_topId, prev) => {
|
|
1770
|
-
if (prev !== undefined) console.log(` [check] ${accountId} INBOX: new message detected`);
|
|
1771
|
-
const api = this.getGmailProvider(accountId);
|
|
1772
|
-
await this.syncFolderViaApi(accountId, inbox, api);
|
|
1773
|
-
this.db.recalcFolderCounts(inbox.id);
|
|
1774
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
1775
|
-
await api.close();
|
|
1776
|
-
},
|
|
1777
|
-
);
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
/** Check all accounts (used by legacy callers) */
|
|
1781
|
-
async quickInboxCheck(): Promise<void> {
|
|
1782
|
-
for (const [accountId] of this.configs) {
|
|
1783
|
-
await this.quickInboxCheckAccount(accountId);
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
/** Start periodic sync */
|
|
1788
|
-
startPeriodicSync(intervalMinutes: number): void {
|
|
1789
|
-
this.stopPeriodicSync();
|
|
1790
|
-
|
|
1791
|
-
// Per-account quick inbox check — adapts to server constraints.
|
|
1792
|
-
// Accounts with IDLE running get a long interval (5 min) because IDLE
|
|
1793
|
-
// already pushes instant notifications — the STATUS poll is just a
|
|
1794
|
-
// safety net. Non-IDLE accounts (rare) use a shorter interval.
|
|
1795
|
-
//
|
|
1796
|
-
// CRITICAL: the previous value (2500ms for everyone) was hammering
|
|
1797
|
-
// Dovecot with 24 logins per minute. That's what tripped the server
|
|
1798
|
-
// operator's fail2ban on mail1, and was still flooding the desktop
|
|
1799
|
-
// connection. Each STATUS poll creates a disposable connection
|
|
1800
|
-
// (TLS + auth + STATUS + close), not a lightweight keep-alive.
|
|
1801
|
-
for (const [accountId] of this.configs) {
|
|
1802
|
-
// Gmail uses API sync, not IMAP STATUS. IMAP accounts use IDLE
|
|
1803
|
-
// which gives instant push — the STATUS poll is just a fallback
|
|
1804
|
-
// in case IDLE silently dropped.
|
|
1805
|
-
const isGmail = this.isGmailAccount(accountId);
|
|
1806
|
-
// IMAP accounts: IDLE gives instant push; STATUS poll is just a
|
|
1807
|
-
// safety net for silent IDLE drops — keep it infrequent.
|
|
1808
|
-
// Gmail accounts: no IDLE (Gmail API doesn't expose it), so the
|
|
1809
|
-
// quick poll IS the primary path to new-mail latency. Drop to 30s
|
|
1810
|
-
// so Gmail mail appears in ~15s average. Gmail quota budget is
|
|
1811
|
-
// huge (250 units/sec per user, 1.2B/day) — 120 polls/hour × 5
|
|
1812
|
-
// units ≈ 600/hour, trivial. Dovecot accounts stay at 5min to
|
|
1813
|
-
// respect connection limits (each poll = fresh connection).
|
|
1814
|
-
const interval = isGmail ? 30000 : 300000; // Gmail: 30s; IMAP: 5min
|
|
1815
|
-
const timer = setInterval(() => {
|
|
1816
|
-
this.quickInboxCheckAccount(accountId).catch(() => {});
|
|
1817
|
-
}, interval);
|
|
1818
|
-
this.syncIntervals.set(`quick:${accountId}`, timer);
|
|
1819
|
-
console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${isGmail ? "API" : "IMAP+IDLE"})`);
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
// Sync actions (sends + flags/deletes/moves) every 30 seconds — skip during active sync
|
|
1823
|
-
const actionsInterval = setInterval(async () => {
|
|
1824
|
-
if (this.syncing) return;
|
|
1825
|
-
for (const [accountId] of this.configs) {
|
|
1826
|
-
this.processSendActions(accountId).catch(() => {});
|
|
1827
|
-
this.processSyncActions(accountId).catch(() => {});
|
|
1828
|
-
}
|
|
1829
|
-
}, 30000);
|
|
1830
|
-
this.syncIntervals.set("actions", actionsInterval);
|
|
1831
|
-
|
|
1832
|
-
// Body prefetch as a first-class background task — independent of
|
|
1833
|
-
// sync success. Prefetch was previously only triggered from inside
|
|
1834
|
-
// sync, so any account with slow/failing IMAP had its "not downloaded"
|
|
1835
|
-
// dots stuck forever even though body fetches use a separate
|
|
1836
|
-
// connection that might succeed. Every 60s, for every account, fire
|
|
1837
|
-
// prefetchBodies() (cheap when body_path is already populated — just a
|
|
1838
|
-
// DB query that returns 0 rows; the prefetchingAccounts guard
|
|
1839
|
-
// short-circuits concurrent triggers).
|
|
1840
|
-
if (getPrefetch()) {
|
|
1841
|
-
const kickPrefetch = (): void => {
|
|
1842
|
-
for (const [accountId] of this.configs) {
|
|
1843
|
-
this.prefetchBodies(accountId).catch(e =>
|
|
1844
|
-
console.error(` [prefetch] ${accountId}: ${e?.message || e}`)
|
|
1845
|
-
);
|
|
1846
|
-
}
|
|
1847
|
-
};
|
|
1848
|
-
// Fire once now so the "not downloaded" dots start filling in
|
|
1849
|
-
// immediately on app start, don't make the user wait a minute.
|
|
1850
|
-
setTimeout(kickPrefetch, 2000);
|
|
1851
|
-
const prefetchInterval = setInterval(kickPrefetch, 60000);
|
|
1852
|
-
this.syncIntervals.set("prefetch", prefetchInterval);
|
|
1853
|
-
console.log(` [periodic] body prefetch every 60s (independent of sync)`);
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
|
-
// Tombstone prune: age out local-delete records older than 30 days.
|
|
1857
|
-
// Runs hourly — cheap (one indexed DELETE).
|
|
1858
|
-
const TOMBSTONE_RETENTION_DAYS = 30;
|
|
1859
|
-
const pruneTombstones = (): void => {
|
|
1860
|
-
const cutoff = Date.now() - TOMBSTONE_RETENTION_DAYS * 86400_000;
|
|
1861
|
-
const n = this.db.pruneTombstones(cutoff);
|
|
1862
|
-
if (n > 0) console.log(` [tombstones] pruned ${n} older than ${TOMBSTONE_RETENTION_DAYS} days`);
|
|
1863
|
-
};
|
|
1864
|
-
setTimeout(pruneTombstones, 30_000); // first run after startup settles
|
|
1865
|
-
this.syncIntervals.set("tombstone-prune", setInterval(pruneTombstones, 3600_000));
|
|
1866
|
-
|
|
1867
|
-
// Full sync (all folders + IDLE restart) at configured interval
|
|
1868
|
-
const fullInterval = setInterval(async () => {
|
|
1869
|
-
console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
|
|
1870
|
-
await this.syncAll();
|
|
1871
|
-
await this.stopWatching();
|
|
1872
|
-
await this.startWatching();
|
|
1873
|
-
}, intervalMinutes * 60000);
|
|
1874
|
-
this.syncIntervals.set("all", fullInterval);
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
/** Stop periodic sync */
|
|
1878
|
-
stopPeriodicSync(): void {
|
|
1879
|
-
for (const [key, interval] of this.syncIntervals) {
|
|
1880
|
-
clearInterval(interval);
|
|
1881
|
-
}
|
|
1882
|
-
this.syncIntervals.clear();
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
/** Check if an account is OAuth (Gmail/Outlook — generous connection limits) */
|
|
1886
|
-
isOAuthAccount(accountId: string): boolean {
|
|
1887
|
-
const config = this.configs.get(accountId);
|
|
1888
|
-
return !!config?.tokenProvider;
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
/** Start IMAP IDLE watchers for INBOX on each account */
|
|
1892
|
-
async startWatching(): Promise<void> {
|
|
1893
|
-
for (const [accountId] of this.configs) {
|
|
1894
|
-
if (this.watchers.has(accountId)) continue;
|
|
1895
|
-
try {
|
|
1896
|
-
// IDLE keeps its own dedicated socket — once the connection
|
|
1897
|
-
// is parked in IDLE, it's unusable for any other command, so
|
|
1898
|
-
// it can't share the ops queue. Counts against the per-host
|
|
1899
|
-
// semaphore (one slot for the IDLE socket).
|
|
1900
|
-
const watchClient = await this.createClient(accountId, "idle");
|
|
1901
|
-
const stop = await watchClient.watchMailbox("INBOX", (newCount: number) => {
|
|
1902
|
-
console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
|
|
1903
|
-
// Fetch only the new UIDs — the heavyweight gap/reconcile
|
|
1904
|
-
// path runs on the 5-minute STATUS poll, so EXISTS lands
|
|
1905
|
-
// in the UI in roughly one round-trip.
|
|
1906
|
-
this.syncInboxNewOnly(accountId).catch(e => console.error(` [idle] sync error: ${e.message}`));
|
|
1907
|
-
});
|
|
1908
|
-
this.watchers.set(accountId, async () => {
|
|
1909
|
-
await stop();
|
|
1910
|
-
await watchClient.logout();
|
|
1911
|
-
});
|
|
1912
|
-
console.log(` [idle] Watching INBOX for ${accountId}`);
|
|
1913
|
-
} catch (e: any) {
|
|
1914
|
-
console.error(` [idle] Failed to watch ${accountId}: ${e.message}`);
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
/** Stop all IDLE watchers */
|
|
1920
|
-
async stopWatching(): Promise<void> {
|
|
1921
|
-
for (const [id, stop] of this.watchers) {
|
|
1922
|
-
try { await stop(); } catch { /* ignore */ }
|
|
1923
|
-
}
|
|
1924
|
-
this.watchers.clear();
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
/** Unlink the on-disk body file for a message by reading its `body_path`
|
|
1928
|
-
* from the DB. Safe to call either before or after `db.deleteMessage`
|
|
1929
|
-
* — read body_path first, store it, then unlink whenever. */
|
|
1930
|
-
private async unlinkBodyFile(accountId: string, uid: number, folderId?: number): Promise<void> {
|
|
1931
|
-
try {
|
|
1932
|
-
const row: any = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1933
|
-
const p = row?.bodyPath;
|
|
1934
|
-
if (p) await this.bodyStore.unlinkByPath(p);
|
|
1935
|
-
} catch { /* row already gone / file already gone — both fine */ }
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
/** Fetch a single message body on demand, caching in the store.
|
|
1939
|
-
*
|
|
1940
|
-
* Cache lookup is folder-agnostic: when a UID exists in multiple folders
|
|
1941
|
-
* (Gmail labels, copy-instead-of-move) the prefetcher may have populated
|
|
1942
|
-
* body_path on only one row. Looking up by (account, uid) without the
|
|
1943
|
-
* folder filter finds the cached `.eml` regardless of which folder
|
|
1944
|
-
* context the UI passed.
|
|
1945
|
-
*
|
|
1946
|
-
* Server fetch goes through the unified ops queue on the fast lane —
|
|
1947
|
-
* the user clicked, they're waiting, this jumps ahead of any background
|
|
1948
|
-
* prefetch sitting in the slow lane. */
|
|
1949
|
-
async fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null> {
|
|
1950
|
-
const envelope: any = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1951
|
-
let storedPath = envelope?.bodyPath || "";
|
|
1952
|
-
if (!storedPath) storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
|
|
1953
|
-
if (storedPath && await this.bodyStore.hasByPath(storedPath)) {
|
|
1954
|
-
return this.bodyStore.readByPath(storedPath);
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
if (!this.configs.has(accountId)) return null;
|
|
1958
|
-
|
|
1959
|
-
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
1960
|
-
if (!folder) return null;
|
|
1961
|
-
|
|
1962
|
-
// Gmail: REST API, no IMAP connection involved.
|
|
1963
|
-
if (this.isGmailAccount(accountId)) {
|
|
1964
|
-
return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
// IMAP: fast lane on the ops queue. One try; if the socket is stale,
|
|
1968
|
-
// withConnection's discard-on-error logic drops the client so the
|
|
1969
|
-
// next attempt (caller-driven retry) gets a fresh one.
|
|
1970
|
-
try {
|
|
1971
|
-
const raw = await this.withConnection(accountId, async (client) => {
|
|
1972
|
-
const msg: any = await client.fetchMessageByUid(folder.path, uid, { source: true });
|
|
1973
|
-
if (!msg) throw makeNotFoundError(accountId, folderId, uid);
|
|
1974
|
-
if (!msg.source) return null;
|
|
1975
|
-
return Buffer.from(msg.source, "utf-8");
|
|
1976
|
-
});
|
|
1977
|
-
if (!raw) return null;
|
|
1978
|
-
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
1979
|
-
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
1980
|
-
this.emit("bodyCached", accountId, uid);
|
|
1981
|
-
return raw;
|
|
1982
|
-
} catch (e: any) {
|
|
1983
|
-
if (e?.isNotFound) throw e;
|
|
1984
|
-
console.error(` Body fetch error (${accountId}/${uid}): ${e?.message || e}`);
|
|
1985
|
-
return null;
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
/** Fetch message body via Gmail/Outlook API.
|
|
1990
|
-
* Throws `MessageNotFoundError` when the server says the message is gone
|
|
1991
|
-
* (deleted from another device, for example). The caller uses that to
|
|
1992
|
-
* delete the stale row locally instead of showing a generic error. */
|
|
1993
|
-
private async fetchMessageBodyViaApi(accountId: string, folderId: number, uid: number, folderPath: string): Promise<Buffer | null> {
|
|
1994
|
-
try {
|
|
1995
|
-
const api = this.getGmailProvider(accountId);
|
|
1996
|
-
// Read provider_id from the local row so fetchOne can skip the
|
|
1997
|
-
// listMessageIds pagination (the dominant Gmail rate-limit cost).
|
|
1998
|
-
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1999
|
-
const providerId = env?.providerId;
|
|
2000
|
-
const msg = await api.fetchOne(folderPath, uid, { source: true, providerId });
|
|
2001
|
-
await api.close();
|
|
2002
|
-
if (!msg) {
|
|
2003
|
-
// fetchOne returned null — message doesn't exist on the server anymore
|
|
2004
|
-
throw makeNotFoundError(accountId, folderId, uid);
|
|
2005
|
-
}
|
|
2006
|
-
if (!msg.source) {
|
|
2007
|
-
// Gmail returned a message object but no raw bytes. Seen when:
|
|
2008
|
-
// (a) the message exists but is larger than the format=raw cap (~10MB),
|
|
2009
|
-
// (b) UID→Gmail-ID resolution picked a collision and the target
|
|
2010
|
-
// exists only as a stub, or (c) the listMessageIds top-1000
|
|
2011
|
-
// didn't include our UID and fetchOne returned null above —
|
|
2012
|
-
// wait, that would hit the !msg branch. So (a)/(b) remain.
|
|
2013
|
-
// Log enough to distinguish; surface the reason up via a non-null
|
|
2014
|
-
// return so the UI stops showing a generic "fetch returned nothing".
|
|
2015
|
-
console.error(` [api] Body fetch empty source (${accountId}/${uid}): Gmail returned no raw body — likely too-large-for-format-raw or UID hash collision`);
|
|
2016
|
-
return null;
|
|
2017
|
-
}
|
|
2018
|
-
const raw = Buffer.from(msg.source, "utf-8");
|
|
2019
|
-
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
2020
|
-
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
2021
|
-
this.emit("bodyCached", accountId, uid);
|
|
2022
|
-
return raw;
|
|
2023
|
-
} catch (e: any) {
|
|
2024
|
-
// Gmail API 404 → the message was deleted on the server
|
|
2025
|
-
if ((e as any)?.isNotFound || /Gmail API 404|404|not found/i.test(e?.message || "")) {
|
|
2026
|
-
throw makeNotFoundError(accountId, folderId, uid);
|
|
2027
|
-
}
|
|
2028
|
-
console.error(` [api] Body fetch error (${accountId}/${uid}): ${e.message}`);
|
|
2029
|
-
return null;
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
/** Background body prefetch — download bodies for messages that don't have them.
|
|
2034
|
-
* Server-side deletions (isNotFound) aren't errors here: we delete the
|
|
2035
|
-
* stale row locally and keep going. Only unrelated errors (network,
|
|
2036
|
-
* auth, rate limits) count against the error budget, and the budget is
|
|
2037
|
-
* generous so a few transient failures don't kill the whole run. */
|
|
2038
|
-
/** Guard against concurrent prefetchBodies for the same account — mirror of
|
|
2039
|
-
* `sendingAccounts`. Without this, every periodic-sync tick spawns a new
|
|
2040
|
-
* prefetch session alongside any still in flight, blowing through Gmail's
|
|
2041
|
-
* per-minute quota and racing on disk writes. One prefetch per account. */
|
|
2042
|
-
private prefetchingAccounts = new Set<string>();
|
|
2043
|
-
|
|
2044
|
-
/** Per-folder error cooldowns — `accountId:folderPath` → [timestamps].
|
|
2045
|
-
* Used to skip folders that repeatedly time out (Dovecot on slow shared
|
|
2046
|
-
* hosting SELECTs big `_Spam` / archive folders at 300s+ latency; the
|
|
2047
|
-
* prefetch error budget was burning out on a handful of bad folders
|
|
2048
|
-
* before the INBOX could finish). A folder with 2+ errors in the last
|
|
2049
|
-
* 15 minutes is skipped until the cooldown passes. User-reported via
|
|
2050
|
-
* log analysis 2026-04-23: bobma prefetch timing out on Added2.organizations,
|
|
2051
|
-
* Added2.technews, _Spam, "Prefirst.Jerry's Retreat", Added2.zines. */
|
|
2052
|
-
private folderErrorCooldown = new Map<string, number[]>();
|
|
2053
|
-
private readonly FOLDER_ERROR_WINDOW_MS = 15 * 60_000;
|
|
2054
|
-
private readonly FOLDER_ERROR_THRESHOLD = 2;
|
|
2055
|
-
|
|
2056
|
-
private shouldSkipFolder(accountId: string, folderPath: string): boolean {
|
|
2057
|
-
const key = `${accountId}:${folderPath}`;
|
|
2058
|
-
const now = Date.now();
|
|
2059
|
-
const errors = (this.folderErrorCooldown.get(key) || [])
|
|
2060
|
-
.filter(t => now - t < this.FOLDER_ERROR_WINDOW_MS);
|
|
2061
|
-
this.folderErrorCooldown.set(key, errors);
|
|
2062
|
-
return errors.length >= this.FOLDER_ERROR_THRESHOLD;
|
|
2063
|
-
}
|
|
2064
|
-
private recordFolderError(accountId: string, folderPath: string): void {
|
|
2065
|
-
const key = `${accountId}:${folderPath}`;
|
|
2066
|
-
const arr = this.folderErrorCooldown.get(key) || [];
|
|
2067
|
-
arr.push(Date.now());
|
|
2068
|
-
this.folderErrorCooldown.set(key, arr);
|
|
2069
|
-
}
|
|
2070
|
-
private clearFolderErrors(accountId: string, folderPath: string): void {
|
|
2071
|
-
this.folderErrorCooldown.delete(`${accountId}:${folderPath}`);
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
private async prefetchBodies(accountId: string): Promise<void> {
|
|
2075
|
-
if (this.prefetchingAccounts.has(accountId)) return;
|
|
2076
|
-
this.prefetchingAccounts.add(accountId);
|
|
2077
|
-
try { await this._prefetchBodies(accountId); }
|
|
2078
|
-
finally { this.prefetchingAccounts.delete(accountId); }
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
private async _prefetchBodies(accountId: string): Promise<void> {
|
|
2082
|
-
const counters = { totalFetched: 0, deleted: 0, errors: 0 };
|
|
2083
|
-
const ERROR_BUDGET = 20;
|
|
2084
|
-
const RATE_LIMIT_PAUSE_MS = 30000;
|
|
2085
|
-
const BATCH_SIZE = 100;
|
|
2086
|
-
const isGmail = this.isGmailAccount(accountId);
|
|
2087
|
-
// Gmail still uses per-message fetch (HTTP /batch is a separate TODO in this file's
|
|
2088
|
-
// governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
|
|
2089
|
-
// one SELECT + one UID FETCH per folder per tick instead of N round trips.
|
|
2090
|
-
let announced = false;
|
|
2091
|
-
while (true) {
|
|
2092
|
-
const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
|
|
2093
|
-
if (missing.length === 0) break;
|
|
2094
|
-
if (!announced) {
|
|
2095
|
-
console.log(` [prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
|
|
2096
|
-
announced = true;
|
|
2097
|
-
}
|
|
2098
|
-
let madeProgress = false;
|
|
2099
|
-
|
|
2100
|
-
if (isGmail) {
|
|
2101
|
-
// Gmail batch path: group by label (what mailx calls "folder"),
|
|
2102
|
-
// list once per label, bounded-concurrency fetch. Far fewer
|
|
2103
|
-
// HTTP round trips than the old one-listMessageIds-per-body path.
|
|
2104
|
-
// Note on the model: Gmail has labels, not folders. A message in
|
|
2105
|
-
// multiple labels gets fetched twice under current grouping. A
|
|
2106
|
-
// deeper label-native redesign is tracked as a separate TODO.
|
|
2107
|
-
const byFolder = new Map<number, number[]>();
|
|
2108
|
-
for (const m of missing) {
|
|
2109
|
-
let arr = byFolder.get(m.folderId);
|
|
2110
|
-
if (!arr) { arr = []; byFolder.set(m.folderId, arr); }
|
|
2111
|
-
arr.push(m.uid);
|
|
2112
|
-
}
|
|
2113
|
-
const folders = this.db.getFolders(accountId);
|
|
2114
|
-
const api = this.getGmailProvider(accountId);
|
|
2115
|
-
try {
|
|
2116
|
-
for (const [folderId, uidsInFolder] of byFolder) {
|
|
2117
|
-
const folder = folders.find(f => f.id === folderId);
|
|
2118
|
-
if (!folder) continue;
|
|
2119
|
-
const received = new Set<number>();
|
|
2120
|
-
const pending: Promise<void>[] = [];
|
|
2121
|
-
let batchSucceeded = false;
|
|
2122
|
-
try {
|
|
2123
|
-
await (api as any).fetchBodiesBatch(folder.path, uidsInFolder, (uid: number, source: string) => {
|
|
2124
|
-
received.add(uid);
|
|
2125
|
-
pending.push((async () => {
|
|
2126
|
-
try {
|
|
2127
|
-
const raw = Buffer.from(source, "utf-8");
|
|
2128
|
-
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
2129
|
-
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
2130
|
-
this.emit("bodyCached", accountId, uid);
|
|
2131
|
-
counters.totalFetched++;
|
|
2132
|
-
madeProgress = true;
|
|
2133
|
-
} catch (e: any) {
|
|
2134
|
-
console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
|
|
2135
|
-
}
|
|
2136
|
-
})());
|
|
2137
|
-
});
|
|
2138
|
-
batchSucceeded = true;
|
|
2139
|
-
} catch (e: any) {
|
|
2140
|
-
const isRate = /429|rate|too many/i.test(String(e?.message || ""));
|
|
2141
|
-
if (isRate) {
|
|
2142
|
-
console.log(` [prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
|
|
2143
|
-
await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
|
|
2144
|
-
} else {
|
|
2145
|
-
console.error(` [prefetch] ${accountId} folder ${folder.path}: Gmail batch fetch failed: ${e.message}`);
|
|
2146
|
-
counters.errors++;
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
await Promise.all(pending);
|
|
2150
|
-
// CRITICAL: only prune as "server-deleted" when the batch
|
|
2151
|
-
// actually completed. If the batch threw (403, 429, network
|
|
2152
|
-
// error, etc.) NOTHING was received, and treating every
|
|
2153
|
-
// requested UID as deleted silently wipes 100 messages per
|
|
2154
|
-
// batch. That's a data-loss bug. Earlier version did this
|
|
2155
|
-
// and pruned 296 messages on a 403 auth error.
|
|
2156
|
-
if (batchSucceeded) {
|
|
2157
|
-
for (const uid of uidsInFolder) {
|
|
2158
|
-
if (received.has(uid)) continue;
|
|
2159
|
-
try {
|
|
2160
|
-
this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
|
|
2161
|
-
this.db.deleteMessage(accountId, uid);
|
|
2162
|
-
counters.deleted++;
|
|
2163
|
-
madeProgress = true;
|
|
2164
|
-
} catch { /* ignore */ }
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
if (counters.errors >= ERROR_BUDGET) break;
|
|
2168
|
-
}
|
|
2169
|
-
} finally {
|
|
2170
|
-
try { await api.close(); } catch { /* ignore */ }
|
|
2171
|
-
}
|
|
2172
|
-
if (counters.errors >= ERROR_BUDGET) {
|
|
2173
|
-
console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
|
|
2174
|
-
return;
|
|
2175
|
-
}
|
|
2176
|
-
} else {
|
|
2177
|
-
// IMAP batch path: one UID FETCH per folder, each on its own
|
|
2178
|
-
// turn through the slow lane. Yielding between folders is
|
|
2179
|
-
// crucial — a click-to-view body should jump ahead of the
|
|
2180
|
-
// next folder's batch via the fast lane, not wait for all
|
|
2181
|
-
// folders to finish.
|
|
2182
|
-
const byFolder = new Map<number, number[]>();
|
|
2183
|
-
for (const m of missing) {
|
|
2184
|
-
let arr = byFolder.get(m.folderId);
|
|
2185
|
-
if (!arr) { arr = []; byFolder.set(m.folderId, arr); }
|
|
2186
|
-
arr.push(m.uid);
|
|
2187
|
-
}
|
|
2188
|
-
const folders = this.db.getFolders(accountId);
|
|
2189
|
-
// INBOX-first ordering so the folder the user actually looks at
|
|
2190
|
-
// gets its bodies even if a later folder eats the error budget.
|
|
2191
|
-
const orderedFolders = Array.from(byFolder.entries()).sort(([aid], [bid]) => {
|
|
2192
|
-
const af = folders.find(f => f.id === aid);
|
|
2193
|
-
const bf = folders.find(f => f.id === bid);
|
|
2194
|
-
const ai = af?.specialUse === "inbox" ? 0 : 1;
|
|
2195
|
-
const bi = bf?.specialUse === "inbox" ? 0 : 1;
|
|
2196
|
-
return ai - bi;
|
|
2197
|
-
});
|
|
2198
|
-
for (const [folderId, uids] of orderedFolders) {
|
|
2199
|
-
const folder = folders.find(f => f.id === folderId);
|
|
2200
|
-
if (!folder) continue;
|
|
2201
|
-
if (this.shouldSkipFolder(accountId, folder.path)) {
|
|
2202
|
-
console.log(` [prefetch] ${accountId}: skipping ${folder.path} (recent timeouts — cooling down)`);
|
|
2203
|
-
continue;
|
|
2204
|
-
}
|
|
2205
|
-
const received = new Set<number>();
|
|
2206
|
-
let batchSucceeded = false;
|
|
2207
|
-
try {
|
|
2208
|
-
// Slow lane: prefetch is the textbook "this might take
|
|
2209
|
-
// a while" case — let interactive ops slip ahead.
|
|
2210
|
-
await this.withConnection(accountId, async (client) => {
|
|
2211
|
-
const pending: Promise<void>[] = [];
|
|
2212
|
-
await client.fetchBodiesBatch(folder.path, uids, (uid: number, source: string) => {
|
|
2213
|
-
received.add(uid);
|
|
2214
|
-
pending.push((async () => {
|
|
2215
|
-
try {
|
|
2216
|
-
const raw = Buffer.from(source, "utf-8");
|
|
2217
|
-
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
2218
|
-
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
2219
|
-
this.emit("bodyCached", accountId, uid);
|
|
2220
|
-
counters.totalFetched++;
|
|
2221
|
-
madeProgress = true;
|
|
2222
|
-
} catch (e: any) {
|
|
2223
|
-
console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
|
|
2224
|
-
}
|
|
2225
|
-
})());
|
|
2226
|
-
});
|
|
2227
|
-
await Promise.all(pending);
|
|
2228
|
-
}, { slow: true });
|
|
2229
|
-
batchSucceeded = true;
|
|
2230
|
-
this.clearFolderErrors(accountId, folder.path);
|
|
2231
|
-
} catch (e: any) {
|
|
2232
|
-
const msg = String(e?.message || "");
|
|
2233
|
-
console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${msg}`);
|
|
2234
|
-
counters.errors++;
|
|
2235
|
-
this.recordFolderError(accountId, folder.path);
|
|
2236
|
-
if (counters.errors >= ERROR_BUDGET) break;
|
|
2237
|
-
}
|
|
2238
|
-
// CRITICAL: only prune when the batch actually completed.
|
|
2239
|
-
// A thrown batch means NOTHING was received; treating
|
|
2240
|
-
// absence as server-deletion lost 296 messages once.
|
|
2241
|
-
if (batchSucceeded) for (const uid of uids) {
|
|
2242
|
-
if (received.has(uid)) continue;
|
|
2243
|
-
try {
|
|
2244
|
-
this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
|
|
2245
|
-
this.db.deleteMessage(accountId, uid);
|
|
2246
|
-
counters.deleted++;
|
|
2247
|
-
madeProgress = true;
|
|
2248
|
-
} catch { /* ignore */ }
|
|
2249
|
-
}
|
|
2250
|
-
}
|
|
2251
|
-
if (counters.errors >= ERROR_BUDGET) {
|
|
2252
|
-
console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
|
|
2253
|
-
return;
|
|
2254
|
-
}
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
// Safety: zero progress this tick → bail rather than loop forever.
|
|
2258
|
-
if (!madeProgress) break;
|
|
2259
|
-
// Emit so the UI refreshes the open-circle → filled-teal indicator
|
|
2260
|
-
// without waiting for the next sync cycle.
|
|
2261
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
2262
|
-
}
|
|
2263
|
-
if (counters.totalFetched > 0 || counters.deleted > 0) {
|
|
2264
|
-
console.log(` [prefetch] ${accountId}: ${counters.totalFetched} bodies cached, ${counters.deleted} stale rows pruned (done)`);
|
|
2265
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
/** Get the body store for direct access */
|
|
2270
|
-
getBodyStore(): FileMessageStore {
|
|
2271
|
-
return this.bodyStore;
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
/** Bulk trash messages — local-first, single IMAP connection for all */
|
|
2275
|
-
async trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise<void> {
|
|
2276
|
-
if (messages.length === 0) return;
|
|
2277
|
-
const trash = this.findFolder(accountId, "trash");
|
|
2278
|
-
|
|
2279
|
-
// Local first — remove all from DB immediately
|
|
2280
|
-
for (const msg of messages) {
|
|
2281
|
-
this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => {});
|
|
2282
|
-
this.db.deleteMessage(accountId, msg.uid);
|
|
2283
|
-
}
|
|
2284
|
-
console.log(` Deleted ${messages.length} messages locally`);
|
|
2285
|
-
|
|
2286
|
-
// Queue IMAP actions
|
|
2287
|
-
for (const msg of messages) {
|
|
2288
|
-
if (trash && trash.id !== msg.folderId) {
|
|
2289
|
-
this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId: trash.id });
|
|
2290
|
-
} else {
|
|
2291
|
-
this.db.queueSyncAction(accountId, "delete", msg.uid, msg.folderId);
|
|
2292
|
-
}
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
|
-
// Recalc folder counts so the tree badge updates immediately instead
|
|
2296
|
-
// of showing stale numbers until the next full sync.
|
|
2297
|
-
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
2298
|
-
for (const fid of sourceFolderIds) this.db.recalcFolderCounts(fid);
|
|
2299
|
-
if (trash) this.db.recalcFolderCounts(trash.id);
|
|
2300
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
2301
|
-
|
|
2302
|
-
// Process all queued actions in one IMAP session
|
|
2303
|
-
this.debounceSyncActions(accountId);
|
|
2304
|
-
}
|
|
2305
|
-
|
|
2306
|
-
/** Bulk move messages — queues the IMAP action only. The service layer
|
|
2307
|
-
* (MailxService.moveMessages) owns the local DB mutation via
|
|
2308
|
-
* updateMessageFolder; this method used to ALSO deleteMessage here,
|
|
2309
|
-
* which wiped the row the service just updated — the message vanished
|
|
2310
|
-
* on the next reconcile and "spam folder empty" was the symptom. */
|
|
2311
|
-
async moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise<void> {
|
|
2312
|
-
if (messages.length === 0) return;
|
|
2313
|
-
for (const msg of messages) {
|
|
2314
|
-
this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
|
|
2315
|
-
}
|
|
2316
|
-
console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) → folder ${targetFolderId}`);
|
|
2317
|
-
this.debounceSyncActions(accountId);
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
/** Debounced sync actions — batches rapid local changes into one IMAP operation */
|
|
2321
|
-
private syncActionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
2322
|
-
|
|
2323
|
-
private debounceSyncActions(accountId: string): void {
|
|
2324
|
-
const existing = this.syncActionTimers.get(accountId);
|
|
2325
|
-
if (existing) clearTimeout(existing);
|
|
2326
|
-
this.syncActionTimers.set(accountId, setTimeout(() => {
|
|
2327
|
-
this.syncActionTimers.delete(accountId);
|
|
2328
|
-
this.processSyncActions(accountId).catch(() => {});
|
|
2329
|
-
}, 1000));
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
/** Move a message to Trash (delete) — local-first, queues IMAP sync */
|
|
2333
|
-
async trashMessage(accountId: string, folderId: number, uid: number): Promise<void> {
|
|
2334
|
-
const trash = this.findFolder(accountId, "trash");
|
|
2335
|
-
|
|
2336
|
-
// Local first — remove from DB immediately
|
|
2337
|
-
this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
|
|
2338
|
-
this.db.deleteMessage(accountId, uid);
|
|
2339
|
-
|
|
2340
|
-
// Queue IMAP action + log the resolution so "I deleted a message and
|
|
2341
|
-
// now it's in neither trash nor deleted" is diagnosable from the log.
|
|
2342
|
-
if (trash && trash.id !== folderId) {
|
|
2343
|
-
const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
|
|
2344
|
-
this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
|
|
2345
|
-
console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
|
|
2346
|
-
} else {
|
|
2347
|
-
this.db.queueSyncAction(accountId, "delete", uid, folderId);
|
|
2348
|
-
console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
// Debounced sync — batches multiple deletes into one IMAP session
|
|
2352
|
-
this.debounceSyncActions(accountId);
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
/** Move a message between folders — queues IMAP sync only. Service
|
|
2356
|
-
* layer owns the local DB update (see MailxService.moveMessage). */
|
|
2357
|
-
async moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void> {
|
|
2358
|
-
this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
|
|
2359
|
-
console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} → ${toFolderId}`);
|
|
2360
|
-
this.debounceSyncActions(accountId);
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
/** Move message across accounts using iflow's moveMessageToServer */
|
|
2364
|
-
async moveMessageCrossAccount(
|
|
2365
|
-
fromAccountId: string, uid: number, fromFolderId: number,
|
|
2366
|
-
toAccountId: string, toFolderId: number
|
|
2367
|
-
): Promise<void> {
|
|
2368
|
-
const fromFolders = this.db.getFolders(fromAccountId);
|
|
2369
|
-
const fromFolder = fromFolders.find(f => f.id === fromFolderId);
|
|
2370
|
-
if (!fromFolder) throw new Error(`Source folder ${fromFolderId} not found`);
|
|
2371
|
-
|
|
2372
|
-
const toFolders = this.db.getFolders(toAccountId);
|
|
2373
|
-
const toFolder = toFolders.find(f => f.id === toFolderId);
|
|
2374
|
-
if (!toFolder) throw new Error(`Target folder ${toFolderId} not found`);
|
|
2375
|
-
|
|
2376
|
-
// Two accounts, two ops connections. Cross-account move is rare
|
|
2377
|
-
// and requires both sockets to be live concurrently (we APPEND to
|
|
2378
|
-
// target while still authenticated to source), so this can't fold
|
|
2379
|
-
// into a single withConnection call.
|
|
2380
|
-
await this.withConnection(fromAccountId, async (sourceClient) => {
|
|
2381
|
-
await this.withConnection(toAccountId, async (targetClient) => {
|
|
2382
|
-
const msg = await sourceClient.fetchMessageByUid(fromFolder.path, uid, { source: true });
|
|
2383
|
-
if (!msg) throw new Error(`Message UID ${uid} not found in ${fromFolder.path}`);
|
|
2384
|
-
await sourceClient.moveMessageToServer(msg, fromFolder.path, targetClient, toFolder.path);
|
|
2385
|
-
this.db.deleteMessage(fromAccountId, uid);
|
|
2386
|
-
console.log(` Cross-account move: ${fromAccountId}/${fromFolder.path} UID ${uid} → ${toAccountId}/${toFolder.path}`);
|
|
2387
|
-
});
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
/** Undelete — move from Trash back to original folder */
|
|
2392
|
-
async undeleteMessage(accountId: string, uid: number, originalFolderId: number): Promise<void> {
|
|
2393
|
-
const trash = this.findFolder(accountId, "trash");
|
|
2394
|
-
if (!trash) throw new Error("No Trash folder found");
|
|
2395
|
-
await this.moveMessage(accountId, uid, trash.id, originalFolderId);
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
/** Update flags — local-first, queues IMAP sync */
|
|
2399
|
-
async updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void> {
|
|
2400
|
-
this.db.updateMessageFlags(accountId, uid, flags);
|
|
2401
|
-
this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
|
|
2402
|
-
// User-visible pink-dot pending state stays until the action drains.
|
|
2403
|
-
// The 30-second periodic tick was too slow — opening one message to
|
|
2404
|
-
// auto-mark-as-read left it pink for half a minute. Same 1-second
|
|
2405
|
-
// debounce as moves/deletes batches rapid flag churn without the
|
|
2406
|
-
// visual lag.
|
|
2407
|
-
this.debounceSyncActions(accountId);
|
|
2408
|
-
}
|
|
2409
|
-
|
|
2410
|
-
/** Process pending sync actions for an account */
|
|
2411
|
-
async processSyncActions(accountId: string): Promise<void> {
|
|
2412
|
-
const actions = this.db.getPendingSyncActions(accountId);
|
|
2413
|
-
if (actions.length === 0) return;
|
|
2414
|
-
const startCount = actions.length;
|
|
2415
|
-
|
|
2416
|
-
const folders = this.db.getFolders(accountId);
|
|
2417
|
-
|
|
2418
|
-
// Gmail path: push flag/label changes through the REST provider so
|
|
2419
|
-
// they actually reach the server. Earlier this method always went
|
|
2420
|
-
// through withConnection → IMAP, which silently no-op'd for Gmail
|
|
2421
|
-
// accounts (REST-only, no IMAP connection) and left local-only stars
|
|
2422
|
-
// that vanished on the next full sync.
|
|
2423
|
-
if (this.isGmailAccount(accountId)) {
|
|
2424
|
-
const api = this.getGmailProvider(accountId) as any;
|
|
2425
|
-
try {
|
|
2426
|
-
for (const action of actions) {
|
|
2427
|
-
const folder = folders.find(f => f.id === action.folderId);
|
|
2428
|
-
if (!folder) { this.db.completeSyncAction(action.id); continue; }
|
|
2429
|
-
try {
|
|
2430
|
-
if (action.action === "flags" && api.setFlags) {
|
|
2431
|
-
await api.setFlags(folder.path, action.uid, action.flags || []);
|
|
2432
|
-
console.log(` [api] ${accountId}: flags synced UID ${action.uid}`);
|
|
2433
|
-
} else if ((action.action === "delete" || action.action === "trash") && api.trashMessage) {
|
|
2434
|
-
await api.trashMessage(folder.path, action.uid);
|
|
2435
|
-
console.log(` [api] ${accountId}: trashed UID ${action.uid} from ${folder.path}`);
|
|
2436
|
-
} else if (action.action === "move" && api.moveMessage) {
|
|
2437
|
-
const target = folders.find(f => f.id === action.targetFolderId);
|
|
2438
|
-
if (!target) {
|
|
2439
|
-
// Unreachable target — drop the action rather than loop.
|
|
2440
|
-
console.error(` [api] ${accountId}: move target folder ${action.targetFolderId} missing — dropping UID ${action.uid}`);
|
|
2441
|
-
this.db.completeSyncAction(action.id);
|
|
2442
|
-
continue;
|
|
2443
|
-
}
|
|
2444
|
-
await api.moveMessage(folder.path, action.uid, target.path);
|
|
2445
|
-
console.log(` [api] ${accountId}: moved UID ${action.uid} ${folder.path} → ${target.path}`);
|
|
2446
|
-
} else {
|
|
2447
|
-
// Unsupported action on Gmail. After 5 retries, drop it
|
|
2448
|
-
// so stale rows don't mark messages pending-reconcile
|
|
2449
|
-
// forever. Previously "continue" here caused the pink
|
|
2450
|
-
// rows that shouldn't have been pink.
|
|
2451
|
-
if (action.attempts >= 5) {
|
|
2452
|
-
console.warn(` [api] ${accountId}: dropping stale action "${action.action}" UID ${action.uid} after ${action.attempts} attempts (unsupported on Gmail API path)`);
|
|
2453
|
-
this.db.completeSyncAction(action.id);
|
|
2454
|
-
} else {
|
|
2455
|
-
this.db.failSyncAction(action.id, `unsupported Gmail action: ${action.action}`);
|
|
2456
|
-
}
|
|
2457
|
-
continue;
|
|
2458
|
-
}
|
|
2459
|
-
this.db.completeSyncAction(action.id);
|
|
2460
|
-
} catch (e: any) {
|
|
2461
|
-
console.error(` [api] ${accountId}: flag sync failed UID ${action.uid}: ${e.message}`);
|
|
2462
|
-
this.db.failSyncAction(action.id, e.message);
|
|
2463
|
-
if (action.attempts >= 5) this.db.completeSyncAction(action.id);
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
} finally { try { await api.close(); } catch { /* */ } }
|
|
2467
|
-
// Nudge the UI so rows that were pending-reconcile re-query their
|
|
2468
|
-
// pending state (pink dot was sticky until this event fired).
|
|
2469
|
-
const remaining = this.db.getPendingSyncActions(accountId).length;
|
|
2470
|
-
if (remaining < startCount) this.emit("folderCountsChanged", accountId, {});
|
|
2471
|
-
return;
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
await this.withConnection(accountId, async (client) => {
|
|
2475
|
-
for (const action of actions) {
|
|
2476
|
-
const folder = folders.find(f => f.id === action.folderId);
|
|
2477
|
-
if (!folder) {
|
|
2478
|
-
this.db.completeSyncAction(action.id);
|
|
2479
|
-
continue;
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
try {
|
|
2483
|
-
switch (action.action) {
|
|
2484
|
-
case "delete":
|
|
2485
|
-
await client.deleteMessageByUid(folder.path, action.uid);
|
|
2486
|
-
console.log(` [sync] Deleted UID ${action.uid} from ${folder.path}`);
|
|
2487
|
-
break;
|
|
2488
|
-
|
|
2489
|
-
case "move": {
|
|
2490
|
-
const target = folders.find(f => f.id === action.targetFolderId);
|
|
2491
|
-
if (!target) {
|
|
2492
|
-
// Target folder gone — treat as permanent failure so the
|
|
2493
|
-
// action doesn't loop forever. User must re-delete manually.
|
|
2494
|
-
console.error(` [sync] Move target folder ${action.targetFolderId} missing — dropping action UID ${action.uid}`);
|
|
2495
|
-
throw new Error(`move target folder ${action.targetFolderId} not found`);
|
|
2496
|
-
}
|
|
2497
|
-
const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
|
|
2498
|
-
if (!msg) {
|
|
2499
|
-
// Message no longer in source folder. Two real cases:
|
|
2500
|
-
// (a) another client already moved/deleted it — nothing to do,
|
|
2501
|
-
// just mark the action done.
|
|
2502
|
-
// (b) the server is lying (transient SELECT miss) — the retry
|
|
2503
|
-
// will pick it up. We can't tell these apart from one fetch,
|
|
2504
|
-
// so log loud and treat as (a) after the first failure; the
|
|
2505
|
-
// attempts counter handles (b) via the failSyncAction path.
|
|
2506
|
-
console.log(` [sync] Move UID ${action.uid} in ${folder.path}: message gone (attempt ${action.attempts + 1}); dropping action`);
|
|
2507
|
-
break;
|
|
2508
|
-
}
|
|
2509
|
-
await client.moveMessage(msg, folder.path, target.path);
|
|
2510
|
-
console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
|
|
2511
|
-
break;
|
|
2512
|
-
}
|
|
2513
|
-
|
|
2514
|
-
case "flags":
|
|
2515
|
-
if (action.flags.length > 0) {
|
|
2516
|
-
await client.addFlags(folder.path, action.uid, action.flags.filter(f => !f.startsWith("-")));
|
|
2517
|
-
const toRemove = action.flags.filter(f => f.startsWith("-")).map(f => f.slice(1));
|
|
2518
|
-
if (toRemove.length > 0) {
|
|
2519
|
-
await client.removeFlags(folder.path, action.uid, toRemove);
|
|
2520
|
-
}
|
|
2521
|
-
console.log(` [sync] Updated flags UID ${action.uid}`);
|
|
2522
|
-
}
|
|
2523
|
-
break;
|
|
2524
|
-
|
|
2525
|
-
case "append": {
|
|
2526
|
-
if (action.rawMessage) {
|
|
2527
|
-
await client.appendMessage(folder.path, action.rawMessage, action.flags);
|
|
2528
|
-
console.log(` [sync] Appended to ${folder.path}`);
|
|
2529
|
-
}
|
|
2530
|
-
break;
|
|
2531
|
-
}
|
|
2532
|
-
}
|
|
2533
|
-
this.db.completeSyncAction(action.id);
|
|
2534
|
-
} catch (e: any) {
|
|
2535
|
-
console.error(` [sync] Failed action ${action.action} UID ${action.uid}: ${e.message}`);
|
|
2536
|
-
this.db.failSyncAction(action.id, e.message);
|
|
2537
|
-
this.emit("syncActionFailed", accountId, action.action, action.uid, e.message);
|
|
2538
|
-
if (action.attempts >= 5) {
|
|
2539
|
-
console.error(` [sync] Giving up on action ${action.id} after 5 attempts`);
|
|
2540
|
-
this.db.completeSyncAction(action.id);
|
|
2541
|
-
this.emit("syncActionFailed", accountId, action.action, action.uid, `Gave up after 5 attempts: ${e.message}`);
|
|
2542
|
-
}
|
|
2543
|
-
}
|
|
2544
|
-
}
|
|
2545
|
-
});
|
|
2546
|
-
// IMAP path: same nudge as the Gmail branch above. Any action that
|
|
2547
|
-
// drained (successful or gave-up-after-5) decrements the pending
|
|
2548
|
-
// count, which flips the pink dot off on the next re-query.
|
|
2549
|
-
const remaining = this.db.getPendingSyncActions(accountId).length;
|
|
2550
|
-
if (remaining < startCount) this.emit("folderCountsChanged", accountId, {});
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
/** Find a folder by specialUse, case-insensitive */
|
|
2554
|
-
private findFolder(accountId: string, specialUse: string): { id: number; path: string } | null {
|
|
2555
|
-
const folders = this.db.getFolders(accountId);
|
|
2556
|
-
return folders.find(f =>
|
|
2557
|
-
f.specialUse === specialUse ||
|
|
2558
|
-
f.path.toLowerCase() === specialUse.toLowerCase()
|
|
2559
|
-
) || null;
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
/** Optimistic local-first Sent insert: write a row into the local DB's
|
|
2563
|
-
* Sent folder the moment the user hits Send, so the list reflects it
|
|
2564
|
-
* immediately instead of waiting for SMTP + IMAP-APPEND + syncFolder
|
|
2565
|
-
* (five server round-trips against a Dovecot that caps at 20 conns).
|
|
2566
|
-
*
|
|
2567
|
-
* Uses a synthetic negative UID so it can't collide with a real APPENDUID
|
|
2568
|
-
* (which is always positive). When the real sync eventually picks the
|
|
2569
|
-
* message up in Sent with the server's UID, `db.upsertMessage` spots
|
|
2570
|
-
* the Message-ID match and rebinds the existing row's UID — no duplicate.
|
|
2571
|
-
* Negative UID also makes the row render pink (getMessages flags uid<0
|
|
2572
|
-
* as pending) so the user sees it's not-yet-reconciled.
|
|
2573
|
-
*
|
|
2574
|
-
* Best-effort — any failure path (no Sent folder yet, parse error, store
|
|
2575
|
-
* write error) is logged and swallowed; the send itself is unaffected. */
|
|
2576
|
-
async insertOptimisticSentRow(
|
|
2577
|
-
accountId: string,
|
|
2578
|
-
envelope: {
|
|
2579
|
-
messageId: string;
|
|
2580
|
-
inReplyTo: string;
|
|
2581
|
-
references: string[];
|
|
2582
|
-
subject: string;
|
|
2583
|
-
from: EmailAddress;
|
|
2584
|
-
to: EmailAddress[];
|
|
2585
|
-
cc: EmailAddress[];
|
|
2586
|
-
bcc: EmailAddress[];
|
|
2587
|
-
date: number;
|
|
2588
|
-
},
|
|
2589
|
-
rawMessage: string,
|
|
2590
|
-
): Promise<void> {
|
|
2591
|
-
try {
|
|
2592
|
-
const sent = this.findFolder(accountId, "sent");
|
|
2593
|
-
if (!sent) {
|
|
2594
|
-
console.log(` [sent-local] no Sent folder for ${accountId}; skipping optimistic row`);
|
|
2595
|
-
return;
|
|
2596
|
-
}
|
|
2597
|
-
// Synthetic UID — negative ms timestamp is monotonic + won't
|
|
2598
|
-
// collide with server UIDs. When the real APPENDUID returns via
|
|
2599
|
-
// sync, upsertMessage's Message-ID rebind swaps this for the
|
|
2600
|
-
// real positive value.
|
|
2601
|
-
const synthUid = -Date.now();
|
|
2602
|
-
const bodyPath = await this.bodyStore.putMessage(
|
|
2603
|
-
accountId, sent.id, synthUid, Buffer.from(rawMessage, "utf-8"),
|
|
2604
|
-
);
|
|
2605
|
-
const parsed = await extractPreview(rawMessage);
|
|
2606
|
-
this.db.upsertMessage({
|
|
2607
|
-
accountId,
|
|
2608
|
-
folderId: sent.id,
|
|
2609
|
-
uid: synthUid,
|
|
2610
|
-
messageId: envelope.messageId,
|
|
2611
|
-
inReplyTo: envelope.inReplyTo,
|
|
2612
|
-
references: envelope.references,
|
|
2613
|
-
date: envelope.date,
|
|
2614
|
-
subject: envelope.subject,
|
|
2615
|
-
from: envelope.from,
|
|
2616
|
-
to: envelope.to,
|
|
2617
|
-
cc: envelope.cc,
|
|
2618
|
-
flags: ["\\Seen"],
|
|
2619
|
-
size: rawMessage.length,
|
|
2620
|
-
hasAttachments: parsed.hasAttachments,
|
|
2621
|
-
preview: parsed.preview,
|
|
2622
|
-
bodyPath,
|
|
2623
|
-
});
|
|
2624
|
-
// Folder-tree badge refresh + message-list reload if the user
|
|
2625
|
-
// is currently on Sent — same event the sync path emits.
|
|
2626
|
-
this.db.recalcFolderCounts(sent.id);
|
|
2627
|
-
this.emit("folderCountsChanged", { accountId, folderId: sent.id });
|
|
2628
|
-
console.log(` [sent-local] wrote optimistic row in Sent (uid=${synthUid}) for ${accountId}: ${envelope.subject}`);
|
|
2629
|
-
} catch (e: any) {
|
|
2630
|
-
// Non-fatal — send continues, Sent folder just won't show the
|
|
2631
|
-
// row until the real APPEND-then-sync cycle completes.
|
|
2632
|
-
console.error(` [sent-local] optimistic insert failed: ${e?.message || e}`);
|
|
2633
|
-
}
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
/** Copy sent message to the Sent folder via IMAP APPEND */
|
|
2637
|
-
async copyToSent(accountId: string, rawMessage: string | Buffer): Promise<void> {
|
|
2638
|
-
const sent = this.findFolder(accountId, "sent");
|
|
2639
|
-
if (!sent) {
|
|
2640
|
-
console.error(` [sent] No Sent folder found for ${accountId}`);
|
|
2641
|
-
return;
|
|
2642
|
-
}
|
|
2643
|
-
|
|
2644
|
-
await this.withConnection(accountId, async (client) => {
|
|
2645
|
-
await client.appendMessage(sent.path, rawMessage, ["\\Seen"]);
|
|
2646
|
-
console.log(` [sent] Copied to ${sent.path}`);
|
|
2647
|
-
});
|
|
2648
|
-
}
|
|
2649
|
-
|
|
2650
|
-
/** Save a draft to the Drafts folder via IMAP APPEND.
|
|
2651
|
-
* Returns the UID of the saved draft (for replacing on next save). */
|
|
2652
|
-
async saveDraft(accountId: string, rawMessage: string | Buffer, previousDraftUid?: number, draftId?: string): Promise<number | null> {
|
|
2653
|
-
const drafts = this.findFolder(accountId, "drafts");
|
|
2654
|
-
if (!drafts) {
|
|
2655
|
-
console.error(` [drafts] No Drafts folder found for ${accountId}`);
|
|
2656
|
-
return null;
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
return this.withConnection(accountId, async (client) => {
|
|
2660
|
-
// Delete previous draft — try UID first (fast path), and ALWAYS also try
|
|
2661
|
-
// searchByHeader(X-Mailx-Draft-ID) as a safety net. Running both catches
|
|
2662
|
-
// orphans from a crash-mid-save or a UID delete that failed silently.
|
|
2663
|
-
if (previousDraftUid) {
|
|
2664
|
-
try {
|
|
2665
|
-
await client.deleteMessageByUid(drafts.path, previousDraftUid);
|
|
2666
|
-
} catch (e: any) {
|
|
2667
|
-
console.error(` [drafts] Delete prev UID ${previousDraftUid} failed: ${e.message}`);
|
|
2668
|
-
}
|
|
2669
|
-
}
|
|
2670
|
-
if (draftId) {
|
|
2671
|
-
try {
|
|
2672
|
-
const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
|
|
2673
|
-
for (const uid of uids) {
|
|
2674
|
-
try { await client.deleteMessageByUid(drafts.path, uid); } catch { /* next */ }
|
|
2675
|
-
}
|
|
2676
|
-
if (uids.length > 0) console.log(` [drafts] Deleted ${uids.length} stale draft(s) by ID ${draftId}`);
|
|
2677
|
-
} catch (e: any) {
|
|
2678
|
-
console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
|
|
2679
|
-
}
|
|
2680
|
-
}
|
|
2681
|
-
|
|
2682
|
-
// Append new draft. If the server returns [TRYCREATE] (RFC 3501 §7.1),
|
|
2683
|
-
// the folder doesn't exist on the server even though mailx's DB has
|
|
2684
|
-
// it. Create it and retry once.
|
|
2685
|
-
let result: any;
|
|
2686
|
-
try {
|
|
2687
|
-
result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
|
|
2688
|
-
} catch (e: any) {
|
|
2689
|
-
const msg = String(e?.message || e);
|
|
2690
|
-
if (/TRYCREATE/i.test(msg)) {
|
|
2691
|
-
console.log(` [drafts] APPEND got TRYCREATE for "${drafts.path}" — creating folder and retrying`);
|
|
2692
|
-
try { await client.createmailbox(drafts.path); }
|
|
2693
|
-
catch (ce: any) {
|
|
2694
|
-
if (!/already exists/i.test(String(ce?.message || ""))) {
|
|
2695
|
-
console.error(` [drafts] Folder create failed for "${drafts.path}": ${ce.message}`);
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
2698
|
-
result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
|
|
2699
|
-
} else {
|
|
2700
|
-
throw e;
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
const uid: number | null = typeof result === "number" ? result : (result as any)?.uid || null;
|
|
2704
|
-
return uid;
|
|
2705
|
-
});
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
|
-
/** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
|
|
2709
|
-
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
2710
|
-
* from earlier failed autosaves are cleaned up at the same time. */
|
|
2711
|
-
async deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void> {
|
|
2712
|
-
const drafts = this.findFolder(accountId, "drafts");
|
|
2713
|
-
if (!drafts) return;
|
|
2714
|
-
if (!draftUid && !draftId) return;
|
|
2715
|
-
|
|
2716
|
-
await this.withConnection(accountId, async (client) => {
|
|
2717
|
-
if (draftUid) {
|
|
2718
|
-
try {
|
|
2719
|
-
await client.deleteMessageByUid(drafts.path, draftUid);
|
|
2720
|
-
console.log(` [drafts] Deleted draft UID ${draftUid}`);
|
|
2721
|
-
} catch (e: any) {
|
|
2722
|
-
console.error(` [drafts] Delete by UID ${draftUid} failed: ${e.message}`);
|
|
2723
|
-
}
|
|
2724
|
-
}
|
|
2725
|
-
if (draftId) {
|
|
2726
|
-
try {
|
|
2727
|
-
const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
|
|
2728
|
-
for (const uid of uids) {
|
|
2729
|
-
try { await client.deleteMessageByUid(drafts.path, uid); } catch { /* next */ }
|
|
2730
|
-
}
|
|
2731
|
-
if (uids.length > 0) console.log(` [drafts] Deleted ${uids.length} draft(s) by ID ${draftId}`);
|
|
2732
|
-
} catch (e: any) {
|
|
2733
|
-
console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
|
|
2734
|
-
}
|
|
2735
|
-
}
|
|
2736
|
-
});
|
|
2737
|
-
}
|
|
2738
|
-
|
|
2739
|
-
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
|
|
2740
|
-
* Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
|
|
2741
|
-
* kick processLocalQueue. The file IS the queue — durable across crashes,
|
|
2742
|
-
* visible in the filesystem, consumed by the existing outbox worker that
|
|
2743
|
-
* handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
|
|
2744
|
-
* sync_actions "send" branch was removed because it duplicated the same
|
|
2745
|
-
* work and risked double-send when both paths fired on the same message. */
|
|
2746
|
-
queueOutgoingLocal(accountId: string, rawMessage: string): void {
|
|
2747
|
-
// Loud logging so a "vanished message" report is diagnosable from the log alone.
|
|
2748
|
-
// ALWAYS leave a backup copy in sending/<acct>/attempted/ first — unconditionally,
|
|
2749
|
-
// before the outbox write. The outbox .ltr may be claimed/consumed by the worker
|
|
2750
|
-
// within milliseconds; this copy survives regardless of SMTP success, IMAP
|
|
2751
|
-
// append, worker crash, or any other downstream failure. User asked for this
|
|
2752
|
-
// as a fallback because "there isn't even the backup copy in sent".
|
|
2753
|
-
this.saveSendingCopy(accountId, rawMessage, "attempted");
|
|
2754
|
-
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
2755
|
-
try {
|
|
2756
|
-
fs.mkdirSync(outboxDir, { recursive: true });
|
|
2757
|
-
} catch (e: any) {
|
|
2758
|
-
console.error(` [outbox] FAIL mkdirSync ${outboxDir}: ${e?.message || e}`);
|
|
2759
|
-
throw new Error(`Cannot create outbox dir ${outboxDir}: ${e?.message || e}`);
|
|
2760
|
-
}
|
|
2761
|
-
const now = new Date();
|
|
2762
|
-
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
2763
|
-
const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
|
|
2764
|
-
const filePath = path.join(outboxDir, filename);
|
|
2765
|
-
try {
|
|
2766
|
-
fs.writeFileSync(filePath, rawMessage);
|
|
2767
|
-
} catch (e: any) {
|
|
2768
|
-
console.error(` [outbox] FAIL writeFileSync ${filePath}: ${e?.message || e}`);
|
|
2769
|
-
throw new Error(`Cannot write outbox file ${filePath}: ${e?.message || e}`);
|
|
2770
|
-
}
|
|
2771
|
-
// Immediate readback verification — if this DOESN'T print, the user's
|
|
2772
|
-
// "neither in outbox nor file system" report has a real explanation.
|
|
2773
|
-
const written = fs.existsSync(filePath);
|
|
2774
|
-
const size = written ? fs.statSync(filePath).size : 0;
|
|
2775
|
-
console.log(` [outbox] WROTE ${filePath} (${size} bytes, exists=${written})`);
|
|
2776
|
-
this.emitOutboxStatus();
|
|
2777
|
-
// CRITICAL: defer to next tick. processLocalQueue runs ~30+ lines
|
|
2778
|
-
// of synchronous fs work BEFORE its first await — calling it inline
|
|
2779
|
-
// blocks the IPC ack on all that work.
|
|
2780
|
-
setImmediate(() => {
|
|
2781
|
-
this.processLocalQueue(accountId)
|
|
2782
|
-
.catch((e: any) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`))
|
|
2783
|
-
.finally(() => this.emitOutboxStatus());
|
|
2784
|
-
});
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
/** Scan the local outbox + sending/queued dirs and return counts + age.
|
|
2788
|
-
* Cheap — a handful of readdir + head-read per file. Called by both the
|
|
2789
|
-
* polling UI (status bar) and emitted as an event after queue mutations. */
|
|
2790
|
-
getOutboxStatus(): OutboxStatus {
|
|
2791
|
-
const configDir = getConfigDir();
|
|
2792
|
-
const perAccount: Record<string, { total: number; retrying: number; claimed: number }> = {};
|
|
2793
|
-
let total = 0;
|
|
2794
|
-
let retrying = 0;
|
|
2795
|
-
let claimed = 0;
|
|
2796
|
-
let oldestMs = 0;
|
|
2797
|
-
let maxAttempts = 0;
|
|
2798
|
-
const now = Date.now();
|
|
2799
|
-
const scan = (accountId: string, dir: string) => {
|
|
2800
|
-
if (!fs.existsSync(dir)) return;
|
|
2801
|
-
for (const f of fs.readdirSync(dir)) {
|
|
2802
|
-
const isClaim = /\.sending-[^-]+-\d+$/.test(f);
|
|
2803
|
-
const isActive = isClaim || f.endsWith(".ltr") || f.endsWith(".eml");
|
|
2804
|
-
if (!isActive) continue;
|
|
2805
|
-
total++;
|
|
2806
|
-
const acctSlot = perAccount[accountId] ||= { total: 0, retrying: 0, claimed: 0 };
|
|
2807
|
-
acctSlot.total++;
|
|
2808
|
-
if (isClaim) { claimed++; acctSlot.claimed++; }
|
|
2809
|
-
const fp = path.join(dir, f);
|
|
2810
|
-
try {
|
|
2811
|
-
const st = fs.statSync(fp);
|
|
2812
|
-
const age = now - st.mtimeMs;
|
|
2813
|
-
if (age > oldestMs) oldestMs = age;
|
|
2814
|
-
// Only read header region to count retry attempts — tiny I/O.
|
|
2815
|
-
const fd = fs.openSync(fp, "r");
|
|
2816
|
-
try {
|
|
2817
|
-
const buf = Buffer.alloc(4096);
|
|
2818
|
-
const n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
2819
|
-
const head = buf.slice(0, n).toString("utf-8");
|
|
2820
|
-
const info = parseRetryInfo(head);
|
|
2821
|
-
if (info.attemptCount > 0) { retrying++; acctSlot.retrying++; }
|
|
2822
|
-
if (info.attemptCount > maxAttempts) maxAttempts = info.attemptCount;
|
|
2823
|
-
} finally { fs.closeSync(fd); }
|
|
2824
|
-
} catch { /* ignore per-file errors */ }
|
|
2825
|
-
}
|
|
2826
|
-
};
|
|
2827
|
-
const outboxRoot = path.join(configDir, "outbox");
|
|
2828
|
-
const sendingRoot = path.join(configDir, "sending");
|
|
2829
|
-
try {
|
|
2830
|
-
if (fs.existsSync(outboxRoot)) {
|
|
2831
|
-
for (const acct of fs.readdirSync(outboxRoot)) scan(acct, path.join(outboxRoot, acct));
|
|
2832
|
-
}
|
|
2833
|
-
if (fs.existsSync(sendingRoot)) {
|
|
2834
|
-
for (const acct of fs.readdirSync(sendingRoot)) {
|
|
2835
|
-
scan(acct, path.join(sendingRoot, acct, "queued"));
|
|
2836
|
-
}
|
|
2837
|
-
}
|
|
2838
|
-
} catch { /* */ }
|
|
2839
|
-
return {
|
|
2840
|
-
total, retrying, claimed,
|
|
2841
|
-
oldestAgeSec: Math.floor(oldestMs / 1000),
|
|
2842
|
-
maxAttempts,
|
|
2843
|
-
perAccount,
|
|
2844
|
-
};
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
|
-
/** Emit outboxStatus now. Call after any queue mutation. */
|
|
2848
|
-
private emitOutboxStatus(): void {
|
|
2849
|
-
try { this.emit("outboxStatus", this.getOutboxStatus()); } catch { /* */ }
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
/** Guard against concurrent processSendActions for the same account */
|
|
2853
|
-
private sendingAccounts = new Set<string>();
|
|
2854
|
-
|
|
2855
|
-
/** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
|
|
2856
|
-
private async processSendActions(accountId: string): Promise<void> {
|
|
2857
|
-
if (this.sendingAccounts.has(accountId)) return; // already processing
|
|
2858
|
-
this.sendingAccounts.add(accountId);
|
|
2859
|
-
try { await this._processSendActions(accountId); }
|
|
2860
|
-
finally { this.sendingAccounts.delete(accountId); }
|
|
2861
|
-
}
|
|
2862
|
-
|
|
2863
|
-
private async _processSendActions(accountId: string): Promise<void> {
|
|
2864
|
-
const actions = this.db.getPendingSyncActions(accountId)
|
|
2865
|
-
.filter(a => a.action === "send");
|
|
2866
|
-
if (actions.length === 0) return;
|
|
2867
|
-
|
|
2868
|
-
for (const action of actions) {
|
|
2869
|
-
if (!action.rawMessage) {
|
|
2870
|
-
this.db.completeSyncAction(action.id);
|
|
2871
|
-
continue;
|
|
2872
|
-
}
|
|
2873
|
-
// Abandon after 10 failed attempts — don't retry forever
|
|
2874
|
-
if (action.attempts >= 10) {
|
|
2875
|
-
console.error(` [outbox] Abandoning send action ${action.id} after ${action.attempts} attempts: ${action.rawMessage?.substring(0, 100)}`);
|
|
2876
|
-
this.db.completeSyncAction(action.id);
|
|
2877
|
-
this.emit("accountError", accountId, `Send permanently failed after ${action.attempts} attempts`, "Message removed from queue", false);
|
|
2878
|
-
continue;
|
|
2879
|
-
}
|
|
2880
|
-
try {
|
|
2881
|
-
await this.queueOutgoing(accountId, action.rawMessage);
|
|
2882
|
-
this.db.completeSyncAction(action.id);
|
|
2883
|
-
} catch (e: any) {
|
|
2884
|
-
console.error(` [outbox] Local→IMAP failed (attempt ${action.attempts + 1}): ${e.message}`);
|
|
2885
|
-
this.db.failSyncAction(action.id, e.message);
|
|
2886
|
-
}
|
|
2887
|
-
}
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
// ── Outbox ──
|
|
2891
|
-
|
|
2892
|
-
private outboxInterval: ReturnType<typeof setInterval> | null = null;
|
|
2893
|
-
private readonly hostname = os.hostname();
|
|
2894
|
-
|
|
2895
|
-
/** Ensure Outbox folder exists, create if needed */
|
|
2896
|
-
private async ensureOutbox(accountId: string): Promise<string> {
|
|
2897
|
-
let outbox = this.findFolder(accountId, "outbox");
|
|
2898
|
-
if (outbox) return outbox.path;
|
|
2899
|
-
|
|
2900
|
-
// Look for existing folder named Outbox (case-insensitive)
|
|
2901
|
-
const folders = this.db.getFolders(accountId);
|
|
2902
|
-
const existing = folders.find(f => f.path.toLowerCase() === "outbox");
|
|
2903
|
-
if (existing) return existing.path;
|
|
2904
|
-
|
|
2905
|
-
try {
|
|
2906
|
-
await this.withConnection(accountId, async (client) => {
|
|
2907
|
-
await client.createmailbox("Outbox");
|
|
2908
|
-
await this.syncFolders(accountId, client);
|
|
2909
|
-
});
|
|
2910
|
-
} catch (e: any) {
|
|
2911
|
-
// Might already exist — benign
|
|
2912
|
-
if (!e.message?.includes("already exists")) throw e;
|
|
2913
|
-
}
|
|
2914
|
-
|
|
2915
|
-
outbox = this.findFolder(accountId, "outbox");
|
|
2916
|
-
return outbox?.path || "Outbox";
|
|
2917
|
-
}
|
|
2918
|
-
|
|
2919
|
-
/** Save a copy of outgoing mail — label is a subdirectory (editing/queued/sent) */
|
|
2920
|
-
private saveSendingCopy(accountId: string, rawMessage: string | Buffer, label: string): void {
|
|
2921
|
-
try {
|
|
2922
|
-
const dir = path.join(getConfigDir(), "sending", accountId, label);
|
|
2923
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
2924
|
-
const now = new Date();
|
|
2925
|
-
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
2926
|
-
const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
|
|
2927
|
-
fs.writeFileSync(path.join(dir, `${ts}.eml`), rawMessage);
|
|
2928
|
-
console.log(` [sending] ${label}/${ts}.eml`);
|
|
2929
|
-
} catch (e: any) {
|
|
2930
|
-
console.error(` [sending] Failed to save copy: ${e.message}`);
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
|
|
2934
|
-
/** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
|
|
2935
|
-
async queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void> {
|
|
2936
|
-
// IMPORTANT: do NOT save a "debug copy" to sending/<acct>/queued/ here.
|
|
2937
|
-
// processLocalQueue also scans sending/<acct>/queued/, so writing there
|
|
2938
|
-
// on every send caused the same message to be re-APPENDed to the IMAP
|
|
2939
|
-
// Outbox on the next outbox tick — resulting in a duplicate send.
|
|
2940
|
-
// The only two legitimate queue locations are:
|
|
2941
|
-
// - IMAP Outbox (primary, populated by APPEND below)
|
|
2942
|
-
// - ~/.mailx/outbox/<acct>/*.ltr (fallback when IMAP is unreachable)
|
|
2943
|
-
try {
|
|
2944
|
-
const outboxPath = await this.ensureOutbox(accountId);
|
|
2945
|
-
await this.withConnection(accountId, async (client) => {
|
|
2946
|
-
await client.appendMessage(outboxPath, rawMessage, ["\\Seen"]);
|
|
2947
|
-
console.log(` [outbox] Queued message in ${outboxPath}`);
|
|
2948
|
-
});
|
|
2949
|
-
const outboxFolder = this.findFolder(accountId, "outbox");
|
|
2950
|
-
if (outboxFolder) {
|
|
2951
|
-
this.syncFolder(accountId, outboxFolder.id).catch(() => {});
|
|
2952
|
-
}
|
|
2953
|
-
return;
|
|
2954
|
-
} catch (e: any) {
|
|
2955
|
-
console.error(` [outbox] IMAP queue failed: ${e.message} — saving locally`);
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
|
-
// Fallback: save to local file queue (processLocalQueue picks these up)
|
|
2959
|
-
const localQueue = path.join(getConfigDir(), "outbox", accountId);
|
|
2960
|
-
fs.mkdirSync(localQueue, { recursive: true });
|
|
2961
|
-
const now = new Date();
|
|
2962
|
-
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
2963
|
-
const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
|
|
2964
|
-
fs.writeFileSync(path.join(localQueue, filename), rawMessage);
|
|
2965
|
-
console.log(` [outbox] Saved locally: ${filename}`);
|
|
2966
|
-
}
|
|
2967
|
-
|
|
2968
|
-
/** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
|
|
2969
|
-
* and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
|
|
2970
|
-
* double-send bug was caused by queueOutgoing() WRITING a debug copy to
|
|
2971
|
-
* sending/queued/ on every send — that write is gone now, so scanning the
|
|
2972
|
-
* directory is safe again. Any legitimate files that land there (crash
|
|
2973
|
-
* recovery, manual drop) will get sent. */
|
|
2974
|
-
private async processLocalQueue(accountId: string): Promise<void> {
|
|
2975
|
-
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
2976
|
-
const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
|
|
2977
|
-
|
|
2978
|
-
// Recovery sweep: any *.sending-<host>-<pid> on THIS host whose PID is
|
|
2979
|
-
// dead (process crashed mid-send) gets unclaimed so the next tick can
|
|
2980
|
-
// retry. Foreign hosts are left alone — we have no way to know if their
|
|
2981
|
-
// process is alive. Cross-host stale recovery is the IMAP-folder path's
|
|
2982
|
-
// job (sweeper looks at server-side claim flags, not local files).
|
|
2983
|
-
for (const dir of [outboxDir, queuedDir]) {
|
|
2984
|
-
if (!fs.existsSync(dir)) continue;
|
|
2985
|
-
for (const f of fs.readdirSync(dir)) {
|
|
2986
|
-
const m = f.match(/^(.+)\.sending-([^-]+)-(\d+)$/);
|
|
2987
|
-
if (!m) continue;
|
|
2988
|
-
const [, original, host, pidStr] = m;
|
|
2989
|
-
if (host !== this.hostname) continue;
|
|
2990
|
-
const pid = parseInt(pidStr);
|
|
2991
|
-
let alive = false;
|
|
2992
|
-
try { process.kill(pid, 0); alive = true; } catch { /* dead */ }
|
|
2993
|
-
if (alive) continue; // live claim — owner (sibling or self) still has it
|
|
2994
|
-
try {
|
|
2995
|
-
fs.renameSync(path.join(dir, f), path.join(dir, original));
|
|
2996
|
-
console.log(` [outbox] Recovered stale claim ${f} → ${original}`);
|
|
2997
|
-
} catch { /* ignore */ }
|
|
2998
|
-
}
|
|
2999
|
-
}
|
|
3000
|
-
|
|
3001
|
-
const filesToSend: { dir: string; file: string }[] = [];
|
|
3002
|
-
for (const dir of [outboxDir, queuedDir]) {
|
|
3003
|
-
if (!fs.existsSync(dir)) continue;
|
|
3004
|
-
for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
|
|
3005
|
-
filesToSend.push({ dir, file });
|
|
3006
|
-
}
|
|
3007
|
-
}
|
|
3008
|
-
if (filesToSend.length === 0) return;
|
|
3009
|
-
|
|
3010
|
-
// Gmail/API accounts: send directly via SMTP from local queue (no IMAP outbox)
|
|
3011
|
-
const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
|
|
3012
|
-
fs.mkdirSync(sentDir, { recursive: true });
|
|
3013
|
-
|
|
3014
|
-
if (this.isGmailAccount(accountId)) {
|
|
3015
|
-
const nowMs = Date.now();
|
|
3016
|
-
for (const { dir, file } of filesToSend) {
|
|
3017
|
-
const filePath = path.join(dir, file);
|
|
3018
|
-
|
|
3019
|
-
// Atomic claim: rename to <file>.sending-<host>-<pid> so a sibling
|
|
3020
|
-
// process scanning the same dir can't grab the same .ltr. Filesystem
|
|
3021
|
-
// rename is atomic; loser sees ENOENT and skips. Without this, two
|
|
3022
|
-
// mailx instances on one machine (or two ticks within one process)
|
|
3023
|
-
// could both pass the Message-ID dedup check and both call SMTP.
|
|
3024
|
-
const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
|
|
3025
|
-
const claimedPath = filePath + claimSuffix;
|
|
3026
|
-
try {
|
|
3027
|
-
fs.renameSync(filePath, claimedPath);
|
|
3028
|
-
} catch (e: any) {
|
|
3029
|
-
if (e.code === "ENOENT") continue; // another process won
|
|
3030
|
-
throw e;
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
let raw = fs.readFileSync(claimedPath, "utf-8");
|
|
3034
|
-
|
|
3035
|
-
// Per-message rate limit: if a prior attempt set X-Mailx-Retry-After
|
|
3036
|
-
// in the future, skip this file for now. Minimizes the race where the
|
|
3037
|
-
// SMTP server actually accepted DATA but we lost the ack and would
|
|
3038
|
-
// otherwise retry immediately on the next 10s tick.
|
|
3039
|
-
const retryInfo = parseRetryInfo(raw);
|
|
3040
|
-
if (retryInfo.nextAttemptAt > nowMs) {
|
|
3041
|
-
// Release claim — let next tick reconsider
|
|
3042
|
-
try { fs.renameSync(claimedPath, filePath); } catch { /* ignore */ }
|
|
3043
|
-
continue;
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
// Record this attempt: strip internal X-Mailx-Retry-After, append a new
|
|
3047
|
-
// X-Mailx-Retry: N <ISO-timestamp> line to the headers. The updated file
|
|
3048
|
-
// is written back *before* the send so a crash mid-send doesn't lose state.
|
|
3049
|
-
const attempt = retryInfo.attemptCount + 1;
|
|
3050
|
-
raw = stripHeaderField(raw, "X-Mailx-Retry-After");
|
|
3051
|
-
raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
|
|
3052
|
-
fs.writeFileSync(claimedPath, raw, "utf-8");
|
|
3053
|
-
|
|
3054
|
-
try {
|
|
3055
|
-
await this.sendRawViaSMTP(accountId, raw);
|
|
3056
|
-
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
3057
|
-
console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
|
|
3058
|
-
} catch (e: any) {
|
|
3059
|
-
// Persist a next-attempt timestamp and release the claim so the
|
|
3060
|
-
// file is visible to the scan loop again.
|
|
3061
|
-
const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
|
|
3062
|
-
const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
|
|
3063
|
-
fs.writeFileSync(claimedPath, withDelay, "utf-8");
|
|
3064
|
-
try { fs.renameSync(claimedPath, filePath); } catch { /* file stays claimed; recovery sweeper will handle */ }
|
|
3065
|
-
console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
|
|
3066
|
-
}
|
|
3067
|
-
}
|
|
3068
|
-
return;
|
|
3069
|
-
}
|
|
3070
|
-
|
|
3071
|
-
// IMAP accounts: append to IMAP Outbox for multi-machine interlock.
|
|
3072
|
-
//
|
|
3073
|
-
// Atomic claim (same pattern as the Gmail path above): rename the file
|
|
3074
|
-
// to <file>.sending-<host>-<pid> BEFORE reading it, so two concurrent
|
|
3075
|
-
// mailx instances scanning the same dir can't both APPEND the same
|
|
3076
|
-
// message to IMAP Outbox and end up with a duplicate. Filesystem rename
|
|
3077
|
-
// is atomic; the loser sees ENOENT and skips. On APPEND success, move
|
|
3078
|
-
// the claimed file to sending/sent/; on APPEND failure, release the
|
|
3079
|
-
// claim so the recovery sweeper picks it up next tick.
|
|
3080
|
-
try {
|
|
3081
|
-
const outboxPath = await this.ensureOutbox(accountId);
|
|
3082
|
-
const client = await this.createClientWithLimit(accountId);
|
|
3083
|
-
try {
|
|
3084
|
-
for (const { dir, file } of filesToSend) {
|
|
3085
|
-
const filePath = path.join(dir, file);
|
|
3086
|
-
const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
|
|
3087
|
-
const claimedPath = filePath + claimSuffix;
|
|
3088
|
-
try {
|
|
3089
|
-
fs.renameSync(filePath, claimedPath);
|
|
3090
|
-
} catch (e: any) {
|
|
3091
|
-
if (e.code === "ENOENT") continue; // sibling claimed first
|
|
3092
|
-
throw e;
|
|
3093
|
-
}
|
|
3094
|
-
try {
|
|
3095
|
-
const raw = fs.readFileSync(claimedPath, "utf-8");
|
|
3096
|
-
await client.appendMessage(outboxPath, raw, ["\\Seen"]);
|
|
3097
|
-
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
3098
|
-
console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
|
|
3099
|
-
} catch (e: any) {
|
|
3100
|
-
// APPEND failed (connection dropped mid-send, server
|
|
3101
|
-
// busy, etc.) — release the claim so next tick can
|
|
3102
|
-
// retry. Don't swallow: rethrow after release so the
|
|
3103
|
-
// outer catch ("IMAP still unreachable") bails out of
|
|
3104
|
-
// the remaining files too — whatever broke will break
|
|
3105
|
-
// the next file the same way.
|
|
3106
|
-
try { fs.renameSync(claimedPath, filePath); } catch { /* recovery sweeper will handle */ }
|
|
3107
|
-
throw e;
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
} finally {
|
|
3111
|
-
try { await client.logout(); } catch { /* ignore */ }
|
|
3112
|
-
}
|
|
3113
|
-
} catch {
|
|
3114
|
-
// IMAP still unreachable — leave files for next attempt
|
|
3115
|
-
}
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
/** Send a raw RFC 2822 message via SMTP for a given account.
|
|
3119
|
-
* Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
|
|
3120
|
-
* same TCP byte-stream interface, no nodemailer dependency. */
|
|
3121
|
-
private async sendRawViaSMTP(accountId: string, raw: string): Promise<void> {
|
|
3122
|
-
const settings = loadSettings();
|
|
3123
|
-
const account = settings.accounts.find(a => a.id === accountId);
|
|
3124
|
-
if (!account?.smtp) throw new Error(`No SMTP config for ${accountId}`);
|
|
3125
|
-
|
|
3126
|
-
const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
|
|
3127
|
-
const smtpHost = account.smtp.host || account.imap?.host;
|
|
3128
|
-
if (!smtpHost) throw new Error(`No SMTP host for ${accountId}`);
|
|
3129
|
-
|
|
3130
|
-
// SMTP auth: explicit SMTP creds, fall back to IMAP creds for password auth.
|
|
3131
|
-
const smtpAuthType = account.smtp.auth || (account.imap?.password ? "password" : undefined);
|
|
3132
|
-
const smtpUser = account.smtp.user || account.imap?.user || account.email;
|
|
3133
|
-
let auth: import("@bobfrankston/smtp-direct").SmtpAuth | undefined;
|
|
3134
|
-
if (smtpAuthType === "password") {
|
|
3135
|
-
const pass = account.smtp.password || account.imap?.password;
|
|
3136
|
-
if (!pass) throw new Error("SMTP password not configured");
|
|
3137
|
-
auth = { method: "PLAIN", user: smtpUser, pass };
|
|
3138
|
-
} else if (smtpAuthType === "oauth2") {
|
|
3139
|
-
const token = await this.getOAuthToken(accountId);
|
|
3140
|
-
if (!token) throw new Error("OAuth token not available");
|
|
3141
|
-
auth = { method: "XOAUTH2", user: smtpUser, token };
|
|
3142
|
-
}
|
|
3143
|
-
|
|
3144
|
-
const parseAddrs = (s: string) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
|
|
3145
|
-
const toMatch = raw.match(/^To:\s*(.+)$/mi);
|
|
3146
|
-
const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
|
|
3147
|
-
const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
|
|
3148
|
-
const fromMatch = raw.match(/^From:\s*(.+)$/mi);
|
|
3149
|
-
const subjectMatch = raw.match(/^Subject:\s*(.+)$/mi);
|
|
3150
|
-
const messageIdMatch = raw.match(/^Message-ID:\s*(<[^>]+>)/mi);
|
|
3151
|
-
const recipients = [
|
|
3152
|
-
...(toMatch ? parseAddrs(toMatch[1]) : []),
|
|
3153
|
-
...(ccMatch ? parseAddrs(ccMatch[1]) : []),
|
|
3154
|
-
...(bccMatch ? parseAddrs(bccMatch[1]) : []),
|
|
3155
|
-
];
|
|
3156
|
-
const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
|
|
3157
|
-
if (recipients.length === 0) throw new Error("No recipients");
|
|
3158
|
-
|
|
3159
|
-
// Dedup: skip if this Message-ID has already been sent. Prevents the
|
|
3160
|
-
// outbox from re-sending the same file across crash/restart cycles.
|
|
3161
|
-
const messageId = messageIdMatch ? messageIdMatch[1] : "";
|
|
3162
|
-
if (messageId && this.db.hasSentMessage(messageId)) {
|
|
3163
|
-
console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
|
|
3164
|
-
return;
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
|
|
3168
|
-
this.saveSendingCopy(accountId, rawToSend, "sent");
|
|
3169
|
-
|
|
3170
|
-
const smtp = new SmtpClient({
|
|
3171
|
-
host: smtpHost,
|
|
3172
|
-
port: smtpPort,
|
|
3173
|
-
secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
|
|
3174
|
-
auth,
|
|
3175
|
-
localname: os.hostname(),
|
|
3176
|
-
}, this.transportFactory);
|
|
3177
|
-
try {
|
|
3178
|
-
await smtp.connect();
|
|
3179
|
-
const result = await smtp.sendMail({ from: sender, to: recipients }, rawToSend);
|
|
3180
|
-
if (result.rejected.length > 0) {
|
|
3181
|
-
console.log(` [smtp] ${accountId}: ${result.rejected.length} recipient(s) rejected: ${result.rejected.map(r => `${r.address} (${r.code})`).join(", ")}`);
|
|
3182
|
-
}
|
|
3183
|
-
} finally {
|
|
3184
|
-
try { await smtp.quit(); } catch { /* ignore */ }
|
|
3185
|
-
}
|
|
3186
|
-
|
|
3187
|
-
if (messageId) {
|
|
3188
|
-
this.db.recordSent(messageId, accountId, subjectMatch?.[1]?.trim() || "", recipients);
|
|
3189
|
-
}
|
|
3190
|
-
console.log(` [smtp] ${accountId}: sent to ${recipients.join(", ")}`);
|
|
3191
|
-
}
|
|
3192
|
-
|
|
3193
|
-
/** Process Outbox — send pending messages with flag-based interlock.
|
|
3194
|
-
* Each per-UID step is its own withConnection({slow}) call so the queue
|
|
3195
|
-
* yields between messages: a click-to-view body in the middle of a
|
|
3196
|
-
* 10-message outbox drain doesn't wait for all 10 to finish. */
|
|
3197
|
-
async processOutbox(accountId: string): Promise<void> {
|
|
3198
|
-
const outboxFolder = this.findFolder(accountId, "outbox");
|
|
3199
|
-
if (!outboxFolder) return;
|
|
3200
|
-
|
|
3201
|
-
// Gmail: skip IMAP outbox check — sending handled by processLocalQueue which sends directly via SMTP
|
|
3202
|
-
if (this.isGmailAccount(accountId)) return;
|
|
3203
|
-
|
|
3204
|
-
const settings = loadSettings();
|
|
3205
|
-
const account = settings.accounts.find(a => a.id === accountId);
|
|
3206
|
-
if (!account) return;
|
|
3207
|
-
|
|
3208
|
-
// List UIDs first — quick command, fast lane.
|
|
3209
|
-
const uids = await this.withConnection(accountId, (client) =>
|
|
3210
|
-
client.getUids(outboxFolder.path)
|
|
3211
|
-
) as number[];
|
|
3212
|
-
if (uids.length === 0) return;
|
|
3213
|
-
|
|
3214
|
-
const STALE_CLAIM_MS = 3600_000; // 1 hour — longer than any reasonable SMTP send
|
|
3215
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
3216
|
-
const sendingFlag = `$Sending-${this.hostname}-${nowSec}`;
|
|
3217
|
-
const sentFolder = this.findFolder(accountId, "sent");
|
|
3218
|
-
|
|
3219
|
-
for (const uid of uids) {
|
|
3220
|
-
// Each iteration is one slow-lane turn — fast-lane work can run
|
|
3221
|
-
// between iterations, so a body click during a long outbox drain
|
|
3222
|
-
// gets serviced promptly.
|
|
3223
|
-
const result = await this.withConnection(accountId, async (client) => {
|
|
3224
|
-
const flags = await client.getFlags(outboxFolder.path, uid);
|
|
3225
|
-
|
|
3226
|
-
// Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
|
|
3227
|
-
// or legacy $Sending-<host> without timestamp (treated as
|
|
3228
|
-
// stale; if the real owner is alive it'll re-claim next tick).
|
|
3229
|
-
const claimFlags = flags.filter((f: string) => f.startsWith("$Sending"));
|
|
3230
|
-
for (const cf of claimFlags) {
|
|
3231
|
-
const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
|
|
3232
|
-
if (!m) continue;
|
|
3233
|
-
const tsSec = m[2] ? parseInt(m[2]) : 0;
|
|
3234
|
-
const ageSec = nowSec - tsSec;
|
|
3235
|
-
if (ageSec * 1000 > STALE_CLAIM_MS) {
|
|
3236
|
-
try {
|
|
3237
|
-
await client.removeFlags(outboxFolder.path, uid, [cf]);
|
|
3238
|
-
console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
|
|
3239
|
-
} catch { /* ignore */ }
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
|
|
3243
|
-
const flagsNow = (claimFlags.length > 0)
|
|
3244
|
-
? await client.getFlags(outboxFolder.path, uid)
|
|
3245
|
-
: flags;
|
|
3246
|
-
if (flagsNow.some((f: string) => f.startsWith("$Sending"))) return { skip: true };
|
|
3247
|
-
if (flagsNow.includes("$PermanentFailure")) return { skip: true };
|
|
3248
|
-
if (flagsNow.includes("$Failed")) {
|
|
3249
|
-
await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
|
|
3250
|
-
}
|
|
3251
|
-
|
|
3252
|
-
await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
3253
|
-
|
|
3254
|
-
// TOCTOU re-check: if two devices addFlags concurrently both
|
|
3255
|
-
// see ≥2 sending flags. Fail-safe: both back off, next tick
|
|
3256
|
-
// one wins.
|
|
3257
|
-
const flagsAfter = await client.getFlags(outboxFolder.path, uid);
|
|
3258
|
-
const sendingFlags = flagsAfter.filter((f: string) => f.startsWith("$Sending"));
|
|
3259
|
-
if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
|
|
3260
|
-
await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
3261
|
-
return { skip: true };
|
|
3262
|
-
}
|
|
3263
|
-
|
|
3264
|
-
const msg = await client.fetchMessageByUid(outboxFolder.path, uid, { source: true });
|
|
3265
|
-
if (!msg?.source) {
|
|
3266
|
-
await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
3267
|
-
return { skip: true };
|
|
3268
|
-
}
|
|
3269
|
-
return { source: msg.source };
|
|
3270
|
-
}, { slow: true });
|
|
3271
|
-
|
|
3272
|
-
if ((result as any).skip) continue;
|
|
3273
|
-
const source = (result as any).source as string;
|
|
3274
|
-
|
|
3275
|
-
// SMTP send is its own connection — not an IMAP op, doesn't go
|
|
3276
|
-
// through withConnection.
|
|
3277
|
-
try {
|
|
3278
|
-
await this.sendRawViaSMTP(accountId, source);
|
|
3279
|
-
console.log(` [outbox] Sent UID ${uid}`);
|
|
3280
|
-
|
|
3281
|
-
// Delete from Outbox + copy to Sent. Done in two separate
|
|
3282
|
-
// withConnection calls so other work can interleave.
|
|
3283
|
-
await this.withConnection(accountId, async (client) => {
|
|
3284
|
-
// Delete FIRST to prevent double-send if Sent-copy fails.
|
|
3285
|
-
await client.deleteMessageByUid(outboxFolder.path, uid);
|
|
3286
|
-
}, { slow: true });
|
|
3287
|
-
if (sentFolder) {
|
|
3288
|
-
try {
|
|
3289
|
-
await this.withConnection(accountId, async (client) => {
|
|
3290
|
-
await client.appendMessage(sentFolder.path, source, ["\\Seen"]);
|
|
3291
|
-
}, { slow: true });
|
|
3292
|
-
this.syncFolder(accountId, sentFolder.id).catch(() => {});
|
|
3293
|
-
} catch (sentErr: any) {
|
|
3294
|
-
console.error(` [outbox] Failed to copy to Sent: ${sentErr.message} — message was sent successfully`);
|
|
3295
|
-
}
|
|
3296
|
-
this.syncFolder(accountId, outboxFolder.id).catch(() => {});
|
|
3297
|
-
}
|
|
3298
|
-
} catch (e: any) {
|
|
3299
|
-
const errMsg = e.message || String(e);
|
|
3300
|
-
console.error(` [outbox] Send failed UID ${uid}: ${errMsg}`);
|
|
3301
|
-
try {
|
|
3302
|
-
await this.withConnection(accountId, async (client) => {
|
|
3303
|
-
await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
|
|
3304
|
-
await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
|
|
3305
|
-
}, { slow: true });
|
|
3306
|
-
} catch { /* best-effort */ }
|
|
3307
|
-
this.emit("accountError", accountId, `Send failed: ${errMsg}`, "Message kept in Outbox", false);
|
|
3308
|
-
if (/auth|login|credential|invalid/i.test(errMsg)) {
|
|
3309
|
-
this.outboxBackoff.set(accountId, Date.now() + 3600000); // 1 hour
|
|
3310
|
-
console.error(` [outbox] Auth failure for ${accountId} — outbox paused for 1 hour`);
|
|
3311
|
-
}
|
|
3312
|
-
}
|
|
3313
|
-
}
|
|
3314
|
-
}
|
|
3315
|
-
|
|
3316
|
-
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
3317
|
-
private outboxBackoff = new Map<string, number>(); // accountId → next retry timestamp
|
|
3318
|
-
private outboxBackoffDelay = new Map<string, number>(); // accountId → current delay ms
|
|
3319
|
-
|
|
3320
|
-
startOutboxWorker(): void {
|
|
3321
|
-
if (this.outboxInterval) return;
|
|
3322
|
-
|
|
3323
|
-
const processAll = async () => {
|
|
3324
|
-
const now = Date.now();
|
|
3325
|
-
for (const [accountId] of this.configs) {
|
|
3326
|
-
// Skip accounts in backoff
|
|
3327
|
-
const retryAfter = this.outboxBackoff.get(accountId) || 0;
|
|
3328
|
-
if (now < retryAfter) continue;
|
|
3329
|
-
|
|
3330
|
-
try {
|
|
3331
|
-
await this.processLocalQueue(accountId);
|
|
3332
|
-
await this.processOutbox(accountId);
|
|
3333
|
-
// Success — clear backoff
|
|
3334
|
-
this.outboxBackoff.delete(accountId);
|
|
3335
|
-
this.outboxBackoffDelay.delete(accountId);
|
|
3336
|
-
} catch (e: any) {
|
|
3337
|
-
// Stale-socket errors (Dovecot silently drops idle connections,
|
|
3338
|
-
// or the sync path timed out and destroyed the socket): force a
|
|
3339
|
-
// fresh ops client so the next tick doesn't keep hitting the same
|
|
3340
|
-
// dead socket. Without reconnectOps, the dead client stays in the
|
|
3341
|
-
// opsClients map and every subsequent processOutbox call fails
|
|
3342
|
-
// immediately with "Not connected" — forever.
|
|
3343
|
-
const msg = String(e?.message || e);
|
|
3344
|
-
if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
|
|
3345
|
-
this.reconnectOps(accountId).catch(() => {});
|
|
3346
|
-
console.error(` [outbox] Stale connection for ${accountId}: ${msg} — reconnecting`);
|
|
3347
|
-
} else {
|
|
3348
|
-
// Exponential backoff: 60s → 120s → 300s (max 5min)
|
|
3349
|
-
const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
|
|
3350
|
-
const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
|
|
3351
|
-
this.outboxBackoffDelay.set(accountId, delay);
|
|
3352
|
-
this.outboxBackoff.set(accountId, now + delay);
|
|
3353
|
-
console.error(` [outbox] Error for ${accountId}: ${imapError(e)} (retry in ${Math.round(delay / 1000)}s)`);
|
|
3354
|
-
}
|
|
3355
|
-
}
|
|
3356
|
-
}
|
|
3357
|
-
// After each full tick, refresh the UI indicator.
|
|
3358
|
-
this.emitOutboxStatus();
|
|
3359
|
-
};
|
|
3360
|
-
// First tick at 500ms so any stale .sending-HOST-DEAD_PID claim file
|
|
3361
|
-
// left behind by a prior crash gets recovered (renamed back to .ltr)
|
|
3362
|
-
// within half a second of startup — otherwise the status-queue pill
|
|
3363
|
-
// shows a red "1 queued" to the user until the first 10s tick passes.
|
|
3364
|
-
setTimeout(() => processAll(), 500);
|
|
3365
|
-
this.outboxInterval = setInterval(processAll, 10000);
|
|
3366
|
-
}
|
|
3367
|
-
|
|
3368
|
-
/** Stop Outbox worker */
|
|
3369
|
-
stopOutboxWorker(): void {
|
|
3370
|
-
if (this.outboxInterval) {
|
|
3371
|
-
clearInterval(this.outboxInterval);
|
|
3372
|
-
this.outboxInterval = null;
|
|
3373
|
-
}
|
|
3374
|
-
}
|
|
3375
|
-
|
|
3376
|
-
// ── Config file watcher ──
|
|
3377
|
-
|
|
3378
|
-
private configWatchers: fs.FSWatcher[] = [];
|
|
3379
|
-
private cloudPollTimers: ReturnType<typeof setInterval>[] = [];
|
|
3380
|
-
|
|
3381
|
-
/** Watch the local config files for external changes. On change, emit
|
|
3382
|
-
* configChanged so the UI can show a "restart to apply" banner. Uses
|
|
3383
|
-
* a debounce to coalesce rapid writes from save tools. */
|
|
3384
|
-
watchConfigFiles(): void {
|
|
3385
|
-
const files = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
|
|
3386
|
-
const configDir = getConfigDir();
|
|
3387
|
-
const debounce = new Map<string, ReturnType<typeof setTimeout>>();
|
|
3388
|
-
// Cache the last-seen normalized content per file. fs.watch fires on
|
|
3389
|
-
// metadata-only events (atime, attrib change) AND on no-op rewrites
|
|
3390
|
-
// that land identical bytes — both would fire a spurious banner.
|
|
3391
|
-
// Compare after the debounce window and only emit on a real change.
|
|
3392
|
-
const normalize = (s: string): string =>
|
|
3393
|
-
s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
|
|
3394
|
-
const lastContent = new Map<string, string>();
|
|
3395
|
-
for (const filename of files) {
|
|
3396
|
-
const full = path.join(configDir, filename);
|
|
3397
|
-
if (!fs.existsSync(full)) continue;
|
|
3398
|
-
try { lastContent.set(filename, normalize(fs.readFileSync(full, "utf-8"))); } catch { /* */ }
|
|
3399
|
-
try {
|
|
3400
|
-
const watcher = fs.watch(full, () => {
|
|
3401
|
-
const prev = debounce.get(filename);
|
|
3402
|
-
if (prev) clearTimeout(prev);
|
|
3403
|
-
debounce.set(filename, setTimeout(() => {
|
|
3404
|
-
debounce.delete(filename);
|
|
3405
|
-
let current = "";
|
|
3406
|
-
try { current = normalize(fs.readFileSync(full, "utf-8")); } catch { /* missing */ }
|
|
3407
|
-
const previous = lastContent.get(filename) || "";
|
|
3408
|
-
if (current === previous) {
|
|
3409
|
-
console.log(` [watch] ${filename} fs.watch fired but content unchanged — no banner`);
|
|
3410
|
-
return;
|
|
3411
|
-
}
|
|
3412
|
-
// Log a short diff hint so repeat-firings are diagnosable.
|
|
3413
|
-
const prevSize = previous.length;
|
|
3414
|
-
const curSize = current.length;
|
|
3415
|
-
const firstDiff = (() => {
|
|
3416
|
-
const n = Math.min(prevSize, curSize);
|
|
3417
|
-
for (let i = 0; i < n; i++) if (previous[i] !== current[i]) return i;
|
|
3418
|
-
return n;
|
|
3419
|
-
})();
|
|
3420
|
-
const prevSnip = previous.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
|
|
3421
|
-
const curSnip = current.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
|
|
3422
|
-
console.log(` [watch] ${filename} changed: size ${prevSize}→${curSize}, first diff at byte ${firstDiff}`);
|
|
3423
|
-
console.log(` [watch] was: ${JSON.stringify(prevSnip)}`);
|
|
3424
|
-
console.log(` [watch] now: ${JSON.stringify(curSnip)}`);
|
|
3425
|
-
lastContent.set(filename, current);
|
|
3426
|
-
this.emit("configChanged", filename);
|
|
3427
|
-
}, 500));
|
|
3428
|
-
});
|
|
3429
|
-
this.configWatchers.push(watcher);
|
|
3430
|
-
} catch (e: any) {
|
|
3431
|
-
console.error(` [watch] Failed to watch ${filename}: ${e.message}`);
|
|
3432
|
-
}
|
|
3433
|
-
}
|
|
3434
|
-
|
|
3435
|
-
// GDrive has no push/watch for arbitrary Drive files, so edits on
|
|
3436
|
-
// another device (or via Drive web) never fire fs.watch locally.
|
|
3437
|
-
// Poll the cloud copies of the replicated-to-cloud config files
|
|
3438
|
-
// (accounts.jsonc, allowlist.jsonc, clients.jsonc) every 3 minutes,
|
|
3439
|
-
// compare to local, and write-through on difference. The local
|
|
3440
|
-
// fs.watch above then picks up the write and emits configChanged.
|
|
3441
|
-
// config.jsonc is per-machine / local-only — never polled.
|
|
3442
|
-
const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "contacts.jsonc"];
|
|
3443
|
-
const CLOUD_POLL_MS = 3 * 60 * 1000;
|
|
3444
|
-
// normalize() reused from the fs.watch block above — same intent:
|
|
3445
|
-
// cloud round-trips that re-wrap newlines / add a trailing newline are
|
|
3446
|
-
// semantically identical; don't overwrite local on those.
|
|
3447
|
-
const pollCloud = async (): Promise<void> => {
|
|
3448
|
-
let cloudRead: any;
|
|
3449
|
-
let parseJsonc: any;
|
|
3450
|
-
try {
|
|
3451
|
-
({ cloudRead } = await import("@bobfrankston/mailx-settings"));
|
|
3452
|
-
({ parseJsonc } = await import("jsonc-parser").then(m => ({ parseJsonc: m.parse })));
|
|
3453
|
-
} catch { return; /* cloud module unavailable */ }
|
|
3454
|
-
for (const filename of cloudFiles) {
|
|
3455
|
-
try {
|
|
3456
|
-
const cloudContent = await cloudRead(filename);
|
|
3457
|
-
if (!cloudContent) continue;
|
|
3458
|
-
const localPath = path.join(configDir, filename);
|
|
3459
|
-
let localContent: string | null = null;
|
|
3460
|
-
try { localContent = fs.readFileSync(localPath, "utf-8"); } catch { /* missing */ }
|
|
3461
|
-
if (localContent !== null) {
|
|
3462
|
-
if (normalize(localContent) === normalize(cloudContent)) continue;
|
|
3463
|
-
// Semantic check: parse both as JSONC and compare structures.
|
|
3464
|
-
// Catches reorderings that normalize() doesn't (e.g. JSON with
|
|
3465
|
-
// same keys in different order after a cloud-side re-serialize).
|
|
3466
|
-
try {
|
|
3467
|
-
const a = parseJsonc(localContent);
|
|
3468
|
-
const b = parseJsonc(cloudContent);
|
|
3469
|
-
if (a !== undefined && b !== undefined &&
|
|
3470
|
-
JSON.stringify(a) === JSON.stringify(b)) continue;
|
|
3471
|
-
} catch { /* fall through to write */ }
|
|
3472
|
-
}
|
|
3473
|
-
fs.writeFileSync(localPath, cloudContent);
|
|
3474
|
-
console.log(` [cloud-poll] ${filename} updated from cloud copy`);
|
|
3475
|
-
} catch (e: any) {
|
|
3476
|
-
console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
|
|
3477
|
-
}
|
|
3478
|
-
}
|
|
3479
|
-
};
|
|
3480
|
-
// First poll ~10s after startup, then every 3 min.
|
|
3481
|
-
setTimeout(() => {
|
|
3482
|
-
pollCloud();
|
|
3483
|
-
const interval = setInterval(pollCloud, CLOUD_POLL_MS);
|
|
3484
|
-
this.cloudPollTimers.push(interval);
|
|
3485
|
-
}, 10_000);
|
|
3486
|
-
}
|
|
3487
|
-
|
|
3488
|
-
/** Stop all config file watchers */
|
|
3489
|
-
stopWatchingConfig(): void {
|
|
3490
|
-
for (const w of this.configWatchers) {
|
|
3491
|
-
try { w.close(); } catch { /* ignore */ }
|
|
3492
|
-
}
|
|
3493
|
-
this.configWatchers = [];
|
|
3494
|
-
for (const t of this.cloudPollTimers) {
|
|
3495
|
-
try { clearInterval(t); } catch { /* ignore */ }
|
|
3496
|
-
}
|
|
3497
|
-
this.cloudPollTimers = [];
|
|
3498
|
-
}
|
|
3499
|
-
|
|
3500
|
-
// ── Google Contacts Sync (incremental via People API syncToken) ──
|
|
3501
|
-
|
|
3502
|
-
/** Per-account in-flight guard so concurrent calls (startup + periodic
|
|
3503
|
-
* timer + manual button) share one round-trip instead of stacking. */
|
|
3504
|
-
private contactsSyncing = new Map<string, Promise<number>>();
|
|
3505
|
-
|
|
3506
|
-
/** Get an OAuth token for Google APIs (contacts, calendar, etc.)
|
|
3507
|
-
* Uses the SAME token as IMAP — scopes are combined in one grant */
|
|
3508
|
-
private async getContactsToken(accountId: string): Promise<string | null> {
|
|
3509
|
-
// Reuse the IMAP token — it now includes contacts.readonly scope
|
|
3510
|
-
return this.getOAuthToken(accountId);
|
|
3511
|
-
}
|
|
3512
|
-
|
|
3513
|
-
/** Sync contacts from Google People API. Incremental: persists
|
|
3514
|
-
* `nextSyncToken` per account in the kv table (`scope='contacts'`,
|
|
3515
|
-
* `key=accountId`) so subsequent calls only fetch changed/deleted rows.
|
|
3516
|
-
* First-ever call passes `requestSyncToken=true` so Google returns a
|
|
3517
|
-
* token to use next time; without that, the first response has no
|
|
3518
|
-
* `nextSyncToken` and incremental never kicks in. Returns the number of
|
|
3519
|
-
* contacts added or removed in this run. */
|
|
3520
|
-
async syncGoogleContacts(accountId: string): Promise<number> {
|
|
3521
|
-
// Coalesce concurrent calls for the same account.
|
|
3522
|
-
const inFlight = this.contactsSyncing.get(accountId);
|
|
3523
|
-
if (inFlight) return inFlight;
|
|
3524
|
-
const promise = this.syncGoogleContactsImpl(accountId)
|
|
3525
|
-
.finally(() => this.contactsSyncing.delete(accountId));
|
|
3526
|
-
this.contactsSyncing.set(accountId, promise);
|
|
3527
|
-
return promise;
|
|
3528
|
-
}
|
|
3529
|
-
|
|
3530
|
-
private async syncGoogleContactsImpl(accountId: string): Promise<number> {
|
|
3531
|
-
const token = await this.getContactsToken(accountId);
|
|
3532
|
-
if (!token) return 0;
|
|
3533
|
-
|
|
3534
|
-
let changed = 0;
|
|
3535
|
-
let nextPageToken: string | undefined;
|
|
3536
|
-
const now = Date.now();
|
|
3537
|
-
|
|
3538
|
-
// Per-account persisted sync token (survives restarts). Empty means
|
|
3539
|
-
// we've never completed an initial sync for this account.
|
|
3540
|
-
let syncToken = this.db.getKv("contacts", accountId) || "";
|
|
3541
|
-
|
|
3542
|
-
try {
|
|
3543
|
-
do {
|
|
3544
|
-
const params = new URLSearchParams({
|
|
3545
|
-
personFields: "names,emailAddresses,organizations,photos",
|
|
3546
|
-
pageSize: "100",
|
|
3547
|
-
});
|
|
3548
|
-
if (nextPageToken) params.set("pageToken", nextPageToken);
|
|
3549
|
-
if (syncToken) {
|
|
3550
|
-
params.set("syncToken", syncToken);
|
|
3551
|
-
} else {
|
|
3552
|
-
// First-ever sync for this account — ask Google to give us
|
|
3553
|
-
// a token in the response so the NEXT call can be cheap.
|
|
3554
|
-
params.set("requestSyncToken", "true");
|
|
3555
|
-
}
|
|
3556
|
-
|
|
3557
|
-
const url = `https://people.googleapis.com/v1/people/me/connections?${params}`;
|
|
3558
|
-
const res = await fetch(url, {
|
|
3559
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
3560
|
-
});
|
|
3561
|
-
|
|
3562
|
-
if (res.status === 410) {
|
|
3563
|
-
// Sync token expired (Google retains tokens for ~7 days).
|
|
3564
|
-
// Drop the stored token and recurse for a full sync — the
|
|
3565
|
-
// in-flight guard is on syncGoogleContacts (the public
|
|
3566
|
-
// wrapper), not Impl, so recursion is safe.
|
|
3567
|
-
this.db.setKv("contacts", accountId, null);
|
|
3568
|
-
return this.syncGoogleContactsImpl(accountId);
|
|
3569
|
-
}
|
|
3570
|
-
|
|
3571
|
-
if (!res.ok) {
|
|
3572
|
-
const err = await res.text();
|
|
3573
|
-
console.error(` [contacts] API error for ${accountId}: ${res.status} ${err}`);
|
|
3574
|
-
return changed;
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
|
-
const data = await res.json() as any;
|
|
3578
|
-
|
|
3579
|
-
if (data.connections) {
|
|
3580
|
-
for (const person of data.connections) {
|
|
3581
|
-
const googleId = person.resourceName || "";
|
|
3582
|
-
// Incremental responses tag deleted contacts via
|
|
3583
|
-
// metadata.deleted=true (and emit no other fields).
|
|
3584
|
-
// Drop those rows so autocomplete stops surfacing them.
|
|
3585
|
-
if (person.metadata?.deleted) {
|
|
3586
|
-
const removed = this.db.deleteContactByGoogleId(googleId);
|
|
3587
|
-
if (removed > 0) changed += removed;
|
|
3588
|
-
continue;
|
|
3589
|
-
}
|
|
3590
|
-
const emails = person.emailAddresses || [];
|
|
3591
|
-
const names = person.names || [];
|
|
3592
|
-
const orgs = person.organizations || [];
|
|
3593
|
-
const name = names[0]?.displayName || "";
|
|
3594
|
-
const org = orgs[0]?.name || "";
|
|
3595
|
-
|
|
3596
|
-
for (const emailEntry of emails) {
|
|
3597
|
-
const email = emailEntry.value?.toLowerCase();
|
|
3598
|
-
if (!email) continue;
|
|
3599
|
-
|
|
3600
|
-
const existing = this.db.searchContacts(email, 1);
|
|
3601
|
-
const wasNew = !(existing.length > 0 && existing[0].email === email);
|
|
3602
|
-
this.db.recordSentAddress(name, email);
|
|
3603
|
-
if (wasNew) changed++;
|
|
3604
|
-
|
|
3605
|
-
try {
|
|
3606
|
-
(this.db as any).db.prepare(
|
|
3607
|
-
"UPDATE contacts SET source = 'google', google_id = ?, organization = ?, updated_at = ? WHERE email = ?"
|
|
3608
|
-
).run(googleId, org, now, email);
|
|
3609
|
-
} catch { /* ignore */ }
|
|
3610
|
-
}
|
|
3611
|
-
}
|
|
3612
|
-
}
|
|
3613
|
-
|
|
3614
|
-
nextPageToken = data.nextPageToken;
|
|
3615
|
-
// Google only returns nextSyncToken on the LAST page of a
|
|
3616
|
-
// sync run (sentinel that the snapshot is consistent). When
|
|
3617
|
-
// it appears, persist it for next time.
|
|
3618
|
-
if (data.nextSyncToken) {
|
|
3619
|
-
this.db.setKv("contacts", accountId, data.nextSyncToken);
|
|
3620
|
-
syncToken = data.nextSyncToken;
|
|
3621
|
-
}
|
|
3622
|
-
} while (nextPageToken);
|
|
3623
|
-
|
|
3624
|
-
console.log(` [contacts] ${accountId}: ${changed} change(s) (${syncToken ? "incremental" : "full"})`);
|
|
3625
|
-
} catch (e: any) {
|
|
3626
|
-
console.error(` [contacts] Sync error for ${accountId}: ${e.message}`);
|
|
3627
|
-
}
|
|
3628
|
-
|
|
3629
|
-
return changed;
|
|
3630
|
-
}
|
|
3631
|
-
|
|
3632
|
-
/** Sync contacts for all OAuth accounts */
|
|
3633
|
-
async syncAllContacts(): Promise<void> {
|
|
3634
|
-
const settings = loadSettings();
|
|
3635
|
-
for (const account of settings.accounts) {
|
|
3636
|
-
if (account.imap.auth === "oauth2" && (account.enabled || (account as any).syncContacts)) {
|
|
3637
|
-
await this.syncGoogleContacts(account.id);
|
|
3638
|
-
}
|
|
3639
|
-
}
|
|
3640
|
-
}
|
|
3641
|
-
|
|
3642
|
-
/** Shut down all watchers and timers */
|
|
3643
|
-
async shutdown(): Promise<void> {
|
|
3644
|
-
this.stopPeriodicSync();
|
|
3645
|
-
this.stopOutboxWorker();
|
|
3646
|
-
await this.stopWatching();
|
|
3647
|
-
// Disconnect all persistent operational connections
|
|
3648
|
-
for (const [accountId] of this.opsClients) {
|
|
3649
|
-
await this.disconnectOps(accountId);
|
|
3650
|
-
}
|
|
3651
|
-
}
|
|
3652
|
-
}
|