Assist_Design/apps/bff/src/infra/email/providers/sendgrid.provider.ts
barsa 26776373f7 refactor: fix all lint errors and reduce warnings across BFF and domain
Eliminate all 12 ESLint errors (nested ternaries, any types) and reduce
warnings by 13 (duplicate strings, complexity). Key changes:

- Domain: extract helpers for nested ternaries in opportunity/contract and whmcs/mapper
- BFF core: fix any type in safe-operation.util, refactor exception filter to use
  options objects, create shared CACHE_CONTROL and normalizeToArray utilities
- Freebit: replace nested ternaries with if/else in client and mapper services
- Sim fulfillment: extract helper methods to reduce complexity (fulfillEsim,
  fulfillPhysicalSim, buildMnpPayload, registerVoiceOptionsIfAvailable, MNP_FIELD_MAPPINGS)
- Modules: fix 8 nested ternary violations across validators, services, controllers
- Constants: extract duplicate strings (CSRF, email, orchestrator, cache control)
2026-03-04 10:52:26 +09:00

234 lines
7.0 KiB
TypeScript

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<string, unknown>;
/** 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<string>("EMAIL_FROM");
this.defaultFromName = this.config.get<string>("EMAIL_FROM_NAME");
}
onModuleInit(): void {
const apiKey = this.config.get<string>("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<void> {
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<string, string>)["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);
}
}