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)
234 lines
7.0 KiB
TypeScript
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);
|
|
}
|
|
}
|