From 10522dc38987129a3276bf8e4ed2ab10c641fdc3 Mon Sep 17 00:00:00 2001 From: Auri Date: Mon, 11 Oct 2021 14:29:32 -0700 Subject: [PATCH] New Imap Handling --- DatabaseLayout.txt | 49 ++++ client/res/logo.svg | 188 ++++++++++++ client/src/App.tsx | 6 + common/package.json | 2 +- package.json | 5 +- server/.eslintrc.js | 1 - server/package-lock.json | 277 +++++++++++------- server/package.json | 12 +- server/src/Account.ts | 458 ++++++++++++++++++++++-------- server/src/Graph.ts | 102 +++---- server/src/Imap.ts | 44 +-- server/src/Log.ts | 10 +- server/src/Main.ts | 80 ++++-- server/src/Message.ts | 1 - server/src/data/Data.ts | 137 +++++++++ server/src/imap/ImapConnection.ts | 233 +++++++++++++++ server/src/imap/ImapController.ts | 68 +++++ 17 files changed, 1327 insertions(+), 346 deletions(-) create mode 100644 DatabaseLayout.txt create mode 100755 client/res/logo.svg create mode 100644 server/src/data/Data.ts create mode 100644 server/src/imap/ImapConnection.ts create mode 100644 server/src/imap/ImapController.ts diff --git a/DatabaseLayout.txt b/DatabaseLayout.txt new file mode 100644 index 0000000..705a925 --- /dev/null +++ b/DatabaseLayout.txt @@ -0,0 +1,49 @@ +IMAP +> Boxes + > Messages (ordered by uid) + +AETHER +> Conversations + > (Parsed) Messages (indexed by MessageID) + +MONGO +> Box + > BoxID + > UIDValidity + > UIDNext + > SeqNext + > ... + +> MsgUUID + > MessageID + > Box + > UID + +> Participant + Name + Image + Addresses + ... + +> Messages + > MsgUUID + > Subject + > Participant[] + > Time + > ParsedContent + +> Conversations + > Title + > Archived + > LatestMessageTime + > Participant[] + > Messages[] + +Box +SEQNO / UID / SUBJECT + +1 / 1: aaa +2 / 3: bbb +--- 3 / 7: ccc +--- +--- 3 / 8: ddd diff --git a/client/res/logo.svg b/client/res/logo.svg new file mode 100755 index 0000000..17ea380 --- /dev/null +++ b/client/res/logo.svg @@ -0,0 +1,188 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + A + + A + + + + + + diff --git a/client/src/App.tsx b/client/src/App.tsx index 7f521ec..e7c1e1e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -68,6 +68,12 @@ export default function App() { contacts={contacts[account.id]} conversation={conversation}/>} } + {!account &&
+
+ Loading +
+
} ); } diff --git a/common/package.json b/common/package.json index 2a174c0..bafb4d9 100644 --- a/common/package.json +++ b/common/package.json @@ -6,7 +6,7 @@ "main": "build/Main.js", "scripts": { "dev": "nodemon", - "build": "tsc --project tsconfig.json", + "build": "tsc --project tsconfig.json --incremental", "clean": "find build -name '*' -not -name 'package.json' -not -path 'build/node_modules*' -not -name 'build' -delete" }, "repository": { diff --git a/package.json b/package.json index f2a3efd..6a41292 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,8 @@ "bugs": { "url": "https://github.com/Aurailus/Aether/issues" }, - "homepage": "https://github.com/Aurailus/Aether#readme" + "homepage": "https://github.com/Aurailus/Aether#readme", + "dependencies": { + "mongoose": "^5.13.8" + } } diff --git a/server/.eslintrc.js b/server/.eslintrc.js index a94f8ec..6e38886 100755 --- a/server/.eslintrc.js +++ b/server/.eslintrc.js @@ -57,7 +57,6 @@ module.exports = { } } ], - "@typescript-eslint/member-ordering": "error", "@typescript-eslint/no-empty-function": "error", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-parameter-properties": "off", diff --git a/server/package-lock.json b/server/package-lock.json index acbdd43..9af343a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -237,6 +237,36 @@ "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", "dev": true }, + "@typegoose/typegoose": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typegoose/typegoose/-/typegoose-8.2.0.tgz", + "integrity": "sha512-iibEA5V2FqtadURFFT2Anq/NnP8oDnlnuMFmccJsBLxanEoNH78hAbdJ9GFoND6BakFInF9BWuhZfpo2OXOb0Q==", + "requires": { + "lodash": "^4.17.20", + "loglevel": "^1.7.0", + "reflect-metadata": "^0.1.13", + "semver": "^7.3.2", + "tslib": "^2.3.0" + }, + "dependencies": { + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@types/bson": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.5.tgz", + "integrity": "sha512-vVLwMUqhYJSQ/WKcE60eFqcyuWse5fGH+NMAXHuKrUAPoryq3ATxk5o4bgYNtg5aOM4APVg7Hnb3ASqUYG0PKg==", + "requires": { + "@types/node": "*" + } + }, "@types/imap": { "version": "0.8.35", "resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.35.tgz", @@ -261,25 +291,20 @@ "@types/node": "*" } }, + "@types/mongodb": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.20.tgz", + "integrity": "sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==", + "requires": { + "@types/bson": "*", + "@types/node": "*" + } + }, "@types/node": { "version": "16.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.7.2.tgz", "integrity": "sha512-TbG4TOx9hng8FKxaVrCisdaxKxqEwJ3zwHoCWXZ0Jw6mnvTInpaB99/2Cy4+XxpXtjNv9/TgfGSvZFyfV/t8Fw==" }, - "@types/webidl-conversions": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz", - "integrity": "sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q==" - }, - "@types/whatwg-url": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.1.tgz", - "integrity": "sha512-2YubE1sjj5ifxievI5Ge1sckb9k/Er66HyR2c+3+I6VDUUg1TLPdYYTEbQ+DjRkS4nTxMJhgWfSfMRD2sl2EYQ==", - "requires": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, "@typescript-eslint/eslint-plugin": { "version": "4.29.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.3.tgz", @@ -514,17 +539,55 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" + }, "boolean": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.1.4.tgz", @@ -576,21 +639,9 @@ } }, "bson": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.5.1.tgz", - "integrity": "sha512-XqFP74pbTVLyLy5KFxVfTUyRrC1mgOlmu/iXHfXqfCKT59jyP9lwbotGfbN59cHBRbJSamZNkrSopjv+N0SqAA==", - "requires": { - "buffer": "^5.6.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==" }, "buffer-crc32": { "version": "0.2.13", @@ -1619,11 +1670,6 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", "dev": true }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - }, "ignore": { "version": "5.1.8", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", @@ -1880,8 +1926,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.clonedeep": { "version": "4.5.0", @@ -1920,6 +1965,11 @@ } } }, + "loglevel": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.1.tgz", + "integrity": "sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==" + }, "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", @@ -1930,7 +1980,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -2031,54 +2080,98 @@ } }, "mongodb": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.1.1.tgz", - "integrity": "sha512-fbACrWEyvr6yl0sSiCGV0sqEiBwTtDJ8iSojmkDjAfw9JnOZSAkUyv9seFSPYhPPKwxp1PDtyjvBNfMDz0WBLQ==", + "version": "3.6.11", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.11.tgz", + "integrity": "sha512-4Y4lTFHDHZZdgMaHmojtNAlqkvddX2QQBEN0K//GzxhGwlI9tZ9R0vhbjr1Decw+TF7qK0ZLjQT292XgHRRQgw==", "requires": { - "bson": "^4.5.1", - "denque": "^1.5.0", - "mongodb-connection-string-url": "^2.0.0", + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "optional-require": "^1.0.3", + "safe-buffer": "^5.1.2", "saslprep": "^1.0.0" } }, - "mongodb-connection-string-url": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.0.0.tgz", - "integrity": "sha512-M0I1vyLoq5+HQTuPSJWbt+hIXsMCfE8sS1fS5mvP9R2DOMoi2ZD32yWqgBIITyu0dFu4qtS50erxKjvUeBiyog==", - "requires": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^9.1.0" - } - }, "mongoose": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.0.1.tgz", - "integrity": "sha512-WESkAtJuJqXKjiQj+HiL3Ipr6eLWx9RIrjCE2HzxScUApnFLXSHdd5gGCeEE3Pl+qcill4fGYy/uysThCMQ6PQ==", + "version": "5.13.8", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.13.8.tgz", + "integrity": "sha512-z3d+qei9Dem/LxRcJi0cdGPKzQnYk71oHEsEfYm17JA/vLiAbJiGuBS2hW7vkd9afkPAqu3KsPZh2ax0c5iPQw==", "requires": { - "bson": "^4.2.2", + "@types/mongodb": "^3.5.27", + "bson": "^1.1.4", "kareem": "2.3.2", - "mongodb": "4.1.1", + "mongodb": "3.6.11", + "mongoose-legacy-pluralize": "1.0.2", "mpath": "0.8.3", - "mquery": "4.0.0", + "mquery": "3.2.5", "ms": "2.1.2", + "optional-require": "1.0.x", "regexp-clone": "1.0.0", + "safe-buffer": "5.2.1", "sift": "13.5.2", "sliced": "1.0.1" + }, + "dependencies": { + "bson": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==" + }, + "mongodb": { + "version": "3.6.11", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.11.tgz", + "integrity": "sha512-4Y4lTFHDHZZdgMaHmojtNAlqkvddX2QQBEN0K//GzxhGwlI9tZ9R0vhbjr1Decw+TF7qK0ZLjQT292XgHRRQgw==", + "requires": { + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "optional-require": "^1.0.3", + "safe-buffer": "^5.1.2", + "saslprep": "^1.0.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } } }, + "mongoose-legacy-pluralize": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz", + "integrity": "sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==" + }, "mpath": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.8.3.tgz", "integrity": "sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA==" }, "mquery": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.0.tgz", - "integrity": "sha512-nGjm89lHja+T/b8cybAby6H0YgA4qYC/lx6UlwvHGqvTq8bDaNeCwl1sY8uRELrNbVWJzIihxVd+vphGGn1vBw==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.5.tgz", + "integrity": "sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==", "requires": { - "debug": "4.x", + "bluebird": "3.5.1", + "debug": "3.1.0", "regexp-clone": "^1.0.0", + "safe-buffer": "5.1.2", "sliced": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } } }, "ms": { @@ -2175,6 +2268,11 @@ "wrappy": "1" } }, + "optional-require": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz", + "integrity": "sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==" + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -2276,8 +2374,7 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "progress": { "version": "2.0.3", @@ -2311,7 +2408,8 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true }, "pupa": { "version": "2.1.1", @@ -2468,8 +2566,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "saslprep": { "version": "1.0.3", @@ -2793,14 +2890,6 @@ "nopt": "~1.0.10" } }, - "tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", - "requires": { - "punycode": "^2.1.1" - } - }, "ts-node": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.2.1.tgz", @@ -2881,14 +2970,6 @@ "is-typedarray": "^1.0.0" } }, - "typegoose": { - "version": "5.9.1", - "resolved": "https://registry.npmjs.org/typegoose/-/typegoose-5.9.1.tgz", - "integrity": "sha512-D+vMhNyZeKBZHrmJFZwOodl3T9W2NOXY+hbnW/f1n60oEL8+L15eryFc9C6fAKrlnkgpui+kdQnNXsLwx2MgCw==", - "requires": { - "reflect-metadata": "^0.1.13" - } - }, "typescript": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", @@ -2985,8 +3066,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "v8-compile-cache": { "version": "2.3.0", @@ -2994,20 +3074,6 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, - "webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==" - }, - "whatwg-url": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-9.1.0.tgz", - "integrity": "sha512-CQ0UcrPHyomtlOCot1TL77WyMIm/bCwrJ2D6AOKGwEczU9EpyoqAokfqrf/MioU9kHcMsmJZcg1egXix2KYEsA==", - "requires": { - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" - } - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3059,8 +3125,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yauzl": { "version": "2.10.0", diff --git a/server/package.json b/server/package.json index c17f3a0..98a4a28 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "nodemon", "lint": "eslint -c .eslintrc.js src/**/*.ts", - "build": "tsc --project tsconfig.json" + "build": "tsc --project tsconfig.json --incremental" }, "repository": { "type": "git", @@ -25,7 +25,7 @@ "src" ], "ext": ".ts,.tsx,.html", - "exec": "npm run lint & npm run build && electron .", + "exec": "npm run lint & (npm run build && electron .)", "quiet": true }, "bugs": { @@ -45,14 +45,14 @@ "eslint-plugin-jsdoc": "^36.0.8" }, "dependencies": { + "@typegoose/typegoose": "^8.2.0", "common": "file:../common/build", "graphql": "^15.5.0", "imap": "^0.8.19", "log4js": "^6.3.0", "md5": "^2.3.0", - "mongodb": "^4.1.1", - "mongoose": "^6.0.1", - "tslib": "^2.2.0", - "typegoose": "^5.9.1" + "mongodb": "^3.6.11", + "mongoose": "^5.13.8", + "tslib": "^2.2.0" } } diff --git a/server/src/Account.ts b/server/src/Account.ts index 5c3f22a..d3f1015 100644 --- a/server/src/Account.ts +++ b/server/src/Account.ts @@ -1,148 +1,372 @@ -import Message from './Message'; -import Conversation from './Conversation'; -import Imap, { ConnectionProperties, Message as RawMessage, MailboxType } from './Imap'; +import Imap from 'imap'; +import { ObjectID } from 'mongodb' -interface Contact { - name: string; - addresses: Set; -} +// import Message from './Message'; +// import Conversation from './Conversation'; +import Log from './Log'; +import * as DB from './data/Data'; +import ImapController from './imap/ImapController'; + +// interface Contact { +// name: string; +// addresses: Set; +// } export default class Account { - private imap: Imap; - private contacts: Contact[] = []; - private conversations: Conversation[] = []; - - private name: string; - private image: string; private address: string; - private unread: boolean; + private accountID: ObjectID; + private conn: ImapController; + // private contacts: Contact[] = []; + // private conversations: Conversation[] = []; - constructor(name: string, image: string, connection: ConnectionProperties) { - this.name = name; - this.image = image; - this.address = connection.username; - this.unread = true; + constructor(data: DB.Account) { + this.accountID = data._id; + this.address = data.address; + Log.info('Created account %s', data.address); - this.imap = new Imap(connection); + this.conn = new ImapController({ + user: data.address, + password: data.password, + host: data.host, + port: data.port, + tls: data.tls + }); } - async connect() { - await this.imap.connect(); + async init() { + await this.conn.connect(); - const messages = await this.fetchAllMessages(); - this.conversations = this.createConversations(messages).filter(c => c.active); - this.contacts = this.createContacts(messages); + Log.perfStart('Synchronizing ' + this.address); + const remoteBoxes = await this.getBoxes(); + await this.synchronizeBoxes(remoteBoxes); + await this.synchronizeMessages(remoteBoxes); + Log.perfEnd('Synchronizing ' + this.address); + + + // Log.info('Connected to %s', this.data.address); + // await this.synchronizeData(); + // Log.perfEnd('Synchronizing ' + this.data.address); + + // const messages = await this.fetchAllMessages(); + // this.conversations = this.createConversations(messages).filter(c => c.active); + // this.contacts = this.createContacts(messages); } - getName() { - return this.name; + async synchronizeBoxes(remoteBoxes: Map): Promise { + const currentBoxes = await DB.MailboxModel.find({ account: this.accountID }); + await Promise.all([ ...remoteBoxes.values() ].map(async box => { + const existing = currentBoxes.filter(b => b.path === box.name)[0]; + if (!existing) await this.addNewBox(box); + else await this.refreshExistingBox(box, existing); + })); } - getAddress() { - return this.address; + private async addNewBox(box: Imap.Box) { + await DB.MailboxModel.create({ + account: this.accountID, + name: box.name, // TODO: This + path: box.name, + delimiter: '.', // TODO: and this + type: DB.MailboxType.Inbox, + treeTypes: new Set([ DB.MailboxType.Inbox ]), + parent: undefined, // and this + uidValidity: box.uidvalidity, + uidNext: 1, + } as DB.Create); } - getImage() { - return this.image; + private async refreshExistingBox(remote: Imap.Box, _existing: DB.Mailbox) { + Log.debug('existing box ' + remote.name); } - hasUnreads() { - return this.unread; - } - - getConversations() { - return this.conversations; - } - - getContacts() { - return this.contacts; - } - - getMessages(_messages: string[]): Message[] { - return [{ - id: 'AOUEOAEu', - date: new Date(), - from: 'me@auri.xyz', - to: [ 'nicole@aurailus.design' ], - content: '

