UBERF-9542: Merge mail service to staging (#8151)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko 2025-03-06 17:02:51 +07:00 committed by GitHub
parent a1cea5487e
commit 2234f32c28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 907 additions and 7 deletions

View File

@ -238,7 +238,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"
"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"
},
{
"commandKind": "global",

View File

@ -673,6 +673,9 @@ importers:
'@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)
'@rush-temp/pod-mail':
specifier: file:./projects/pod-mail.tgz
version: file:projects/pod-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-print':
specifier: file:./projects/pod-print.tgz
version: file:projects/pod-print.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)
@ -1390,6 +1393,9 @@ importers:
'@types/node-fetch':
specifier: ~2.6.2
version: 2.6.11
'@types/nodemailer':
specifier: ~6.4.17
version: 6.4.17
'@types/otp-generator':
specifier: ^4.0.2
version: 4.0.2
@ -1798,6 +1804,9 @@ importers:
node-loader:
specifier: ~2.0.0
version: 2.0.0(webpack@5.97.1)
nodemailer:
specifier: ~6.10.0
version: 6.10.0
octokit:
specifier: ^3.1.1
version: 3.2.1
@ -6214,6 +6223,9 @@ packages:
'@types/node@20.11.19':
resolution: {integrity: sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==}
'@types/nodemailer@6.4.17':
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
'@types/oauth@0.9.4':
resolution: {integrity: sha512-qk9orhti499fq5XxKCCEbd0OzdPZuancneyse3KtR+vgMiHRbh+mn8M4G6t64ob/Fg+GZGpa565MF/2dKWY32A==}
@ -10805,6 +10817,10 @@ packages:
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
nodemailer@6.10.0:
resolution: {integrity: sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==}
engines: {node: '>=6.0.0'}
nopt@1.0.10:
resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==}
hasBin: true
@ -20956,6 +20972,47 @@ snapshots:
- supports-color
- utf-8-validate
'@rush-temp/pod-mail@file:projects/pod-mail.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))':
dependencies:
'@aws-sdk/client-ses': 3.738.0
'@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/nodemailer': 6.4.17
'@types/web-push': 3.6.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
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)
eslint-plugin-import: 2.29.1(eslint@8.56.0)
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-node: 11.1.0(eslint@8.56.0)
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))
nodemailer: 6.10.0
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
web-push: 3.6.7
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- '@swc/core'
- '@swc/wasm'
- aws-crt
- babel-jest
- babel-plugin-macros
- node-notifier
- supports-color
'@rush-temp/pod-print@file:projects/pod-print.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)':
dependencies:
'@tsconfig/node16': 1.0.4
@ -27161,6 +27218,10 @@ snapshots:
dependencies:
undici-types: 5.26.5
'@types/nodemailer@6.4.17':
dependencies:
'@types/node': 20.11.19
'@types/oauth@0.9.4':
dependencies:
'@types/node': 20.11.19
@ -32965,6 +33026,8 @@ snapshots:
node-releases@2.0.19: {}
nodemailer@6.10.0: {}
nopt@1.0.10:
dependencies:
abbrev: 1.1.1
@ -35176,6 +35239,24 @@ snapshots:
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
ts-node@10.9.2(@types/node@20.11.19)(typescript@5.7.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.9
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 20.11.19
acorn: 8.11.3
acorn-walk: 8.3.2
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 5.7.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
ts-node@7.0.1:
dependencies:
arrify: 1.0.1

View File

@ -73,6 +73,7 @@ setMetadata(serverCore.metadata.FilesUrl, config.filesUrl)
setMetadata(serverToken.metadata.Secret, config.serverSecret)
setMetadata(serverNotification.metadata.SesUrl, config.sesUrl ?? '')
setMetadata(serverNotification.metadata.SesAuthToken, config.sesAuthToken)
setMetadata(serverNotification.metadata.WebPushUrl, config.webPushUrl ?? config.sesUrl)
setMetadata(serverTelegram.metadata.BotUrl, process.env.TELEGRAM_BOT_URL)
setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE)
setMetadata(serverAiBot.metadata.EndpointURL, process.env.AI_BOT_URL)

View File

@ -2280,6 +2280,11 @@
"packageName": "@hcengineering/model-server-card",
"projectFolder": "models/server-card",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-mail",
"projectFolder": "services/mail/pod-mail",
"shouldPublish": false
}
]
}

View File

