diff --git a/server-plugins/gmail-resources/src/index.ts b/server-plugins/gmail-resources/src/index.ts index 7d66fc500d..740370f6e9 100644 --- a/server-plugins/gmail-resources/src/index.ts +++ b/server-plugins/gmail-resources/src/index.ts @@ -117,7 +117,7 @@ export async function sendEmailNotification ( return } const mailAuth: string | undefined = getMetadata(serverNotification.metadata.MailAuthToken) - await fetch(concatLink(mailURL, '/send'), { + const response = await fetch(concatLink(mailURL, '/send'), { method: 'post', keepalive: true, headers: { @@ -131,6 +131,9 @@ export async function sendEmailNotification ( to: [receiver] }) }) + if (!response.ok) { + ctx.error(`Failed to send email notification: ${response.statusText}`) + } } catch (err) { ctx.error('Could not send email notification', { err, receiver }) } diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 869685bb25..7a2583ee0b 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -179,7 +179,7 @@ async function getAccountInfo ( return toAccountInfo(account) } -async function sendOtpEmail (branding: Branding | null, otp: string, email: string): Promise { +async function sendOtpEmail (ctx: MeasureContext, branding: Branding | null, otp: string, email: string): Promise { const mailURL = getMetadata(accountPlugin.metadata.MAIL_URL) if (mailURL === undefined || mailURL === '') { console.info('Please provide email service url to enable email otp.') @@ -194,7 +194,7 @@ async function sendOtpEmail (branding: Branding | null, otp: string, email: stri const subject = await translate(accountPlugin.string.OtpSubject, { code: otp, app }, lang) const to = email - await fetch(concatLink(mailURL, '/send'), { + const response = await fetch(concatLink(mailURL, '/send'), { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -206,6 +206,12 @@ async function sendOtpEmail (branding: Branding | null, otp: string, email: stri to }) }) + if (!response.ok) { + ctx.error('Failed to send OTP email', { + email, + error: response.statusText + }) + } } export async function getAccountInfoByToken ( @@ -318,7 +324,7 @@ export async function sendOtp ( const expires = now + timeToLive const otp = await getNewOtp(db) - await sendOtpEmail(branding, otp, email) + await sendOtpEmail(ctx, branding, otp, email) await db.otp.insertOne({ account: account._id, otp, expires, createdOn: now }) return { sent: true, retryOn: now + retryDelay * 1000 } @@ -2299,7 +2305,7 @@ export async function requestPassword ( const subject = await translate(accountPlugin.string.RecoverySubject, {}, lang) const to = account.email - await fetch(concatLink(mailURL, '/send'), { + const response = await fetch(concatLink(mailURL, '/send'), { method: 'post', headers: { 'Content-Type': 'application/json' @@ -2311,7 +2317,15 @@ export async function requestPassword ( to }) }) - ctx.info('recovery email sent', { email, accountEmail: account.email }) + if (response.ok) { + ctx.info('recovery email sent', { email, accountEmail: account.email }) + } else { + ctx.error('Failed to send reset password email', { + email, + accountEmail: account.email, + error: response.statusText + }) + } } /** @@ -2561,7 +2575,7 @@ export async function sendInvite ( const subject = await translate(accountPlugin.string.InviteSubject, { ws }, lang) const to = email - await fetch(concatLink(mailURL, '/send'), { + const response = await fetch(concatLink(mailURL, '/send'), { method: 'post', headers: { 'Content-Type': 'application/json' @@ -2573,7 +2587,16 @@ export async function sendInvite ( to }) }) - ctx.info('Invite sent', { email, workspace, link }) + if (response.ok) { + ctx.info('Invite sent', { email, workspace, link }) + } else { + ctx.error('Failed to send invite email', { + email, + workspace, + link, + error: response.statusText + }) + } } async function checkSendRateLimit (currentAccount: Account, workspace: string, db: AccountDB): Promise { @@ -2645,7 +2668,7 @@ export async function resendInvite ( const subject = await translate(accountPlugin.string.ResendInviteSubject, { ws }, lang) const to = emailMask - await fetch(concatLink(mailURL, '/send'), { + const response = await fetch(concatLink(mailURL, '/send'), { method: 'post', headers: { 'Content-Type': 'application/json' @@ -2657,7 +2680,16 @@ export async function resendInvite ( to }) }) - ctx.info('Invite resend and email sent', { email: emailMask, workspace: wsPromise.workspace, link }) + if (response.ok) { + ctx.info('Invite resend and email sent', { email: emailMask, workspace: wsPromise.workspace, link }) + } else { + ctx.error('Failed to send invite resend email', { + email: emailMask, + workspace: wsPromise.workspace, + link, + error: response.statusText + }) + } } async function deactivatePersonAccount ( diff --git a/services/mail/pod-mail/package.json b/services/mail/pod-mail/package.json index adfa35700a..fe3a7e9822 100644 --- a/services/mail/pod-mail/package.json +++ b/services/mail/pod-mail/package.json @@ -55,6 +55,9 @@ "dependencies": { "@aws-sdk/client-ses": "^3.738.0", "@types/nodemailer": "^6.4.17", + "@hcengineering/analytics-service": "^0.6.0", + "@hcengineering/core": "^0.6.32", + "@hcengineering/server-core": "^0.6.1", "cors": "^2.8.5", "dotenv": "~16.0.0", "express": "^4.21.2", diff --git a/services/mail/pod-mail/src/__tests__/main.test.ts b/services/mail/pod-mail/src/__tests__/main.test.ts index ea740f3b80..a5524f73ae 100644 --- a/services/mail/pod-mail/src/__tests__/main.test.ts +++ b/services/mail/pod-mail/src/__tests__/main.test.ts @@ -14,6 +14,7 @@ // import { Request, Response } from 'express' +import { type MeasureContext } from '@hcengineering/core' import { MailClient } from '../mail' import { handleSendMail } from '../main' @@ -31,6 +32,7 @@ describe('handleSendMail', () => { let res: Response let sendMailMock: jest.Mock let mailClient: MailClient + let mockCtx: MeasureContext beforeEach(() => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -49,12 +51,16 @@ describe('handleSendMail', () => { mailClient = new MailClient() sendMailMock = (mailClient.sendMessage as jest.Mock).mockResolvedValue({}) + mockCtx = { + info: jest.fn(), + error: jest.fn() + } as unknown as MeasureContext }) it('should return 400 if text is missing', async () => { req.body.text = undefined - await handleSendMail(new MailClient(), req, res) + await handleSendMail(new MailClient(), req, res, mockCtx) // eslint-disable-next-line @typescript-eslint/unbound-method expect(res.status).toHaveBeenCalledWith(400) @@ -64,7 +70,7 @@ describe('handleSendMail', () => { it('should return 400 if subject is missing', async () => { req.body.subject = undefined - await handleSendMail(new MailClient(), req, res) + await handleSendMail(new MailClient(), req, res, mockCtx) // eslint-disable-next-line @typescript-eslint/unbound-method expect(res.status).toHaveBeenCalledWith(400) @@ -74,7 +80,7 @@ describe('handleSendMail', () => { it('should return 400 if to is missing', async () => { req.body.to = undefined - await handleSendMail(new MailClient(), req, res) + await handleSendMail(new MailClient(), req, res, mockCtx) // eslint-disable-next-line @typescript-eslint/unbound-method expect(res.status).toHaveBeenCalledWith(400) @@ -84,13 +90,13 @@ describe('handleSendMail', () => { it('handles errors thrown by MailClient', async () => { sendMailMock.mockRejectedValue(new Error('Email service error')) - await handleSendMail(new MailClient(), req, res) + await handleSendMail(new MailClient(), req, res, mockCtx) expect(res.send).toHaveBeenCalled() // Check that a response is still sent }) it('should use source from config if from is not provided', async () => { - await handleSendMail(mailClient, req, res) + await handleSendMail(mailClient, req, res, mockCtx) expect(sendMailMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -98,13 +104,14 @@ describe('handleSendMail', () => { to: 'test@example.com', subject: 'Test Subject', text: 'Hello, world!' - }) + }), + mockCtx ) }) it('should use from if it is provided', async () => { req.body.from = 'test.from@example.com' - await handleSendMail(mailClient, req, res) + await handleSendMail(mailClient, req, res, mockCtx) expect(sendMailMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -112,13 +119,14 @@ describe('handleSendMail', () => { to: 'test@example.com', subject: 'Test Subject', text: 'Hello, world!' - }) + }), + mockCtx ) }) it('should send to multiple addresses', async () => { req.body.to = ['test1@example.com', 'test2@example.com'] - await handleSendMail(mailClient, req, res) + await handleSendMail(mailClient, req, res, mockCtx) expect(sendMailMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -126,7 +134,8 @@ describe('handleSendMail', () => { to: ['test1@example.com', 'test2@example.com'], // Verify that multiple addresses are passed subject: 'Test Subject', text: 'Hello, world!' - }) + }), + mockCtx ) }) }) diff --git a/services/mail/pod-mail/src/mail.ts b/services/mail/pod-mail/src/mail.ts index ca7d312020..258827ee44 100644 --- a/services/mail/pod-mail/src/mail.ts +++ b/services/mail/pod-mail/src/mail.ts @@ -13,6 +13,7 @@ // limitations under the License. // import { type SendMailOptions, type Transporter } from 'nodemailer' +import { MeasureContext } from '@hcengineering/core' import config from './config' import { getTransport } from './transport' @@ -24,14 +25,13 @@ export class MailClient { this.transporter = getTransport(config) } - async sendMessage (message: SendMailOptions): Promise { + 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})` if (err !== null) { - console.error(`Failed to send email ${messageInfo}: `, err.message) - console.log('Failed message details: ', message) + ctx.error(`Failed to send email ${messageInfo}: ${err.message}`) } else { - console.log(`Email request ${messageInfo} sent: ${info?.response}`) + ctx.info(`Email request ${messageInfo} sent: ${info?.response}`) } }) } diff --git a/services/mail/pod-mail/src/main.ts b/services/mail/pod-mail/src/main.ts index cb3e4f9d2a..d5b715c7ca 100644 --- a/services/mail/pod-mail/src/main.ts +++ b/services/mail/pod-mail/src/main.ts @@ -16,6 +16,10 @@ import { type SendMailOptions } from 'nodemailer' import { Request, Response } from 'express' import Mail from 'nodemailer/lib/mailer' +import { initStatisticsContext } from '@hcengineering/server-core' +import { MeasureContext, MeasureMetricsContext, newMetrics } from '@hcengineering/core' +import { SplitLogger } from '@hcengineering/analytics-service' +import { join } from 'path' import config from './config' import { createServer, listen } from './server' @@ -23,15 +27,28 @@ import { MailClient } from './mail' import { Endpoint } from './types' export const main = async (): Promise => { + const measureCtx = initStatisticsContext('mail', { + factory: () => + new MeasureMetricsContext( + 'mail', + {}, + {}, + newMetrics(), + new SplitLogger('mail', { + root: join(process.cwd(), 'logs'), + enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' + }) + ) + }) const client = new MailClient() - console.log('Mail service has been started') + measureCtx.info('Mail service has been started') const endpoints: Endpoint[] = [ { endpoint: '/send', type: 'post', handler: async (req, res) => { - await handleSendMail(client, req, res) + await handleSendMail(client, req, res, measureCtx) } } ] @@ -46,15 +63,20 @@ export const main = async (): Promise => { process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) - process.on('uncaughtException', (e) => { - console.error(e) + process.on('uncaughtException', (e: any) => { + measureCtx.error(e.message) }) - process.on('unhandledRejection', (e) => { - console.error(e) + process.on('unhandledRejection', (e: any) => { + measureCtx.error(e.message) }) } -export async function handleSendMail (client: MailClient, req: Request, res: Response): Promise { +export async function handleSendMail ( + client: MailClient, + req: Request, + res: Response, + ctx: MeasureContext +): Promise { // Skip auth check, since service should be internal const { from, to, subject, text, html, attachments } = req.body const fromAddress = from ?? config.source @@ -87,9 +109,9 @@ export async function handleSendMail (client: MailClient, req: Request, res: Res message.attachments = getAttachments(attachments) } try { - await client.sendMessage(message) - } catch (err) { - console.log(err) + await client.sendMessage(message, ctx) + } catch (err: any) { + ctx.error(err.message) } res.send()