Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/wallet/backend/src/admin-notification/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down
27 changes: 22 additions & 5 deletions packages/wallet/backend/src/email/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface EmailArgs {
to: string
subject: string
html: string
bcc?: string
}

const disposableDomains: Set<string> = new Set(domains)
Expand Down Expand Up @@ -57,7 +58,12 @@ export class EmailService implements IEmailService {
}

private async send(email: EmailArgs): Promise<void> {
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<void> {
Expand Down Expand Up @@ -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
Expand All @@ -162,7 +173,8 @@ export class EmailService implements IEmailService {
subject: emailSubject,
html,
personalizations: batch.map((email) => ({
to: [{ email }]
to: [{ email }],
...bccPersonalization
}))
})
sent += batch.length
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ describe('Admin Notification Controller', () => {
expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith(
['user1@example.com', 'user2@example.com'],
'Test',
'<p>Test</p>'
'<p>Test</p>',
undefined
)
expect(res._getJSONData()).toEqual({
success: true,
Expand Down Expand Up @@ -423,7 +424,32 @@ describe('Admin Notification Controller', () => {
expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith(
['verified1@example.com', 'verified2@example.com'],
'Broadcast',
'<p>Broadcast</p>'
'<p>Broadcast</p>',
undefined
)
})

it('forwards optional bcc to the email service', async () => {
mockEmailService.sendAnnouncementBatch.mockResolvedValue({
sent: 1,
failed: 0,
failedRecipients: []
})
req.body = {
subject: 'Test',
bodyHtml: '<p>Test</p>',
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',
'<p>Test</p>',
'ops@example.com'
)
})
})
99 changes: 98 additions & 1 deletion packages/wallet/backend/tests/admin-notification/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ describe('Admin Notification Service', () => {
expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith(
['user@example.com'],
'Test',
'<p>Test</p>'
'<p>Test</p>',
undefined
)
expect(result).toEqual({
dryRun: false,
Expand Down Expand Up @@ -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: '<p>Test</p>',
recipients: ['user@example.com'],
bcc: 'ops@example.com'
})

expect(mockEmailService.sendAnnouncementBatch).toHaveBeenCalledWith(
['user@example.com'],
'Test',
'<p>Test</p>',
'ops@example.com'
)
})

it('returns failed recipients when all sends fail', async () => {
await createUser({
...args,
Expand Down Expand Up @@ -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',
'<p>Body</p>',
'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',
'<p>Body</p>',
'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'))
Expand Down
Loading