Lorem ipsum dolor sit amet.

' - }]; - } - - async fetchAllMessages(): Promise { - const boxes = (await this.imap.listBoxes()).filter(box => - !box.treeTypes.has(MailboxType.Spam) && !box.treeTypes.has(MailboxType.Trash)); - - let allMeta: RawMessage[] = []; - - for (let box of boxes) { - await this.imap.openBox(box.path); - const meta = await this.imap.fetchMessages('1:*'); - Object.keys(meta).forEach(id => allMeta.push(meta[id])); - } - - allMeta = allMeta.sort((a, b) => +a.date - +b.date); - return allMeta; - } - - private createConversations(messages: RawMessage[]): Conversation[] { - const conversations: Conversation[] = []; - - messages.forEach(message => { - if (message.replyTo) { - for (let conversation of conversations) { - for (let reference of [ ...message.references, message.replyTo ]) { - if (conversation.messages.has(reference)) { - conversation.date = message.date; - conversation.messages.add(message.messageId); - conversation.title = this.cleanSubjectLine(message.subject); - conversation.active = conversation.active || message.active; - message.to.forEach(p => conversation.participants.add(p.address)); - conversation.participants.add(message.from.address); - return; - } - } + async synchronizeMessages(remoteBoxes: Map): Promise { + const currentBoxes = await DB.MailboxModel.find({ account: this.accountID }); + await Promise.all(currentBoxes.map(async box => { + const remote = remoteBoxes.get(box.path)!; + console.log(box.uidNext, remote.uidnext); + // if (box.uidValidity !== remote.uidvalidity) { + // // Reacquire existing messages + // } + if (box.uidNext !== remote.uidnext) { + // Get new messages + const messages = await (await this.conn.get(box.path)).fetchMessagesByUID(`${box.uidNext}:*`); + await DB.MailboxModel.updateOne({ _id: box._id }, { uidNext: remote.uidnext }); + if (messages.size > 0) { + console.log('adding ' + messages.size + ' messages.'); + await DB.MessageModel.insertMany([ ...messages.keys() ].map(uid => { + const message = messages.get(uid)!; + const headers = this.parseHeaders(message.headers); + return { + account: this.accountID, + box: box._id, + uid: uid, + messageId: headers.get('MESSAGE-ID') ?? '[!DATE:' + (+message.attrs.date) + ']', + subject: this.cleanSubject(headers.get('SUBJECT')), + date: message.attrs.date, + } as DB.Message; + })); } } - - conversations.push({ - title: this.cleanSubjectLine(message.subject), - messages: new Set([ message.messageId ]), - date: message.date, - active: message.active, - participants: new Set([ message.from.address, ...message.to.map(p => p.address) ]) - }); - }); - - conversations.forEach(conversation => { - conversation.participants.delete(this.address); - }); - - return conversations.sort((a, b) => +a.date - +b.date); + })); } - private createContacts(messages: RawMessage[]): Contact[] { - const contacts: Contact[] = []; + private async getBoxes(): Promise> { + Log.perfStart('Getting boxes for ' + this.address); - for (let message of messages) { - [ message.from, ...message.to ].forEach(participant => { - for (let contact of contacts) { - if (contact.addresses.has(participant.address)) { - if (participant.name) contact.name = participant.name; - return; - } - } + const remoteBoxes = await (await this.conn.get()).getBoxes(); + const boxReqs: Promise[] = []; + const boxes: Map = new Map(); - contacts.push({ - name: participant.name ?? participant.address, - addresses: new Set([ participant.address ]) - }); + const reqBoxesRecursively = (tree: Imap.MailBoxes, path: string = '') => { + Object.keys(tree).forEach(name => { + boxReqs.push((async () => { + const box = (await this.conn.get(path + name)).getOpenBoxProps(); + boxes.set(path + name, box); + })()); + if (tree[name].children) reqBoxesRecursively(tree[name].children, + path + name + tree[name].delimiter); }); } - return contacts; + reqBoxesRecursively(remoteBoxes); + await Promise.all(boxReqs); + + Log.perfEnd('Getting boxes for ' + this.address); + return boxes; } - private cleanSubjectLine(subject: string = '') { + private parseHeaders(rawHeaders: string): Map { + const headers: Map = new Map(); + rawHeaders + .split(/\r?\n(?=[A-z:\-_]+)/g) + .map(h => h.trim()) + .filter(h => h) + .forEach(h => { + const delimiter = h.indexOf(':'); + const name = h.substr(0, delimiter).trim(); + const value = h.substr(delimiter + 1).trim(); + headers.set(name.toUpperCase(), value); + }); + return headers; + } + + private cleanSubject(subject: string = '') { return subject.replace(/^((re|fwd?|b?cc)(:| ) *)*/gi, '').trim(); } + + // async synchronizeData(): Promise { + // const existingBoxes = await DB.MailboxModel.find({ account: this.data._id }); + // const remoteBoxes = (await this.imap.listBoxes()); + + // const newBoxes: (Mailbox & { uidValidity: number })[] = []; + // const boxValidityChanged: { _id: ObjectID, uidValidity: number }[] = []; + // const removedBoxes: Set = new Set(existingBoxes.map(box => box._id)); + + // for (let remote of remoteBoxes) { + // if (!remote.treeTypes.has(DB.MailboxType.Inbox) && + // !remote.treeTypes.has(DB.MailboxType.Sent) && + // !remote.treeTypes.has(DB.MailboxType.Archives)) continue; + + // const existing = existingBoxes.filter(box => box.path === remote.path)[0]; + // let uidValidity = (await this.imap.openBox(remote.path)).uidvalidity; + // // Log.debug('Opened %s', remote.path); + // if (!existing) newBoxes.push({ ...remote, uidValidity }); + // else { + // removedBoxes.delete(existing._id); + // if (existing.uidValidity != uidValidity) boxValidityChanged.push({ _id: existing._id, uidValidity }); + // } + // }; + + // let createdIDs: Map = new Map(); + + // for (let box of newBoxes) { + // createdIDs.set(box.path, (await DB.MailboxModel.create({ + // name: box.name, + // path: box.path, + // account: this.data._id, + // delimiter: box.delimiter, + // type: box.type, + // treeTypes: box.treeTypes, + // parent: (await DB.MailboxModel.findOne({ path: box.parent }))?._id, + // uidValidity: box.uidValidity, + // uidNext: 1, + // } as DB.Create)).id); + // } + + // await Promise.all(boxValidityChanged.map(({ _id, uidValidity }) => + // DB.MailboxModel.updateOne({ _id }, { uidValidity, uidNext: 1 }))); + + // await DB.MailboxModel.deleteMany({ _id: { $in: [ ...removedBoxes ] } }); + + // for (let box of await DB.MailboxModel.find({ account: this.data._id })) { + // await this.imap.openBox(box.path); + // const meta = Object.values(await this.imap.fetchMessages(box.uidNext + ':*')); + + // const contacts: { name: string; addresses: Set }[] = []; + + // for (let m of meta) { + // [ m.from, ...m.to ].forEach(participant => { + // for (let contact of contacts) { + // if (contact.addresses.has(participant.address)) { + // if (participant.name) contact.name = participant.name; + // return; + // } + // } + + // contacts.push({ + // name: participant.name ?? participant.address, + // addresses: new Set([ participant.address ]) + // }); + // }); + // } + + // await Promise.all(contacts.map(async contact => { + // const addresses = [ ...contact.addresses ]; + // await DB.ContactModel.updateOne( + // { addresses: { $elemMatch: { $in: addresses } as any } }, + // { + // $set: { name: contact.name }, + // $addToSet: { addresses } + // }, + // { upsert: true }); + // })); + + // await Promise.all(meta.map(async meta => { + // await DB.MessageModel.updateOne({ messageId: meta.messageId }, { + // box: box._id, + // uid: meta.boxId, + // account: this.data._id, + // $setOnInsert: { + // subject: meta.subject, + // date: meta.date, + // from: (await DB.ContactModel.findOne({ addresses: meta.from.address }))!._id + // } + // } as any as DB.Message, + // { upsert: true }); + // })); + + // // await DB.MessageIDModel.insertMany(Object.values(meta).map(meta => + // // ({ messageId: meta.messageId, uid: meta.boxId, account: this.data._id, box: box._id }))); + // } + + // const boxes = (await this.imap.listBoxes()).filter(box => + // !box.treeTypes.has(MailboxType.Spam) && !box.treeTypes.has(MailboxType.Trash)); + + // let allMeta: RawMessage[] = []; + + // for (let box of boxes) { + // await this.imap.openBox(box.path); + // const meta = await this.imap.fetchMessages('1:*'); + // Object.keys(meta).forEach(id => allMeta.push(meta[id])); + // } + // } + + // getName() { + // return this.name; + // } + + // getAddress() { + // return this.address; + // } + + // getImage() { + // return this.image; + // } + + // hasUnreads() { + // return this.unread; + // } + + // getConversations() { + // return this.conversations; + // } + + // getContacts() { + // return this.contacts; + // } + + // getMessages(_messages: string[]): Message[] { + // return [{ + // id: 'AOUEOAEu', + // date: new Date(), + // from: 'me@auri.xyz', + // to: [ 'nicole@aurailus.design' ], + // content: '

