diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index ab4189fe12..3535bf519c 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -246,7 +246,7 @@ "summary": "Build docker with platform", "description": "use to build all docker containers required for platform", "safeForSimultaneousRushProcesses": true, - "shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-workspace --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot --to @hcengineering/import-tool --to @hcengineering/pod-stats --to @hcengineering/pod-fulltext --to @hcengineering/pod-love --to @hcengineering/green" + "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", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e619e7bfcf..4306837270 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -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 diff --git a/rush.json b/rush.json index 7ce403c45f..f366776de0 100644 --- a/rush.json +++ b/rush.json @@ -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 } ] } diff --git a/services/mail/pod-mail/.eslintrc.js b/services/mail/pod-mail/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/services/mail/pod-mail/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/services/mail/pod-mail/.gitignore b/services/mail/pod-mail/.gitignore new file mode 100644 index 0000000000..2eea525d88 --- /dev/null +++ b/services/mail/pod-mail/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/services/mail/pod-mail/.npmignore b/services/mail/pod-mail/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/services/mail/pod-mail/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/services/mail/pod-mail/Dockerfile b/services/mail/pod-mail/Dockerfile new file mode 100644 index 0000000000..5734511fdd --- /dev/null +++ b/services/mail/pod-mail/Dockerfile @@ -0,0 +1,7 @@ +FROM hardcoreeng/base:v20250113a +WORKDIR /usr/src/app + +COPY bundle/bundle.js ./ + +EXPOSE 8097 +CMD [ "node", "bundle.js" ] diff --git a/services/mail/pod-mail/README.md b/services/mail/pod-mail/README.md new file mode 100644 index 0000000000..c2290dd64d --- /dev/null +++ b/services/mail/pod-mail/README.md @@ -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. diff --git a/services/mail/pod-mail/build.sh b/services/mail/pod-mail/build.sh new file mode 100755 index 0000000000..d52b6258ea --- /dev/null +++ b/services/mail/pod-mail/build.sh @@ -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 diff --git a/services/mail/pod-mail/config/rig.json b/services/mail/pod-mail/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/services/mail/pod-mail/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/services/mail/pod-mail/jest.config.js b/services/mail/pod-mail/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/services/mail/pod-mail/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/services/mail/pod-mail/package.json b/services/mail/pod-mail/package.json new file mode 100644 index 0000000000..adfa35700a --- /dev/null +++ b/services/mail/pod-mail/package.json @@ -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" + } +} diff --git a/services/mail/pod-mail/src/__tests__/config.test.ts b/services/mail/pod-mail/src/__tests__/config.test.ts new file mode 100644 index 0000000000..7d9ad2891a --- /dev/null +++ b/services/mail/pod-mail/src/__tests__/config.test.ts @@ -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') + }) +}) diff --git a/services/mail/pod-mail/src/config.ts b/services/mail/pod-mail/src/config.ts new file mode 100644 index 0000000000..bea520967a --- /dev/null +++ b/services/mail/pod-mail/src/config.ts @@ -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 diff --git a/services/mail/pod-mail/src/error.ts b/services/mail/pod-mail/src/error.ts new file mode 100644 index 0000000000..98eb3adba6 --- /dev/null +++ b/services/mail/pod-mail/src/error.ts @@ -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) + } +} diff --git a/services/mail/pod-mail/src/index.ts b/services/mail/pod-mail/src/index.ts new file mode 100644 index 0000000000..a2ce3d2a24 --- /dev/null +++ b/services/mail/pod-mail/src/index.ts @@ -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) + } +}) diff --git a/services/mail/pod-mail/src/mail.ts b/services/mail/pod-mail/src/mail.ts new file mode 100644 index 0000000000..c1dc6fc1b0 --- /dev/null +++ b/services/mail/pod-mail/src/mail.ts @@ -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 { + 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}`) + } + }) + } +} diff --git a/services/mail/pod-mail/src/main.ts b/services/mail/pod-mail/src/main.ts new file mode 100644 index 0000000000..0d673beaba --- /dev/null +++ b/services/mail/pod-mail/src/main.ts @@ -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 => { + 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) + }) +} diff --git a/services/mail/pod-mail/src/server.ts b/services/mail/pod-mail/src/server.ts new file mode 100644 index 0000000000..fb29598359 --- /dev/null +++ b/services/mail/pod-mail/src/server.ts @@ -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) +} diff --git a/services/mail/pod-mail/src/transport.ts b/services/mail/pod-mail/src/transport.ts new file mode 100644 index 0000000000..32f26f94c2 --- /dev/null +++ b/services/mail/pod-mail/src/transport.ts @@ -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') +} diff --git a/services/mail/pod-mail/src/types.ts b/services/mail/pod-mail/src/types.ts new file mode 100644 index 0000000000..c9d2ace095 --- /dev/null +++ b/services/mail/pod-mail/src/types.ts @@ -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 + +export interface Endpoint { + endpoint: string + type: RequestType + handler: RequestHandler +} diff --git a/services/mail/pod-mail/tsconfig.json b/services/mail/pod-mail/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/services/mail/pod-mail/tsconfig.json @@ -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" + } +} \ No newline at end of file