From e53ac911e59f1950fb7da6689e7b3f86a3cb37fb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 17:40:03 -0700 Subject: [PATCH 1/6] feat(mailer): add AWS SES and SMTP providers with auto-detect fallback --- .../en/self-hosting/environment-variables.mdx | 39 +- apps/sim/.env.example | 26 +- apps/sim/lib/core/config/env.ts | 23 + apps/sim/lib/messaging/email/mailer.ts | 547 +++++------------- apps/sim/lib/messaging/email/prepare.ts | 120 ++++ .../messaging/email/providers/_nodemailer.ts | 39 ++ .../lib/messaging/email/providers/azure.ts | 45 ++ .../lib/messaging/email/providers/index.ts | 38 ++ .../lib/messaging/email/providers/resend.ts | 71 +++ apps/sim/lib/messaging/email/providers/ses.ts | 29 + .../sim/lib/messaging/email/providers/smtp.ts | 26 + apps/sim/lib/messaging/email/types.ts | 68 +++ helm/sim/values.schema.json | 24 + helm/sim/values.yaml | 11 +- 14 files changed, 699 insertions(+), 407 deletions(-) create mode 100644 apps/sim/lib/messaging/email/prepare.ts create mode 100644 apps/sim/lib/messaging/email/providers/_nodemailer.ts create mode 100644 apps/sim/lib/messaging/email/providers/azure.ts create mode 100644 apps/sim/lib/messaging/email/providers/index.ts create mode 100644 apps/sim/lib/messaging/email/providers/resend.ts create mode 100644 apps/sim/lib/messaging/email/providers/ses.ts create mode 100644 apps/sim/lib/messaging/email/providers/smtp.ts create mode 100644 apps/sim/lib/messaging/email/types.ts diff --git a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx index 1a048777552..8d481b0d62f 100644 --- a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx @@ -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 `). 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 diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f554797ea1e..95c5115cb2b 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -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 " +# 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 diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 63070896dd2..f4c05294690 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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.coerce.boolean().optional(), // Force TLS on connect (defaults to true on port 465) // SMS & Messaging TWILIO_ACCOUNT_SID: z.string().min(1).optional(), // Twilio Account SID for SMS sending @@ -555,3 +561,20 @@ export function envNumber( const parsed = Number(value) return Number.isFinite(parsed) && parsed >= min ? parsed : fallback } + +/** + * Coerce an env-derived value to a boolean, returning `undefined` when the + * value is unset so callers can apply context-aware defaults. + * + * Required because `skipValidation: true` lets raw strings reach consumers, + * and `Boolean("false")` is `true` in JavaScript — so `z.coerce.boolean()` + * silently flips the meaning of `MY_FLAG=false`. Accepts the common truthy + * spellings (`"true"`, `"1"`, `"yes"`, `"on"`) and treats everything else as + * `false`. Case-insensitive. + */ +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' +} diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts index 06f70869146..813674b8b98 100644 --- a/apps/sim/lib/messaging/email/mailer.ts +++ b/apps/sim/lib/messaging/email/mailer.ts @@ -1,458 +1,199 @@ -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' -} - -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?: 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 - attachments?: EmailAttachment[] - replyTo?: string -} - -interface PreparedEmailHeaderData { - to: string | string[] - subject: string - senderEmail: string - replyTo?: string +const SKIPPED_UNSUBSCRIBED_RESULT: SendEmailResult = { + success: true, + message: 'Email skipped (user unsubscribed)', + data: { id: 'skipped-unsubscribed' }, } -function sanitizeEmailSubject(subject: string): string { - return subject.replace(/[\r\n]+/g, ' ').trim() +const MOCK_EMAIL_RESULT: SendEmailResult = { + success: true, + message: 'Email logging successful (no email service configured)', + data: { id: 'mock-email-id' }, } -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 + * True when at least one email provider is configured via env vars. */ export function hasEmailService(): boolean { - return !!(resend || azureEmailClient) + return activeProviders.length > 0 } +/** + * Send a single email. Iterates configured providers in priority order + * (resend → ses → smtp → azure) and falls back to the next on error. + * Returns a successful "logged" result when no provider is configured, + * so dev environments don't break on missing email creds. + */ export async function sendEmail(options: EmailOptions): Promise { 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 (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', - } - } + 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 } - 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' } } } -interface UnsubscribeData { - headers: Record - 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}` - - 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), - } -} - -async function processEmailData(options: EmailOptions): Promise { - const { - to, - html, - text, - emailType = 'transactional', - includeUnsubscribe = true, - attachments, - } = options - - const preparedHeaders = prepareEmailHeaders(options) - - let finalHtml = html - let finalText = text - let headers: Record = {} - - 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 +async function dispatchWithFallback(data: ProcessedEmailData): Promise { + 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) + } } - + logger.error('All email providers failed', lastError) return { - to: preparedHeaders.to, - subject: preparedHeaders.subject, - html: finalHtml, - text: finalText, - senderEmail: preparedHeaders.senderEmail, - headers, - attachments, - replyTo: preparedHeaders.replyTo, + success: false, + message: `All email providers failed: ${getErrorMessage(lastError, 'unknown error')}`, } } -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, - } +interface PreparedBatchEntry { + index: number + data: ProcessedEmailData | null + skippedResult: SendEmailResult | null } -async function sendWithResend(data: ProcessedEmailData): Promise { - 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 { - 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 { + return Promise.all( + emails.map(async (email, index) => { + if (await shouldSkipForUnsubscribe(email)) { + return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT } + } + try { + return { index, data: processEmailData(email), skippedResult: null } + } catch (error) { + return { + index, + data: null, + skippedResult: { + success: false, + message: getErrorMessage(error, 'Failed to prepare email'), + }, } - : { - 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}`) + } + }) + ) } +/** + * Send a batch of emails. Uses the first configured provider with a + * native `sendBatch` capability (currently only Resend); falls back to + * per-message sends for providers without batch support, or if the + * batch call itself fails. + */ export async function sendBatchEmails(options: BatchEmailOptions): Promise { try { - const results: SendEmailResult[] = [] - - if (resend) { - try { - return await sendBatchWithResend(options.emails) - } catch (error) { - logger.warn('Resend batch failed, falling back to individual sends:', error) + const entries = await prepareBatch(options.emails) + const sendable = entries.filter( + (e): e is PreparedBatchEntry & { data: ProcessedEmailData } => e.data !== null + ) + + if (sendable.length === 0) { + const results = entries.map((e) => e.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT) + return { + success: results.every((r) => r.success), + message: + options.emails.length === 0 + ? 'No emails to send' + : 'All batch emails skipped (users unsubscribed)', + 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 { - if (!resend) throw new Error('Resend not configured') - - const results: SendEmailResult[] = [] - const skippedIndices: number[] = [] - const batchEmails: any[] = [] - - 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 }, - } - } +function mergeBatchResults( + entries: PreparedBatchEntry[], + sendable: PreparedBatchEntry[], + sentResults: SendEmailResult[] +): BatchSendEmailResult { + const resultsByIndex = new Map() + sendable.forEach((entry, i) => { + resultsByIndex.set(entry.index, sentResults[i]) + }) - try { - const response = await resend.batch.send(batchEmails as any) - - if (response.error) { - throw new Error(response.error.message || 'Resend batch API error') - } + const results = entries.map( + (entry) => resultsByIndex.get(entry.index) ?? entry.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT + ) - 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 + const successCount = results.filter((r) => r.success).length + const skippedCount = entries.length - sendable.length + return { + success: successCount === results.length, + message: + skippedCount > 0 + ? `${sendable.length} emails sent, ${skippedCount} skipped` + : successCount === results.length + ? 'All batch emails sent successfully' + : `${successCount}/${results.length} emails sent successfully`, + results, + data: { count: successCount }, } } diff --git a/apps/sim/lib/messaging/email/prepare.ts b/apps/sim/lib/messaging/email/prepare.ts new file mode 100644 index 00000000000..2b469a0162a --- /dev/null +++ b/apps/sim/lib/messaging/email/prepare.ts @@ -0,0 +1,120 @@ +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 + 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), + } +} + +/** + * Validate sender, recipients, reply-to, and subject. Throws on header + * injection attempts or empty subjects. + */ +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 } +} + +/** + * Normalize an outgoing email into the provider-agnostic + * {@link ProcessedEmailData} shape. Does not perform unsubscribe + * gating — call {@link shouldSkipForUnsubscribe} first. + */ +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 = {} + + 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, + } +} + +/** + * Returns true if the email should be skipped because the recipient has + * unsubscribed from this email type. Transactional emails are never gated. + */ +export async function shouldSkipForUnsubscribe(options: EmailOptions): Promise { + const { emailType = 'transactional', to } = options + if (emailType === 'transactional') return false + const primaryEmail = Array.isArray(to) ? to[0] : to + return isUnsubscribed(primaryEmail, emailType) +} diff --git a/apps/sim/lib/messaging/email/providers/_nodemailer.ts b/apps/sim/lib/messaging/email/providers/_nodemailer.ts new file mode 100644 index 00000000000..800f6a40791 --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/_nodemailer.ts @@ -0,0 +1,39 @@ +import type { Transporter } from 'nodemailer' +import type { + MailProviderName, + ProcessedEmailData, + SendEmailResult, +} from '@/lib/messaging/email/types' + +/** + * Send a prepared email through any nodemailer transporter (SMTP, SES, etc.). + * Returns a uniform {@link SendEmailResult}; the underlying transport's + * messageId is surfaced in `data.id` so call sites can correlate. + */ +export async function sendViaNodemailer( + transporter: Transporter, + data: ProcessedEmailData, + provider: MailProviderName +): Promise { + 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 }, + } +} diff --git a/apps/sim/lib/messaging/email/providers/azure.ts b/apps/sim/lib/messaging/email/providers/azure.ts new file mode 100644 index 00000000000..2e5013a76e5 --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/azure.ts @@ -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 { + 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 }, + } + }, + } +} diff --git a/apps/sim/lib/messaging/email/providers/index.ts b/apps/sim/lib/messaging/email/providers/index.ts new file mode 100644 index 00000000000..621e51d161a --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/index.ts @@ -0,0 +1,38 @@ +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') + +/** + * Provider factories in priority order. The first configured one becomes + * the primary; the rest serve as automatic fallbacks. Operators select + * a provider by setting its credentials — there is no `EMAIL_PROVIDER` + * env var to maintain. + */ +const factories = [ + createResendProvider, + createSesProvider, + createSmtpProvider, + createAzureProvider, +] as const + +/** + * Safely invoke a factory; a misconfigured provider must not prevent the + * mailer module from loading or block other providers from registering. + */ +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) diff --git a/apps/sim/lib/messaging/email/providers/resend.ts b/apps/sim/lib/messaging/email/providers/resend.ts new file mode 100644 index 00000000000..cd82148a7c6 --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/resend.ts @@ -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 { + 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 { + 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 }, + } + }, + } +} diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts new file mode 100644 index 00000000000..5fa466290eb --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -0,0 +1,29 @@ +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 are resolved through the SDK's default credential provider + * chain (env vars, shared config, ECS/EKS task role, EC2 instance profile, + * SSO). Only the region needs to be set explicitly via `AWS_SES_REGION`. + */ +export function createSesProvider(): MailProvider | null { + const region = env.AWS_SES_REGION + if (!region) return null + + const sesClient = new SESv2Client({ region }) + const sesOptions: SESTransport.Options = { + SES: { sesClient, SendEmailCommand }, + } + const transporter = nodemailer.createTransport(sesOptions) + + return { + name: 'ses', + send: (data) => sendViaNodemailer(transporter, data, 'ses'), + } +} diff --git a/apps/sim/lib/messaging/email/providers/smtp.ts b/apps/sim/lib/messaging/email/providers/smtp.ts new file mode 100644 index 00000000000..965ffe5e426 --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/smtp.ts @@ -0,0 +1,26 @@ +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' + +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) return null + + const user = env.SMTP_USER + const pass = env.SMTP_PASS + 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'), + } +} diff --git a/apps/sim/lib/messaging/email/types.ts b/apps/sim/lib/messaging/email/types.ts new file mode 100644 index 00000000000..3eb1a5d4d45 --- /dev/null +++ b/apps/sim/lib/messaging/email/types.ts @@ -0,0 +1,68 @@ +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 +} + +/** + * A fully-prepared email, ready for any provider to dispatch. + * Headers, sender, subject sanitization, and unsubscribe injection + * are already applied — providers only translate this shape into + * their own API and send. + */ +export interface ProcessedEmailData { + to: string | string[] + subject: string + html?: string + text?: string + senderEmail: string + headers: Record + attachments?: EmailAttachment[] + replyTo?: string +} + +export type MailProviderName = 'resend' | 'ses' | 'smtp' | 'azure' + +/** + * A transport for sending email. Providers receive normalized data + * and translate it to their own SDK. `sendBatch` is optional — providers + * with a native batch API implement it; otherwise the orchestrator + * falls back to per-message sends. + */ +export interface MailProvider { + readonly name: MailProviderName + send(data: ProcessedEmailData): Promise + sendBatch?(emails: ProcessedEmailData[]): Promise +} diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index 1c7504b8884..724f58161e5 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -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" diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 1305d363768..6b48a957bd3 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -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 " 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 From 945f94b3a2aee946bc3e7ff2e7f2177e02252585 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 17:59:44 -0700 Subject: [PATCH 2/6] fix(mailer): cast SES options to bridge duplicate @aws-sdk type identities --- apps/sim/lib/messaging/email/providers/ses.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts index 5fa466290eb..94c752a65ea 100644 --- a/apps/sim/lib/messaging/email/providers/ses.ts +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -17,8 +17,12 @@ export function createSesProvider(): MailProvider | null { if (!region) return null const sesClient = new SESv2Client({ region }) + // `@types/nodemailer` bundles its own copy of `@aws-sdk/client-sesv2`, so the + // SendEmailCommand and SESv2Client we import are structurally identical at + // runtime but TS sees them as a different declarations. Cast through the + // nodemailer SES shape to bridge the two type identities. const sesOptions: SESTransport.Options = { - SES: { sesClient, SendEmailCommand }, + SES: { sesClient, SendEmailCommand } as SESTransport.Options['SES'], } const transporter = nodemailer.createTransport(sesOptions) From f56f78e05176cd70506863ae0b35c91a1a5fb19f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 18:17:12 -0700 Subject: [PATCH 3/6] fix(mailer): dedupe aws-sdk-sesv2, address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/sim/lib/core/config/env.ts | 14 +++++--------- apps/sim/lib/messaging/email/mailer.ts | 17 +---------------- apps/sim/lib/messaging/email/prepare.ts | 13 ------------- .../messaging/email/providers/_nodemailer.ts | 5 ----- apps/sim/lib/messaging/email/providers/index.ts | 10 ---------- apps/sim/lib/messaging/email/providers/ses.ts | 12 +++--------- apps/sim/lib/messaging/email/providers/smtp.ts | 16 +++++++++++++++- apps/sim/lib/messaging/email/types.ts | 12 ------------ bun.lock | 1 + package.json | 3 ++- 10 files changed, 27 insertions(+), 76 deletions(-) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index f4c05294690..41dc464ff7c 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -104,7 +104,7 @@ export const env = createEnv({ 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.coerce.boolean().optional(), // Force TLS on connect (defaults to true on port 465) + 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 @@ -563,14 +563,10 @@ export function envNumber( } /** - * Coerce an env-derived value to a boolean, returning `undefined` when the - * value is unset so callers can apply context-aware defaults. - * - * Required because `skipValidation: true` lets raw strings reach consumers, - * and `Boolean("false")` is `true` in JavaScript — so `z.coerce.boolean()` - * silently flips the meaning of `MY_FLAG=false`. Accepts the common truthy - * spellings (`"true"`, `"1"`, `"yes"`, `"on"`) and treats everything else as - * `false`. Case-insensitive. + * 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 diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts index 813674b8b98..fa1d16f9a70 100644 --- a/apps/sim/lib/messaging/email/mailer.ts +++ b/apps/sim/lib/messaging/email/mailer.ts @@ -36,19 +36,10 @@ const MOCK_EMAIL_RESULT: SendEmailResult = { data: { id: 'mock-email-id' }, } -/** - * True when at least one email provider is configured via env vars. - */ export function hasEmailService(): boolean { return activeProviders.length > 0 } -/** - * Send a single email. Iterates configured providers in priority order - * (resend → ses → smtp → azure) and falls back to the next on error. - * Returns a successful "logged" result when no provider is configured, - * so dev environments don't break on missing email creds. - */ export async function sendEmail(options: EmailOptions): Promise { try { if (await shouldSkipForUnsubscribe(options)) { @@ -123,12 +114,6 @@ async function prepareBatch(emails: EmailOptions[]): Promise { try { const entries = await prepareBatch(options.emails) @@ -189,7 +174,7 @@ function mergeBatchResults( success: successCount === results.length, message: skippedCount > 0 - ? `${sendable.length} emails sent, ${skippedCount} skipped` + ? `${successCount} emails sent, ${skippedCount} skipped` : successCount === results.length ? 'All batch emails sent successfully' : `${successCount}/${results.length} emails sent successfully`, diff --git a/apps/sim/lib/messaging/email/prepare.ts b/apps/sim/lib/messaging/email/prepare.ts index 2b469a0162a..26a82210d00 100644 --- a/apps/sim/lib/messaging/email/prepare.ts +++ b/apps/sim/lib/messaging/email/prepare.ts @@ -38,10 +38,6 @@ function buildUnsubscribeInjection( } } -/** - * Validate sender, recipients, reply-to, and subject. Throws on header - * injection attempts or empty subjects. - */ function validateAndSanitize(options: EmailOptions): { senderEmail: string subject: string @@ -68,11 +64,6 @@ function validateAndSanitize(options: EmailOptions): { return { senderEmail, subject, replyTo: options.replyTo } } -/** - * Normalize an outgoing email into the provider-agnostic - * {@link ProcessedEmailData} shape. Does not perform unsubscribe - * gating — call {@link shouldSkipForUnsubscribe} first. - */ export function processEmailData(options: EmailOptions): ProcessedEmailData { const { senderEmail, subject, replyTo } = validateAndSanitize(options) const { @@ -108,10 +99,6 @@ export function processEmailData(options: EmailOptions): ProcessedEmailData { } } -/** - * Returns true if the email should be skipped because the recipient has - * unsubscribed from this email type. Transactional emails are never gated. - */ export async function shouldSkipForUnsubscribe(options: EmailOptions): Promise { const { emailType = 'transactional', to } = options if (emailType === 'transactional') return false diff --git a/apps/sim/lib/messaging/email/providers/_nodemailer.ts b/apps/sim/lib/messaging/email/providers/_nodemailer.ts index 800f6a40791..1650b65c1a9 100644 --- a/apps/sim/lib/messaging/email/providers/_nodemailer.ts +++ b/apps/sim/lib/messaging/email/providers/_nodemailer.ts @@ -5,11 +5,6 @@ import type { SendEmailResult, } from '@/lib/messaging/email/types' -/** - * Send a prepared email through any nodemailer transporter (SMTP, SES, etc.). - * Returns a uniform {@link SendEmailResult}; the underlying transport's - * messageId is surfaced in `data.id` so call sites can correlate. - */ export async function sendViaNodemailer( transporter: Transporter, data: ProcessedEmailData, diff --git a/apps/sim/lib/messaging/email/providers/index.ts b/apps/sim/lib/messaging/email/providers/index.ts index 621e51d161a..71a07517506 100644 --- a/apps/sim/lib/messaging/email/providers/index.ts +++ b/apps/sim/lib/messaging/email/providers/index.ts @@ -7,12 +7,6 @@ import type { MailProvider } from '@/lib/messaging/email/types' const logger = createLogger('MailProviders') -/** - * Provider factories in priority order. The first configured one becomes - * the primary; the rest serve as automatic fallbacks. Operators select - * a provider by setting its credentials — there is no `EMAIL_PROVIDER` - * env var to maintain. - */ const factories = [ createResendProvider, createSesProvider, @@ -20,10 +14,6 @@ const factories = [ createAzureProvider, ] as const -/** - * Safely invoke a factory; a misconfigured provider must not prevent the - * mailer module from loading or block other providers from registering. - */ function safeCreate(factory: () => MailProvider | null): MailProvider | null { try { return factory() diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts index 94c752a65ea..cf2eb044003 100644 --- a/apps/sim/lib/messaging/email/providers/ses.ts +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -7,22 +7,16 @@ import type { MailProvider } from '@/lib/messaging/email/types' /** * AWS SES via nodemailer's SES transport using the AWS SDK v3 client. - * - * Credentials are resolved through the SDK's default credential provider - * chain (env vars, shared config, ECS/EKS task role, EC2 instance profile, - * SSO). Only the region needs to be set explicitly via `AWS_SES_REGION`. + * 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 }) - // `@types/nodemailer` bundles its own copy of `@aws-sdk/client-sesv2`, so the - // SendEmailCommand and SESv2Client we import are structurally identical at - // runtime but TS sees them as a different declarations. Cast through the - // nodemailer SES shape to bridge the two type identities. const sesOptions: SESTransport.Options = { - SES: { sesClient, SendEmailCommand } as SESTransport.Options['SES'], + SES: { sesClient, SendEmailCommand }, } const transporter = nodemailer.createTransport(sesOptions) diff --git a/apps/sim/lib/messaging/email/providers/smtp.ts b/apps/sim/lib/messaging/email/providers/smtp.ts index 965ffe5e426..ef90e6046c8 100644 --- a/apps/sim/lib/messaging/email/providers/smtp.ts +++ b/apps/sim/lib/messaging/email/providers/smtp.ts @@ -1,17 +1,31 @@ +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) return null + 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, diff --git a/apps/sim/lib/messaging/email/types.ts b/apps/sim/lib/messaging/email/types.ts index 3eb1a5d4d45..f554b65db89 100644 --- a/apps/sim/lib/messaging/email/types.ts +++ b/apps/sim/lib/messaging/email/types.ts @@ -36,12 +36,6 @@ export interface BatchSendEmailResult { data?: unknown } -/** - * A fully-prepared email, ready for any provider to dispatch. - * Headers, sender, subject sanitization, and unsubscribe injection - * are already applied — providers only translate this shape into - * their own API and send. - */ export interface ProcessedEmailData { to: string | string[] subject: string @@ -55,12 +49,6 @@ export interface ProcessedEmailData { export type MailProviderName = 'resend' | 'ses' | 'smtp' | 'azure' -/** - * A transport for sending email. Providers receive normalized data - * and translate it to their own SDK. `sendBatch` is optional — providers - * with a native batch API implement it; otherwise the orchestrator - * falls back to per-message sends. - */ export interface MailProvider { readonly name: MailProviderName send(data: ProcessedEmailData): Promise diff --git a/bun.lock b/bun.lock index 8dd5cb897e4..977980d94c5 100644 --- a/bun.lock +++ b/bun.lock @@ -478,6 +478,7 @@ "sharp", ], "overrides": { + "@aws-sdk/client-sesv2": "3.1032.0", "@next/env": "16.2.6", "drizzle-orm": "^0.45.2", "mermaid": "11.15.0", diff --git a/package.json b/package.json index d587cebb54a..0184a4a1736 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", - "mermaid": "11.15.0" + "mermaid": "11.15.0", + "@aws-sdk/client-sesv2": "3.1032.0" }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", From f4b272df8ce5e2050cf1a131b71adf7a7e3b6b2b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 18:44:44 -0700 Subject: [PATCH 4/6] 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. --- apps/sim/lib/messaging/email/mailer.ts | 21 ++++++++++++------- apps/sim/lib/messaging/email/providers/ses.ts | 4 +++- bun.lock | 1 - package.json | 3 +-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts index fa1d16f9a70..085be7506f5 100644 --- a/apps/sim/lib/messaging/email/mailer.ts +++ b/apps/sim/lib/messaging/email/mailer.ts @@ -123,12 +123,16 @@ export async function sendBatchEmails(options: BatchEmailOptions): Promise 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' - : 'All batch emails skipped (users unsubscribed)', + : allUnsubscribed + ? 'All batch emails skipped (users unsubscribed)' + : 'No emails sent (all entries skipped or failed validation)', results, data: { count: 0 }, } @@ -168,17 +172,20 @@ function mergeBatchResults( (entry) => resultsByIndex.get(entry.index) ?? entry.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT ) - const successCount = results.filter((r) => r.success).length + // 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: successCount === results.length, + success: results.every((r) => r.success), message: skippedCount > 0 - ? `${successCount} emails sent, ${skippedCount} skipped` - : successCount === results.length + ? `${sentCount} emails sent, ${skippedCount} skipped` + : allSucceeded ? 'All batch emails sent successfully' - : `${successCount}/${results.length} emails sent successfully`, + : `${sentCount}/${sendable.length} emails sent successfully`, results, - data: { count: successCount }, + data: { count: sentCount }, } } diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts index cf2eb044003..cb08d3d3aa9 100644 --- a/apps/sim/lib/messaging/email/providers/ses.ts +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -15,8 +15,10 @@ export function createSesProvider(): MailProvider | null { if (!region) return null const sesClient = new SESv2Client({ region }) + // `@types/nodemailer` pulls in its own nested `@aws-sdk/client-sesv2`, giving + // the SDK classes two nominal identities; runtime is identical, cast bridges them. const sesOptions: SESTransport.Options = { - SES: { sesClient, SendEmailCommand }, + SES: { sesClient, SendEmailCommand } as unknown as SESTransport.Options['SES'], } const transporter = nodemailer.createTransport(sesOptions) diff --git a/bun.lock b/bun.lock index 977980d94c5..8dd5cb897e4 100644 --- a/bun.lock +++ b/bun.lock @@ -478,7 +478,6 @@ "sharp", ], "overrides": { - "@aws-sdk/client-sesv2": "3.1032.0", "@next/env": "16.2.6", "drizzle-orm": "^0.45.2", "mermaid": "11.15.0", diff --git a/package.json b/package.json index 0184a4a1736..d587cebb54a 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,7 @@ "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", - "mermaid": "11.15.0", - "@aws-sdk/client-sesv2": "3.1032.0" + "mermaid": "11.15.0" }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", From 111aae05900a05f9ccf1a54e31079951d0833f6d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 19:01:06 -0700 Subject: [PATCH 5/6] fix(mailer): annotate double-cast in ses provider for strict api-validation --- apps/sim/lib/messaging/email/providers/ses.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts index cb08d3d3aa9..a5e9b7795a5 100644 --- a/apps/sim/lib/messaging/email/providers/ses.ts +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -15,9 +15,8 @@ export function createSesProvider(): MailProvider | null { if (!region) return null const sesClient = new SESv2Client({ region }) - // `@types/nodemailer` pulls in its own nested `@aws-sdk/client-sesv2`, giving - // the SDK classes two nominal identities; runtime is identical, cast bridges them. 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) From 7705d3196ec93bad025f19e152d5db8b1d397478 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 19:15:25 -0700 Subject: [PATCH 6/6] 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. --- apps/sim/lib/messaging/email/mailer.test.ts | 14 ++++++++++++++ apps/sim/lib/messaging/email/mailer.ts | 8 ++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/messaging/email/mailer.test.ts b/apps/sim/lib/messaging/email/mailer.test.ts index cfa432f0228..eba95dc1c23 100644 --- a/apps/sim/lib/messaging/email/mailer.test.ts +++ b/apps/sim/lib/messaging/email/mailer.test.ts @@ -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) + }) }) }) diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts index 085be7506f5..a285ecc71ee 100644 --- a/apps/sim/lib/messaging/email/mailer.ts +++ b/apps/sim/lib/messaging/email/mailer.ts @@ -94,11 +94,11 @@ interface PreparedBatchEntry { async function prepareBatch(emails: EmailOptions[]): Promise { return Promise.all( - emails.map(async (email, index) => { - if (await shouldSkipForUnsubscribe(email)) { - return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT } - } + emails.map(async (email, index): Promise => { try { + if (await shouldSkipForUnsubscribe(email)) { + return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT } + } return { index, data: processEmailData(email), skippedResult: null } } catch (error) { return {