import { Injectable, Inject } from "@nestjs/common"; import type { OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import sgMail from "@sendgrid/mail"; import type { MailDataRequired, ResponseError } from "@sendgrid/mail"; export interface ProviderSendOptions { to: string | string[]; from?: string; /** BCC recipients - useful for Email-to-Case integration */ bcc?: string | string[]; subject: string; text?: string; html?: string; templateId?: string; dynamicTemplateData?: Record; /** Optional category for tracking/analytics */ category?: string; } export interface SendGridErrorDetail { message: string; field?: string | undefined; help?: string | undefined; } export interface ParsedSendGridError { statusCode: number; message: string; errors: SendGridErrorDetail[]; isRetryable: boolean; } /** HTTP status codes that indicate transient failures worth retrying */ const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); const UNKNOWN_ERROR = "Unknown error"; @Injectable() export class SendGridEmailProvider implements OnModuleInit { private isConfigured = false; private readonly sandboxMode: boolean; private readonly defaultFrom: string | undefined; private readonly defaultFromName: string | undefined; constructor( private readonly config: ConfigService, @Inject(Logger) private readonly logger: Logger ) { this.sandboxMode = this.config.get("SENDGRID_SANDBOX", "false") === "true"; this.defaultFrom = this.config.get("EMAIL_FROM"); this.defaultFromName = this.config.get("EMAIL_FROM_NAME"); } onModuleInit(): void { const apiKey = this.config.get("SENDGRID_API_KEY"); if (!apiKey) { this.logger.warn("SendGrid API key not configured - email sending will fail", { provider: "sendgrid", }); return; } sgMail.setApiKey(apiKey); this.isConfigured = true; this.logger.log("SendGrid email provider initialized", { provider: "sendgrid", sandboxMode: this.sandboxMode, defaultFrom: this.defaultFrom, }); } async send(options: ProviderSendOptions): Promise { const emailContext = { provider: "sendgrid", to: this.maskEmail(options.to), subject: options.subject, category: options.category, hasTemplate: !!options.templateId, templateId: options.templateId, }; if (!this.isConfigured) { this.logger.error("SendGrid not configured - cannot send email", emailContext); throw new Error("SendGrid API key not configured"); } const from = this.buildFromAddress(options.from); if (!from) { this.logger.error("No sender address configured - cannot send email", emailContext); throw new Error("EMAIL_FROM is not configured"); } const message = this.buildMessage(options, from); this.logger.log("Sending email via SendGrid", emailContext); try { const [response] = await sgMail.send(message); this.logger.log("Email sent successfully via SendGrid", { ...emailContext, statusCode: response.statusCode, messageId: (response.headers as Record)["x-message-id"], }); } catch (error) { const parsed = this.parseError(error); this.logger.error("Failed to send email via SendGrid", { ...emailContext, statusCode: parsed.statusCode, errorMessage: parsed.message, errors: parsed.errors, isRetryable: parsed.isRetryable, }); // Attach metadata to error for upstream handling const enrichedError = new Error(parsed.message); (enrichedError as Error & { sendgrid: ParsedSendGridError }).sendgrid = parsed; throw enrichedError; } } private buildFromAddress( overrideFrom?: string ): string | { email: string; name: string } | undefined { const email = overrideFrom || this.defaultFrom; if (!email) return undefined; // Always include sender name when configured (regardless of email override) if (this.defaultFromName) { return { email, name: this.defaultFromName }; } return email; } private buildMessage( options: ProviderSendOptions, from: string | { email: string; name: string } ): MailDataRequired { const message: MailDataRequired = { to: options.to, from, subject: options.subject, mailSettings: { sandboxMode: { enable: this.sandboxMode }, }, } as MailDataRequired; // Add BCC if provided (useful for Email-to-Case integration) if (options.bcc) { message.bcc = options.bcc; } // Content: template or direct HTML/text if (options.templateId) { message.templateId = options.templateId; if (options.dynamicTemplateData) { message.dynamicTemplateData = options.dynamicTemplateData; } } else { if (options.html) message.html = options.html; if (options.text) message.text = options.text; } // Categories for SendGrid analytics if (options.category) { message.categories = [options.category]; } return message; } private parseError(error: unknown): ParsedSendGridError { // SendGrid errors have a specific structure if (this.isSendGridError(error)) { const statusCode = error.code || 500; const body = error.response?.body; const responseErrors = this.extractErrorsFromBody(body); return { statusCode, message: responseErrors[0]?.message || error.message || "SendGrid error", errors: responseErrors, isRetryable: RETRYABLE_STATUS_CODES.has(statusCode), }; } // Generic error fallback const message = error instanceof Error ? error.message : String(error); return { statusCode: 500, message, errors: [{ message }], isRetryable: false, }; } private extractErrorsFromBody(body: unknown): SendGridErrorDetail[] { if (!body || typeof body !== "object") { return [{ message: UNKNOWN_ERROR }]; } // SendGrid returns { errors: [...] } in the body const bodyObj = body as { errors?: Array<{ message?: string; field?: string; help?: string }> }; if (Array.isArray(bodyObj.errors)) { return bodyObj.errors.map(e => ({ message: e.message || UNKNOWN_ERROR, field: e.field, help: e.help, })); } return [{ message: UNKNOWN_ERROR }]; } private isSendGridError(error: unknown): error is ResponseError { return typeof error === "object" && error !== null && "code" in error && "response" in error; } private maskEmail(email: string | string[]): string | string[] { const mask = (e: string): string => { const [local, domain] = e.split("@"); if (!domain || !local) return "***"; const maskedLocal = local.length > 2 ? `${local[0]}***${local.at(-1)}` : "***"; return `${maskedLocal}@${domain}`; }; return Array.isArray(email) ? email.map(mask) : mask(email); } }