diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7b84c2f439..bde53cbe01 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1612,6 +1612,9 @@ importers: '@types/archiver': specifier: ^6.0.3 version: 6.0.3 + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/body-parser': specifier: ~1.19.2 version: 1.19.5 @@ -1783,6 +1786,9 @@ importers: base64-js: specifier: ^1.5.1 version: 1.5.1 + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 big-integer: specifier: ^1.6.51 version: 1.6.52 @@ -4906,7 +4912,7 @@ packages: version: 0.0.0 '@rush-temp/model-card@file:projects/model-card.tgz': - resolution: {integrity: sha512-SsrZ+0AVeICDFwFdWZt9Zwr8b8/EFjnJcJcqkUKVqqvw7EaABvHV0Gl0/DfvV0xxnxoeJjO+2z/I/QoTB47g7w==, tarball: file:projects/model-card.tgz} + resolution: {integrity: sha512-0Gm7Yjag3m6EvKtCWSke4phJ5+N8YcudQLvrphiDWlgxEoVl7FsDq4lVNKX5CZjPcldm79usxWrCJ8+GaAgDuQ==, tarball: file:projects/model-card.tgz} version: 0.0.0 '@rush-temp/model-chat@file:projects/model-chat.tgz': @@ -5346,7 +5352,7 @@ packages: version: 0.0.0 '@rush-temp/pod-mail@file:projects/pod-mail.tgz': - resolution: {integrity: sha512-WhDC0I0bu1HeY3HN/yz6xQHsanXYf0ZAWEToJuKb3IAuLTsq1k/um2url73TyBYmWT+XbzKDRMh+AWM5xkav+w==, tarball: file:projects/pod-mail.tgz} + resolution: {integrity: sha512-0kGmfCDk+EBVRFn28ht0cb+WUUF2sl0BkednIhT99vM5q/g5hfTZeIn5OLBFhqwJyHkj99xA+5eha1nMgH81UA==, tarball: file:projects/pod-mail.tgz} version: 0.0.0 '@rush-temp/pod-media@file:projects/pod-media.tgz': @@ -6658,6 +6664,9 @@ packages: '@types/babel__traverse@7.20.5': resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} @@ -7730,6 +7739,10 @@ packages: batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -11583,6 +11596,10 @@ packages: node-addon-api@6.1.0: resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + node-api-version@0.2.0: resolution: {integrity: sha512-fthTTsi8CxaBXMaBAD7ST2uylwvsnYxh2PfaScwpMhos6KlSFajXQPcM4ogNE1q2s3Lbz9GCGqeIHC+C6OZnKg==} @@ -11620,6 +11637,10 @@ packages: resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==} hasBin: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} @@ -24016,6 +24037,7 @@ snapshots: dependencies: '@aws-sdk/client-ses': 3.738.0 '@tsconfig/node16': 1.0.4 + '@types/bcrypt': 6.0.0 '@types/cors': 2.8.17 '@types/express': 4.17.21 '@types/jest': 29.5.12 @@ -24023,6 +24045,7 @@ snapshots: '@types/nodemailer': 6.4.17 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3) + bcrypt: 6.0.0 cors: 2.8.5 cross-env: 7.0.3 dotenv: 16.0.3 @@ -24035,6 +24058,7 @@ snapshots: eslint-plugin-promise: 6.1.1(eslint@8.56.0) express: 4.21.2 jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)) + lru-cache: 11.1.0 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@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) @@ -30196,6 +30220,10 @@ snapshots: dependencies: '@babel/types': 7.23.9 + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 22.15.29 + '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 @@ -31546,6 +31574,11 @@ snapshots: batch@0.6.1: {} + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + before-after-hook@2.2.3: {} big-integer@1.6.52: {} @@ -36172,6 +36205,8 @@ snapshots: node-addon-api@6.1.0: {} + node-addon-api@8.5.0: {} + node-api-version@0.2.0: dependencies: semver: 7.6.3 @@ -36199,6 +36234,8 @@ snapshots: node-gyp-build@4.8.0: {} + node-gyp-build@4.8.4: {} + node-gyp@9.4.1: dependencies: env-paths: 2.2.1 diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 9d0fb2cf6c..56eae9a15b 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -42,6 +42,7 @@ import type { LoginInfoWithWorkspaces, MailboxInfo, MailboxOptions, + MailboxSecret, OtpInfo, ProviderInfo, RegionInfo, @@ -126,6 +127,7 @@ export interface AccountClient { findFullSocialIdBySocialKey: (socialKey: string) => Promise findFullSocialIds: (socialIds: PersonId[]) => Promise getMailboxOptions: () => Promise + getMailboxSecret: (mailbox: string) => Promise createMailbox: (name: string, domain: string) => Promise<{ mailbox: string, socialId: PersonId }> getMailboxes: () => Promise deleteMailbox: (mailbox: string) => Promise @@ -853,6 +855,15 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } + async getMailboxSecret (mailbox: string): Promise { + const request = { + method: 'getMailboxSecret' as const, + params: { mailbox } + } + + return await this.rpc(request) + } + async createMailbox (name: string, domain: string): Promise<{ mailbox: string, socialId: PersonId }> { const request = { method: 'createMailbox' as const, diff --git a/packages/account-client/src/types.ts b/packages/account-client/src/types.ts index e5a0b7c198..2d2391ed05 100644 --- a/packages/account-client/src/types.ts +++ b/packages/account-client/src/types.ts @@ -91,6 +91,12 @@ export interface MailboxInfo { appPasswords: string[] } +export interface MailboxSecret { + mailbox: string + app?: string + secret: string +} + export interface Integration { socialId: PersonId kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index eca35075d9..fcbb0c5c3c 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -42,6 +42,7 @@ import { accountPlugin } from './plugin' import { type AccountServiceMethods, getServiceMethods } from './serviceOperations' import { AccountEventType, + type MailboxSecret, type AccountDB, type AccountMethodHandler, type LoginInfo, @@ -1973,6 +1974,20 @@ async function getMailboxes ( return await db.mailbox.find({ accountUuid: account }) } +async function getMailboxSecret ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + mailbox: string + } +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(['huly-mail'], extra, false) + return await db.mailboxSecret.findOne({ mailbox: params.mailbox }) +} + async function deleteMailbox ( ctx: MeasureContext, db: AccountDB, @@ -2205,6 +2220,7 @@ export type AccountMethods = | 'getMailboxOptions' | 'createMailbox' | 'getMailboxes' + | 'getMailboxSecret' | 'deleteMailbox' | 'getAccountInfo' | 'isReadOnlyGuest' @@ -2268,6 +2284,7 @@ export function getMethods (hasSignUp: boolean = true): Partial address != null && address !== '' ) as string[] - await sendEmail(this.ctx, { - from: emailSocialId.value, - to, - subject, - html, - text, - headers: getMailHeaders(HulyMessageType, message._id) - }) + const email = emailSocialId.value + const secret = (await this.accountClient.getMailboxSecret(email))?.secret + if (secret === undefined) { + this.ctx.error('Mailbox secret not found for email', { email }) + return + } + + await sendEmail( + this.ctx, + { + from: email, + to, + subject, + html, + text, + headers: getMailHeaders(HulyMessageType, message._id) + }, + secret + ) } catch (err: any) { this.ctx.error('Failed to send message as email', { messageId: message.messageId, diff --git a/services/mail/pod-mail-worker/src/send.ts b/services/mail/pod-mail-worker/src/send.ts index d53b23c3c5..4087a79924 100644 --- a/services/mail/pod-mail-worker/src/send.ts +++ b/services/mail/pod-mail-worker/src/send.ts @@ -3,7 +3,7 @@ import { concatLink, MeasureContext } from '@hcengineering/core' import config from './config' import { MailMessage } from './types' -export async function sendEmail (ctx: MeasureContext, message: MailMessage): Promise { +export async function sendEmail (ctx: MeasureContext, message: MailMessage, secret: string): Promise { const mailURL = config.mailUrl if (mailURL === undefined || mailURL === '') { ctx.error('Please provide email service url to enable email sending') @@ -19,6 +19,7 @@ export async function sendEmail (ctx: MeasureContext, message: MailMessage): Pro }, body: JSON.stringify({ ...message, + password: secret, apiKey: mailAuth }) }) diff --git a/services/mail/pod-mail/package.json b/services/mail/pod-mail/package.json index 5e344f55f0..d2707ce5cd 100644 --- a/services/mail/pod-mail/package.json +++ b/services/mail/pod-mail/package.json @@ -49,6 +49,7 @@ "@tsconfig/node16": "^1.0.4", "@types/cors": "^2.8.12", "@types/express": "^4.17.13", + "@types/bcrypt": "^6.0.0", "eslint-plugin-node": "^11.1.0", "typescript": "^5.8.3" }, @@ -62,6 +63,8 @@ "cors": "^2.8.5", "dotenv": "~16.0.0", "express": "^4.21.2", - "nodemailer": "^6.10.0" + "lru-cache": "^11.1.0", + "nodemailer": "^6.10.0", + "bcrypt": "^6.0.0" } } diff --git a/services/mail/pod-mail/src/__tests__/main.test.ts b/services/mail/pod-mail/src/__tests__/main.test.ts index 560fe74c2a..6a41aee01b 100644 --- a/services/mail/pod-mail/src/__tests__/main.test.ts +++ b/services/mail/pod-mail/src/__tests__/main.test.ts @@ -106,7 +106,8 @@ describe('handleSendMail', () => { subject: 'Test Subject', text: 'Hello, world!' }), - mockCtx + mockCtx, + undefined ) }) @@ -121,7 +122,8 @@ describe('handleSendMail', () => { subject: 'Test Subject', text: 'Hello, world!' }), - mockCtx + mockCtx, + undefined ) }) @@ -136,7 +138,25 @@ describe('handleSendMail', () => { subject: 'Test Subject', text: 'Hello, world!' }), - mockCtx + mockCtx, + undefined + ) + }) + + it('should send email with credentials', async () => { + req.body.to = ['test1@example.com', 'test2@example.com'] + req.body.password = 'test-password' + await handleSendMail(mailClient, req, res, mockCtx) + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'noreply@example.com', + to: ['test1@example.com', 'test2@example.com'], // Verify that multiple addresses are passed + subject: 'Test Subject', + text: 'Hello, world!' + }), + mockCtx, + 'test-password' ) }) }) diff --git a/services/mail/pod-mail/src/mail.ts b/services/mail/pod-mail/src/mail.ts index 729eaaeaa2..ff7c2fcda6 100644 --- a/services/mail/pod-mail/src/mail.ts +++ b/services/mail/pod-mail/src/mail.ts @@ -13,23 +13,43 @@ // limitations under the License. // import { type SendMailOptions, type Transporter } from 'nodemailer' +import { LRUCache } from 'lru-cache' +import * as bcrypt from 'bcrypt' import { Analytics } from '@hcengineering/analytics' import { MeasureContext } from '@hcengineering/core' import config from './config' -import { getTransport } from './transport' +import { getDefaultTransport, getSmtpTransport } from './transport' export class MailClient { private readonly transporter: Transporter + private readonly transporterCache: LRUCache + private static readonly cacheSalt: string = bcrypt.genSaltSync(12) constructor () { - this.transporter = getTransport(config) + this.transporter = getDefaultTransport(config) + + // Configure LRU cache for transporters + this.transporterCache = new LRUCache({ + max: 50, // Maximum number of cached transporters + ttl: 30 * 60 * 1000, // 30 minutes TTL + dispose: (transporter: Transporter) => { + // Close transporter connection when evicted from cache + if ('close' in transporter && typeof transporter.close === 'function') { + transporter.close() + } + }, + updateAgeOnGet: true, // Reset TTL on cache hit + updateAgeOnHas: false + }) } - async sendMessage (message: SendMailOptions, ctx: MeasureContext): Promise { - this.transporter.sendMail(message, (err, info) => { - const messageInfo = `(from: ${message.from as string}, to: ${message.to as string})` + async sendMessage (message: SendMailOptions, ctx: MeasureContext, password?: string | undefined): Promise { + const from = message.from as string + const transporter = this.getTransporter(from, password) + transporter.sendMail(message, (err, info) => { + const messageInfo = `(from: ${from}, to: ${message.to as string})` if (err !== null) { ctx.error(`Failed to send email ${messageInfo}: ${err.message}`) Analytics.handleError(err) @@ -38,4 +58,47 @@ export class MailClient { } }) } + + getTransporter (email: string, password?: string): Transporter { + if (config.smtpConfig !== undefined && password != null && password !== '') { + return this.getCachedTransporter(email, password) + } + return this.transporter + } + + private getCachedTransporter (email: string, password: string): Transporter { + const cacheKey = this.generateCacheKey(email, password) + + // Check if transporter exists in cache + const cachedTransporter = this.transporterCache.get(cacheKey) + if (cachedTransporter !== undefined) { + return cachedTransporter + } + + // Create new transporter and cache it + if (config.smtpConfig === undefined) { + throw new Error('SMTP config is required for custom transporter') + } + const newTransporter = getSmtpTransport(config.smtpConfig, email, password) + this.transporterCache.set(cacheKey, newTransporter) + + return newTransporter + } + + private generateCacheKey (email: string, password: string): string { + const passwordHash = this.generateHash(password) + return `${email}:${passwordHash}` + } + + private generateHash (input: string): string { + return bcrypt.hashSync(input, MailClient.cacheSalt) + } + + close (): void { + this.transporterCache.clear() + + if (this.transporter?.close !== undefined) { + this.transporter.close() + } + } } diff --git a/services/mail/pod-mail/src/main.ts b/services/mail/pod-mail/src/main.ts index cad46e24b7..d225897490 100644 --- a/services/mail/pod-mail/src/main.ts +++ b/services/mail/pod-mail/src/main.ts @@ -81,7 +81,7 @@ export async function handleSendMail ( res: Response, ctx: MeasureContext ): Promise { - const { from, to, subject, text, html, attachments, headers, apiKey } = req.body + const { from, to, subject, text, html, attachments, headers, apiKey, password } = req.body if (process.env.API_KEY !== undefined && process.env.API_KEY !== apiKey) { ctx.warn('Unauthorized access attempt to send email', { from, @@ -131,7 +131,7 @@ export async function handleSendMail ( message.attachments = getAttachments(attachments) } try { - await client.sendMessage(message, ctx) + await client.sendMessage(message, ctx, password) } catch (err: any) { ctx.error(err.message) } diff --git a/services/mail/pod-mail/src/transport.ts b/services/mail/pod-mail/src/transport.ts index 9a431be8f4..40c0815503 100644 --- a/services/mail/pod-mail/src/transport.ts +++ b/services/mail/pod-mail/src/transport.ts @@ -52,7 +52,7 @@ function ses (config: SesConfig): Transporter { }) } -export function getTransport (config: Config): Transporter { +export function getDefaultTransport (config: Config): Transporter { if (config.smtpConfig !== undefined) { return smtp(config.smtpConfig) } @@ -61,3 +61,16 @@ export function getTransport (config: Config): Transporter { } throw new Error('No transport protocol is configured') } + +export function getSmtpTransport (config: SmtpConfig, user: string, pass: string): Transporter { + const auth = { user, pass } + const tlsSettings = getTlsSettings(config) + return nodemailer.createTransport({ + host: config.Host, + port: config.Port, + auth, + logger: true, + debug: config.DebugLog, + ...tlsSettings + }) +}