@ -164,9 +164,10 @@ export async function createPushNotification (
senderAvatar?: Data<AvatarInfo>,
path?: string[]
): Promise<void> {
const sesURL: string | undefined = getMetadata(serverNotification.metadata.SesUrl)
const sesAuth: string | undefined = getMetadata(serverNotification.metadata.SesAuthToken)
if (sesURL === undefined || sesURL === '') return
const pushURL: string | undefined = getMetadata(serverNotification.metadata.WebPushUrl)
// TODO: Remove auth token after migration to new services
const authToken: string | undefined = getMetadata(serverNotification.metadata.SesAuthToken)
if (pushURL === undefined || pushURL === '') return
const userSubscriptions = subscriptions.filter((it) => it.user === target)
const data: PushData = {
title,
@ -193,11 +194,11 @@ export async function createPushNotification (
}
}
void sendPushToSubscription(sesURL, sesAuth, control, target, userSubscriptions, data)
void sendPushToSubscription(pushURL, authToken, control, target, userSubscriptions, data)
}
async function sendPushToSubscription (
sesURL: string,
pushURL: string,
sesAuth: string | undefined,
control: TriggerControl,
targetUser: Ref<Account>,
@ -207,7 +208,7 @@ async function sendPushToSubscription (
try {
const result: Ref<PushSubscription>[] = (
await (
await fetch(concatLink(sesURL, '/web-push'), {
await fetch(concatLink(pushURL, '/web-push'), {
method: 'post',
keepalive: true,
headers: {

View File

@ -152,6 +152,7 @@ export default plugin(serverNotificationId, {
metadata: {
SesUrl: '' as Metadata<string>,
SesAuthToken: '' as Metadata<string>,
WebPushUrl: '' as Metadata<string>,
InboxOnlyNotifications: '' as Metadata<boolean>
},
class: {

View File

@ -7,6 +7,7 @@ export interface ServerEnv {
filesUrl: string | undefined
sesUrl: string | undefined
sesAuthToken: string | undefined
webPushUrl: string | undefined
accountsUrl: string
serverPort: number
enableCompression: boolean
@ -46,6 +47,7 @@ export function serverConfigFromEnv (): ServerEnv {
const filesUrl = process.env.FILES_URL
const sesUrl = process.env.SES_URL
const sesAuthToken = process.env.SES_AUTH_TOKEN
const webPushUrl = process.env.WEB_PUSH_URL
const accountsUrl = process.env.ACCOUNTS_URL
if (accountsUrl === undefined) {
@ -64,6 +66,7 @@ export function serverConfigFromEnv (): ServerEnv {
filesUrl,
sesUrl,
sesAuthToken,
webPushUrl,
accountsUrl,
serverPort,
enableCompression,

View File

@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}

1
services/mail/pod-mail/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.env

View File

@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/

View File

@ -0,0 +1,7 @@
FROM hardcoreeng/base:v20250113a
WORKDIR /usr/src/app
COPY bundle/bundle.js ./
EXPOSE 8097
CMD [ "node", "bundle.js" ]

View File

@ -0,0 +1,86 @@
# Mail Service
## Overview
The Mail Service is responsible for sending emails using SMTP or SES transfer.
It supports sending emails with multiple recipients, along with optional CC, BCC, and HTML content.
### Configuration
Environment variables should be set to configure the Mail Service:
- `PORT`: The port on which the mail service listens for incoming HTTP requests.
Settings for SMTP or SES email service should be specified, simultaneous use of both protocols is not supported
SMTP settings:
- `SMTP_HOST`: Hostname of the SMTP server used for sending emails.
- `SMTP_PORT`: Port number of the SMTP server.
- `SMTP_USERNAME`: Username for authenticating with the SMTP server. Refer to your SMTP server documentation for the appropriate format.
- `SMTP_PASSWORD`: Password for authenticating with the SMTP server. Refer to your SMTP server documentation for the appropriate format.
SES settings:
- `SES_ACCESS_KEY`: AWS SES access key for authentication.
- `SES_SECRET_KEY`: AWS SES secret key for authentication.
- `SES_REGION`: AWS SES region where your SES service is hosted.
### Running the Service
Add .env file to the root of the project with the following content to add integration with fake SMTP server:
```
PORT=8097
SMTP_HOST="mail.smtpbucket.com"
SMTP_PORT=8025
```
To use the real SMTP server it is required to register an account in some email service provider and specify settings and credentials for it.
Start the service locally using:
```bash
rushx run-local
```
The service will run and listen for incoming requests on the configured port.
## API Endpoints
### POST /send
Send an email message.
#### Request Body
- `to`: Required. String or array of strings containing recipient email addresses.
- `text`: Required. String containing the plain text message body.
- `subject`: Required. String containing the email subject.
- `html`: Optional. String containing HTML message body.
- `from`: Optional. Sender's email address.
- `attachments`: Optional. Array of objects, each object can have the following fields:
- `filename`: Filename to be reported as the name of the attached file. Use of unicode is allowed.
- `contentType`: Optional. Content type for the attachment, if not set will be derived from the filename property.
- `content`: String, Buffer, or a Stream contents for the attachment.
- `href`: Optional. An URL to the file (data URIs are allowed as well).
- `contentDisposition`: Optional. Content disposition type for the attachment, defaults to attachment.
- `cid`: Optional. Content id for using inline images in HTML message source.
- `encoding`: Optional. If set and content is a string, then encodes the content to a Buffer using the specified encoding. Example values: base64, hex, binary etc. Useful if you want to use binary attachments in a JSON formatted email object.
- `raw`: An optional special value that overrides the entire contents of the current MIME node, including MIME headers. Useful if you want to prepare node contents yourself.
Request body example:
```
{
"subject": "Test SMTP",
"text": "My text",
"from": "test1@example.com",
"to": "test2@example.com",
"attachments": [
{
"filename": "test.txt",
"content": "Hello world",
"contentType": "text/plain"
}
]
}
```
#### Response
- `200 OK` on success.
- `400 Bad Request` if required fields are missing.

19
services/mail/pod-mail/build.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
#
# 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.
#
rushx bundle
rushx docker:build
rushx docker:push

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}

View File

@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}

View File

@ -0,0 +1,63 @@
{
"name": "@hcengineering/pod-mail",
"version": "0.6.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"tsconfig.json"
],
"author": "Hardcore Engineering Inc.",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent",
"_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/mail",
"docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/mail staging",
"docker:abuild": "docker build -t hardcoreeng/mail . --platform=linux/arm64 && ../../../common/scripts/docker_tag_push.sh hardcoreeng/mail",
"docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/mail",
"run-local": "ts-node src/index.ts",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"cross-env": "~7.0.3",
"@hcengineering/platform-rig": "^0.6.0",
"@types/node": "~20.11.16",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"esbuild": "^0.24.2",
"prettier": "^3.1.0",
"ts-node": "^10.8.0",
"typescript": "^5.3.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@tsconfig/node16": "^1.0.4",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"eslint-plugin-node": "^11.1.0"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.738.0",
"@types/nodemailer": "^6.4.17",
"cors": "^2.8.5",
"dotenv": "~16.0.0",
"express": "^4.21.2",
"nodemailer": "^6.10.0"
}
}

View File

@ -0,0 +1,83 @@
//
// 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.
//
describe('Config', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetModules()
process.env = { ...originalEnv }
})
afterAll(() => {
process.env = originalEnv
})
test('should throw an error if PORT is missing', () => {
process.env.PORT = undefined
expect(() => require('../config')).toThrow('Missing env variable: Port')
})
test('should load default SMTP config if all required env variables are set', () => {
process.env.PORT = '1025'
process.env.SMTP_HOST = 'smtp.example.com'
process.env.SMTP_PORT = '587'
process.env.SMTP_USERNAME = 'user'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const loadedConfig = require('../config').default
expect(loadedConfig.port).toBe(1025)
expect(loadedConfig.smtpConfig).toEqual({
Host: 'smtp.example.com',
Port: 587,
Username: 'user',
Password: undefined
})
})
test('should load SES config if all required env variables are set', () => {
process.env.SMTP_HOST = undefined
process.env.PORT = '1025'
process.env.SES_ACCESS_KEY = 'access_key'
process.env.SES_SECRET_KEY = 'secret_key'
process.env.SES_REGION = 'us-west-2'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const loadedConfig = require('../config').default
expect(loadedConfig.sesConfig).toEqual({
AccessKey: 'access_key',
SecretKey: 'secret_key',
Region: 'us-west-2'
})
})
test('should throw an error if both SES and SMTP configs are missing', () => {
process.env.PORT = '1025'
process.env.SES_ACCESS_KEY = undefined
process.env.SMTP_HOST = undefined
expect(() => require('../config')).toThrow('Please specify SES or SMTP configuration')
})
test('should throw an error if both SES and SMTP are configured', () => {
process.env.PORT = '1025'
process.env.SES_ACCESS_KEY = 'access_key'
process.env.SMTP_HOST = 'smtp.example.com'
expect(() => require('../config')).toThrow('Both SMTP and SES configuration are specified, please specify only one')
})
})

View File

@ -0,0 +1,87 @@
//
// 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 { Request, Response } from 'express'
import { MailClient } from '../mail'
import { handleSendMail } from '../main'
jest.mock('../mail', () => ({
MailClient: jest.fn().mockImplementation(() => ({
sendMessage: jest.fn()
}))
}))
jest.mock('../config', () => ({}))
describe('handleSendMail', () => {
let req: Request
let res: Response
let sendMailMock: jest.Mock
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
req = {
body: {
text: 'Hello, world!',
subject: 'Test Subject',
to: 'test@example.com'
}
} as Request
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn()
} as unknown as Response
sendMailMock = (new MailClient().sendMessage as jest.Mock).mockResolvedValue({})
})
it('should return 400 if text is missing', async () => {
req.body.text = undefined
await handleSendMail(new MailClient(), req, res)
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(res.status).toHaveBeenCalledWith(400)
expect(res.send).toHaveBeenCalledWith({ err: "'text' is missing" })
})
it('should return 400 if subject is missing', async () => {
req.body.subject = undefined
await handleSendMail(new MailClient(), req, res)
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(res.status).toHaveBeenCalledWith(400)
expect(res.send).toHaveBeenCalledWith({ err: "'subject' is missing" })
})
it('should return 400 if to is missing', async () => {
req.body.to = undefined
await handleSendMail(new MailClient(), req, res)
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(res.status).toHaveBeenCalledWith(400)
expect(res.send).toHaveBeenCalledWith({ err: "'to' is missing" })
})
it('handles errors thrown by MailClient', async () => {
sendMailMock.mockRejectedValue(new Error('Email service error'))
await handleSendMail(new MailClient(), req, res)
expect(res.send).toHaveBeenCalled() // Check that a response is still sent
})
})

View File

@ -0,0 +1,119 @@
//
// 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 { config as dotenvConfig } from 'dotenv'
dotenvConfig()
export interface Config {
port: number
source?: string
sesConfig?: SesConfig
smtpConfig?: SmtpConfig
}
export interface SesConfig {
AccessKey: string
SecretKey: string
Region: string
}
export interface SmtpConfig {
Host: string
Port: number
Username: string | undefined
Password: string | undefined
}
const envMap = {
Port: 'PORT',
Source: 'SOURCE',
DefaultProtocol: 'DEFAULT_PROTOCOL',
SesAccessKey: 'SES_ACCESS_KEY',
SesSecretKey: 'SES_SECRET_KEY',
SesRegion: 'SES_REGION',
SmtpHost: 'SMTP_HOST',
SmtpPort: 'SMTP_PORT',
SmtpUsername: 'SMTP_USERNAME',
SmtpPassword: 'SMTP_PASSWORD'
}
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
const isEmpty = (str: string | undefined): boolean => str === undefined || str.trim().length === 0
const buildSesConfig = (): SesConfig => {
const accessKey = process.env[envMap.SesAccessKey]
const secretKey = process.env[envMap.SesSecretKey]
const region = process.env[envMap.SesRegion]
if (isEmpty(accessKey) || isEmpty(secretKey) || isEmpty(region)) {
const missingKeys = [
isEmpty(accessKey) && 'SES_ACCESS_KEY',
isEmpty(secretKey) && 'SES_SECRET_KEY',
isEmpty(region) && 'SES_REGION'
].filter(Boolean)
throw Error(`Missing env variables for SES configuration: ${missingKeys.join(', ')}`)
}
return {
AccessKey: accessKey as string,
SecretKey: secretKey as string,
Region: region as string
}
}
const buildSmtpConfig = (): SmtpConfig => {
const host = process.env[envMap.SmtpHost]
const port = parseNumber(process.env[envMap.SmtpPort])
const username = process.env[envMap.SmtpUsername]
const password = process.env[envMap.SmtpPassword]
if (isEmpty(host) || port === undefined) {
const missingKeys = [isEmpty(host) && 'SMTP_HOST', port === undefined && 'SMTP_PORT'].filter(Boolean)
throw Error(`Missing env variables for SMTP configuration: ${missingKeys.join(', ')}`)
}
return {
Host: host as string,
Port: port,
Username: username,
Password: password
}
}
const config: Config = (() => {
const port = parseNumber(process.env[envMap.Port])
if (port === undefined) {
throw Error('Missing env variable: Port')
}
const isSmtpConfig = !isEmpty(process.env[envMap.SmtpHost])
const isSesConfig = !isEmpty(process.env[envMap.SesAccessKey])
if (isSmtpConfig && isSesConfig) {
throw Error('Both SMTP and SES configuration are specified, please specify only one')
}
if (!isSmtpConfig && !isSesConfig) {
throw Error('Please specify SES or SMTP configuration')
}
const params: Config = {
port,
source: process.env[envMap.Source],
sesConfig: isSesConfig ? buildSesConfig() : undefined,
smtpConfig: isSmtpConfig ? buildSmtpConfig() : undefined
}
return params
})()
export default config

View File

@ -0,0 +1,23 @@
//
// 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)
}
}

View File

@ -0,0 +1,22 @@
//
// 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)
}
})

View File

@ -0,0 +1,36 @@
//
// 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 { type SendMailOptions, type Transporter } from 'nodemailer'
import config from './config'
import { getTransport } from './transport'
export class MailClient {
private readonly transporter: Transporter
constructor () {
this.transporter = getTransport(config)
}
async sendMessage (message: SendMailOptions): Promise<void> {
this.transporter.sendMail(message, (err, info) => {
if (err !== null) {
console.error('Failed to send email: ', err.message)
} else {
console.log(`Email request ${info?.messageId} sent: ${info?.response}`)
}
})
}
}

View File

@ -0,0 +1,78 @@
//
// 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 { type SendMailOptions } from 'nodemailer'
import { Request, Response } from 'express'
import config from './config'
import { createServer, listen } from './server'
import { MailClient } from './mail'
import { Endpoint } from './types'
export const main = async (): Promise<void> => {
const client = new MailClient()
console.log('Mail service has been started')
const endpoints: Endpoint[] = [
{
endpoint: '/send',
type: 'post',
handler: async (req, res) => {
await handleSendMail(client, req, res)
}
}
]
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)
})
}
export async function handleSendMail (client: MailClient, req: Request, res: Response): Promise<void> {
// Skip auth check, since service should be internal
const message: SendMailOptions = req.body
if (message?.text === undefined) {
res.status(400).send({ err: "'text' is missing" })
return
}
if (message?.subject === undefined) {
res.status(400).send({ err: "'subject' is missing" })
return
}
if (message?.to === undefined) {
res.status(400).send({ err: "'to' is missing" })
return
}
try {
await client.sendMessage(message)
} catch (err) {
console.log(err)
}
res.send()
}

