|
1 | 1 | import { Injectable, Logger } from '@nestjs/common'; |
2 | 2 | import { ConfigService } from '@nestjs/config'; |
3 | | -import { Attachment, Resend } from 'resend'; |
| 3 | +import { Attachment, ErrorResponse, Resend } from 'resend'; |
4 | 4 | import { SendEmailRequest, User } from '@refly/openapi-schema'; |
5 | 5 | import { PrismaService } from '../common/prisma.service'; |
6 | 6 | import { ParamsError } from '@refly/errors'; |
7 | 7 | import { MiscService } from '../misc/misc.service'; |
| 8 | +import { guard } from '../../utils/guard'; |
8 | 9 |
|
9 | 10 | @Injectable() |
10 | 11 | export class NotificationService { |
11 | 12 | private readonly logger = new Logger(NotificationService.name); |
12 | 13 | private readonly resend: Resend; |
| 14 | + private readonly maxRetries: number; |
| 15 | + private readonly baseDelayMs: number; |
| 16 | + private lastEmailSentAt = 0; |
| 17 | + private readonly minTimeBetweenEmailsMs: number; |
13 | 18 |
|
14 | 19 | constructor( |
15 | 20 | private readonly configService: ConfigService, |
16 | 21 | private readonly prisma: PrismaService, |
17 | 22 | private readonly miscService: MiscService, |
18 | 23 | ) { |
19 | 24 | this.resend = new Resend(this.configService.get('email.resendApiKey')); |
| 25 | + this.maxRetries = this.configService.get<number>('email.maxRetries') ?? 3; |
| 26 | + this.baseDelayMs = this.configService.get<number>('email.baseDelayMs') ?? 500; |
| 27 | + this.minTimeBetweenEmailsMs = |
| 28 | + this.configService.get<number>('email.minTimeBetweenEmailsMs') ?? 500; |
| 29 | + } |
| 30 | + |
| 31 | + /** |
| 32 | + * Ensure minimum time between email sends to respect rate limits |
| 33 | + */ |
| 34 | + private async enforceRateLimit(): Promise<void> { |
| 35 | + const now = Date.now(); |
| 36 | + const timeSinceLastEmail = now - this.lastEmailSentAt; |
| 37 | + |
| 38 | + if (timeSinceLastEmail < this.minTimeBetweenEmailsMs) { |
| 39 | + const delayNeeded = this.minTimeBetweenEmailsMs - timeSinceLastEmail; |
| 40 | + this.logger.debug(`Rate limiting: waiting ${delayNeeded}ms before sending next email`); |
| 41 | + await new Promise((resolve) => setTimeout(resolve, delayNeeded)); |
| 42 | + } |
| 43 | + |
| 44 | + this.lastEmailSentAt = Date.now(); |
| 45 | + } |
| 46 | + |
| 47 | + /** |
| 48 | + * Check if error is rate limit related |
| 49 | + */ |
| 50 | + private isRateLimitError(error: ErrorResponse): boolean { |
| 51 | + return ( |
| 52 | + error?.name === 'rate_limit_exceeded' || |
| 53 | + error?.message?.toLowerCase().includes('rate') || |
| 54 | + error?.message?.toLowerCase().includes('limit') |
| 55 | + ); |
| 56 | + } |
| 57 | + |
| 58 | + /** |
| 59 | + * Send email with retry logic for rate limit errors using guard.retry |
| 60 | + * @param emailData - Email data to send |
| 61 | + * @returns Resend response |
| 62 | + */ |
| 63 | + private async sendEmailWithRetry(emailData: { |
| 64 | + from: string; |
| 65 | + to: string; |
| 66 | + subject: string; |
| 67 | + html: string; |
| 68 | + attachments?: Attachment[]; |
| 69 | + }): Promise<any> { |
| 70 | + return guard |
| 71 | + .retry( |
| 72 | + async () => { |
| 73 | + // Enforce rate limit before sending |
| 74 | + await this.enforceRateLimit(); |
| 75 | + |
| 76 | + const res = await this.resend.emails.send(emailData); |
| 77 | + |
| 78 | + // Check for rate limit error in response |
| 79 | + if (res.error) { |
| 80 | + if (this.isRateLimitError(res.error)) { |
| 81 | + // Throw to trigger retry |
| 82 | + throw new Error(`Rate limit exceeded: ${res.error.message}`); |
| 83 | + } |
| 84 | + // Other errors should not be retried |
| 85 | + throw new Error(res.error.message); |
| 86 | + } |
| 87 | + |
| 88 | + return res; |
| 89 | + }, |
| 90 | + { |
| 91 | + maxAttempts: this.maxRetries, |
| 92 | + initialDelay: this.baseDelayMs, |
| 93 | + maxDelay: this.baseDelayMs * 2 ** (this.maxRetries - 1), |
| 94 | + backoffFactor: 2, |
| 95 | + retryIf: (error: any) => this.isRateLimitError(error), |
| 96 | + onRetry: (error: any, attempt: number) => { |
| 97 | + this.logger.warn( |
| 98 | + `Rate limit error detected. Retrying... (attempt ${attempt}/${this.maxRetries}): ${error.message}`, |
| 99 | + ); |
| 100 | + }, |
| 101 | + }, |
| 102 | + ) |
| 103 | + .orThrow((error: any) => { |
| 104 | + this.logger.error( |
| 105 | + `Failed to send email after ${this.maxRetries} retries: ${error.message}`, |
| 106 | + ); |
| 107 | + return new Error(`Failed to send email: ${error.message}`); |
| 108 | + }); |
20 | 109 | } |
21 | 110 |
|
22 | 111 | /** |
@@ -171,20 +260,16 @@ export class NotificationService { |
171 | 260 | attachments = await Promise.all(attachmentUrls.map((url) => this.processAttachmentURL(url))); |
172 | 261 | } |
173 | 262 |
|
174 | | - const res = await this.resend.emails.send({ |
| 263 | + await this.sendEmailWithRetry({ |
175 | 264 | from: sender, |
176 | 265 | to: receiver, |
177 | 266 | subject, |
178 | 267 | html, |
179 | 268 | attachments, |
180 | 269 | }); |
181 | 270 |
|
182 | | - this.logger.log(`Email sent successfully to ${receiver}`); |
183 | | - |
184 | | - if (res.error) { |
185 | | - throw new Error(res.error?.message); |
186 | | - } |
187 | | - |
188 | | - this.logger.log(`Email sent to successfully in ${new Date().getTime() - now.getTime()}ms`); |
| 271 | + this.logger.log( |
| 272 | + `Email sent successfully to ${receiver} in ${new Date().getTime() - now.getTime()}ms`, |
| 273 | + ); |
189 | 274 | } |
190 | 275 | } |
0 commit comments