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:
Waleed
2026-05-21 19:27:33 -07:00
committed by GitHub
parent 1af65381f5
commit 952eb1216f
15 changed files with 677 additions and 410 deletions
@@ -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
View File
@@ -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
+19
View File
@@ -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)
})
})
})
+139 -406
View File
@@ -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 },
}
}
+107
View File
@@ -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'),
}
}
+56
View File
@@ -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>
}
+24
View File
@@ -178,6 +178,30 @@
"type": "string",
"description": "Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)"
},
"AWS_SES_REGION": {
"type": "string",
"description": "AWS region for SES (e.g., 'us-east-1'). Credentials are resolved via the standard AWS provider chain (env vars, IRSA, EC2/ECS task role, SSO)."
},
"SMTP_HOST": {
"type": "string",
"description": "SMTP server hostname. When set together with SMTP_PORT, enables the SMTP mail provider."
},
"SMTP_PORT": {
"type": "string",
"description": "SMTP server port. 465 = implicit TLS, 587 = STARTTLS, 25 = plain."
},
"SMTP_USER": {
"type": "string",
"description": "SMTP username (optional — leave empty for unauthenticated relays like MailHog)."
},
"SMTP_PASS": {
"type": "string",
"description": "SMTP password (optional — leave empty for unauthenticated relays)."
},
"SMTP_SECURE": {
"type": "string",
"description": "Set to 'true' to force TLS on connect. Defaults to true when SMTP_PORT=465."
},
"GOOGLE_CLIENT_ID": {
"type": "string",
"description": "Google OAuth client ID"
+10 -1
View File
@@ -110,11 +110,20 @@ app:
REDIS_URL: "" # OPTIONAL - Redis connection string for caching/sessions; can also come from app secret or External Secrets
# Email & Communication
# Configure one provider — the mailer auto-detects in priority order:
# Resend → AWS SES → SMTP → Azure Communication Services.
EMAIL_VERIFICATION_ENABLED: "" # Set to "true" to enable email verification for user registration and login (default "false" via envDefaults)
RESEND_API_KEY: "" # Resend API key for transactional emails
FROM_EMAIL_ADDRESS: "" # Complete from address (e.g., "Sim <noreply@domain.com>" or "DoNotReply@domain.com")
EMAIL_DOMAIN: "" # Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
AWS_SES_REGION: "" # AWS region for SES (e.g., "us-east-1"); credentials resolved via the standard AWS provider chain (env, IRSA, instance profile)
SMTP_HOST: "" # SMTP server hostname (alternative to Resend/SES)
SMTP_PORT: "" # SMTP server port (465 for TLS, 587 for STARTTLS, 25 for plain)
SMTP_USER: "" # SMTP username (optional — omit for unauthenticated relays like MailHog)
SMTP_PASS: "" # SMTP password (optional — omit for unauthenticated relays)
SMTP_SECURE: "" # Set to "true" to force TLS on connect; defaults to true when SMTP_PORT=465
# OAuth Integration Credentials (leave empty if not using)
GOOGLE_CLIENT_ID: "" # Google OAuth client ID
GOOGLE_CLIENT_SECRET: "" # Google OAuth client secret