Inbound mail service (#8379)

* UBERF-9612: Send mail creation request

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-9612: Mail creation

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-9612: Rename pod

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* use hook in inbound mail pod

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* inbound mail mta handler

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* fix formatting

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* revert account changes, save messathes in both from and to spaces

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* remove pod-hook

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* fix port dockerfile

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* fix deps versions

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* mark ws dep as external

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

* create social id for mailbox

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>
Co-authored-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Chunosov
2025-03-28 13:48:19 +07:00
committed by GitHub
co-authored by Artem Savchenko
parent f6c8085583
commit 357d0dcfe9
22 changed files with 536 additions and 229 deletions
+1 -1
View File
@@ -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",
+50 -26
View File
@@ -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: {}
+5 -5
View File
@@ -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
}
]
}
+1
View File
@@ -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 (
-23
View File
@@ -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)
}
}
-22
View File
@@ -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)
}
})
-78
View File
@@ -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<void> => {
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
}
}
-26
View File
@@ -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<void>
export interface Endpoint {
endpoint: string
type: RequestType
handler: RequestHandler
}
@@ -3,5 +3,5 @@ WORKDIR /usr/src/app
COPY bundle/bundle.js ./
EXPOSE 8098
EXPOSE 4050
CMD [ "node", "bundle.js" ]
@@ -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"
}
}
@@ -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
@@ -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<void> {
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<string> {
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<ReadedEmlJson>((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('<div', '<p').replaceAll('</div>', '</p>')
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() ?? ''
}
@@ -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<void>
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<void> {
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)
})
@@ -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<void> {
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<PersonSpace[]> {
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<void> {
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<Card> | undefined
if (inReplyTo !== undefined) {
const route = await client.findOne(mail.class.MailRoute, { mailId: inReplyTo })
if (route !== undefined) {
threadId = route.threadId as Ref<Card>
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<Card>
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()
}
@@ -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<PersonId | undefined> {
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<void> {
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()
)
}
}