UBERF-13120: Support SMTP mail for different accounts (#9674)

* UBERF-13120: Support SMTP mail for different accounts

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

* Potential fix for code scanning alert no. 219: Use of password hash with insufficient computational effort

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Update server/account/src/operations.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* UBERF-13120: Minor fixes

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

* UBERF-13120: Fix get secret

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

* UBERF-13120: Fix lru cache version

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

* UBERF-13120: Fix pod-mail

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

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
Signed-off-by: Artyom Savchenko <armisav@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Artyom Savchenko
2025-08-14 11:28:36 +07:00
committed by GitHub
co-authored by Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Copilot Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent c4f9953d7d
commit 1316a8475a
11 changed files with 205 additions and 23 deletions
+39 -2
View File
@@ -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
+11
View File
@@ -42,6 +42,7 @@ import type {
LoginInfoWithWorkspaces,
MailboxInfo,
MailboxOptions,
MailboxSecret,
OtpInfo,
ProviderInfo,
RegionInfo,
@@ -126,6 +127,7 @@ export interface AccountClient {
findFullSocialIdBySocialKey: (socialKey: string) => Promise<SocialId | undefined>
findFullSocialIds: (socialIds: PersonId[]) => Promise<SocialId[]>
getMailboxOptions: () => Promise<MailboxOptions>
getMailboxSecret: (mailbox: string) => Promise<MailboxSecret | undefined>
createMailbox: (name: string, domain: string) => Promise<{ mailbox: string, socialId: PersonId }>
getMailboxes: () => Promise<MailboxInfo[]>
deleteMailbox: (mailbox: string) => Promise<void>
@@ -853,6 +855,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async getMailboxSecret (mailbox: string): Promise<MailboxSecret | undefined> {
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,
+6
View File
@@ -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.
+17
View File
@@ -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<MailboxSecret | null> {
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<Record<AccountMe
findSocialIdBySocialKey: wrap(findSocialIdBySocialKey),
getWorkspaceMembers: wrap(getWorkspaceMembers),
getMailboxOptions: wrap(getMailboxOptions),
getMailboxSecret: wrap(getMailboxSecret),
getAccountInfo: wrap(getAccountInfo),
isReadOnlyGuest: wrap(isReadOnlyGuest),
@@ -244,14 +244,25 @@ export class MailWorker {
(address) => 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,
+2 -1
View File
@@ -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<void> {
export async function sendEmail (ctx: MeasureContext, message: MailMessage, secret: string): Promise<void> {
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
})
})
+4 -1
View File
@@ -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"
}
}
@@ -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'
)
})
})
+68 -5
View File
@@ -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<string, Transporter>
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<string, Transporter>({
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<void> {
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<void> {
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()
}
}
}
+2 -2
View File
@@ -81,7 +81,7 @@ export async function handleSendMail (
res: Response,
ctx: MeasureContext
): Promise<void> {
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)
}
+14 -1
View File
@@ -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
})
}