When the ESP SDK is imported and called in six different worker files, route handlers, and service modules, switching providers requires auditing and rewriting every one of those call sites. More practically, a team that needs to rotate to a backup ESP during an outage must make coordinated changes across the codebase under time pressure. ISO-25010:2011 maintainability.modifiability requires that changes to one component do not cascade to unrelated modules — direct SDK spread is the structural cause of that cascade.
High because tight coupling to a specific ESP SDK prevents safe provider rotation and forces disruptive multi-file changes whenever the ESP contract changes.
Create an EmailProvider interface in lib/email/types.ts and a concrete adapter in lib/email/providers/sendgrid.ts. All other application code calls only the interface:
// lib/email/types.ts
export interface EmailProvider {
send(message: EmailMessage): Promise<{ messageId: string }>
healthCheck(): Promise<boolean>
}
The ESP SDK import belongs in at most 2 files: the adapter implementation and its test. Workers, route handlers, and service modules import the interface, not @sendgrid/mail or equivalent.
ID: sending-pipeline-infrastructure.esp-integration.esp-abstraction
Severity: high
What to look for: Count all files that import the ESP client SDK (SendGrid, Mailgun, Resend, SES, Postmark, Nodemailer) directly. Enumerate each import location. A well-abstracted system has at most 2 files (adapter + tests) importing the ESP SDK; the rest of the application calls a generic sendEmail(options) function.
Pass criteria: ESP SDK calls are isolated in no more than 2 files (adapter + test). The rest of the application calls a generic sendEmail(options) function or equivalent. Switching ESPs requires changing only the adapter implementation, not business logic. Report even on pass: "ESP SDK imported in N files: [list]."
Fail criteria: ESP SDK methods are called directly in more than 2 files outside the adapter layer. The SDK namespace is imported in worker files, route handlers, or service modules directly.
Skip (N/A) when: The project is a small one-off tool with only one email send path and the team has explicitly decided against abstraction — documented in comments.
Detail on fail: "@sendgrid/mail imported and called directly in 6 different worker and route files — ESP swap would require changes across the codebase" or "Nodemailer transport created inline in multiple service files with no shared interface"
Remediation: Create an ESP adapter interface and a concrete implementation:
// lib/email/types.ts
export interface EmailMessage {
to: string
subject: string
html: string
text: string
replyTo?: string
headers?: Record<string, string>
}
export interface EmailProvider {
send(message: EmailMessage): Promise<{ messageId: string }>
healthCheck(): Promise<boolean>
}
// lib/email/providers/sendgrid.ts
import sgMail from '@sendgrid/mail'
import type { EmailMessage, EmailProvider } from '../types'
export class SendGridProvider implements EmailProvider {
constructor(private readonly apiKey: string) {
sgMail.setApiKey(apiKey)
}
async send(message: EmailMessage): Promise<{ messageId: string }> {
const [response] = await sgMail.send({
to: message.to,
from: process.env.EMAIL_FROM!,
subject: message.subject,
html: message.html,
text: message.text
})
return { messageId: response.headers['x-message-id'] ?? '' }
}
async healthCheck(): Promise<boolean> {
// Validate API key by calling a lightweight endpoint
try {
await sgMail.send({ to: 'health@check.invalid', from: 'noreply@check.invalid', subject: 'health', text: 'health' })
} catch (err: unknown) {
// 400 means the API key is valid but the address is rejected — that's fine
if ((err as { code?: number }).code === 400) return true
return false
}
return true
}
}