From 4b8c4bafb94f466c6acf7ba2f83eb7bbaad4afbf Mon Sep 17 00:00:00 2001 From: dragosp1011 Date: Thu, 1 Oct 2026 10:08:33 +0300 Subject: [PATCH] feat: add optional BCC to admin announcement emails --- .../backend/src/admin-notification/service.ts | 3 +- .../src/admin-notification/validation.ts | 1 + packages/wallet/backend/src/email/service.ts | 27 ++++- .../admin-notification/controller.test.ts | 30 +++++- .../tests/admin-notification/service.test.ts | 99 ++++++++++++++++++- 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/packages/wallet/backend/src/admin-notification/service.ts b/packages/wallet/backend/src/admin-notification/service.ts index 4da625cb9..31292c1f2 100644 --- a/packages/wallet/backend/src/admin-notification/service.ts +++ b/packages/wallet/backend/src/admin-notification/service.ts @@ -69,7 +69,8 @@ export class AdminNotificationService { batch = await this.emailService.sendAnnouncementBatch( recipients, input.subject, - input.bodyHtml + input.bodyHtml, + input.bcc ) } catch (error) { await this.releaseIdempotencyKey(idempotencyKey) diff --git a/packages/wallet/backend/src/admin-notification/validation.ts b/packages/wallet/backend/src/admin-notification/validation.ts index 5e7eb3c4b..325c0d0a6 100644 --- a/packages/wallet/backend/src/admin-notification/validation.ts +++ b/packages/wallet/backend/src/admin-notification/validation.ts @@ -8,6 +8,7 @@ const sendNotificationBody = z bodyHtml: z.string().min(1).max(MAX_BODY_HTML_LENGTH), sendToAll: z.boolean().optional(), recipients: z.array(z.string().email()).optional(), + bcc: z.string().email().optional(), dryRun: z.boolean().optional(), idempotencyKey: z.string().min(1).max(200).optional() }) diff --git a/packages/wallet/backend/src/email/service.ts b/packages/wallet/backend/src/email/service.ts index e7d25ec44..741302fa5 100644 --- a/packages/wallet/backend/src/email/service.ts +++ b/packages/wallet/backend/src/email/service.ts @@ -15,6 +15,7 @@ interface EmailArgs { to: string subject: string html: string + bcc?: string } const disposableDomains: Set = new Set(domains) @@ -57,7 +58,12 @@ export class EmailService implements IEmailService { } private async send(email: EmailArgs): Promise { - await sendgrid.send({ from: this.from, ...email }) + const { bcc, ...rest } = email + await sendgrid.send({ + from: this.from, + ...rest, + ...(bcc ? { bcc } : {}) + }) } async sendForgotPassword(to: string, token: string): Promise { @@ -134,20 +140,25 @@ export class EmailService implements IEmailService { async sendAnnouncementBatch( recipients: string[], subject: string, - bodyHtml: string + bodyHtml: string, + bcc?: string ): Promise<{ sent: number; failed: number; failedRecipients: string[] }> { const BATCH_SIZE = 100 const BATCH_DELAY_MS = 100 + const bccAddress = bcc?.trim() || undefined if (!this.env.SEND_EMAIL) { this.logger.info( - `Send email is disabled. Would send announcement "${subject}" to ${recipients.length} recipients` + `Send email is disabled. Would send announcement "${subject}" to ${recipients.length} recipients${bccAddress ? ` (bcc: ${bccAddress})` : ''}` ) return { sent: recipients.length, failed: 0, failedRecipients: [] } } const emailSubject = `[${this.subjectPrefix}] ${subject}` const html = getAnnouncementEmailTemplate(subject, bodyHtml, this.imageSrc) + const bccPersonalization = bccAddress + ? { bcc: [{ email: bccAddress }] } + : undefined let sent = 0 let failed = 0 @@ -162,7 +173,8 @@ export class EmailService implements IEmailService { subject: emailSubject, html, personalizations: batch.map((email) => ({ - to: [{ email }] + to: [{ email }], + ...bccPersonalization })) }) sent += batch.length @@ -174,7 +186,12 @@ export class EmailService implements IEmailService { for (const to of batch) { try { - await this.send({ to, subject: emailSubject, html }) + await this.send({ + to, + subject: emailSubject, + html, + ...(bccAddress ? { bcc: bccAddress } : {}) + }) sent++ } catch (individualError) { this.logger.error( diff --git a/packages/wallet/backend/tests/admin-notification/controller.test.ts b/packages/wallet/backend/tests/admin-notification/controller.test.ts index 946c61d73..92e13c9f6 100644 --- a/packages/wallet/backend/tests/admin-notification/controller.test.ts +++ b/packages/wallet/backend/tests/admin-notification/controller.test.ts @@ -306,7 +306,8 @@ describe('Admin Notification Controller', () => { expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith( ['user1@example.com', 'user2@example.com'], 'Test', - '

Test

' + '

Test

', + undefined ) expect(res._getJSONData()).toEqual({ success: true, @@ -423,7 +424,32 @@ describe('Admin Notification Controller', () => { expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith( ['verified1@example.com', 'verified2@example.com'], 'Broadcast', - '

Broadcast

' + '

Broadcast

', + undefined + ) + }) + + it('forwards optional bcc to the email service', async () => { + mockEmailService.sendAnnouncementBatch.mockResolvedValue({ + sent: 1, + failed: 0, + failedRecipients: [] + }) + req.body = { + subject: 'Test', + bodyHtml: '

Test

', + recipients: ['user1@example.com'], + bcc: 'ops@example.com' + } + + await adminNotificationController.send(req, res, next) + + expect(res.statusCode).toBe(200) + expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith( + ['user1@example.com'], + 'Test', + '

Test

', + 'ops@example.com' ) }) }) diff --git a/packages/wallet/backend/tests/admin-notification/service.test.ts b/packages/wallet/backend/tests/admin-notification/service.test.ts index 3977ff49f..dbff953f7 100644 --- a/packages/wallet/backend/tests/admin-notification/service.test.ts +++ b/packages/wallet/backend/tests/admin-notification/service.test.ts @@ -96,7 +96,8 @@ describe('Admin Notification Service', () => { expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith( ['user@example.com'], 'Test', - '

Test

' + '

Test

', + undefined ) expect(result).toEqual({ dryRun: false, @@ -126,6 +127,33 @@ describe('Admin Notification Service', () => { expect(mockEmailService.sendAnnouncementBatch).not.toHaveBeenCalled() }) + it('passes optional bcc through to the email service', async () => { + await createUser({ + ...args, + email: 'user@example.com', + isEmailVerified: true + }) + mockEmailService.sendAnnouncementBatch.mockResolvedValue({ + sent: 1, + failed: 0, + failedRecipients: [] + }) + + await adminNotificationService.sendNotification({ + subject: 'Test', + bodyHtml: '

Test

', + recipients: ['user@example.com'], + bcc: 'ops@example.com' + }) + + expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith( + ['user@example.com'], + 'Test', + '

Test

', + 'ops@example.com' + ) + }) + it('returns failed recipients when all sends fail', async () => { await createUser({ ...args, @@ -458,6 +486,75 @@ describe('EmailService sendAnnouncementBatch', () => { ) }) + it('adds bcc to every personalization', async () => { + mockedSendgrid.send.mockResolvedValueOnce([{ statusCode: 202 }] as never) + + const emailService = new EmailService( + { ...env, SEND_EMAIL: true, SENDGRID_API_KEY: 'test-key' }, + { info: jest.fn(), error: jest.fn() } as never + ) + + await emailService.sendAnnouncementBatch( + ['a@example.com', 'b@example.com', 'c@example.com'], + 'Subject', + '

Body

', + 'ops@example.com' + ) + + expect(mockedSendgrid.send).toHaveBeenCalledWith( + expect.objectContaining({ + personalizations: [ + { + to: [{ email: 'a@example.com' }], + bcc: [{ email: 'ops@example.com' }] + }, + { + to: [{ email: 'b@example.com' }], + bcc: [{ email: 'ops@example.com' }] + }, + { + to: [{ email: 'c@example.com' }], + bcc: [{ email: 'ops@example.com' }] + } + ] + }) + ) + }) + + it('includes bcc on every individual send when a batch fails', async () => { + mockedSendgrid.send + .mockRejectedValueOnce(new Error('Batch failed')) + .mockResolvedValue([{ statusCode: 202 }] as never) + + const emailService = new EmailService( + { ...env, SEND_EMAIL: true, SENDGRID_API_KEY: 'test-key' }, + { info: jest.fn(), error: jest.fn() } as never + ) + + await emailService.sendAnnouncementBatch( + ['a@example.com', 'b@example.com'], + 'Subject', + '

Body

', + 'ops@example.com' + ) + + expect(mockedSendgrid.send).toHaveBeenCalledTimes(3) + expect(mockedSendgrid.send).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + to: 'a@example.com', + bcc: 'ops@example.com' + }) + ) + expect(mockedSendgrid.send).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + to: 'b@example.com', + bcc: 'ops@example.com' + }) + ) + }) + it('falls back to individual sends when a batch fails', async () => { mockedSendgrid.send .mockRejectedValueOnce(new Error('Batch failed'))