Lorem ipsum dolor sit amet.

' + // }]; + // } + + // async fetchAllMessages(): Promise { + // const boxes = (await this.imap.listBoxes()).filter(box => + // !box.treeTypes.has(MailboxType.Spam) && !box.treeTypes.has(MailboxType.Trash)); + + // let allMeta: RawMessage[] = []; + + // for (let box of boxes) { + // await this.imap.openBox(box.path); + // const meta = await this.imap.fetchMessages('1:*'); + // Object.keys(meta).forEach(id => allMeta.push(meta[id])); + // } + + // allMeta = allMeta.sort((a, b) => +a.date - +b.date); + // return allMeta; + // } + + // private createConversations(messages: RawMessage[]): Conversation[] { + // const conversations: Conversation[] = []; + + // messages.forEach(message => { + // if (message.replyTo) { + // for (let conversation of conversations) { + // for (let reference of [ ...message.references, message.replyTo ]) { + // if (conversation.messages.has(reference)) { + // conversation.date = message.date; + // conversation.messages.add(message.messageId); + // conversation.title = this.cleanSubjectLine(message.subject); + // conversation.active = conversation.active || message.active; + // message.to.forEach(p => conversation.participants.add(p.address)); + // conversation.participants.add(message.from.address); + // return; + // } + // } + // } + // } + + // conversations.push({ + // title: this.cleanSubjectLine(message.subject), + // messages: new Set([ message.messageId ]), + // date: message.date, + // active: message.active, + // participants: new Set([ message.from.address, ...message.to.map(p => p.address) ]) + // }); + // }); + + // conversations.forEach(conversation => { + // conversation.participants.delete(this.address); + // }); + + // return conversations.sort((a, b) => +a.date - +b.date); + // } + + // private createContacts(messages: RawMessage[]): Contact[] { + // const contacts: Contact[] = []; + + // for (let message of messages) { + // [ message.from, ...message.to ].forEach(participant => { + // for (let contact of contacts) { + // if (contact.addresses.has(participant.address)) { + // if (participant.name) contact.name = participant.name; + // return; + // } + // } + + // contacts.push({ + // name: participant.name ?? participant.address, + // addresses: new Set([ participant.address ]) + // }); + // }); + // } + + // return contacts; + // } + }; diff --git a/server/src/Graph.ts b/server/src/Graph.ts index 49b22d1..4dea6fe 100644 --- a/server/src/Graph.ts +++ b/server/src/Graph.ts @@ -1,61 +1,61 @@ -import { buildSchema } from 'graphql'; +// import { buildSchema } from 'graphql'; -import { Type, SCHEMA } from 'common/graph'; +// import { Type, SCHEMA } from 'common/graph'; -import Message from './Message'; -import Account from './Account'; -import Conversation from './Conversation'; +// import Message from './Message'; +// import Account from './Account'; +// import Conversation from './Conversation'; -export interface Context { - accounts: Record; -} +// export interface Context { +// accounts: Record; +// } -export const Schema = buildSchema(SCHEMA); +// export const Schema = buildSchema(SCHEMA); -function messageResolver(message: Message) { - return { - id: message.id, - date: message.date, - from: message.from, - to: message.to, +// function messageResolver(message: Message) { +// return { +// id: message.id, +// date: message.date, +// from: message.from, +// to: message.to, - html: () => message.content, - markdown: () => message.content - }; -} +// html: () => message.content, +// markdown: () => message.content +// }; +// } -function conversationResolver(conversation: Conversation, id: string) { - return { - id: id, - unread: false, - title: conversation.title, - lastMessage: conversation.date, - messages: conversation.messages, - participants: conversation.participants - }; -} +// function conversationResolver(conversation: Conversation, id: string) { +// return { +// id: id, +// unread: false, +// title: conversation.title, +// lastMessage: conversation.date, +// messages: conversation.messages, +// participants: conversation.participants +// }; +// } -function accountResolver(account: Account, id: string) { - return { - id: id, - name: account.getName(), - image: account.getImage(), - address: account.getAddress(), - unread: account.hasUnreads(), +// function accountResolver(account: Account, id: string) { +// return { +// id: id, +// name: account.getName(), +// image: account.getImage(), +// address: account.getAddress(), +// unread: account.hasUnreads(), - messages: [], - contacts: () => account.getContacts(), - conversations: () => { - const conversations = account.getConversations(); - for (let key in conversations) if (!conversations[key].active) delete conversations[key]; - return Object.keys(conversations).map(id => conversationResolver(conversations[id as any], id)); - } - }; -} +// messages: [], +// contacts: () => account.getContacts(), +// conversations: () => { +// const conversations = account.getConversations(); +// for (let key in conversations) if (!conversations[key].active) delete conversations[key]; +// return Object.keys(conversations).map(id => conversationResolver(conversations[id as any], id)); +// } +// }; +// } -export const Resolver = { - accounts: (_: any, ctx: Context) => Object.keys(ctx.accounts).map(id => accountResolver(ctx.accounts[id], id)), - account: ({ account: id }: { account: string }, ctx: Context) => accountResolver(ctx.accounts[id], id), - messages: async ({ account, ids }: { account: string; ids: string[] }, ctx: Context) => - (await ctx.accounts[account].getMessages(ids)).map(msg => messageResolver(msg)) -}; +// export const Resolver = { +// accounts: (_: any, ctx: Context) => Object.keys(ctx.accounts).map(id => accountResolver(ctx.accounts[id], id)), +// account: ({ account: id }: { account: string }, ctx: Context) => accountResolver(ctx.accounts[id], id), +// messages: async ({ account, ids }: { account: string; ids: string[] }, ctx: Context) => +// (await ctx.accounts[account].getMessages(ids)).map(msg => messageResolver(msg)) +// }; diff --git a/server/src/Imap.ts b/server/src/Imap.ts index f7e2c0a..6b561dc 100644 --- a/server/src/Imap.ts +++ b/server/src/Imap.ts @@ -1,6 +1,7 @@ import md5 from 'md5'; -import RawImap, { MailBoxes as RawBoxes } from 'imap'; +import { MailboxType } from './data/Data'; +import RawImap, { MailBoxes as RawBoxes, Box as RawBox } from 'imap'; /** Credentials and properties used to establish an IMAP connection. */ @@ -12,27 +13,6 @@ export interface ConnectionProperties { tls: boolean; } - -/** - * Mailbox types (attributes). - * Only the attributes relevant to Aether are included, - * and some are named differently to better match interface language. - */ - -export enum MailboxType { - Box = 'NORMAL_BOX', - All = '\\All', - Archive = '\\Archive', - Drafts = '\\Drafts', - Starred = '\\Flagged', - Important = '\\Important', - Inbox = '\\Inbox', - Spam = '\\Junk', - Sent = '\\Sent', - Trash = '\\Trash' -} - - /** * Message flags (keywords). * Only the flags relevant to Aether are included, @@ -106,7 +86,7 @@ export interface Message { */ export default class Imap { - private raw: RawImap; + raw: RawImap; private connected: boolean = false; private boxes: Mailbox[] = []; @@ -189,9 +169,11 @@ export default class Imap { for (let boxName in boxes) { if ({}.hasOwnProperty.call(boxes, boxName)) { const box = boxes[boxName]; + const thisPath = path + boxName + box.delimiter; const type = (box as any).special_use_attrib as MailboxType ?? - (boxName === 'INBOX' ? MailboxType.Inbox : MailboxType.Box); + (boxName === 'INBOX' ? MailboxType.Inbox : + boxName.match(/^Archives?$/gi) !== null ? MailboxType.Archives : MailboxType.Box); const thisTreeTypes = new Set([ ...treeTypes, type ]); foundBoxes.push({ @@ -223,7 +205,7 @@ export default class Imap { * @returns a raw node-imap box instance. */ - openBox(box: string): Promise { + openBox(box: string): Promise { return new Promise((resolve, reject) => { if (!this.connected) reject('Cannot get box when the connection is closed.'); @@ -233,8 +215,9 @@ export default class Imap { return; } + resolve(box); this.box = this.boxes.filter(b => b.path === box.name)[0]; - resolve(this.box); + // resolve(this.box); }); }); } @@ -269,8 +252,9 @@ export default class Imap { const id = parseInt(idStr, 10); const headers = bodies[id].headers; const attrs = bodies[id].attrs; + const messageId = headers['MESSAGE-ID'] || `HASH:${md5(attrs.date.toString())}:${md5(headers.SUBJECT)}`; - messages[headers['MESSAGE-ID']] = { + messages[messageId] = { to: this.parseParticipants(headers.TO ?? ''), from: this.parseParticipants(headers.FROM ?? '')[0], subject: headers.SUBJECT, @@ -278,7 +262,7 @@ export default class Imap { id: id, boxId: attrs.uid, - messageId: headers['MESSAGE-ID'] || `HASH:${md5(attrs.date.toString())}:${md5(headers.SUBJECT)}`, + messageId: messageId, active: attrs.flags.has(MessageFlag.Active) || this.box!.type === MailboxType.Inbox, replyTo: headers['IN-REPLY-TO'], @@ -364,10 +348,10 @@ export default class Imap { private parseParticipants(header: string): Participant[] { return header.split(',').map(raw => { const delimiter = raw.indexOf('<'); - if (delimiter === -1) return { name: undefined, address: raw.trim() }; + if (delimiter === -1) return { name: undefined, address: raw.trim().toLowerCase() }; const name = raw.substr(0, delimiter).replace(/^[\s'"]+/g, '').trim().replace(/[\s'"]+$/g, ''); - const address = raw.substr(delimiter + 1).replace(/[<>]/g, '').replace(/^[\s'"]+/g, '').trim().replace(/[\s'"]+$/g, ''); + const address = raw.substr(delimiter + 1).replace(/[<>]/g, '').replace(/^[\s'"]+/g, '').trim().replace(/[\s'"]+$/g, '').toLowerCase(); return { name: name ? name : undefined, address }; }); diff --git a/server/src/Log.ts b/server/src/Log.ts index 0047a32..c504664 100644 --- a/server/src/Log.ts +++ b/server/src/Log.ts @@ -20,17 +20,17 @@ log4js.configure({ const logger = log4js.getLogger(); -const activePerfs: Record = {}; +const activePerfs: Record = {}; const perfStart = (identifier: string) => { - activePerfs[identifier] = process.hrtime(); + activePerfs[identifier] = process.hrtime.bigint(); }; const perfEnd = (identifier: string) => { - let perf = activePerfs[identifier]; - if (!perf) logger.warn('Attempted to perf invalid identifier \'%s\'.', identifier); + let start = activePerfs[identifier]; + if (!start) logger.warn('Attempted to perf invalid identifier \'%s\'.', identifier); else { - const elapsed = process.hrtime(perf)[1] / 1000000; + const elapsed = Number((process.hrtime.bigint() - start) / BigInt(10000)) / 100; // @ts-ignore logger.perf('%s took %s ms.', identifier, elapsed.toFixed(3)); delete activePerfs[identifier]; diff --git a/server/src/Main.ts b/server/src/Main.ts index c1cb812..bd08d5c 100644 --- a/server/src/Main.ts +++ b/server/src/Main.ts @@ -1,38 +1,64 @@ -import fs from 'fs'; -import { graphql } from 'graphql'; +// import fs from 'fs'; +import mongoose from 'mongoose'; + +// import { graphql } from 'graphql'; import { ipcMain } from 'electron'; import Log from './Log'; import Account from './Account'; -import { Schema, Resolver } from './Graph'; -import { openWindow } from './ElectronWindow'; +import * as DB from './data/Data'; +// import { Schema, Resolver } from './Graph'; +// import { openWindow } from './ElectronWindow'; -Log.setLogLevel('debug'); +Log.setLogLevel('all'); (async () => { - const accounts: Record = { - '0': new Account('Personal', '../../client/res/user-home.png', { - username: 'me@auri.xyz', - password: fs.readFileSync(__dirname + '/../../client/pw.txt').toString().trim(), - host: 'mail.hover.com', - port: 993, - tls: true - }), - '1': new Account('Work', '../../client/res/user-work.png', { - username: 'nicole@aurailus.design', - password: fs.readFileSync(__dirname + '/../../client/pw.txt').toString().trim(), - host: 'mail.hover.com', - port: 993, - tls: true - }) - }; + // openWindow(); + Log.info('Initialized.'); - await Promise.all(Object.values(accounts).map(account => account.connect())); + await mongoose.connect('mongodb://localhost:27017/aether',{ + useNewUrlParser: true, + useUnifiedTopology: true, + useCreateIndex: true, + useFindAndModify: true + } as mongoose.ConnectOptions); - ipcMain.handle('graphql', async (_, req: { query: string; data: any }) => { - return graphql(Schema, req.query, Resolver, { accounts }, req.data); + Log.info('Connected to Mongoose.'); + + // await DB.AccountModel.deleteMany({}); + // await DB.AccountModel.insertMany([{ + // name: 'Personal', + // image: '../../client/res/user-home.png', + // address: 'me@auri.xyz', + // password: fs.readFileSync(__dirname + '/../../client/pw.txt').toString().trim(), + // host: 'mail.hover.com', + // port: 993, + // tls: true + // }, { + // name: 'Work', + // image: '../../client/res/user-work.png', + // address: 'nicole@aurailus.design', + // password: fs.readFileSync(__dirname + '/../../client/pw.txt').toString().trim(), + // host: 'mail.hover.com', + // port: '993', + // tls: true + // }] as DB.Create[]); + + Log.perfStart('Initial DB Fetch'); + const dbAccounts = (await DB.AccountModel.find({}))!; + Log.perfEnd('Initial DB Fetch'); + + Log.perfStart('Accounts Connect'); + await Promise.all(dbAccounts.map(async dbAccount => { + const account = new Account(dbAccount); + await account.init(); + return account; + })); + Log.perfEnd('Accounts Connect'); + + ipcMain.handle('graphql', async (_, _req: { query: string; data: any }) => { + return { data: { } }; }); - - openWindow(); +// return graphql(Schema, req.query, Resolver, { accounts }, req.data); +// }); })(); - diff --git a/server/src/Message.ts b/server/src/Message.ts index 3438268..a3edb6b 100644 --- a/server/src/Message.ts +++ b/server/src/Message.ts @@ -1,7 +1,6 @@ export default interface Message { id: string; - date: Date; from: string; to: string[]; diff --git a/server/src/data/Data.ts b/server/src/data/Data.ts new file mode 100644 index 0000000..7d6fe06 --- /dev/null +++ b/server/src/data/Data.ts @@ -0,0 +1,137 @@ +import { ObjectID } from 'mongodb'; +import { prop, index, Ref, getModelForClass, modelOptions } from '@typegoose/typegoose'; + +export type Create = Omit; + +export enum MailboxType { + Box = 'NORMAL_BOX', + Archives = 'ARCHIVES', + All = '\\All', + Drafts = '\\Drafts', + Starred = '\\Flagged', + Important = '\\Important', + Inbox = '\\Inbox', + Spam = '\\Junk', + Sent = '\\Sent', + Trash = '\\Trash' +}; + +@modelOptions({ schemaOptions: { versionKey: false }}) +@index({ address: 1 }) +export class Account { + id!: string; + _id!: ObjectID; + + @prop({ required: true }) + name!: string; + + @prop() + image?: string; + + @prop({ required: true }) + address!: string; + + @prop({ required: true }) + password!: string; + + @prop({ required: true }) + host!: string; + + @prop({ required: true }) + port!: number; + + @prop({ default: true }) + tls!: boolean; +}; + +export const AccountModel = getModelForClass(Account); + +@modelOptions({ schemaOptions: { versionKey: false }}) +@index({ account: 1, path: 1 }) +export class Mailbox { + id!: string; + _id!: ObjectID; + + @prop({ required: true, ref: Account }) + account!: Ref; + + @prop({ required: true }) + name!: string; + + @prop({ required: true }) + path!: string; + + @prop({ required: true }) + delimiter!: string; + + @prop({ required: true }) + type!: MailboxType; + + @prop({ required: true, type: [String] }) + private _treeTypes!: MailboxType[]; + + get treeTypes(): Set { return new Set(this._treeTypes); } + set treeTypes(treeTypes: Set) { this._treeTypes = [ ...treeTypes ]; } + + @prop({ ref: Mailbox }) + parent?: Ref; + + @prop({ required: true }) + uidValidity!: number; + + @prop({ required: true }) + uidNext!: number; +}; + +export const MailboxModel = getModelForClass(Mailbox); + +@modelOptions({ schemaOptions: { versionKey: false }}) +export class Contact { + id!: string; + _id!: ObjectID; + + @prop({ default: false }) + userCreated?: boolean; + + @prop({ required: true }) + name!: string; + + @prop({ default: [], type: [String]}) + addresses?: string[]; +}; + +export const ContactModel = getModelForClass(Contact); + +@modelOptions({ schemaOptions: { versionKey: false }}) +@index({ account: 1, messageId: 1 }) +@index({ account: 1, box: 1, uid: 1 }) +export class Message { + id!: string; + _id!: ObjectID; + + @prop({ required: true, ref: Account }) + account!: Ref; + + @prop({ required: true, ref: Mailbox }) + box!: Ref; + + @prop({ required: true }) + uid!: number; + + @prop({ required: true }) + messageId!: string; + + @prop({ required: true }) + subject!: string; + + @prop({ required: true }) + date!: Date; + + // @prop({ required: true, ref: Contact }) + // from!: Ref; + + // @prop({ required: true, ref: Contact }) + // to!: Ref[]; +}; + +export const MessageModel = getModelForClass(Message); diff --git a/server/src/imap/ImapConnection.ts b/server/src/imap/ImapConnection.ts new file mode 100644 index 0000000..986dc43 --- /dev/null +++ b/server/src/imap/ImapConnection.ts @@ -0,0 +1,233 @@ +import Imap from 'imap'; +import EventEmitter from 'events'; + +export enum FetchMode { SEQ, UID }; + +export type FetchSpecifier = string | string[] | number | number[]; + +export interface FetchMessage { + headers: string; + attrs: Imap.ImapMessageAttributes; +} + +export const FETCH_DEFAULT_BODIES = 'HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID)'; + +export interface ConnectionProperties { + user: string; + password: string; + host: string; + port: number; + tls: boolean; +} + +export default class ImapConnection { + readonly event: EventEmitter = new EventEmitter(); + + private conn: Imap; + private connected: boolean = false; + private currentBox: string | null = null; + private currentBoxProps: Imap.Box | null = null; + + private operationNext: number = 0; + private operationsPending: Set = new Set(); + + constructor(connectionProps: ConnectionProperties) { + this.conn = new Imap(connectionProps); + } + + /** + * Checks if the connection is idle. + * + * @returns a boolean indicating if the connection is idle. + */ + + isIdle(): boolean { + if (!this.connected) throw new Error('Attempted to check if an unconnected connection is idle.'); + return this.operationsPending.size === 0; + } + + /** + * Tracks and executes the provided function to prevent disconnection + * or box changes occuring while it is in progress. All operations that + * need to access the open box on the remote server should be wrapped + * by this function. Propagates the returned values or errors up. + * + * @param fn - The function to execute. + * @returns the function's return value. + */ + + private executeOperation(fn: () => Promise): Promise { + return new Promise(async (resolve, reject) => { + if (!this.connected) reject(new Error( + 'Attempted to perform an operation on an unconnected connection.')); + + const operation = this.operationNext++; + this.operationsPending.add(operation); + + try { + this.operationsPending.delete(operation); + resolve(await fn()); + if (this.isIdle()) setTimeout(() => this.event.emit('idle'), 0); + } + catch (e: unknown) { + this.operationsPending.delete(operation); + reject(e); + if (this.isIdle()) setTimeout(() => this.event.emit('idle'), 0); + } + }); + } + + /** + * Initiates the Imap connection, resolves when complete. + * + * @returns a promise that resolves upon connection or rejects with an error. + */ + + connect(): Promise { + return new Promise((resolve, reject) => { + if (this.connected) { + reject(new Error('Attempted to connect while already connected.')); + return; + } + + this.conn.once('end', () => this.connected = false); + this.conn.once('error', (error: any) => reject(error)); + this.conn.once('ready', async () => { + this.connected = true; + resolve(); + }); + + this.conn.connect(); + }); + } + + /** + * Checks if the connection is connected. + * + * @returns a boolean indicating if there is an active connection. + */ + + isConnected(): boolean { + return this.connected; + } + + /** + * Opens the specified box (safely). Throws if there are pending operations. + * + * @param path - The path of the box to open. + * @returns the box that was opened. + */ + + async openBox(path: string): Promise { + return this.executeOperation(() => new Promise((resolve, reject) => { + if (!this.connected) reject(new Error('Tried to open a box while not connected.')); + if (!this.isIdle()) reject(new Error('Tried to change box while the connection was not idle.')); + + this.currentBox = null; + this.conn.openBox(path, false, (err, box) => { + if (err) reject(err); + else { + this.currentBox = path; + this.currentBoxProps = JSON.parse(JSON.stringify(box)); + resolve(this.currentBoxProps!); + } + }); + })); + } + + /** + * Gets the name of the currently open box, if one is open. + * + * @returns the name of the box that is open, or null if none are open. + */ + + getOpenBox(): string | null { + return this.currentBox; + } + + /** + * Gets the properties of the currently open box, or throws if there isn't one open. + * + * @returns an Imap.Box for the currently open box. + */ + + getOpenBoxProps(): Imap.Box { + if (!this.currentBoxProps) throw new Error('Tried to get box props when there wasn\'t an open box.'); + return this.currentBoxProps; + } + + /** + * Gets a tree of boxes on the server. + * + * @returns a tree of imap mailboxes. + */ + + getBoxes(): Promise { + return new Promise((resolve, reject) => { + this.conn.getBoxes((err, boxes) => { + if (err) reject(err); + else resolve(boxes); + }); + }); + } + + /** + * Fetches messages either by SeqNo or by UID and returns their raw headers and attributes. + * + * @param mode - The mode to fetch with. + * @param query - The query to send to the server. + * @param bodies - The header bodies to fetch. + * @returns a map of messages indexed by UID or SeqNo. + */ + + async fetchMessages(mode: FetchMode, query: FetchSpecifier, + bodies: string = FETCH_DEFAULT_BODIES): Promise> { + + return this.executeOperation(() => new Promise((resolve, reject) => { + const fetchRoot = mode === FetchMode.SEQ ? this.conn.seq : this.conn; + + const messages: Map = new Map(); + const fetch = fetchRoot.fetch(query, { bodies, struct: false }); + + fetch.on('error', e => reject(e)); + + fetch.on('message', (msg, id) => { + messages.set(id, { headers: '', attrs: null as any }); + + msg.on('body', stream => stream.on('data', + chunk => messages.get(id)!.headers += chunk.toString('utf8'))); + + msg.once('attributes', + attrs => messages.get(id)!.attrs = attrs); + }); + + fetch.on('end', () => { + resolve(messages); + }); + })); + } + + /** + * Fetches messages by SeqNo and returns their raw headers and attributes. + * + * @param query - The query to send to the server. + * @param bodies - The header bodies to fetch. + * @returns a map of messages indexed by SeqNo. + */ + + async fetchMessagesBySeqNo(query: FetchSpecifier, bodies?: string): Promise> { + return this.fetchMessages(FetchMode.SEQ, query, bodies); + } + + /** + * Fetches messages by UID and returns their raw headers and attributes. + * + * @param query - The query to send to the server. + * @param bodies - The header bodies to fetch. + * @returns a map of messages indexed by UID. + */ + + async fetchMessagesByUID(query: FetchSpecifier, bodies?: string): Promise> { + return this.fetchMessages(FetchMode.UID, query, bodies); + } +} diff --git a/server/src/imap/ImapController.ts b/server/src/imap/ImapController.ts new file mode 100644 index 0000000..c8ba19e --- /dev/null +++ b/server/src/imap/ImapController.ts @@ -0,0 +1,68 @@ +import EventEmitter from 'events'; + +import Log from '../Log'; +import ImapConnection, { ConnectionProperties } from './ImapConnection'; + +export const DEFAULT_CONNECTIONS = 6; + +export default class ImapController { + readonly event: EventEmitter = new EventEmitter(); + + private address: string; + private connected: boolean = false; + private connections: ImapConnection[] = []; + + private awaitingIdle: ((conn: ImapConnection) => void)[] = []; + + constructor(connectionProps: ConnectionProperties, connectionCount: number = DEFAULT_CONNECTIONS) { + this.address = connectionProps.user; + for (let i = 0; i < connectionCount; i++) this.connections.push(new ImapConnection(connectionProps)); + + this.connections.forEach(conn => conn.event.on('idle', + () => this.event.emit('idle', conn))); + + this.event.on('idle', (conn: ImapConnection) => { + if (this.awaitingIdle.length <= 0) return; + this.awaitingIdle[0](conn); + this.awaitingIdle.splice(0, 1); + }); + } + + async connect() { + await Promise.all(this.connections.map(async (conn, i) => { + Log.perfStart(`Connection ${i + 1} for ${this.address}`); + await conn.connect(); + Log.perfEnd(`Connection ${i + 1} for ${this.address}`); + })); + this.connected = true; + } + + async get(path?: string): Promise { + if (!this.connected) throw new Error('Tried to access a box when the connections aren\'t connected.'); + + if (path === undefined) return this.connections[Math.floor(Math.random() * 100000) % this.connections.length]; + + for (let conn of this.connections) { + if (conn.getOpenBox() === path) { + return conn; + } + } + + for (let conn of this.connections) { + if (conn.getOpenBox() === null) { + await conn.openBox(path); + return conn; + } + } + + for (let conn of this.connections) { + if (!conn.isIdle()) continue; + await conn.openBox(path); + return conn; + } + + return new Promise((resolve) => + this.awaitingIdle.push((conn: ImapConnection) => + conn.openBox(path).then(() => resolve(conn)))); + } +}