From 7420d6cc54f886a09f773ef1cb486896c8415fb9 Mon Sep 17 00:00:00 2001 From: CodeVoyager Date: Sat, 16 May 2026 10:40:46 +0200 Subject: [PATCH] fix: email notification sending - Add a new email service to handle email sending with timeout management. - Refactor existing email sending logic in client service to utilize the new email service. - Ensure proper error handling and logging for email operations. - Update common service to parse IDs correctly. --- .gitignore | 12 + .../services/__tests__/client.service.test.ts | 447 +++++++++++++++++- .../services/__tests__/common.service.test.ts | 1 + .../services/__tests__/email.service.test.ts | 126 +++++ server/src/services/client.service.ts | 104 ++-- server/src/services/common.service.ts | 1 + server/src/services/email.service.ts | 42 ++ 7 files changed, 684 insertions(+), 49 deletions(-) create mode 100644 server/src/services/__tests__/email.service.test.ts create mode 100644 server/src/services/email.service.ts diff --git a/.gitignore b/.gitignore index 98cb3672..b31ab8cd 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,15 @@ exports .cache build dist + +############################ +# AI +############################ + +local-LLM + +############################ +# Biome +############################ + +biome.json \ No newline at end of file diff --git a/server/src/services/__tests__/client.service.test.ts b/server/src/services/__tests__/client.service.test.ts index 26ac28a1..98dac8bf 100644 --- a/server/src/services/__tests__/client.service.test.ts +++ b/server/src/services/__tests__/client.service.test.ts @@ -5,6 +5,7 @@ import { caster } from '../../test/utils'; import PluginError from '../../utils/error'; import { getPluginService } from '../../utils/getPluginService'; import clientService from '../client.service'; +import { emailService } from '../email.service'; jest.mock('../../repositories', () => ({ getCommentRepository: jest.fn(), @@ -15,6 +16,10 @@ jest.mock('../../utils/getPluginService', () => ({ getPluginService: jest.fn(), })); +jest.mock('../email.service', () => ({ + emailService: jest.fn(), +})); + const defaultPopulate = { authorUser: { populate: ['avatar'], @@ -53,8 +58,16 @@ describe('client.service', () => { findOne: jest.fn(), }); - const mockEmailService = { - send: jest.fn(), + const mockEmailSend = jest.fn(); + + const mockAdminUserQuery = { + findMany: jest.fn(), + findOne: jest.fn(), + }; + + const mockLog = { + warn: jest.fn(), + error: jest.fn(), }; beforeEach(() => { @@ -66,6 +79,7 @@ describe('client.service', () => { caster(getReportCommentRepository).mockReturnValue( mockReportCommentRepository ); + caster(emailService).mockReturnValue({ send: mockEmailSend }); mockFindOne.mockReset(); mockUserQuery().findOne.mockReset(); }); @@ -80,8 +94,12 @@ describe('client.service', () => { if (model === 'plugin::users-permissions.user') { return mockUserQuery(); } + if (model === 'admin::user') { + return mockAdminUserQuery; + } return { findOne: jest.fn() }; }, + log: mockLog, }, }); const getService = (strapi: StrapiContext) => clientService(strapi); @@ -457,4 +475,429 @@ describe('client.service', () => { await expect(service.markAsRemoved(mockPayload, mockUser)).rejects.toThrow(PluginError); }); }); + + describe('sendAbuseReportEmail', () => { + it('should send email to moderators using emailService', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + mockCommonService.getConfig.mockResolvedValue(['strapi-super-admin', 'moderator']); + mockAdminUserQuery.findMany.mockResolvedValue([ + { email: 'admin1@test.com' }, + { email: 'admin2@test.com' }, + ]); + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'admin1@test.com' }); + + await service.sendAbuseReportEmail('BAD_LANGUAGE', 'offensive content'); + + expect(emailService).toHaveBeenCalledWith({ strapi: strapi.strapi }); + expect(mockEmailSend).toHaveBeenCalledWith({ + to: ['admin1@test.com', 'admin2@test.com'], + from: 'admin1@test.com', + subject: 'New abuse report on comment', + text: expect.stringContaining('BAD_LANGUAGE'), + }); + expect(mockEmailSend).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('offensive content'), + }), + ); + }); + + it('should not send email when rolesToNotify is empty', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + mockCommonService.getConfig.mockResolvedValue([]); + + await service.sendAbuseReportEmail('BAD_LANGUAGE', 'offensive content'); + + expect(mockAdminUserQuery.findMany).not.toHaveBeenCalled(); + expect(mockEmailSend).not.toHaveBeenCalled(); + }); + + it('should not send email when no moderator emails found', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + mockCommonService.getConfig.mockResolvedValue(['strapi-super-admin']); + mockAdminUserQuery.findMany.mockResolvedValue([]); + + await service.sendAbuseReportEmail('BAD_LANGUAGE', 'offensive content'); + + expect(mockAdminUserQuery.findOne).not.toHaveBeenCalled(); + expect(mockEmailSend).not.toHaveBeenCalled(); + }); + + it('should query admin users with correct role filter', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const roles = ['strapi-super-admin', 'editor']; + + mockCommonService.getConfig.mockResolvedValue(roles); + mockAdminUserQuery.findMany.mockResolvedValue([{ email: 'a@test.com' }]); + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'a@test.com' }); + + await service.sendAbuseReportEmail('OTHER', 'test'); + + expect(mockAdminUserQuery.findMany).toHaveBeenCalledWith({ + where: { roles: { code: roles } }, + }); + expect(mockAdminUserQuery.findOne).toHaveBeenCalledWith({ + where: { roles: { code: 'strapi-super-admin' } }, + }); + }); + + it('should throw when super admin user is not found for From address', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + + mockCommonService.getConfig.mockResolvedValue(['strapi-super-admin']); + mockAdminUserQuery.findMany.mockResolvedValue([{ email: 'moderator@test.com' }]); + mockAdminUserQuery.findOne.mockResolvedValue(null); + + await expect( + service.sendAbuseReportEmail('BAD_LANGUAGE', 'offensive content'), + ).rejects.toThrow(/Cannot read properties of null \(reading 'email'\)/); + + expect(mockEmailSend).not.toHaveBeenCalled(); + }); + }); + + describe('sendResponseNotification', () => { + it('should send notification email when threadOf has author email', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + author: { name: 'Thread Author', email: 'thread@test.com' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce('https://app.com'); + + await service.sendResponseNotification(entity as any); + + expect(emailService).toHaveBeenCalledWith({ strapi: strapi.strapi }); + expect(mockEmailSend).toHaveBeenCalledWith({ + to: ['thread@test.com'], + from: 'noreply@app.com', + subject: "You've got a new response to your comment", + text: expect.stringContaining('Thread Author'), + }); + }); + + it('should fetch thread when threadOf is an id', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: 10, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + const thread = { + id: 10, + author: { name: 'Thread Author', email: 'thread@test.com' }, + }; + + mockCommonService.findOne.mockResolvedValue(thread); + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce('https://app.com'); + + await service.sendResponseNotification(entity as any); + + expect(mockCommonService.findOne).toHaveBeenCalledWith({ id: 10 }); + expect(mockEmailSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: ['thread@test.com'], + }), + ); + }); + + it('should resolve email from users-permissions user when authorUser exists and no author email', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + authorUser: 42, + author: { name: 'Thread Author' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockUserQuery().findOne.mockResolvedValue({ email: 'user42@test.com' }); + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce('https://app.com'); + + await service.sendResponseNotification(entity as any); + + expect(mockUserQuery().findOne).toHaveBeenCalledWith({ + where: { id: 42 }, + }); + expect(mockEmailSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: ['user42@test.com'], + }), + ); + }); + + it('should use authorUser object email directly when authorUser is an object', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + authorUser: { id: 42, email: 'userobj@test.com' }, + author: { name: 'Thread Author' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce('https://app.com'); + + await service.sendResponseNotification(entity as any); + + expect(mockEmailSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: ['userobj@test.com'], + }), + ); + }); + + it('should do nothing when entity has no threadOf', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { id: 1, content: 'Top-level', threadOf: null }; + + await service.sendResponseNotification(entity as any); + + expect(mockEmailSend).not.toHaveBeenCalled(); + expect(mockAdminUserQuery.findOne).not.toHaveBeenCalled(); + }); + + it('should do nothing when no emailRecipient is found', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { id: 10, author: {} }, + }; + + await service.sendResponseNotification(entity as any); + + expect(mockEmailSend).not.toHaveBeenCalled(); + }); + + it('should return early and warn when emailSender is falsy', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + author: { name: 'Thread Author', email: 'thread@test.com' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('https://app.com'); + + await service.sendResponseNotification(entity as any); + + expect(mockLog.warn).toHaveBeenCalledWith('Email sender or client app URL not found'); + expect(mockEmailSend).not.toHaveBeenCalled(); + }); + + it('should return early and warn when clientAppUrl is falsy', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + author: { name: 'Thread Author', email: 'thread@test.com' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce(undefined); + + await service.sendResponseNotification(entity as any); + + expect(mockLog.warn).toHaveBeenCalledWith('Email sender or client app URL not found'); + expect(mockEmailSend).not.toHaveBeenCalled(); + }); + + it('should throw PluginError(500) when emailService send fails', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + author: { name: 'Thread Author', email: 'thread@test.com' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce('https://app.com'); + const sendError = new Error('SMTP down'); + mockEmailSend.mockRejectedValueOnce(sendError); + + await expect(service.sendResponseNotification(entity as any)).rejects.toMatchObject({ + name: 'Strapi:Plugin:Comments', + status: 500, + message: 'Failed to send response notification email', + }); + + expect(mockLog.error).toHaveBeenCalledWith(sendError); + }); + + it('should include reply content and clientAppUrl in email text', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Insightful reply', + threadOf: { + id: 10, + author: { name: 'Author', email: 'author@test.com' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce('https://myapp.com'); + + await service.sendResponseNotification(entity as any); + + const sentText = mockEmailSend.mock.calls[0][0].text as string; + expect(sentText).toContain('Insightful reply'); + expect(sentText).toContain('https://myapp.com'); + expect(sentText).toContain('Replier'); + }); + + it('should fallback to emailRecipient when thread author name is missing', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply', + threadOf: { + id: 10, + author: { email: 'noname@test.com' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue({ email: 'super@test.com' }); + mockCommonService.getConfig + .mockResolvedValueOnce('noreply@app.com') + .mockResolvedValueOnce('https://app.com'); + + await service.sendResponseNotification(entity as any); + + const sentText = mockEmailSend.mock.calls[0][0].text as string; + expect(sentText).toContain('Hello noname@test.com'); + }); + + it('should throw when super admin user is not found before reading contact config', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + author: { name: 'Thread Author', email: 'thread@test.com' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockAdminUserQuery.findOne.mockResolvedValue(null); + + await expect(service.sendResponseNotification(entity as any)).rejects.toThrow( + /Cannot read properties of null \(reading 'email'\)/, + ); + + expect(mockCommonService.getConfig).not.toHaveBeenCalled(); + expect(mockEmailSend).not.toHaveBeenCalled(); + }); + + it('should not send when threadOf has no author and no authorUser', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { id: 10 }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + await service.sendResponseNotification(entity as any); + + expect(mockEmailSend).not.toHaveBeenCalled(); + expect(mockAdminUserQuery.findOne).not.toHaveBeenCalled(); + }); + + it('should not send when users-permissions user is not found for authorUser', async () => { + const strapi = getStrapi(); + const service = getService(strapi); + const entity = { + id: 1, + content: 'Reply content', + threadOf: { + id: 10, + authorUser: 99, + author: { name: 'Thread Author' }, + }, + author: { name: 'Replier', email: 'replier@test.com' }, + }; + + mockUserQuery().findOne.mockResolvedValue(null); + + await service.sendResponseNotification(entity as any); + + expect(mockUserQuery().findOne).toHaveBeenCalledWith({ + where: { id: 99 }, + }); + expect(mockEmailSend).not.toHaveBeenCalled(); + expect(mockAdminUserQuery.findOne).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/services/__tests__/common.service.test.ts b/server/src/services/__tests__/common.service.test.ts index 260093ee..f5651e2a 100644 --- a/server/src/services/__tests__/common.service.test.ts +++ b/server/src/services/__tests__/common.service.test.ts @@ -874,6 +874,7 @@ describe('common.service', () => { documentId: '1', locale: 'en', title: 'Test Title 1', + id: 1 }; mockFindOne.mockResolvedValue(mockRelatedEntities); diff --git a/server/src/services/__tests__/email.service.test.ts b/server/src/services/__tests__/email.service.test.ts new file mode 100644 index 00000000..07b35341 --- /dev/null +++ b/server/src/services/__tests__/email.service.test.ts @@ -0,0 +1,126 @@ +import { StrapiContext } from '../../@types'; +import { caster } from '../../test/utils'; +import { emailService } from '../email.service'; + +describe('email.service', () => { + const mockEmailPluginSend = jest.fn(); + + const mockLog = { + warn: jest.fn(), + error: jest.fn(), + }; + + const emailOptions = { + to: ['author@example.com'], + from: 'noreply@example.com', + subject: 'Test subject', + text: 'Test body', + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const createStrapi = (config?: { noPlugin?: boolean; noEmailService?: boolean }) => { + const pluginMock = jest.fn(); + if (config?.noPlugin) { + pluginMock.mockReturnValue(undefined); + } else if (config?.noEmailService) { + pluginMock.mockReturnValue({ + service: jest.fn().mockReturnValue(undefined), + }); + } else { + pluginMock.mockReturnValue({ + service: jest.fn().mockReturnValue({ send: mockEmailPluginSend }), + }); + } + + return caster({ + strapi: { + plugin: pluginMock, + log: mockLog, + }, + }); + }; + + const getService = (strapi: StrapiContext) => emailService(strapi); + + describe('send', () => { + it('should warn and return when the email plugin is not available', async () => { + const strapi = createStrapi({ noPlugin: true }); + const service = getService(strapi); + + const result = await service.send(emailOptions); + + expect(result).toBeUndefined(); + expect(strapi.strapi.plugin).toHaveBeenCalledWith('email'); + expect(mockLog.warn).toHaveBeenCalledWith('Email service not found'); + expect(mockEmailPluginSend).not.toHaveBeenCalled(); + }); + + it('should warn and return when the email plugin service is not available', async () => { + const strapi = createStrapi({ noEmailService: true }); + const service = getService(strapi); + + const result = await service.send(emailOptions); + + expect(result).toBeUndefined(); + expect(strapi.strapi.plugin).toHaveBeenCalledWith('email'); + expect(mockLog.warn).toHaveBeenCalledWith('Email service not found'); + expect(mockEmailPluginSend).not.toHaveBeenCalled(); + }); + + it('should call the Strapi email plugin send with the given options', async () => { + mockEmailPluginSend.mockResolvedValue({ messageId: 'msg-1' }); + const strapi = createStrapi(); + const service = getService(strapi); + + const result = await service.send(emailOptions); + + expect(mockEmailPluginSend).toHaveBeenCalledWith(emailOptions); + expect(result).toEqual({ messageId: 'msg-1' }); + expect(mockLog.warn).not.toHaveBeenCalled(); + expect(mockLog.error).not.toHaveBeenCalled(); + }); + + it('should log the error and rethrow when send rejects', async () => { + const err = new Error('SMTP failure'); + mockEmailPluginSend.mockRejectedValue(err); + const strapi = createStrapi(); + const service = getService(strapi); + + await expect(service.send(emailOptions)).rejects.toThrow('SMTP failure'); + + expect(mockLog.error).toHaveBeenCalledWith(err); + }); + + it('should reject with timeout and log when send does not resolve in time', async () => { + jest.useFakeTimers(); + mockEmailPluginSend.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ late: true }), 11_000); + }), + ); + const strapi = createStrapi(); + const service = getService(strapi); + + const sendPromise = service.send(emailOptions); + const expectation = expect(sendPromise).rejects.toThrow('Email service timeout'); + + await jest.advanceTimersByTimeAsync(10_000); + await expectation; + + expect(mockLog.error).toHaveBeenCalledWith(expect.any(Error)); + const loggedError = caster(mockLog.error.mock.calls[0][0]); + expect(loggedError.message).toBe('Email service timeout'); + + await jest.advanceTimersByTimeAsync(1_000); + }); + }); +}); diff --git a/server/src/services/client.service.ts b/server/src/services/client.service.ts index 20d5d392..740bb099 100644 --- a/server/src/services/client.service.ts +++ b/server/src/services/client.service.ts @@ -8,8 +8,8 @@ import { getPluginService } from '../utils/getPluginService'; import { tryCatch } from '../utils/tryCatch'; import { client } from '../validators/api'; import { Comment } from '../validators/repositories'; +import { emailService } from './email.service'; import { resolveUserContextError } from './utils/functions'; -import sanitizeHtml from 'sanitize-html'; /** * Comments Plugin - Client services @@ -151,13 +151,13 @@ export const clientService = ({ strapi }: StrapiContext) => { if (reportAgainstEntity) { const entity = await getReportCommentRepository(strapi) - .create({ - data: { - ...payload, - resolved: false, - related: commentId, - }, - }); + .create({ + data: { + ...payload, + resolved: false, + related: commentId, + }, + }); if (entity) { const response = { ...entity, @@ -211,14 +211,14 @@ export const clientService = ({ strapi }: StrapiContext) => { }); if (entity) { const removedEntity = await getCommentRepository(strapi) - .update({ - where: { - id: commentId, - related: relation, - }, - data: { removed: true }, - populate: { threadOf: true, authorUser: { populate: ['avatar'] }, }, - }); + .update({ + where: { + id: commentId, + related: relation, + }, + data: { removed: true }, + populate: { threadOf: true, authorUser: { populate: ['avatar'] }, }, + }); await this.markAsRemovedNested(commentId, true); const doNotPopulateAuthor = await this.getCommonService().getConfig(CONFIG_PARAMS.AUTHOR_BLOCKED_PROPS, []); @@ -241,26 +241,27 @@ export const clientService = ({ strapi }: StrapiContext) => { async sendAbuseReportEmail(reason: string, content: string) { const SUPER_ADMIN_ROLE = 'strapi-super-admin'; const rolesToNotify = await this.getCommonService().getConfig(CONFIG_PARAMS.MODERATOR_ROLES, [SUPER_ADMIN_ROLE]); + if (rolesToNotify.length > 0) { const emails = await strapi.query('admin::user') - .findMany({ where: { roles: { code: rolesToNotify } } }) - .then((users) => users.map((user) => user.email)); + .findMany({ where: { roles: { code: rolesToNotify } } }) + .then((users) => users.map((user) => user.email)); if (emails.length > 0) { const from = await strapi.query('admin::user').findOne({ where: { roles: { code: SUPER_ADMIN_ROLE } } }); - if (strapi.plugin('email')) { - await strapi.plugin('email') - .service('email') - .send({ - to: emails, - from: from.email, - subject: 'New abuse report on comment', - text: ` - There was a new abuse report on your app. - Reason: ${reason} - Message: ${content} - `, - }); - } + + const sender = emailService({ strapi }); + + await sender + .send({ + to: emails, + from: from.email, + subject: 'New abuse report on comment', + text: ` + There was a new abuse report on your app. + Reason: ${reason} + Message: ${content} + `, + }); } } }, @@ -286,24 +287,29 @@ export const clientService = ({ strapi }: StrapiContext) => { if (emailRecipient) { const superAdmin = await strapi.query('admin::user') - .findOne({ - where: { - roles: { code: 'strapi-super-admin' }, - }, - }); + .findOne({ + where: { + roles: { code: 'strapi-super-admin' }, + }, + }); const emailSender = await this.getCommonService().getConfig('client.contactEmail', superAdmin.email); const clientAppUrl = await this.getCommonService().getConfig('client.url', 'our site'); + if (!emailSender || !clientAppUrl) { + strapi.log.warn('Email sender or client app URL not found'); + return; + } + + const sender = emailService({ strapi }); + try { - await strapi - .plugin('email') - .service('email') - .send({ - to: [emailRecipient], - from: emailSender, - subject: 'You\'ve got a new response to your comment', - text: `Hello ${thread?.author?.name || emailRecipient}! + await sender + .send({ + to: [emailRecipient], + from: emailSender, + subject: 'You\'ve got a new response to your comment', + text: `Hello ${thread?.author?.name || emailRecipient}! You've got a new response to your comment by ${entity?.author?.name || entity?.author?.email}. ------ @@ -314,10 +320,14 @@ export const clientService = ({ strapi }: StrapiContext) => { Visit ${clientAppUrl} and continue the discussion. `, - }); + }); } catch (err) { strapi.log.error(err); - throw err; + + throw new PluginError( + 500, + `Failed to send response notification email`, + ); } } } diff --git a/server/src/services/common.service.ts b/server/src/services/common.service.ts index 78c7764a..2ccc8c02 100644 --- a/server/src/services/common.service.ts +++ b/server/src/services/common.service.ts @@ -445,6 +445,7 @@ const commonService = ({ strapi }: StrapiContext) => ({ .map((_) => ({ ..._, uid: relatedUid, + id: typeof _.id === 'number' ? _.id : parseInt(_.id, 10), })) ); }) diff --git a/server/src/services/email.service.ts b/server/src/services/email.service.ts new file mode 100644 index 00000000..de23159a --- /dev/null +++ b/server/src/services/email.service.ts @@ -0,0 +1,42 @@ +import { StrapiContext } from "../@types"; + +interface EmailOptions { + to: string[]; + from: string; + subject: string; + text: string; +} + +const EMAIL_SERVICE_TIMEOUT_MS = 10000; + +const getTimeoutPromise = () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Email service timeout')); + }, EMAIL_SERVICE_TIMEOUT_MS); + }); +}; + +export const emailService = ({ strapi }: StrapiContext) => { + const plugin = strapi.plugin('email'); + const service = plugin ? plugin.service('email') : undefined; + + return { + send: async (options: EmailOptions) => { + if (!service) { + strapi.log.warn('Email service not found'); + return; + } + + try { + return await Promise.race([ + service.send(options), + getTimeoutPromise(), + ]); + } catch (error) { + strapi.log.error(error); + throw error; + } + }, + }; +}; \ No newline at end of file