View File

@ -0,0 +1,69 @@
//
// 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 cors from 'cors'
import express, { Express, NextFunction, Request, Response } from 'express'
import { Server } from 'http'
import { Endpoint, RequestHandler } from './types'
import { ApiError } from './error'
const catchError = (fn: RequestHandler) => (req: Request, res: Response, next: NextFunction) => {
void (async () => {
try {
await fn(req, res, next)
} catch (err: unknown) {
next(err)
}
})()
}
export function createServer (endpoints: Endpoint[]): Express {
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.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
}
export function listen (e: Express, port: number, host?: string): Server {
const cb = (): void => {
console.log(`Mail service has been started at ${host ?? '*'}:${port}`)
}
return host !== undefined ? e.listen(port, host, cb) : e.listen(port, cb)
}

View File

@ -0,0 +1,57 @@
//
// 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 nodemailer, { type Transporter } from 'nodemailer'
import aws from '@aws-sdk/client-ses'
import type { Config, SmtpConfig, SesConfig } from './config'
function smtp (config: SmtpConfig): Transporter {
const auth =
config.Username !== undefined && config.Password !== undefined
? {
user: config.Username,
pass: config.Password
}
: undefined
return nodemailer.createTransport({
host: config.Host,
port: config.Port,
auth
})
}
function ses (config: SesConfig): Transporter {
const ses = new aws.SES({
region: config.Region,
credentials: {
accessKeyId: config.AccessKey,
secretAccessKey: config.SecretKey
}
})
return nodemailer.createTransport({
SES: { ses, aws }
})
}
export function getTransport (config: Config): Transporter {
if (config.smtpConfig !== undefined) {
return smtp(config.smtpConfig)
}
if (config.sesConfig !== undefined) {
return ses(config.sesConfig)
}
throw new Error('No transport protocol is configured')
}

View File

@ -0,0 +1,26 @@
//
// 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
}

View File

@ -0,0 +1,10 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
}
}