UBERF-9542: Add mail service with SMTP and SES support (#8130)
Some checks are pending
CI / uitest-pg (push) Waiting to run
CI / uitest (push) Waiting to run
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko 2025-03-04 15:06:41 +07:00 committed by GitHub
parent 3b7ef214e0
commit 1e134664e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 816 additions and 2 deletions

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"
"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

@ -700,6 +700,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)
@ -1420,6 +1423,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
@ -1834,6 +1840,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
@ -4016,7 +4025,7 @@ packages:
version: 0.0.0
'@rush-temp/api-tests@file:projects/api-tests.tgz':
resolution: {integrity: sha512-gleyNro85I24sFWGaNjqqz1AF2c8sugQ4vZX/UD/OVu+It6mI96P2Zd8pDl9FqPaIUvtYng8ePj0ThjwI5uGFw==, tarball: file:projects/api-tests.tgz}
resolution: {integrity: sha512-sZvswWPvNBNUJ3rmWnDInxFbuHO494V1BWc786PDGXXlzeOA+KyuAw1LjuDH68VCatzruASrxcKjtekhMWHzNg==, tarball: file:projects/api-tests.tgz}
version: 0.0.0
'@rush-temp/attachment-assets@file:projects/attachment-assets.tgz':
@ -4803,6 +4812,10 @@ packages:
resolution: {integrity: sha512-m+9dPMR45FJpQB+M5ka4PiJcTpV8COlQA5F36is2LkMLwnsDjcB94Sx+TFdIyjhVFpfB8l+t886mSPveHql4ag==, tarball: file:projects/pod-love.tgz}
version: 0.0.0
'@rush-temp/pod-mail@file:projects/pod-mail.tgz':
resolution: {integrity: sha512-XO6I8SkpJ2CFVPWBZFflMCDjKbH1uQpKXhS/DPdGmneb8u+8PB3QbYRZT5R01+GPZIvIcLw11q5Q4psD60nDOQ==, tarball: file:projects/pod-mail.tgz}
version: 0.0.0
'@rush-temp/pod-print@file:projects/pod-print.tgz':
resolution: {integrity: sha512-i0Y9ShIeieNqCNMCXax1CZTStiDTw9M156m7fCDSrNS6VV4v2e5jy/HyNLtpyXz/ZRlOKndBdyiKsls+5pDL8g==, tarball: file:projects/pod-print.tgz}
version: 0.0.0
@ -6394,6 +6407,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==}
@ -10993,6 +11009,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
@ -21495,6 +21515,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
@ -27696,6 +27757,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
@ -33502,6 +33567,8 @@ snapshots:
node-releases@2.0.19: {}
nodemailer@6.10.0: {}
nopt@1.0.10:
dependencies:
abbrev: 1.1.1
@ -35764,6 +35831,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

@ -2325,6 +2325,11 @@
"packageName": "@hcengineering/model-my-space",
"projectFolder": "models/my-space",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-mail",
"projectFolder": "services/mail/pod-mail",
"shouldPublish": false
}
]
}

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,60 @@
# 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.
#### 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,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,53 @@
//
// 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 { Message, Receivers } from './types'
import { getTransport } from './transport'
export class MailClient {
private readonly transporter: Transporter
constructor () {
this.transporter = getTransport(config)
}
async sendMessage (message: Message, receivers: Receivers, from?: string): Promise<void> {
const mailOptions: SendMailOptions = {
from: from ?? config.source,
to: receivers.to,
text: message.text,
subject: message.subject
}
if (receivers.cc !== undefined) {
mailOptions.cc = receivers.cc
}
if (receivers.bcc !== undefined) {
mailOptions.bcc = receivers.bcc
}
if (message.html !== undefined) {
mailOptions.html = message.html
}
this.transporter.sendMail(mailOptions, (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 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) => {
// Skip auth check, since service should be internal
const text = req.body?.text
if (text === undefined) {
res.status(400).send({ err: "'text' is missing" })
return
}
const subject = req.body?.subject
if (subject === undefined) {
res.status(400).send({ err: "'subject' is missing" })
return
}
const html = req.body?.html
const to = req.body?.to
if (to === undefined) {
res.status(400).send({ err: "'to' is missing" })
return
}
const receivers = {
to: Array.isArray(to) ? to : [to]
}
const from = req.body?.from
try {
await client.sendMessage({ text, subject, html }, receivers, from)
} catch (err) {
console.log(err)
}
res.send()
}
}
]
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)
})
}

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,38 @@
//
// 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 interface Receivers {
to: string[]
cc?: string[]
bcc?: string[]
}
export interface Message {
text: string
subject: string
html?: string
}
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"
}
}