diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index 6c84621136..d93d577c5c 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -246,7 +246,7 @@ "summary": "Build docker with platform", "description": "use to build all docker containers required for platform", "safeForSimultaneousRushProcesses": true, - "shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats --to @hcengineering/pod-fulltext --to @hcengineering/pod-love --to @hcengineering/green --to @hcengineering/pod-mail --to @hcengineering/pod-datalake --to @hcengineering/pod-hook --to @hcengineering/pod-export" + "shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats --to @hcengineering/pod-fulltext --to @hcengineering/pod-love --to @hcengineering/green --to @hcengineering/pod-mail --to @hcengineering/pod-datalake --to @hcengineering/pod-inbound-mail --to @hcengineering/pod-export" }, { "commandKind": "global", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 0d624c8168..a45fcf452a 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -715,9 +715,9 @@ importers: '@rush-temp/pod-gmail': specifier: file:./projects/pod-gmail.tgz version: file:projects/pod-gmail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) - '@rush-temp/pod-hook': - specifier: file:./projects/pod-hook.tgz - version: file:projects/pod-hook.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9)) + '@rush-temp/pod-inbound-mail': + specifier: file:./projects/pod-inbound-mail.tgz + version: file:projects/pod-inbound-mail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9)) '@rush-temp/pod-love': specifier: file:./projects/pod-love.tgz version: file:projects/pod-love.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(utf-8-validate@6.0.4) @@ -1600,9 +1600,6 @@ importers: crypto-js: specifier: ^4.2.0 version: 4.2.0 - crypto-random-string: - specifier: ^5.0.0 - version: 5.0.0 css-loader: specifier: ^5.2.1 version: 5.2.7(webpack@5.97.1) @@ -1675,6 +1672,9 @@ importers: email-addresses: specifier: ^5.0.0 version: 5.0.0 + eml-parse-js: + specifier: ^1.2.0-beta.0 + version: 1.2.0-beta.0 emojibase: specifier: ^16.0.0 version: 16.0.0 @@ -3744,7 +3744,7 @@ packages: version: 0.0.0 '@rush-temp/account@file:projects/account.tgz': - resolution: {integrity: sha512-MZD8UjjRyNvtVAoo3gXVrxOV0QpbBt3Et5mAoNYr1mj5pI4cz9fQSc+NuLbNn62cAKlyLXm+frPER4PQ8GUbRA==, tarball: file:projects/account.tgz} + resolution: {integrity: sha512-NN+0JN9QcVA0sk5OmSpSwhjf0lhiWqXFztNPSOZbNnyziTzKgufXiLjho7Nwubi+aev+EIL8weUdW93QBYh07A==, tarball: file:projects/account.tgz} version: 0.0.0 '@rush-temp/activity-assets@file:projects/activity-assets.tgz': @@ -4284,7 +4284,7 @@ packages: version: 0.0.0 '@rush-temp/model-process@file:projects/model-process.tgz': - resolution: {integrity: sha512-ITHTesVu40defmcmB6wso0mRb3Yb9bPLxfRg3oNxo4tE2y1ZFDJtLteL0eHbUMHdyKmtkew/Dn5pNwEPo9GEyQ==, tarball: file:projects/model-process.tgz} + resolution: {integrity: sha512-6kPxJqCYNcG1l1eHYYCxRGV2BGNFEob8nrSjlJXYPbpq3ZOkRSx5am1LyqmdF2I+Flq/HOBSLHyel0gFkIk8ZQ==, tarball: file:projects/model-process.tgz} version: 0.0.0 '@rush-temp/model-products@file:projects/model-products.tgz': @@ -4603,8 +4603,8 @@ packages: resolution: {integrity: sha512-HyyHadfmgT1OP7pdln0WJvK6cGPTYVVvxq21JBHKshu40WojCB6dLdAch/YrTO0aSS7UzluuLmYnnCn5JGvfcg==, tarball: file:projects/pod-gmail.tgz} version: 0.0.0 - '@rush-temp/pod-hook@file:projects/pod-hook.tgz': - resolution: {integrity: sha512-RgcAHfNOUxdFKPBrtcgIsNpQLo4ro8kuPIbNvG8omy/njf/HHWU5YJMFlznSlv+gCrvZmGsP9YV4k+X0uL/bdw==, tarball: file:projects/pod-hook.tgz} + '@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz': + resolution: {integrity: sha512-aRbv7vSb4jFYBQc+EroIkXWtJK21XAmGCYeMdCuhyKU862V5Ik3Dag68jW7pNLRupf4dw9gzJ2/0LlSr89FvgQ==, tarball: file:projects/pod-inbound-mail.tgz} version: 0.0.0 '@rush-temp/pod-love@file:projects/pod-love.tgz': @@ -4688,7 +4688,7 @@ packages: version: 0.0.0 '@rush-temp/process-resources@file:projects/process-resources.tgz': - resolution: {integrity: sha512-pCcsKyA9USfFOyuQ9TEY22sx2CdSI3oPCnIaRvnAJbltCZesjg/QsCJJTJ+taGA9aqSTbwsLVtpmG5LeKbPE3g==, tarball: file:projects/process-resources.tgz} + resolution: {integrity: sha512-ZUhW+nxxAlPdXaL99Tdd5iKav4paqRvB1t4zOs5Pd4ez2K9EVIe0/OFbZAn1ic0MClrp5TxmLSPbu//YrFT3VQ==, tarball: file:projects/process-resources.tgz} version: 0.0.0 '@rush-temp/process@file:projects/process.tgz': @@ -4756,7 +4756,7 @@ packages: version: 0.0.0 '@rush-temp/recruit-resources@file:projects/recruit-resources.tgz': - resolution: {integrity: sha512-57h+96tqCbz2JuXP2uXAQU5SpWaZTqCLBrL6BTNwod9D28ZXxz7W03rWdL0eCVE7zFsm+rC13EMPg+e0lAuHkw==, tarball: file:projects/recruit-resources.tgz} + resolution: {integrity: sha512-zxPPlqTejoRW+M/nuH5nOCX9/Bl8s3wrlB3TYoTFX8Ej423+gjbUr0UbetcuckWYM+L38yI/asE6xO+BcG8NpA==, tarball: file:projects/recruit-resources.tgz} version: 0.0.0 '@rush-temp/recruit@file:projects/recruit.tgz': @@ -5407,6 +5407,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@sinonjs/text-encoding@0.7.3': + resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + '@smithy/abort-controller@4.0.1': resolution: {integrity: sha512-fiUIYgIgRjMWznk6iLJz35K2YxSLHzLBA/RC6lBrKfQ8fHbPfvk7Pk9UvpKoHgJjI18MnbPuEju53zcVy6KF1g==} engines: {node: '>=18.0.0'} @@ -7556,10 +7559,6 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - crypto-random-string@5.0.0: - resolution: {integrity: sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==} - engines: {node: '>=14.16'} - css-loader@5.2.7: resolution: {integrity: sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==} engines: {node: '>= 10.13.0'} @@ -8206,6 +8205,9 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + eml-parse-js@1.2.0-beta.0: + resolution: {integrity: sha512-fDA5OcT9DmU+6Qiv6Ki6/+fIjrZ97SE6KIB0PUK2r0nnRqBbnbaWm844l8SLTd4mc3rF0T3izc8E7E/qXFCthA==} + emoji-regex@10.3.0: resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} @@ -13464,7 +13466,7 @@ snapshots: dependencies: '@aws-sdk/types': 3.734.0 '@smithy/util-utf8': 2.3.0 - tslib: 2.7.0 + tslib: 2.8.1 '@aws-sdk/client-s3@3.738.0': dependencies: @@ -13746,6 +13748,22 @@ snapshots: '@smithy/types': 4.1.0 tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.735.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.734.0 + '@aws-sdk/types': 3.734.0 + '@smithy/is-array-buffer': 4.0.0 + '@smithy/node-config-provider': 4.0.1 + '@smithy/protocol-http': 5.0.1 + '@smithy/types': 4.1.0 + '@smithy/util-middleware': 4.0.1 + '@smithy/util-stream': 4.0.2 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.734.0': dependencies: '@aws-sdk/types': 3.734.0 @@ -15692,7 +15710,6 @@ snapshots: '@types/pg': 8.11.11 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) - crypto-random-string: 5.0.0 eslint: 8.56.0 eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.3.3) eslint-plugin-import: 2.29.1(eslint@8.56.0) @@ -21209,18 +21226,20 @@ snapshots: - supports-color - ts-node - '@rush-temp/pod-hook@file:projects/pod-hook.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))': + '@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))': dependencies: '@tsconfig/node16': 1.0.4 '@types/cors': 2.8.17 '@types/express': 4.17.21 '@types/jest': 29.5.12 '@types/node': 20.11.19 + '@types/uuid': 8.3.4 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.7.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) cors: 2.8.5 cross-env: 7.0.3 dotenv: 16.0.3 + eml-parse-js: 1.2.0-beta.0 esbuild: 0.24.2 eslint: 8.56.0 eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.7.3) @@ -21230,10 +21249,12 @@ snapshots: eslint-plugin-promise: 6.1.1(eslint@8.56.0) express: 4.21.2 jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) + jwt-simple: 0.5.6 prettier: 3.2.5 ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.7.3) ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.7.3) typescript: 5.7.3 + uuid: 8.3.2 transitivePeerDependencies: - '@babel/core' - '@jest/types' @@ -22784,9 +22805,9 @@ snapshots: '@rush-temp/s3@file:projects/s3.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))': dependencies: - '@aws-sdk/client-s3': 3.772.0 - '@aws-sdk/lib-storage': 3.772.0(@aws-sdk/client-s3@3.772.0) - '@aws-sdk/s3-request-presigner': 3.772.0 + '@aws-sdk/client-s3': 3.738.0 + '@aws-sdk/lib-storage': 3.738.0(@aws-sdk/client-s3@3.738.0) + '@aws-sdk/s3-request-presigner': 3.738.0 '@smithy/node-http-handler': 4.0.2 '@types/jest': 29.5.12 '@types/node': 20.11.19 @@ -26733,6 +26754,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sinonjs/text-encoding@0.7.3': {} + '@smithy/abort-controller@4.0.1': dependencies: '@smithy/types': 4.1.0 @@ -29524,10 +29547,6 @@ snapshots: crypto-js@4.2.0: {} - crypto-random-string@5.0.0: - dependencies: - type-fest: 2.19.0 - css-loader@5.2.7(webpack@5.97.1): dependencies: icss-utils: 5.1.0(postcss@8.4.35) @@ -30229,6 +30248,11 @@ snapshots: emittery@0.13.1: {} + eml-parse-js@1.2.0-beta.0: + dependencies: + '@sinonjs/text-encoding': 0.7.3 + js-base64: 3.7.7 + emoji-regex@10.3.0: {} emoji-regex@8.0.0: {} diff --git a/rush.json b/rush.json index d6a63d2208..092c09ea6c 100644 --- a/rush.json +++ b/rush.json @@ -2363,11 +2363,6 @@ "projectFolder": "services/mail/pod-mail", "shouldPublish": false }, - { - "packageName": "@hcengineering/pod-hook", - "projectFolder": "services/hook/pod-hook", - "shouldPublish": false - }, { "packageName": "@hcengineering/process", "projectFolder": "plugins/process", @@ -2407,6 +2402,11 @@ "packageName": "@hcengineering/kafka", "projectFolder": "server/kafka", "shouldPublish": false + }, + { + "packageName": "@hcengineering/pod-inbound-mail", + "projectFolder": "services/mail/pod-inbound-mail", + "shouldPublish": false } ] } diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index df51fe5e08..58dcb24372 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -2038,6 +2038,7 @@ async function createMailbox ( await db.mailbox.insertOne({ accountUuid: account, mailbox }) await db.mailboxSecret.insertOne({ mailbox, secret: generatePassword() }) + await db.socialId.insertOne({ personUuid: account, type: SocialIdType.EMAIL, value: mailbox }) } async function getMailboxes ( diff --git a/services/hook/pod-hook/src/error.ts b/services/hook/pod-hook/src/error.ts deleted file mode 100644 index 98eb3adba6..0000000000 --- a/services/hook/pod-hook/src/error.ts +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright © 2025 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -export class ApiError extends Error { - constructor ( - readonly code: string, - readonly message: string - ) { - super(message) - } -} diff --git a/services/hook/pod-hook/src/index.ts b/services/hook/pod-hook/src/index.ts deleted file mode 100644 index a2ce3d2a24..0000000000 --- a/services/hook/pod-hook/src/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright © 2025 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import { main } from './main' - -void main().catch((err) => { - if (err != null) { - console.error(err) - } -}) diff --git a/services/hook/pod-hook/src/main.ts b/services/hook/pod-hook/src/main.ts deleted file mode 100644 index dd56f12f41..0000000000 --- a/services/hook/pod-hook/src/main.ts +++ /dev/null @@ -1,78 +0,0 @@ -// -// Copyright © 2025 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import { createServer, listen } from './server' -import { Endpoint } from './types' -import config from './config' - -export const main = async (): Promise => { - const endpoints: Endpoint[] = [ - { - endpoint: '/mta', - type: 'post', - handler: async (req, res) => { - console.log('mta-hook retrieved') - const message = getMessageInfo(req.body) - console.log('Email from:', message?.from) - // TODO: Send request to add message or put event to the queue - - res.json({ - action: 'accept' - }) - } - } - ] - - const server = listen(createServer(endpoints), config.port) - - const shutdown = (): void => { - server.close(() => { - process.exit() - }) - } - - process.on('SIGINT', shutdown) - process.on('SIGTERM', shutdown) - process.on('uncaughtException', (e) => { - console.error(e) - }) - process.on('unhandledRejection', (e) => { - console.error(e) - }) -} - -const getMessageInfo = ( - body: any -): { from: string, to: string[], subject: string, contents: any, size: number } | undefined => { - try { - const from = body.envelope.from.address - const to = body.envelope.to.map((recipient: any) => recipient.address) - const subjectHeader = body.message.headers.find((header: any) => header[0] === 'Subject') - const subject = subjectHeader !== undefined ? subjectHeader[1] : 'No Subject' - const contents = body.message.contents - const size = body.message.size - - return { - from, - to, - subject, - contents, - size - } - } catch (e) { - console.error('Failed to parse message:', e) - return undefined - } -} diff --git a/services/hook/pod-hook/src/types.ts b/services/hook/pod-hook/src/types.ts deleted file mode 100644 index e47be22f1a..0000000000 --- a/services/hook/pod-hook/src/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright © 2025 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import { NextFunction, Request, Response } from 'express' - -export type RequestType = 'get' | 'post' - -export type RequestHandler = (req: Request, res: Response, next?: NextFunction) => Promise - -export interface Endpoint { - endpoint: string - type: RequestType - handler: RequestHandler -} diff --git a/services/hook/pod-hook/.eslintrc.js b/services/mail/pod-inbound-mail/.eslintrc.js similarity index 100% rename from services/hook/pod-hook/.eslintrc.js rename to services/mail/pod-inbound-mail/.eslintrc.js diff --git a/services/hook/pod-hook/.gitignore b/services/mail/pod-inbound-mail/.gitignore similarity index 100% rename from services/hook/pod-hook/.gitignore rename to services/mail/pod-inbound-mail/.gitignore diff --git a/services/hook/pod-hook/.npmignore b/services/mail/pod-inbound-mail/.npmignore similarity index 100% rename from services/hook/pod-hook/.npmignore rename to services/mail/pod-inbound-mail/.npmignore diff --git a/services/hook/pod-hook/Dockerfile b/services/mail/pod-inbound-mail/Dockerfile similarity index 90% rename from services/hook/pod-hook/Dockerfile rename to services/mail/pod-inbound-mail/Dockerfile index 33ebc8e0b0..bfca59ca3b 100644 --- a/services/hook/pod-hook/Dockerfile +++ b/services/mail/pod-inbound-mail/Dockerfile @@ -3,5 +3,5 @@ WORKDIR /usr/src/app COPY bundle/bundle.js ./ -EXPOSE 8098 +EXPOSE 4050 CMD [ "node", "bundle.js" ] diff --git a/services/hook/pod-hook/build.sh b/services/mail/pod-inbound-mail/build.sh similarity index 100% rename from services/hook/pod-hook/build.sh rename to services/mail/pod-inbound-mail/build.sh diff --git a/services/hook/pod-hook/config/rig.json b/services/mail/pod-inbound-mail/config/rig.json similarity index 100% rename from services/hook/pod-hook/config/rig.json rename to services/mail/pod-inbound-mail/config/rig.json diff --git a/services/hook/pod-hook/jest.config.js b/services/mail/pod-inbound-mail/jest.config.js similarity index 100% rename from services/hook/pod-hook/jest.config.js rename to services/mail/pod-inbound-mail/jest.config.js diff --git a/services/hook/pod-hook/package.json b/services/mail/pod-inbound-mail/package.json similarity index 66% rename from services/hook/pod-hook/package.json rename to services/mail/pod-inbound-mail/package.json index a52e5fcc10..41f97095ed 100644 --- a/services/hook/pod-hook/package.json +++ b/services/mail/pod-inbound-mail/package.json @@ -1,5 +1,5 @@ { - "name": "@hcengineering/pod-hook", + "name": "@hcengineering/pod-inbound-mail", "version": "0.6.0", "main": "lib/index.js", "svelte": "src/index.ts", @@ -17,11 +17,11 @@ "_phase:bundle": "rushx bundle", "_phase:docker-build": "rushx docker:build", "_phase:docker-staging": "rushx docker:staging", - "bundle": "node ../../../common/scripts/esbuild.js", - "docker:build": "../../../common/scripts/docker_build.sh hardcoreeng/hook", - "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/hook staging", - "docker:abuild": "docker build -t hardcoreeng/hook . --platform=linux/arm64 && ../../../common/scripts/docker_tag_push.sh hardcoreeng/hook", - "docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/hook", + "bundle": "node ../../../common/scripts/esbuild.js --external=ws", + "docker:build": "../../../common/scripts/docker_build.sh hardcoreeng/inbound-mail", + "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/inbound-mail staging", + "docker:abuild": "docker build -t hardcoreeng/inbound-mail . --platform=linux/arm64 && ../../../common/scripts/docker_tag_push.sh hardcoreeng/inbound-mail", + "docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/inbound-mail", "run-local": "ts-node src/index.ts", "format": "format src", "_phase:build": "compile transpile src", @@ -53,8 +53,21 @@ "eslint-plugin-node": "^11.1.0" }, "dependencies": { + "@hcengineering/account-client": "^0.6.0", + "@hcengineering/api-client": "^0.6.0", + "@hcengineering/attachment": "^0.6.14", + "@hcengineering/card": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/mail": "^0.6.0", + "@hcengineering/text-html": "^0.6.0", + "@types/uuid": "^8.3.1", + "eml-parse-js": "^1.2.0-beta.0", + "uuid": "^8.3.2", "cors": "^2.8.5", "express": "^4.21.2", - "dotenv": "~16.0.0" + "dotenv": "~16.0.0", + "jwt-simple": "^0.5.6" } } diff --git a/services/hook/pod-hook/src/config.ts b/services/mail/pod-inbound-mail/src/config.ts similarity index 61% rename from services/hook/pod-hook/src/config.ts rename to services/mail/pod-inbound-mail/src/config.ts index 170c524693..46ff3fbcad 100644 --- a/services/hook/pod-hook/src/config.ts +++ b/services/mail/pod-inbound-mail/src/config.ts @@ -16,26 +16,23 @@ import { config as dotenvConfig } from 'dotenv' dotenvConfig() -export interface Config { +interface Config { port: number + secret: string + accountsUrl: string + workspaceUrl: string } -const envMap = { - Port: 'PORT' +const config: Config = { + port: parseInt(process.env.PORT ?? '4050'), + secret: process.env.SECRET ?? 'secret', + accountsUrl: process.env.ACCOUNTS_URL ?? 'http://localhost:3000', + workspaceUrl: (() => { + if (process.env.WORKSPACE_URL !== undefined) { + return process.env.WORKSPACE_URL + } + throw Error('WORKSPACE_URL env var is not set') + })() } -const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) - -const config: Config = (() => { - const port = parseNumber(process.env[envMap.Port]) - if (port === undefined) { - throw Error('Missing env variable: Port') - } - const params: Config = { - port - } - - return params -})() - export default config diff --git a/services/mail/pod-inbound-mail/src/handlerMta.ts b/services/mail/pod-inbound-mail/src/handlerMta.ts new file mode 100644 index 0000000000..4eb4a60653 --- /dev/null +++ b/services/mail/pod-inbound-mail/src/handlerMta.ts @@ -0,0 +1,118 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { createHash } from 'crypto' +import { readEml, ReadedEmlJson } from 'eml-parse-js' +import { Request, Response } from 'express' +import { htmlToMarkup } from '@hcengineering/text-html' +import { createMessages } from './message' + +interface MtaMessage { + envelope: { + from: { + address: string + } + to: { + address: string + }[] + } + message: { + headers: string[][] + contents: string + } +} + +export async function handleMtaHook (req: Request, res: Response): Promise { + try { + const mta: MtaMessage = req.body + + const from = { address: mta.envelope.from.address, name: '' } + const fromHeader = mta.message.headers.find((header) => header[0] === 'From')?.[1] + if (fromHeader !== undefined) { + from.name = extractContactName(fromHeader) + } + + const tos = mta.envelope.to.map((to) => ({ address: to.address, name: '' })) + const toHeader = mta.message.headers.find((header) => header[0] === 'To')?.[1] + if (toHeader !== undefined) { + for (const part of toHeader.split(',')) { + for (const to of tos) { + if (part.includes(to.address)) { + to.name = extractContactName(part) + } + } + } + } + + const subject = (mta.message.headers.find((header) => header[0] === 'Subject')?.[1] ?? '').trim() + const inReplyTo = mta.message.headers.find((header) => header[0] === 'In-Reply-To')?.[1]?.trim() + const content = await getContent(mta) + + let mailId = mta.message.headers.find((header) => header[0] === 'Message-ID')?.[1].trim() + if (mailId === undefined) { + mailId = createHash('sha256') + .update( + JSON.stringify({ + from: from.address, + to: tos.map((to) => to.address), + subject, + content + }) + ) + .digest('hex') + } + + await createMessages(mailId, from, tos, subject, content, inReplyTo) + } catch (err) { + console.error('mta-hook', err) + } finally { + res.status(200).send({ action: 'accept' }) + } +} + +async function getContent (mta: MtaMessage): Promise { + const contentType = mta.message.headers.find((header) => header[0] === 'Content-Type')?.[1] + if (contentType === undefined) { + throw new Error('Content-Type header not found') + } + const contents = `Content-Type: ${contentType}\r\n${mta.message.contents}` + const email = await new Promise((resolve, reject) => { + readEml(contents, (err, json) => { + if (err !== undefined && err !== null) { + reject(err) + } else if (json === undefined) { + reject(new Error('Failed to parse email')) + } else { + resolve(json) + } + }) + }) + if (email.html !== undefined) { + try { + // Some mailers (e.g. Google) use divs instead of paragraphs + const html = email.html.replaceAll('', '

') + const markup = htmlToMarkup(html) + return JSON.stringify(markup) + } catch (err) { + console.warn('Failed to parse html content', err) + } + } + return email.text ?? '' +} + +function extractContactName (fromHeader: string): string { + // Match name part that appears before an email in angle brackets + const nameMatch = fromHeader.match(/^\s*"?([^"<]+?)"?\s*<.+?>/) + return nameMatch?.[1].trim() ?? '' +} diff --git a/services/hook/pod-hook/src/server.ts b/services/mail/pod-inbound-mail/src/index.ts similarity index 56% rename from services/hook/pod-hook/src/server.ts rename to services/mail/pod-inbound-mail/src/index.ts index 8d44919695..07517fc292 100644 --- a/services/hook/pod-hook/src/server.ts +++ b/services/mail/pod-inbound-mail/src/index.ts @@ -14,56 +14,60 @@ // import cors from 'cors' -import express, { Express, NextFunction, Request, Response } from 'express' -import { Server } from 'http' +import express, { NextFunction, Request, Response } from 'express' +import { handleMtaHook } from './handlerMta' +import config from './config' -import { Endpoint, RequestHandler } from './types' -import { ApiError } from './error' +type RequestHandler = (req: Request, res: Response, next?: NextFunction) => Promise const catchError = (fn: RequestHandler) => (req: Request, res: Response, next: NextFunction) => { void (async () => { try { await fn(req, res, next) } catch (err: unknown) { + console.error(req.method, req.path, err) next(err) } })() } -export function createServer (endpoints: Endpoint[]): Express { +async function main (): Promise { const app = express() app.use(cors()) app.use(express.json()) - endpoints.forEach((endpoint) => { - if (endpoint.type === 'get') { - app.get(endpoint.endpoint, catchError(endpoint.handler)) - } else if (endpoint.type === 'post') { - app.post(endpoint.endpoint, catchError(endpoint.handler)) - } - }) + app.post('/mta-hook', catchError(handleMtaHook)) app.use((_req, res, _next) => { res.status(404).send({ message: 'Not found' }) }) app.use((err: any, _req: any, res: any, _next: any) => { - if (err instanceof ApiError) { - res.status(400).send({ code: err.code, message: err.message }) - return - } - res.status(500).send({ message: err.message }) }) - return app -} + const server = app.listen(config.port, () => { + console.log(`server started on port ${config.port}`) + console.log({ ...config, secret: '(stripped)' }) + }) -export function listen (e: Express, port: number, host?: string): Server { - const cb = (): void => { - console.log(`Hook service has been started at ${host ?? '*'}:${port}`) + const shutdown = (): void => { + server.close(() => { + process.exit() + }) } - return host !== undefined ? e.listen(port, host, cb) : e.listen(port, cb) + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + process.on('uncaughtException', (e) => { + console.error(e) + }) + process.on('unhandledRejection', (e) => { + console.error(e) + }) } + +void main().catch((err) => { + console.error(err) +}) diff --git a/services/mail/pod-inbound-mail/src/message.ts b/services/mail/pod-inbound-mail/src/message.ts new file mode 100644 index 0000000000..72cee10b8c --- /dev/null +++ b/services/mail/pod-inbound-mail/src/message.ts @@ -0,0 +1,193 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the 'License'); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an 'AS IS' BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { encode } from 'jwt-simple' +import { + type PersonId, + type Ref, + type TxOperations, + generateId, + RateLimiter, + SocialIdType, + systemAccountUuid +} from '@hcengineering/core' +import { getClient as getAccountClient } from '@hcengineering/account-client' +import { createRestTxOperations } from '@hcengineering/api-client' +import { type Card } from '@hcengineering/card' +import chunter from '@hcengineering/chunter' +import contact, { PersonSpace } from '@hcengineering/contact' +import mail from '@hcengineering/mail' +import config from './config' +import { ensureGlobalPerson } from './person' + +function generateToken (): string { + return encode( + { + account: systemAccountUuid, + extra: { service: 'mail' } + }, + config.secret + ) +} + +export async function createMessages ( + mailId: string, + from: { address: string, name: string }, + tos: { address: string, name: string }[], + subject: string, + content: string, + inReplyTo?: string +): Promise { + console.log(`[${mailId}] Sending message ${from.address} --> ${tos.map((to) => to.address).join(',')}`) + + const accountClient = getAccountClient(config.accountsUrl, generateToken()) + const wsInfo = await accountClient.selectWorkspace(config.workspaceUrl) + const transactorUrl = wsInfo.endpoint.replace('ws://', 'http://').replace('wss://', 'https://') + const client = await createRestTxOperations(transactorUrl, wsInfo.workspace, wsInfo.token) + + const fromPersonId = await ensureGlobalPerson(accountClient, mailId, from) + if (fromPersonId === undefined) { + console.error(`[${mailId}] Unable to create message without a proper FROM`) + return + } + + const toPersons: { address: string, socialId: PersonId }[] = [] + for (const to of tos) { + const toPersonId = await ensureGlobalPerson(accountClient, mailId, to) + if (toPersonId === undefined) { + continue + } + toPersons.push({ address: to.address, socialId: toPersonId }) + } + if (toPersons.length === 0) { + console.error(`[${mailId}] Unable to create message without a proper TO`) + return + } + + const modifiedBy = fromPersonId + const participants = [fromPersonId, ...toPersons.map((p) => p.socialId)] + + try { + const spaces = await getPersonSpaces(client, mailId, fromPersonId, from.address) + if (spaces.length > 0) { + await saveMessageToSpaces(client, mailId, spaces, participants, modifiedBy, subject, content, inReplyTo) + } + } catch (err) { + console.error(`[${mailId}] Failed to save message to personal spaces of ${fromPersonId} (${from.address})`, err) + } + + for (const to of toPersons) { + try { + const spaces = await getPersonSpaces(client, mailId, to.socialId, to.address) + if (spaces.length > 0) { + await saveMessageToSpaces(client, mailId, spaces, participants, modifiedBy, subject, content, inReplyTo) + } + } catch (err) { + console.error(`[${mailId}] Failed to save message spaces of ${to.socialId} (${to.address})`, err) + } + } +} + +async function getPersonSpaces ( + client: TxOperations, + mailId: string, + personId: PersonId, + email: string +): Promise { + const socialIdents = await client.findAll(contact.class.SocialIdentity, { type: SocialIdType.EMAIL, value: email }) + const personRefs = socialIdents.map((socialId) => socialId.attachedTo) + const spaces = await client.findAll(contact.class.PersonSpace, { person: { $in: personRefs } }) + if (spaces.length === 0) { + console.log(`[${mailId}] No personal space found for ${personId} (${email}), skip`) + } + return spaces +} + +async function saveMessageToSpaces ( + client: TxOperations, + mailId: string, + spaces: PersonSpace[], + participants: PersonId[], + modifiedBy: PersonId, + subject: string, + content: string, + inReplyTo?: string +): Promise { + const rateLimiter = new RateLimiter(10) + for (const space of spaces) { + await rateLimiter.add(async () => { + console.log(`[${mailId}] Saving message to space ${space._id}`) + + const route = await client.findOne(mail.class.MailRoute, { mailId }) + if (route !== undefined) { + console.log(`[${mailId}] Message is already in the thread ${route.threadId}, skip`) + return + } + + let threadId: Ref | undefined + if (inReplyTo !== undefined) { + const route = await client.findOne(mail.class.MailRoute, { mailId: inReplyTo }) + if (route !== undefined) { + threadId = route.threadId as Ref + console.log(`[${mailId}] Found existing thread ${threadId}`) + } + } + if (threadId === undefined) { + const newThreadId = await client.createDoc( + mail.class.MailThread, + space._id, + { + title: subject, + description: content, + private: true, + members: participants, + archived: false, + createdBy: modifiedBy, + modifiedBy + }, + generateId(), + undefined, + modifiedBy + ) + threadId = newThreadId as Ref + console.log(`[${mailId}] Created new thread ${threadId}`) + } + + await client.addCollection( + chunter.class.ChatMessage, + space._id, + threadId, + mail.class.MailThread, + 'messages', + { message: content }, + generateId(), + undefined, + modifiedBy + ) + + await client.createDoc( + mail.class.MailRoute, + space._id, + { + mailId, + threadId + }, + generateId(), + undefined, + modifiedBy + ) + }) + } + await rateLimiter.waitProcessing() +} diff --git a/services/mail/pod-inbound-mail/src/person.ts b/services/mail/pod-inbound-mail/src/person.ts new file mode 100644 index 0000000000..0132f59944 --- /dev/null +++ b/services/mail/pod-inbound-mail/src/person.ts @@ -0,0 +1,106 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the 'License'); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an 'AS IS' BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { buildSocialIdString, generateId, PersonId, PersonUuid, SocialIdType, TxOperations } from '@hcengineering/core' +import contact, { AvatarType, combineName } from '@hcengineering/contact' +import { AccountClient } from '@hcengineering/account-client' + +export async function ensureGlobalPerson ( + client: AccountClient, + mailId: string, + contact: { address: string, name: string } +): Promise { + const socialKey = buildSocialIdString({ type: SocialIdType.EMAIL, value: contact.address }) + const personId = await client.findSocialIdBySocialKey(socialKey) + if (personId !== undefined) { + console.log(`[${mailId}] Found global person for ${contact.address}: ${personId}`) + return personId + } + const [firstName, lastName] = contact.name.split(' ') + try { + const globalPerson = await client.ensurePerson(SocialIdType.EMAIL, contact.address, firstName, lastName) + return globalPerson.socialId + } catch (err) { + console.error(`[${mailId}] Failed to create global person for ${contact.address}`, err) + } + return undefined +} + +export async function ensureLocalPerson ( + client: TxOperations, + personUuid: PersonUuid, + socialId: PersonId, + email: string, + firstName: string, + lastName: string +): Promise { + let person = await client.findOne( + contact.class.Person, + { + personUuid + }, + { + projection: { name: 1 } + } + ) + if (person === undefined) { + const newPersonId = await client.createDoc( + contact.class.Person, + contact.space.Contacts, + { + avatarType: AvatarType.COLOR, + name: combineName(firstName, lastName), + personUuid + }, + generateId() + ) + person = await client.findOne(contact.class.Person, { _id: newPersonId }) + if (person === undefined) { + throw new Error(`Failed to create local person for ${personUuid}`) + } + await client.addCollection( + contact.class.SocialIdentity, + contact.space.Contacts, + person._id, + contact.class.Person, + 'socialIds', + { + key: socialId, + type: SocialIdType.EMAIL, + value: email + }, + generateId() + ) + } + const channel = await client.findOne(contact.class.Channel, { + attachedTo: person._id, + attachedToClass: contact.class.Person, + provider: contact.channelProvider.Email, + value: email + }) + if (channel === undefined) { + await client.addCollection( + contact.class.Channel, + contact.space.Contacts, + person._id, + contact.class.Person, + 'channels', + { + provider: contact.channelProvider.Email, + value: email + }, + generateId() + ) + } +} diff --git a/services/hook/pod-hook/tsconfig.json b/services/mail/pod-inbound-mail/tsconfig.json similarity index 100% rename from services/hook/pod-hook/tsconfig.json rename to services/mail/pod-inbound-mail/tsconfig.json