mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 23:06:59 +08:00
feat(mailer): add AWS SES and SMTP providers with auto-detect fallback (#4710)
* feat(mailer): add AWS SES and SMTP providers with auto-detect fallback * fix(mailer): cast SES options to bridge duplicate @aws-sdk type identities * fix(mailer): dedupe aws-sdk-sesv2, address review feedback - Force a single @aws-sdk/client-sesv2 install via root package.json overrides; @types/nodemailer pulled in a nested copy whose nominal class brand made the two SDK type identities incompatible, breaking the CI build. With one install the cast disappears. - Batch result message now reports successCount instead of sendable.length when entries are skipped, so "5 emails sent" no longer overstates delivery on partial failures. - SMTP provider now warns when SMTP_HOST is set without SMTP_PORT, and when only one of SMTP_USER/SMTP_PASS is set — both previously silent misconfigurations. - SMTP_SECURE schema is z.boolean() to match every other boolean in env.ts; runtime parsing is still handled by envBoolean. - Strip the verbose TSDoc comments I had added. * fix(mailer): exact sent counts in batch results, restore SES type cast - mergeBatchResults: data.count and the message now report only emails that were actually delivered, not skipped-unsubscribed ones (they returned success: true and inflated the count). Empty-sendable branch distinguishes "all unsubscribed" from "mixed skip/failure" so the message stops lying when some entries fail validation. - ses.ts: revert the package.json override approach (bun honors it locally but CI still installs a nested @types/nodemailer copy). Reinstate the `as unknown as` cast with a single-line WHY comment. * fix(mailer): annotate double-cast in ses provider for strict api-validation * fix(mailer): batch degrades isUnsubscribed errors to per-entry failures A transient DB error in isUnsubscribed used to abort the whole batch because the call sat outside the per-email try/catch in prepareBatch. Wrap the unsubscribe check inside the same catch so a rejection becomes a per-recipient failure, matching sendEmail's behavior. Lock it in with a regression test.
This commit is contained in:
@@ -66,11 +66,48 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
||||
| `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` |
|
||||
| `COPILOT_API_KEY` | API key for copilot features |
|
||||
| `ADMIN_API_KEY` | Admin API key for GitOps operations |
|
||||
| `RESEND_API_KEY` | Email service for notifications |
|
||||
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
|
||||
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
|
||||
| `DISABLE_REGISTRATION` | Set to `true` to disable new user signups |
|
||||
|
||||
## Email Providers
|
||||
|
||||
Configure one provider — the mailer auto-detects in priority order: **Resend → AWS SES → SMTP → Azure Communication Services**. If none are configured, emails are logged to the console instead.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FROM_EMAIL_ADDRESS` | Sender address (e.g. `Sim <noreply@example.com>`). Falls back to `noreply@EMAIL_DOMAIN`. |
|
||||
| `EMAIL_DOMAIN` | Default domain when `FROM_EMAIL_ADDRESS` is unset |
|
||||
| `EMAIL_VERIFICATION_ENABLED` | Set to `true` to require email verification on signup |
|
||||
|
||||
**Resend**
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | API key from [resend.com](https://resend.com) |
|
||||
|
||||
**AWS SES**
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AWS_SES_REGION` | AWS region for SES (e.g. `us-east-1`). Credentials are resolved through the standard AWS SDK provider chain (env vars, IRSA, ECS/EC2 instance role, SSO). |
|
||||
|
||||
**SMTP** (works with MailHog, Postfix, SendGrid SMTP, etc.)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SMTP_HOST` | SMTP server hostname |
|
||||
| `SMTP_PORT` | `465` for implicit TLS, `587` for STARTTLS, `25` for plain |
|
||||
| `SMTP_USER` | Optional — omit for unauthenticated relays |
|
||||
| `SMTP_PASS` | Optional — omit for unauthenticated relays |
|
||||
| `SMTP_SECURE` | Set to `true` to force TLS on connect; auto-true on port 465 |
|
||||
|
||||
**Azure Communication Services**
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string |
|
||||
|
||||
## Example .env
|
||||
|
||||
```bash
|
||||
|
||||
+24
-2
@@ -19,8 +19,30 @@ INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to gen
|
||||
API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt api keys
|
||||
|
||||
# Email Provider (Optional)
|
||||
# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails
|
||||
# If left commented out, emails will be logged to console instead
|
||||
# Configure ONE provider — the mailer auto-detects in priority order:
|
||||
# Resend → AWS SES → SMTP → Azure Communication Services. If none are
|
||||
# configured, emails are logged to console instead.
|
||||
#
|
||||
# Resend
|
||||
# RESEND_API_KEY= # API key from https://resend.com
|
||||
#
|
||||
# AWS SES (credentials resolved via the standard AWS provider chain:
|
||||
# env vars, shared config, ECS/EKS task role, EC2 instance profile, SSO)
|
||||
# AWS_SES_REGION=us-east-1
|
||||
#
|
||||
# SMTP (works with MailHog locally: host=localhost port=1025, no auth)
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587 # 465 = implicit TLS, 587 = STARTTLS, 25 = plain
|
||||
# SMTP_USER= # Optional — omit for unauthenticated relays
|
||||
# SMTP_PASS= # Optional — omit for unauthenticated relays
|
||||
# SMTP_SECURE= # Set "true" to force TLS on connect; auto-true on port 465
|
||||
#
|
||||
# Azure Communication Services
|
||||
# AZURE_ACS_CONNECTION_STRING=
|
||||
#
|
||||
# Shared sender configuration
|
||||
# FROM_EMAIL_ADDRESS="Sim <noreply@example.com>"
|
||||
# EMAIL_DOMAIN=example.com # Fallback when FROM_EMAIL_ADDRESS is unset
|
||||
|
||||
# Local AI Models (Optional)
|
||||
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
|
||||
|
||||
@@ -99,6 +99,12 @@ export const env = createEnv({
|
||||
PERSONAL_EMAIL_FROM: z.string().min(1).optional(), // From address for personalized emails
|
||||
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
|
||||
AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string
|
||||
AWS_SES_REGION: z.string().min(1).optional(), // AWS region for SES (credentials resolved via default SDK provider chain)
|
||||
SMTP_HOST: z.string().min(1).optional(), // SMTP server hostname
|
||||
SMTP_PORT: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
SMTP_USER: z.string().min(1).optional(), // SMTP username
|
||||
SMTP_PASS: z.string().min(1).optional(), // SMTP password
|
||||
SMTP_SECURE: z.boolean().optional(), // Force TLS on connect (defaults to true on port 465); read via envBoolean to handle string values from process.env
|
||||
|
||||
// SMS & Messaging
|
||||
TWILIO_ACCOUNT_SID: z.string().min(1).optional(), // Twilio Account SID for SMS sending
|
||||
@@ -555,3 +561,16 @@ export function envNumber(
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= min ? parsed : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an env-derived value to a boolean. Returns `undefined` when unset
|
||||
* so callers can apply context-aware defaults. Required because
|
||||
* `Boolean("false") === true`, so `z.coerce.boolean()` would silently flip
|
||||
* the meaning of `MY_FLAG=false`.
|
||||
*/
|
||||
export function envBoolean(value: boolean | string | undefined | null): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const normalized = String(value).trim().toLowerCase()
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
|
||||
}
|
||||
|
||||
@@ -251,5 +251,19 @@ describe('mailer', () => {
|
||||
|
||||
expect(isUnsubscribed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should degrade isUnsubscribed rejections to per-entry failures', async () => {
|
||||
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
|
||||
|
||||
const result = await sendBatchEmails({
|
||||
emails: [
|
||||
{ ...testEmailOptions, to: 'user1@example.com', emailType: 'marketing' as EmailType },
|
||||
{ ...testEmailOptions, to: 'user2@example.com', emailType: 'marketing' as EmailType },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.results).toHaveLength(2)
|
||||
expect(result.results.every((r) => r.success === false)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,458 +1,191 @@
|
||||
import { EmailClient, type EmailMessage } from '@azure/communication-email'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Resend } from 'resend'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
|
||||
import { getFromEmailAddress, hasEmailHeaderControlChars } from '@/lib/messaging/email/utils'
|
||||
import { processEmailData, shouldSkipForUnsubscribe } from '@/lib/messaging/email/prepare'
|
||||
import { activeProviders } from '@/lib/messaging/email/providers'
|
||||
import type {
|
||||
BatchEmailOptions,
|
||||
BatchSendEmailResult,
|
||||
EmailOptions,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
export type {
|
||||
BatchEmailOptions,
|
||||
BatchSendEmailResult,
|
||||
EmailAttachment,
|
||||
EmailOptions,
|
||||
EmailType,
|
||||
MailProvider,
|
||||
MailProviderName,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
const logger = createLogger('Mailer')
|
||||
|
||||
export type EmailType = 'transactional' | 'marketing' | 'updates' | 'notifications'
|
||||
|
||||
interface EmailAttachment {
|
||||
filename: string
|
||||
content: string | Buffer
|
||||
contentType: string
|
||||
disposition?: 'attachment' | 'inline'
|
||||
const SKIPPED_UNSUBSCRIBED_RESULT: SendEmailResult = {
|
||||
success: true,
|
||||
message: 'Email skipped (user unsubscribed)',
|
||||
data: { id: 'skipped-unsubscribed' },
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string | string[]
|
||||
subject: string
|
||||
html?: string
|
||||
text?: string
|
||||
from?: string
|
||||
emailType?: EmailType
|
||||
includeUnsubscribe?: boolean
|
||||
attachments?: EmailAttachment[]
|
||||
replyTo?: string
|
||||
const MOCK_EMAIL_RESULT: SendEmailResult = {
|
||||
success: true,
|
||||
message: 'Email logging successful (no email service configured)',
|
||||
data: { id: 'mock-email-id' },
|
||||
}
|
||||
|
||||
export interface BatchEmailOptions {
|
||||
emails: EmailOptions[]
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
export interface BatchSendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
results: SendEmailResult[]
|
||||
data?: any
|
||||
}
|
||||
|
||||
interface ProcessedEmailData {
|
||||
to: string | string[]
|
||||
subject: string
|
||||
html?: string
|
||||
text?: string
|
||||
senderEmail: string
|
||||
headers: Record<string, string>
|
||||
attachments?: EmailAttachment[]
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
interface PreparedEmailHeaderData {
|
||||
to: string | string[]
|
||||
subject: string
|
||||
senderEmail: string
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
function sanitizeEmailSubject(subject: string): string {
|
||||
return subject.replace(/[\r\n]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
const resendApiKey = env.RESEND_API_KEY
|
||||
const azureConnectionString = env.AZURE_ACS_CONNECTION_STRING
|
||||
|
||||
const resend =
|
||||
resendApiKey && resendApiKey !== 'placeholder' && resendApiKey.trim() !== ''
|
||||
? new Resend(resendApiKey)
|
||||
: null
|
||||
|
||||
const azureEmailClient =
|
||||
azureConnectionString && azureConnectionString.trim() !== ''
|
||||
? new EmailClient(azureConnectionString)
|
||||
: null
|
||||
|
||||
/**
|
||||
* Check if any email service is configured and available
|
||||
*/
|
||||
export function hasEmailService(): boolean {
|
||||
return !!(resend || azureEmailClient)
|
||||
return activeProviders.length > 0
|
||||
}
|
||||
|
||||
export async function sendEmail(options: EmailOptions): Promise<SendEmailResult> {
|
||||
try {
|
||||
if (options.emailType !== 'transactional') {
|
||||
const unsubscribeType = options.emailType as 'marketing' | 'updates' | 'notifications'
|
||||
const primaryEmail = Array.isArray(options.to) ? options.to[0] : options.to
|
||||
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
|
||||
if (hasUnsubscribed) {
|
||||
logger.info('Email not sent (user unsubscribed):', {
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
emailType: options.emailType,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email skipped (user unsubscribed)',
|
||||
data: { id: 'skipped-unsubscribed' },
|
||||
}
|
||||
}
|
||||
if (await shouldSkipForUnsubscribe(options)) {
|
||||
logger.info('Email not sent (user unsubscribed):', {
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
emailType: options.emailType,
|
||||
})
|
||||
return SKIPPED_UNSUBSCRIBED_RESULT
|
||||
}
|
||||
|
||||
const processedData = await processEmailData(options)
|
||||
const data = processEmailData(options)
|
||||
|
||||
if (resend) {
|
||||
try {
|
||||
return await sendWithResend(processedData)
|
||||
} catch (error) {
|
||||
logger.warn('Resend failed, attempting Azure Communication Services fallback:', error)
|
||||
}
|
||||
if (activeProviders.length === 0) {
|
||||
logger.info('Email not sent (no email service configured):', {
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
from: data.senderEmail,
|
||||
})
|
||||
return MOCK_EMAIL_RESULT
|
||||
}
|
||||
|
||||
if (azureEmailClient) {
|
||||
try {
|
||||
return await sendWithAzure(processedData)
|
||||
} catch (error) {
|
||||
logger.error('Azure Communication Services also failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Both Resend and Azure Communication Services failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Email not sent (no email service configured):', {
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
from: processedData.senderEmail,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email logging successful (no email service configured)',
|
||||
data: { id: 'mock-email-id' },
|
||||
}
|
||||
return await dispatchWithFallback(data)
|
||||
} catch (error) {
|
||||
logger.error('Error sending email:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send email',
|
||||
return { success: false, message: 'Failed to send email' }
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchWithFallback(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
let lastError: unknown
|
||||
for (const provider of activeProviders) {
|
||||
try {
|
||||
return await provider.send(data)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
logger.warn(`${provider.name} failed, trying next provider`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UnsubscribeData {
|
||||
headers: Record<string, string>
|
||||
html?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function addUnsubscribeData(
|
||||
recipientEmail: string,
|
||||
emailType: string,
|
||||
html?: string,
|
||||
text?: string
|
||||
): UnsubscribeData {
|
||||
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
|
||||
const baseUrl = getBaseUrl()
|
||||
const encodedEmail = encodeURIComponent(recipientEmail)
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodedEmail}`
|
||||
|
||||
logger.error('All email providers failed', lastError)
|
||||
return {
|
||||
headers: {
|
||||
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
html: html
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
text: text
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
success: false,
|
||||
message: `All email providers failed: ${getErrorMessage(lastError, 'unknown error')}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function processEmailData(options: EmailOptions): Promise<ProcessedEmailData> {
|
||||
const {
|
||||
to,
|
||||
html,
|
||||
text,
|
||||
emailType = 'transactional',
|
||||
includeUnsubscribe = true,
|
||||
attachments,
|
||||
} = options
|
||||
|
||||
const preparedHeaders = prepareEmailHeaders(options)
|
||||
|
||||
let finalHtml = html
|
||||
let finalText = text
|
||||
let headers: Record<string, string> = {}
|
||||
|
||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||
const primaryEmail = Array.isArray(to) ? to[0] : to
|
||||
const unsubData = addUnsubscribeData(primaryEmail, emailType, html, text)
|
||||
headers = unsubData.headers
|
||||
finalHtml = unsubData.html
|
||||
finalText = unsubData.text
|
||||
}
|
||||
|
||||
return {
|
||||
to: preparedHeaders.to,
|
||||
subject: preparedHeaders.subject,
|
||||
html: finalHtml,
|
||||
text: finalText,
|
||||
senderEmail: preparedHeaders.senderEmail,
|
||||
headers,
|
||||
attachments,
|
||||
replyTo: preparedHeaders.replyTo,
|
||||
}
|
||||
interface PreparedBatchEntry {
|
||||
index: number
|
||||
data: ProcessedEmailData | null
|
||||
skippedResult: SendEmailResult | null
|
||||
}
|
||||
|
||||
function prepareEmailHeaders(options: EmailOptions): PreparedEmailHeaderData {
|
||||
const senderEmail = options.from || getFromEmailAddress()
|
||||
const recipients = Array.isArray(options.to) ? options.to : [options.to]
|
||||
|
||||
if (recipients.some(hasEmailHeaderControlChars)) {
|
||||
throw new Error('Invalid recipient email header')
|
||||
}
|
||||
|
||||
if (hasEmailHeaderControlChars(senderEmail)) {
|
||||
throw new Error('Invalid from email header')
|
||||
}
|
||||
|
||||
if (options.replyTo && hasEmailHeaderControlChars(options.replyTo)) {
|
||||
throw new Error('Invalid reply-to email header')
|
||||
}
|
||||
|
||||
const subject = sanitizeEmailSubject(options.subject)
|
||||
if (subject.length === 0) {
|
||||
throw new Error('Email subject cannot be empty')
|
||||
}
|
||||
|
||||
return {
|
||||
to: options.to,
|
||||
subject,
|
||||
senderEmail,
|
||||
replyTo: options.replyTo,
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWithResend(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
if (!resend) throw new Error('Resend not configured')
|
||||
|
||||
const fromAddress = data.senderEmail
|
||||
|
||||
const emailData: any = {
|
||||
from: fromAddress,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
|
||||
}
|
||||
|
||||
if (data.html) emailData.html = data.html
|
||||
if (data.text) emailData.text = data.text
|
||||
if (data.replyTo) emailData.replyTo = data.replyTo
|
||||
if (data.attachments) {
|
||||
emailData.attachments = data.attachments.map((att) => ({
|
||||
filename: att.filename,
|
||||
content: typeof att.content === 'string' ? att.content : att.content.toString('base64'),
|
||||
contentType: att.contentType,
|
||||
disposition: att.disposition || 'attachment',
|
||||
}))
|
||||
}
|
||||
|
||||
const { data: responseData, error } = await resend.emails.send(emailData)
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to send email via Resend')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend',
|
||||
data: responseData,
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWithAzure(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
if (!azureEmailClient) throw new Error('Azure Communication Services not configured')
|
||||
|
||||
if (!data.html && !data.text) {
|
||||
throw new Error('Azure Communication Services requires either HTML or text content')
|
||||
}
|
||||
|
||||
const senderEmailOnly = data.senderEmail.includes('<')
|
||||
? data.senderEmail.match(/<(.+)>/)?.[1] || data.senderEmail
|
||||
: data.senderEmail
|
||||
|
||||
const message: EmailMessage = {
|
||||
senderAddress: senderEmailOnly,
|
||||
content: data.html
|
||||
? {
|
||||
subject: data.subject,
|
||||
html: data.html,
|
||||
async function prepareBatch(emails: EmailOptions[]): Promise<PreparedBatchEntry[]> {
|
||||
return Promise.all(
|
||||
emails.map(async (email, index): Promise<PreparedBatchEntry> => {
|
||||
try {
|
||||
if (await shouldSkipForUnsubscribe(email)) {
|
||||
return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT }
|
||||
}
|
||||
: {
|
||||
subject: data.subject,
|
||||
plainText: data.text!,
|
||||
},
|
||||
recipients: {
|
||||
to: Array.isArray(data.to)
|
||||
? data.to.map((email) => ({ address: email }))
|
||||
: [{ address: data.to }],
|
||||
},
|
||||
headers: data.headers,
|
||||
}
|
||||
|
||||
const poller = await azureEmailClient.beginSend(message)
|
||||
const result = await poller.pollUntilDone()
|
||||
|
||||
if (result.status === 'Succeeded') {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Azure Communication Services',
|
||||
data: { id: result.id },
|
||||
}
|
||||
}
|
||||
throw new Error(`Azure Communication Services failed with status: ${result.status}`)
|
||||
return { index, data: processEmailData(email), skippedResult: null }
|
||||
} catch (error) {
|
||||
return {
|
||||
index,
|
||||
data: null,
|
||||
skippedResult: {
|
||||
success: false,
|
||||
message: getErrorMessage(error, 'Failed to prepare email'),
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function sendBatchEmails(options: BatchEmailOptions): Promise<BatchSendEmailResult> {
|
||||
try {
|
||||
const results: SendEmailResult[] = []
|
||||
const entries = await prepareBatch(options.emails)
|
||||
const sendable = entries.filter(
|
||||
(e): e is PreparedBatchEntry & { data: ProcessedEmailData } => e.data !== null
|
||||
)
|
||||
|
||||
if (resend) {
|
||||
try {
|
||||
return await sendBatchWithResend(options.emails)
|
||||
} catch (error) {
|
||||
logger.warn('Resend batch failed, falling back to individual sends:', error)
|
||||
if (sendable.length === 0) {
|
||||
const results = entries.map((e) => e.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT)
|
||||
const allUnsubscribed =
|
||||
entries.length > 0 && entries.every((e) => e.skippedResult === SKIPPED_UNSUBSCRIBED_RESULT)
|
||||
return {
|
||||
success: results.every((r) => r.success),
|
||||
message:
|
||||
options.emails.length === 0
|
||||
? 'No emails to send'
|
||||
: allUnsubscribed
|
||||
? 'All batch emails skipped (users unsubscribed)'
|
||||
: 'No emails sent (all entries skipped or failed validation)',
|
||||
results,
|
||||
data: { count: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Sending batch emails individually')
|
||||
for (const email of options.emails) {
|
||||
const batchProvider = activeProviders.find((p) => p.sendBatch)
|
||||
if (batchProvider) {
|
||||
try {
|
||||
const result = await sendEmail(email)
|
||||
results.push(result)
|
||||
const batchResult = await batchProvider.sendBatch!(sendable.map((e) => e.data))
|
||||
return mergeBatchResults(entries, sendable, batchResult.results)
|
||||
} catch (error) {
|
||||
results.push({
|
||||
success: false,
|
||||
message: getErrorMessage(error, 'Failed to send email'),
|
||||
})
|
||||
logger.warn(`${batchProvider.name} batch failed, falling back to per-message sends`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length
|
||||
return {
|
||||
success: successCount === results.length,
|
||||
message:
|
||||
successCount === results.length
|
||||
? 'All batch emails sent successfully'
|
||||
: `${successCount}/${results.length} emails sent successfully`,
|
||||
results,
|
||||
data: { count: successCount },
|
||||
}
|
||||
const sentResults = await Promise.all(
|
||||
sendable.map((entry) => sendEmail(options.emails[entry.index]))
|
||||
)
|
||||
return mergeBatchResults(entries, sendable, sentResults)
|
||||
} catch (error) {
|
||||
logger.error('Error in batch email sending:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send batch emails',
|
||||
results: [],
|
||||
}
|
||||
return { success: false, message: 'Failed to send batch emails', results: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEmailResult> {
|
||||
if (!resend) throw new Error('Resend not configured')
|
||||
function mergeBatchResults(
|
||||
entries: PreparedBatchEntry[],
|
||||
sendable: PreparedBatchEntry[],
|
||||
sentResults: SendEmailResult[]
|
||||
): BatchSendEmailResult {
|
||||
const resultsByIndex = new Map<number, SendEmailResult>()
|
||||
sendable.forEach((entry, i) => {
|
||||
resultsByIndex.set(entry.index, sentResults[i])
|
||||
})
|
||||
|
||||
const results: SendEmailResult[] = []
|
||||
const skippedIndices: number[] = []
|
||||
const batchEmails: any[] = []
|
||||
const results = entries.map(
|
||||
(entry) => resultsByIndex.get(entry.index) ?? entry.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT
|
||||
)
|
||||
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
const email = emails[i]
|
||||
const { emailType = 'transactional', includeUnsubscribe = true } = email
|
||||
|
||||
if (emailType !== 'transactional') {
|
||||
const unsubscribeType = emailType as 'marketing' | 'updates' | 'notifications'
|
||||
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
||||
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
|
||||
if (hasUnsubscribed) {
|
||||
skippedIndices.push(i)
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email skipped (user unsubscribed)',
|
||||
data: { id: 'skipped-unsubscribed' },
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const preparedHeaders = prepareEmailHeaders(email)
|
||||
const emailData: any = {
|
||||
from: preparedHeaders.senderEmail,
|
||||
to: preparedHeaders.to,
|
||||
subject: preparedHeaders.subject,
|
||||
}
|
||||
|
||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
||||
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
|
||||
emailData.headers = unsubData.headers
|
||||
if (unsubData.html) emailData.html = unsubData.html
|
||||
if (unsubData.text) emailData.text = unsubData.text
|
||||
} else {
|
||||
if (email.html) emailData.html = email.html
|
||||
if (email.text) emailData.text = email.text
|
||||
}
|
||||
|
||||
batchEmails.push(emailData)
|
||||
}
|
||||
|
||||
if (batchEmails.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'All batch emails skipped (users unsubscribed)',
|
||||
results,
|
||||
data: { count: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await resend.batch.send(batchEmails as any)
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || 'Resend batch API error')
|
||||
}
|
||||
|
||||
batchEmails.forEach((_, index) => {
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend batch',
|
||||
data: { id: `batch-${index}` },
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
skippedIndices.length > 0
|
||||
? `${batchEmails.length} emails sent, ${skippedIndices.length} skipped (unsubscribed)`
|
||||
: 'All batch emails sent successfully via Resend',
|
||||
results,
|
||||
data: { count: batchEmails.length },
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Resend batch send failed:', error)
|
||||
throw error
|
||||
// sentCount excludes both unsubscribe-skipped (success but not delivered)
|
||||
// and prepare-failed entries — only counts what actually went out the wire.
|
||||
const sentCount = sentResults.filter((r) => r.success).length
|
||||
const skippedCount = entries.length - sendable.length
|
||||
const allSucceeded = sentCount === sendable.length && skippedCount === 0
|
||||
return {
|
||||
success: results.every((r) => r.success),
|
||||
message:
|
||||
skippedCount > 0
|
||||
? `${sentCount} emails sent, ${skippedCount} skipped`
|
||||
: allSucceeded
|
||||
? 'All batch emails sent successfully'
|
||||
: `${sentCount}/${sendable.length} emails sent successfully`,
|
||||
results,
|
||||
data: { count: sentCount },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { EmailOptions, EmailType, ProcessedEmailData } from '@/lib/messaging/email/types'
|
||||
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
|
||||
import { getFromEmailAddress, hasEmailHeaderControlChars } from '@/lib/messaging/email/utils'
|
||||
|
||||
function sanitizeEmailSubject(subject: string): string {
|
||||
return subject.replace(/[\r\n]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
interface UnsubscribeInjection {
|
||||
headers: Record<string, string>
|
||||
html?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function buildUnsubscribeInjection(
|
||||
recipientEmail: string,
|
||||
emailType: EmailType,
|
||||
html?: string,
|
||||
text?: string
|
||||
): UnsubscribeInjection {
|
||||
const token = generateUnsubscribeToken(recipientEmail, emailType)
|
||||
const baseUrl = getBaseUrl()
|
||||
const encodedEmail = encodeURIComponent(recipientEmail)
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${token}&email=${encodedEmail}`
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
html: html
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, token)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
text: text
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, token)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
}
|
||||
}
|
||||
|
||||
function validateAndSanitize(options: EmailOptions): {
|
||||
senderEmail: string
|
||||
subject: string
|
||||
replyTo?: string
|
||||
} {
|
||||
const senderEmail = options.from || getFromEmailAddress()
|
||||
const recipients = Array.isArray(options.to) ? options.to : [options.to]
|
||||
|
||||
if (recipients.some(hasEmailHeaderControlChars)) {
|
||||
throw new Error('Invalid recipient email header')
|
||||
}
|
||||
if (hasEmailHeaderControlChars(senderEmail)) {
|
||||
throw new Error('Invalid from email header')
|
||||
}
|
||||
if (options.replyTo && hasEmailHeaderControlChars(options.replyTo)) {
|
||||
throw new Error('Invalid reply-to email header')
|
||||
}
|
||||
|
||||
const subject = sanitizeEmailSubject(options.subject)
|
||||
if (subject.length === 0) {
|
||||
throw new Error('Email subject cannot be empty')
|
||||
}
|
||||
|
||||
return { senderEmail, subject, replyTo: options.replyTo }
|
||||
}
|
||||
|
||||
export function processEmailData(options: EmailOptions): ProcessedEmailData {
|
||||
const { senderEmail, subject, replyTo } = validateAndSanitize(options)
|
||||
const {
|
||||
to,
|
||||
html,
|
||||
text,
|
||||
emailType = 'transactional',
|
||||
includeUnsubscribe = true,
|
||||
attachments,
|
||||
} = options
|
||||
|
||||
let finalHtml = html
|
||||
let finalText = text
|
||||
let headers: Record<string, string> = {}
|
||||
|
||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||
const primaryEmail = Array.isArray(to) ? to[0] : to
|
||||
const injection = buildUnsubscribeInjection(primaryEmail, emailType, html, text)
|
||||
headers = injection.headers
|
||||
finalHtml = injection.html
|
||||
finalText = injection.text
|
||||
}
|
||||
|
||||
return {
|
||||
to,
|
||||
subject,
|
||||
html: finalHtml,
|
||||
text: finalText,
|
||||
senderEmail,
|
||||
headers,
|
||||
attachments,
|
||||
replyTo,
|
||||
}
|
||||
}
|
||||
|
||||
export async function shouldSkipForUnsubscribe(options: EmailOptions): Promise<boolean> {
|
||||
const { emailType = 'transactional', to } = options
|
||||
if (emailType === 'transactional') return false
|
||||
const primaryEmail = Array.isArray(to) ? to[0] : to
|
||||
return isUnsubscribed(primaryEmail, emailType)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import type {
|
||||
MailProviderName,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
export async function sendViaNodemailer(
|
||||
transporter: Transporter,
|
||||
data: ProcessedEmailData,
|
||||
provider: MailProviderName
|
||||
): Promise<SendEmailResult> {
|
||||
const info = await transporter.sendMail({
|
||||
from: data.senderEmail,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
html: data.html,
|
||||
text: data.text,
|
||||
replyTo: data.replyTo,
|
||||
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
|
||||
attachments: data.attachments?.map((att) => ({
|
||||
filename: att.filename,
|
||||
content: att.content,
|
||||
contentType: att.contentType,
|
||||
contentDisposition: att.disposition || 'attachment',
|
||||
})),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Email sent successfully via ${provider}`,
|
||||
data: { id: info.messageId },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { EmailClient, type EmailMessage } from '@azure/communication-email'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { MailProvider, ProcessedEmailData, SendEmailResult } from '@/lib/messaging/email/types'
|
||||
|
||||
function extractBareAddress(addressOrFormatted: string): string {
|
||||
if (!addressOrFormatted.includes('<')) return addressOrFormatted
|
||||
return addressOrFormatted.match(/<(.+)>/)?.[1] ?? addressOrFormatted
|
||||
}
|
||||
|
||||
export function createAzureProvider(): MailProvider | null {
|
||||
const connectionString = env.AZURE_ACS_CONNECTION_STRING
|
||||
if (!connectionString || connectionString.trim() === '') return null
|
||||
const client = new EmailClient(connectionString)
|
||||
|
||||
return {
|
||||
name: 'azure',
|
||||
async send(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
if (!data.html && !data.text) {
|
||||
throw new Error('Azure Communication Services requires either HTML or text content')
|
||||
}
|
||||
|
||||
const message: EmailMessage = {
|
||||
senderAddress: extractBareAddress(data.senderEmail),
|
||||
content: data.html
|
||||
? { subject: data.subject, html: data.html }
|
||||
: { subject: data.subject, plainText: data.text as string },
|
||||
recipients: {
|
||||
to: (Array.isArray(data.to) ? data.to : [data.to]).map((address) => ({ address })),
|
||||
},
|
||||
headers: data.headers,
|
||||
}
|
||||
|
||||
const poller = await client.beginSend(message)
|
||||
const result = await poller.pollUntilDone()
|
||||
if (result.status !== 'Succeeded') {
|
||||
throw new Error(`Azure Communication Services failed with status: ${result.status}`)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Azure Communication Services',
|
||||
data: { id: result.id },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { createAzureProvider } from '@/lib/messaging/email/providers/azure'
|
||||
import { createResendProvider } from '@/lib/messaging/email/providers/resend'
|
||||
import { createSesProvider } from '@/lib/messaging/email/providers/ses'
|
||||
import { createSmtpProvider } from '@/lib/messaging/email/providers/smtp'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
const logger = createLogger('MailProviders')
|
||||
|
||||
const factories = [
|
||||
createResendProvider,
|
||||
createSesProvider,
|
||||
createSmtpProvider,
|
||||
createAzureProvider,
|
||||
] as const
|
||||
|
||||
function safeCreate(factory: () => MailProvider | null): MailProvider | null {
|
||||
try {
|
||||
return factory()
|
||||
} catch (error) {
|
||||
logger.error('Mail provider factory threw at startup; skipping', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const activeProviders: readonly MailProvider[] = factories
|
||||
.map((factory) => safeCreate(factory))
|
||||
.filter((provider): provider is MailProvider => provider !== null)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Resend } from 'resend'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type {
|
||||
BatchSendEmailResult,
|
||||
MailProvider,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
function isConfigured(key: string | undefined): key is string {
|
||||
return !!key && key !== 'placeholder' && key.trim() !== ''
|
||||
}
|
||||
|
||||
function toResendPayload(data: ProcessedEmailData) {
|
||||
return {
|
||||
from: data.senderEmail,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
html: data.html,
|
||||
text: data.text,
|
||||
replyTo: data.replyTo,
|
||||
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
|
||||
attachments: data.attachments?.map((att) => ({
|
||||
filename: att.filename,
|
||||
content: typeof att.content === 'string' ? att.content : att.content.toString('base64'),
|
||||
contentType: att.contentType,
|
||||
disposition: att.disposition || 'attachment',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createResendProvider(): MailProvider | null {
|
||||
if (!isConfigured(env.RESEND_API_KEY)) return null
|
||||
const client = new Resend(env.RESEND_API_KEY)
|
||||
|
||||
return {
|
||||
name: 'resend',
|
||||
async send(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
const payload = toResendPayload(data)
|
||||
const { data: responseData, error } = await client.emails.send(payload as never)
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to send email via Resend')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend',
|
||||
data: responseData,
|
||||
}
|
||||
},
|
||||
async sendBatch(emails: ProcessedEmailData[]): Promise<BatchSendEmailResult> {
|
||||
const payloads = emails.map(toResendPayload)
|
||||
const response = await client.batch.send(payloads as never)
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || 'Resend batch API error')
|
||||
}
|
||||
|
||||
const results: SendEmailResult[] = emails.map((_, index) => ({
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend batch',
|
||||
data: { id: `batch-${index}` },
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'All batch emails sent successfully via Resend',
|
||||
results,
|
||||
data: { count: emails.length },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
|
||||
import nodemailer from 'nodemailer'
|
||||
import type SESTransport from 'nodemailer/lib/ses-transport'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
/**
|
||||
* AWS SES via nodemailer's SES transport using the AWS SDK v3 client.
|
||||
* Credentials resolve through the SDK's default provider chain (env vars,
|
||||
* shared config, ECS/EKS task role, EC2 instance profile, SSO).
|
||||
*/
|
||||
export function createSesProvider(): MailProvider | null {
|
||||
const region = env.AWS_SES_REGION
|
||||
if (!region) return null
|
||||
|
||||
const sesClient = new SESv2Client({ region })
|
||||
const sesOptions: SESTransport.Options = {
|
||||
// double-cast-allowed: @types/nodemailer bundles a nested @aws-sdk/client-sesv2 whose nominal class types do not unify with the top-level install
|
||||
SES: { sesClient, SendEmailCommand } as unknown as SESTransport.Options['SES'],
|
||||
}
|
||||
const transporter = nodemailer.createTransport(sesOptions)
|
||||
|
||||
return {
|
||||
name: 'ses',
|
||||
send: (data) => sendViaNodemailer(transporter, data, 'ses'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { env, envBoolean, envNumber } from '@/lib/core/config/env'
|
||||
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
const logger = createLogger('SmtpMailProvider')
|
||||
|
||||
export function createSmtpProvider(): MailProvider | null {
|
||||
const host = env.SMTP_HOST
|
||||
if (!host) return null
|
||||
|
||||
const port = envNumber(env.SMTP_PORT, 0, { min: 1 })
|
||||
if (port === 0) {
|
||||
logger.warn(
|
||||
'SMTP_HOST is set but SMTP_PORT is missing or invalid; skipping SMTP provider. Set SMTP_PORT to 465 (TLS), 587 (STARTTLS), or 25 (plain).'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const user = env.SMTP_USER
|
||||
const pass = env.SMTP_PASS
|
||||
if ((user && !pass) || (!user && pass)) {
|
||||
logger.warn(
|
||||
'SMTP_USER and SMTP_PASS must both be set for authenticated relays; proceeding without auth.'
|
||||
)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: envBoolean(env.SMTP_SECURE) ?? port === 465,
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
name: 'smtp',
|
||||
send: (data) => sendViaNodemailer(transporter, data, 'smtp'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type EmailType = 'transactional' | 'marketing' | 'updates' | 'notifications'
|
||||
|
||||
export interface EmailAttachment {
|
||||
filename: string
|
||||
content: string | Buffer
|
||||
contentType: string
|
||||
disposition?: 'attachment' | 'inline'
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string | string[]
|
||||
subject: string
|
||||
html?: string
|
||||
text?: string
|
||||
from?: string
|
||||
emailType?: EmailType
|
||||
includeUnsubscribe?: boolean
|
||||
attachments?: EmailAttachment[]
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
export interface BatchEmailOptions {
|
||||
emails: EmailOptions[]
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface BatchSendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
results: SendEmailResult[]
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface ProcessedEmailData {
|
||||
to: string | string[]
|
||||
subject: string
|
||||
html?: string
|
||||
text?: string
|
||||
senderEmail: string
|
||||
headers: Record<string, string>
|
||||
attachments?: EmailAttachment[]
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
export type MailProviderName = 'resend' | 'ses' | 'smtp' | 'azure'
|
||||
|
||||
export interface MailProvider {
|
||||
readonly name: MailProviderName
|
||||
send(data: ProcessedEmailData): Promise<SendEmailResult>
|
||||
sendBatch?(emails: ProcessedEmailData[]): Promise<BatchSendEmailResult>
|
||||
}
|
||||
Reference in New Issue
Block a user