chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,27 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { isFreeEmailDomain } from './free-email'
describe('isFreeEmailDomain', () => {
it('returns true for known free/personal providers', () => {
expect(isFreeEmailDomain('jane@gmail.com')).toBe(true)
expect(isFreeEmailDomain('jane@yahoo.com')).toBe(true)
expect(isFreeEmailDomain('jane@hotmail.com')).toBe(true)
})
it('returns false for work domains', () => {
expect(isFreeEmailDomain('jane@acme.co')).toBe(false)
expect(isFreeEmailDomain('jane@sim.ai')).toBe(false)
})
it('is case-insensitive on the domain', () => {
expect(isFreeEmailDomain('Jane@GMAIL.com')).toBe(true)
})
it('returns false when there is no domain', () => {
expect(isFreeEmailDomain('jane')).toBe(false)
expect(isFreeEmailDomain('')).toBe(false)
})
})
@@ -0,0 +1,17 @@
import freeEmailDomains from 'free-email-domains'
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
/**
* True when the email's domain is a known free/personal provider (Gmail, Yahoo,
* …) rather than a work address. Shared by the demo-request schema and form so
* client gating and server validation agree on what counts as a work email.
*
* Isolated in its own module (not `validation.ts`) so the sizable domain list
* only enters bundles that need the work-email check, not every consumer of
* {@link quickValidateEmail}.
*/
export function isFreeEmailDomain(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase()
return domain ? FREE_EMAIL_DOMAINS.has(domain) : false
}
+330
View File
@@ -0,0 +1,330 @@
import { createEnvMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
const mockSend = vi.fn()
const mockBatchSend = vi.fn()
const mockAzureBeginSend = vi.fn()
const mockAzurePollUntilDone = vi.fn()
vi.mock('resend', () => {
return {
Resend: vi.fn().mockImplementation(
class {
emails = {
send: (...args: any[]) => mockSend(...args),
}
batch = {
send: (...args: any[]) => mockBatchSend(...args),
}
}
),
}
})
vi.mock('@azure/communication-email', () => {
return {
EmailClient: vi.fn().mockImplementation(
class {
beginSend = (...args: any[]) => mockAzureBeginSend(...args)
}
),
}
})
vi.mock('@/lib/messaging/email/unsubscribe', () => ({
isUnsubscribed: vi.fn(),
generateUnsubscribeToken: vi.fn(),
}))
vi.mock('@/lib/auth/access-control', () => ({
getAccessControlConfig: vi.fn().mockResolvedValue({
blockedSignupDomains: [],
blockedEmails: [],
allowedLoginEmails: [],
allowedLoginDomains: [],
blockedEmailMxHosts: [],
}),
isEmailBlockedByAccessControl: vi.fn().mockReturnValue(false),
}))
vi.mock('@/lib/core/config/env', () =>
createEnvMock({
RESEND_API_KEY: 'test-api-key',
AZURE_ACS_CONNECTION_STRING: 'test-azure-connection-string',
AZURE_COMMUNICATION_EMAIL_DOMAIN: 'test.azurecomm.net',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
FROM_EMAIL_ADDRESS: 'Sim <noreply@sim.ai>',
})
)
vi.mock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
getBaseDomain: vi.fn().mockReturnValue('test.sim.ai'),
}))
vi.mock('@/lib/messaging/email/utils', () => ({
getFromEmailAddress: vi.fn().mockReturnValue('Sim <noreply@sim.ai>'),
hasEmailHeaderControlChars: vi.fn().mockImplementation((value: string) => /[\r\n]/.test(value)),
EMAIL_HEADER_CONTROL_CHARS_REGEX: /[\r\n]/,
NO_EMAIL_HEADER_CONTROL_CHARS_REGEX: /^[^\r\n]*$/,
}))
import { isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { type EmailType, hasEmailService, sendBatchEmails, sendEmail } from './mailer'
import { generateUnsubscribeToken, isUnsubscribed } from './unsubscribe'
describe('mailer', () => {
const testEmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test email content</p>',
}
beforeEach(() => {
vi.clearAllMocks()
;(isEmailBlockedByAccessControl as Mock).mockReturnValue(false)
;(isUnsubscribed as Mock).mockResolvedValue(false)
;(generateUnsubscribeToken as Mock).mockReturnValue('mock-token-123')
mockSend.mockResolvedValue({
data: { id: 'test-email-id' },
error: null,
})
mockBatchSend.mockResolvedValue({
data: [{ id: 'batch-email-1' }, { id: 'batch-email-2' }],
error: null,
})
mockAzurePollUntilDone.mockResolvedValue({
status: 'Succeeded',
id: 'azure-email-id',
})
mockAzureBeginSend.mockReturnValue({
pollUntilDone: mockAzurePollUntilDone,
})
})
describe('hasEmailService', () => {
it('should return true when email service is configured', () => {
const result = hasEmailService()
expect(typeof result).toBe('boolean')
})
})
describe('sendEmail', () => {
it('should send a transactional email successfully', async () => {
const result = await sendEmail({
...testEmailOptions,
emailType: 'transactional',
})
expect(result.success).toBe(true)
expect(isUnsubscribed).not.toHaveBeenCalled()
})
it('should check unsubscribe status for marketing emails', async () => {
const result = await sendEmail({
...testEmailOptions,
emailType: 'marketing',
})
expect(result.success).toBe(true)
expect(isUnsubscribed).toHaveBeenCalledWith(testEmailOptions.to, 'marketing')
})
it('should skip sending if user has unsubscribed', async () => {
;(isUnsubscribed as Mock).mockResolvedValue(true)
const result = await sendEmail({
...testEmailOptions,
emailType: 'marketing',
})
expect(result.success).toBe(true)
expect(result.message).toBe('Email skipped (user unsubscribed)')
expect(result.data).toEqual({ id: 'skipped-unsubscribed' })
})
it('should not include unsubscribe when includeUnsubscribe is false', async () => {
await sendEmail({
...testEmailOptions,
emailType: 'marketing',
includeUnsubscribe: false,
})
expect(generateUnsubscribeToken).not.toHaveBeenCalled()
})
it('should handle text-only emails without HTML', async () => {
const result = await sendEmail({
to: 'test@example.com',
subject: 'Text Only',
text: 'Plain text content',
})
expect(result.success).toBe(true)
})
it('should sanitize CRLF characters in subjects before sending', async () => {
const result = await sendEmail({
to: 'test@example.com',
subject: 'Hello\r\nBcc: attacker@evil.com',
text: 'Plain text content',
})
expect(result.success).toBe(true)
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Hello Bcc: attacker@evil.com',
})
)
})
it('should reject reply-to values containing header control characters', async () => {
const result = await sendEmail({
to: 'test@example.com',
subject: 'Test Subject',
text: 'Plain text content',
replyTo: 'user@example.com\r\nBcc: attacker@evil.com',
})
expect(result.success).toBe(false)
expect(result.message).toBe('Failed to send email')
expect(mockSend).not.toHaveBeenCalled()
})
it('should handle multiple recipients as array', async () => {
const recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']
const result = await sendEmail({
...testEmailOptions,
to: recipients,
emailType: 'marketing',
})
expect(result.success).toBe(true)
expect(isUnsubscribed).toHaveBeenCalledWith('user1@example.com', 'marketing')
})
it('should skip sending when the recipient is on the ban list', async () => {
;(isEmailBlockedByAccessControl as Mock).mockReturnValue(true)
const result = await sendEmail({
...testEmailOptions,
emailType: 'transactional',
})
expect(result.success).toBe(true)
expect(result.message).toBe('Email skipped (recipient on access-control ban list)')
expect(result.data).toEqual({ id: 'skipped-banned' })
expect(mockSend).not.toHaveBeenCalled()
expect(isUnsubscribed).not.toHaveBeenCalled()
})
it('should drop only the banned recipients from a multi-recipient send', async () => {
;(isEmailBlockedByAccessControl as Mock).mockImplementation(
(email: string) => email === 'banned@example.com'
)
const result = await sendEmail({
...testEmailOptions,
to: ['good@example.com', 'banned@example.com'],
emailType: 'transactional',
})
expect(result.success).toBe(true)
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ to: 'good@example.com' }))
})
it('should handle general exceptions gracefully', async () => {
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
const result = await sendEmail({
...testEmailOptions,
emailType: 'marketing',
})
expect(result.success).toBe(false)
expect(result.message).toBe('Failed to send email')
})
})
describe('sendBatchEmails', () => {
const testBatchEmails = [
{ ...testEmailOptions, to: 'user1@example.com' },
{ ...testEmailOptions, to: 'user2@example.com' },
]
it('should handle empty batch', async () => {
const result = await sendBatchEmails({ emails: [] })
expect(result.success).toBe(true)
expect(result.results).toHaveLength(0)
})
it('should process multiple emails in batch', async () => {
const result = await sendBatchEmails({ emails: testBatchEmails })
expect(result.success).toBe(true)
expect(result.results.length).toBeGreaterThanOrEqual(0)
})
it('should sanitize CRLF characters in batch email subjects', async () => {
await sendBatchEmails({
emails: [
{
...testEmailOptions,
subject: 'Batch\r\nCc: attacker@evil.com',
},
],
})
expect(mockBatchSend).toHaveBeenCalledWith([
expect.objectContaining({
subject: 'Batch Cc: attacker@evil.com',
}),
])
})
it('should handle transactional emails without unsubscribe check', async () => {
const batchEmails = [
{ ...testEmailOptions, to: 'user1@example.com', emailType: 'transactional' as EmailType },
{ ...testEmailOptions, to: 'user2@example.com', emailType: 'transactional' as EmailType },
]
await sendBatchEmails({ emails: batchEmails })
expect(isUnsubscribed).not.toHaveBeenCalled()
})
it('should skip banned recipients in a batch', async () => {
;(isEmailBlockedByAccessControl as Mock).mockImplementation(
(email: string) => email === 'user2@example.com'
)
const result = await sendBatchEmails({ emails: testBatchEmails })
expect(result.results).toHaveLength(2)
const bannedEntry = result.results.find(
(r) => r.message === 'Email skipped (recipient on access-control ban list)'
)
expect(bannedEntry).toBeDefined()
})
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)
})
})
})
+228
View File
@@ -0,0 +1,228 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
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')
const SKIPPED_UNSUBSCRIBED_RESULT: SendEmailResult = {
success: true,
message: 'Email skipped (user unsubscribed)',
data: { id: 'skipped-unsubscribed' },
}
const MOCK_EMAIL_RESULT: SendEmailResult = {
success: true,
message: 'Email logging successful (no email service configured)',
data: { id: 'mock-email-id' },
}
const SKIPPED_BANNED_RESULT: SendEmailResult = {
success: true,
message: 'Email skipped (recipient on access-control ban list)',
data: { id: 'skipped-banned' },
}
export function hasEmailService(): boolean {
return activeProviders.length > 0
}
/**
* Drop recipients that are on the AppConfig access-control ban list. Returns the
* original options when nothing is banned, options narrowed to the allowed
* recipients when some are, or `null` when every recipient is banned. Config is
* cached (~30s TTL) with an env fallback, so a missing/unreachable AppConfig
* fails open rather than blocking all mail.
*/
async function applyBanList(options: EmailOptions): Promise<EmailOptions | null> {
const recipients = Array.isArray(options.to) ? options.to : [options.to]
const config = await getAccessControlConfig()
const allowed = recipients.filter((email) => !isEmailBlockedByAccessControl(email, config))
if (allowed.length === 0) return null
if (allowed.length === recipients.length) return options
return { ...options, to: allowed.length === 1 ? allowed[0] : allowed }
}
export async function sendEmail(options: EmailOptions): Promise<SendEmailResult> {
try {
const allowed = await applyBanList(options)
if (!allowed) {
logger.info('Email not sent (recipient on access-control ban list):', {
to: options.to,
subject: options.subject,
emailType: options.emailType,
})
return SKIPPED_BANNED_RESULT
}
if (await shouldSkipForUnsubscribe(allowed)) {
logger.info('Email not sent (user unsubscribed):', {
to: allowed.to,
subject: allowed.subject,
emailType: allowed.emailType,
})
return SKIPPED_UNSUBSCRIBED_RESULT
}
const data = processEmailData(allowed)
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
}
return await dispatchWithFallback(data)
} catch (error) {
logger.error('Error sending email:', error)
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)
}
}
logger.error('All email providers failed', lastError)
return {
success: false,
message: `All email providers failed: ${getErrorMessage(lastError, 'unknown error')}`,
}
}
interface PreparedBatchEntry {
index: number
data: ProcessedEmailData | null
skippedResult: SendEmailResult | null
}
async function prepareBatch(emails: EmailOptions[]): Promise<PreparedBatchEntry[]> {
return Promise.all(
emails.map(async (email, index): Promise<PreparedBatchEntry> => {
try {
const allowed = await applyBanList(email)
if (!allowed) {
return { index, data: null, skippedResult: SKIPPED_BANNED_RESULT }
}
if (await shouldSkipForUnsubscribe(allowed)) {
return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT }
}
return { index, data: processEmailData(allowed), 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 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)
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 },
}
}
const batchProvider = activeProviders.find((p) => p.sendBatch)
if (batchProvider) {
try {
const batchResult = await batchProvider.sendBatch!(sendable.map((e) => e.data))
return mergeBatchResults(entries, sendable, batchResult.results)
} catch (error) {
logger.warn(`${batchProvider.name} batch failed, falling back to per-message sends`, error)
}
}
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: [] }
}
}
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 = entries.map(
(entry) => resultsByIndex.get(entry.index) ?? entry.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT
)
// 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>
}
@@ -0,0 +1,530 @@
import { createEnvMock, databaseMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EmailType } from '@/lib/messaging/email/mailer'
vi.mock('drizzle-orm', () => ({
eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })),
}))
const mockDb = databaseMock.db as Record<string, ReturnType<typeof vi.fn>>
vi.mock('@/lib/core/config/env', () => createEnvMock({ BETTER_AUTH_SECRET: 'test-secret-key' }))
import {
generateUnsubscribeToken,
getEmailPreferences,
isTransactionalEmail,
isUnsubscribed,
resubscribe,
unsubscribeFromAll,
updateEmailPreferences,
verifyUnsubscribeToken,
} from '@/lib/messaging/email/unsubscribe'
describe('unsubscribe utilities', () => {
const testEmail = 'test@example.com'
const testEmailType = 'marketing'
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateUnsubscribeToken', () => {
it.concurrent('should generate a token with salt:hash:emailType format', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const parts = token.split(':')
expect(parts).toHaveLength(3)
expect(parts[0]).toHaveLength(32) // Salt should be 32 chars (16 bytes hex)
expect(parts[1]).toHaveLength(64) // SHA256 hash should be 64 chars
expect(parts[2]).toBe(testEmailType)
})
it.concurrent(
'should generate different tokens for the same email (due to random salt)',
() => {
const token1 = generateUnsubscribeToken(testEmail, testEmailType)
const token2 = generateUnsubscribeToken(testEmail, testEmailType)
expect(token1).not.toBe(token2)
}
)
it.concurrent('should default to marketing email type', () => {
const token = generateUnsubscribeToken(testEmail)
const parts = token.split(':')
expect(parts[2]).toBe('marketing')
})
it.concurrent('should generate different tokens for different email types', () => {
const marketingToken = generateUnsubscribeToken(testEmail, 'marketing')
const updatesToken = generateUnsubscribeToken(testEmail, 'updates')
expect(marketingToken).not.toBe(updatesToken)
})
})
describe('verifyUnsubscribeToken', () => {
it.concurrent('should verify a valid token', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const result = verifyUnsubscribeToken(testEmail, token)
expect(result.valid).toBe(true)
expect(result.emailType).toBe(testEmailType)
})
it.concurrent('should reject an invalid token', () => {
const invalidToken = 'invalid:token:format'
const result = verifyUnsubscribeToken(testEmail, invalidToken)
expect(result.valid).toBe(false)
expect(result.emailType).toBe('format')
})
it.concurrent('should reject a token for wrong email', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const result = verifyUnsubscribeToken('wrong@example.com', token)
expect(result.valid).toBe(false)
})
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
const salt = 'abc123'
const secret = 'test-secret-key'
const { createHash } = require('crypto')
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
const legacyToken = `${salt}:${hash}`
const result = verifyUnsubscribeToken(testEmail, legacyToken)
expect(result.valid).toBe(true)
expect(result.emailType).toBe('marketing')
})
it.concurrent('should reject malformed tokens', () => {
const malformedTokens = ['', 'single-part', 'too:many:parts:here:invalid', ':empty:parts:']
malformedTokens.forEach((token) => {
const result = verifyUnsubscribeToken(testEmail, token)
expect(result.valid).toBe(false)
})
})
})
describe('isTransactionalEmail', () => {
it.concurrent('should identify transactional emails correctly', () => {
expect(isTransactionalEmail('transactional')).toBe(true)
})
it.concurrent('should identify non-transactional emails correctly', () => {
const nonTransactionalTypes: EmailType[] = ['marketing', 'updates', 'notifications']
nonTransactionalTypes.forEach((type) => {
expect(isTransactionalEmail(type)).toBe(false)
})
})
})
describe('getEmailPreferences', () => {
it('should return email preferences for a user', async () => {
const mockPreferences = {
unsubscribeAll: false,
unsubscribeMarketing: true,
}
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: mockPreferences }]),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toEqual(mockPreferences)
})
it('should return null when user is not found', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toBeNull()
})
it('should return empty object when emailPreferences is null', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: null }]),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toEqual({})
})
it('should return null on database error', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockRejectedValue(new Error('Database connection failed')),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toBeNull()
})
})
describe('updateEmailPreferences', () => {
it('should update email preferences for existing user', async () => {
const userId = 'user-123'
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: { unsubscribeAll: false } }]),
}),
}),
})
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
}),
})
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(result).toBe(true)
expect(mockDb.insert).toHaveBeenCalled()
})
it('should return false when user is not found', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
})
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(result).toBe(false)
})
it('should merge with existing preferences', async () => {
const userId = 'user-123'
const existingPrefs = { unsubscribeAll: false, unsubscribeUpdates: true }
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: existingPrefs }]),
}),
}),
})
const mockInsertValues = vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
})
mockDb.insert.mockReturnValue({
values: mockInsertValues,
})
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
emailPreferences: {
unsubscribeAll: false,
unsubscribeUpdates: true,
unsubscribeMarketing: true,
},
})
)
})
it('should return false on database error', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockRejectedValue(new Error('Database error')),
}),
}),
})
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(result).toBe(false)
})
})
describe('isUnsubscribed', () => {
it('should return false when user has no preferences', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(false)
})
it('should return true when unsubscribeAll is true', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: { unsubscribeAll: true } }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(true)
})
it('should return true when specific type is unsubscribed', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([
{ emailPreferences: { unsubscribeMarketing: true, unsubscribeUpdates: false } },
]),
}),
}),
}),
})
const resultMarketing = await isUnsubscribed(testEmail, 'marketing')
expect(resultMarketing).toBe(true)
})
it('should return false when specific type is not unsubscribed', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([
{ emailPreferences: { unsubscribeMarketing: false, unsubscribeUpdates: true } },
]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(false)
})
it('should check updates unsubscribe status', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([{ emailPreferences: { unsubscribeUpdates: true } }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'updates')
expect(result).toBe(true)
})
it('should check notifications unsubscribe status', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([{ emailPreferences: { unsubscribeNotifications: true } }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'notifications')
expect(result).toBe(true)
})
it('should return false for unknown email type', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: {} }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'all')
expect(result).toBe(false)
})
it('should return false on database error', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockRejectedValue(new Error('Database error')),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(false)
})
})
describe('unsubscribeFromAll', () => {
it('should call updateEmailPreferences with unsubscribeAll: true', async () => {
const userId = 'user-123'
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: {} }]),
}),
}),
})
const mockInsertValues = vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
})
mockDb.insert.mockReturnValue({
values: mockInsertValues,
})
const result = await unsubscribeFromAll(testEmail)
expect(result).toBe(true)
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
emailPreferences: expect.objectContaining({ unsubscribeAll: true }),
})
)
})
})
describe('resubscribe', () => {
it('should reset all unsubscribe flags to false', async () => {
const userId = 'user-123'
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
emailPreferences: {
unsubscribeAll: true,
unsubscribeMarketing: true,
unsubscribeUpdates: true,
unsubscribeNotifications: true,
},
},
]),
}),
}),
})
const mockInsertValues = vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
})
mockDb.insert.mockReturnValue({
values: mockInsertValues,
})
const result = await resubscribe(testEmail)
expect(result).toBe(true)
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
emailPreferences: {
unsubscribeAll: false,
unsubscribeMarketing: false,
unsubscribeUpdates: false,
unsubscribeNotifications: false,
},
})
)
})
})
})
+195
View File
@@ -0,0 +1,195 @@
import { randomBytes } from 'crypto'
import { db } from '@sim/db'
import { settings, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { eq } from 'drizzle-orm'
import { env } from '@/lib/core/config/env'
import type { EmailType } from '@/lib/messaging/email/mailer'
const logger = createLogger('Unsubscribe')
export interface EmailPreferences {
unsubscribeAll?: boolean
unsubscribeMarketing?: boolean
unsubscribeUpdates?: boolean
unsubscribeNotifications?: boolean
}
/**
* Generate a secure unsubscribe token for an email address
*/
export function generateUnsubscribeToken(email: string, emailType = 'marketing'): string {
const salt = randomBytes(16).toString('hex')
const hash = sha256Hex(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`)
return `${salt}:${hash}:${emailType}`
}
/**
* Verify an unsubscribe token for an email address and return email type
*/
export function verifyUnsubscribeToken(
email: string,
token: string
): { valid: boolean; emailType?: string } {
try {
const parts = token.split(':')
if (parts.length < 2) return { valid: false }
if (parts.length === 2) {
const [salt, expectedHash] = parts
const hash = sha256Hex(`${email}:${salt}:${env.BETTER_AUTH_SECRET}`)
return { valid: hash === expectedHash, emailType: 'marketing' }
}
const [salt, expectedHash, emailType] = parts
if (!salt || !expectedHash || !emailType) return { valid: false }
const hash = sha256Hex(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`)
return { valid: hash === expectedHash, emailType }
} catch (error) {
logger.error('Error verifying unsubscribe token:', error)
return { valid: false }
}
}
/**
* Check if an email type is transactional
*/
export function isTransactionalEmail(emailType: EmailType): boolean {
return emailType === ('transactional' as EmailType)
}
/**
* Get user's email preferences
*/
export async function getEmailPreferences(email: string): Promise<EmailPreferences | null> {
try {
const result = await db
.select({
emailPreferences: settings.emailPreferences,
})
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.email, email))
.limit(1)
if (!result[0]) return null
return (result[0].emailPreferences as EmailPreferences) || {}
} catch (error) {
logger.error('Error getting email preferences:', error)
return null
}
}
/**
* Update user's email preferences
*/
export async function updateEmailPreferences(
email: string,
preferences: EmailPreferences
): Promise<boolean> {
try {
const userResult = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1)
if (!userResult[0]) {
logger.warn(`User not found for email: ${email}`)
return false
}
const userId = userResult[0].id
const existingSettings = await db
.select({ emailPreferences: settings.emailPreferences })
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
let currentEmailPreferences = {}
if (existingSettings[0]) {
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
}
const updatedEmailPreferences = {
...currentEmailPreferences,
...preferences,
}
await db
.insert(settings)
.values({
id: userId,
userId,
emailPreferences: updatedEmailPreferences,
})
.onConflictDoUpdate({
target: settings.userId,
set: {
emailPreferences: updatedEmailPreferences,
updatedAt: new Date(),
},
})
logger.info(`Updated email preferences for user: ${email}`)
return true
} catch (error) {
logger.error('Error updating email preferences:', error)
return false
}
}
/**
* Check if user has unsubscribed from a specific email type
*/
export async function isUnsubscribed(
email: string,
emailType: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
): Promise<boolean> {
try {
const preferences = await getEmailPreferences(email)
if (!preferences) return false
if (preferences.unsubscribeAll) return true
switch (emailType) {
case 'marketing':
return preferences.unsubscribeMarketing || false
case 'updates':
return preferences.unsubscribeUpdates || false
case 'notifications':
return preferences.unsubscribeNotifications || false
default:
return false
}
} catch (error) {
logger.error('Error checking unsubscribe status:', error)
return false
}
}
/**
* Unsubscribe user from all emails
*/
export async function unsubscribeFromAll(email: string): Promise<boolean> {
return updateEmailPreferences(email, { unsubscribeAll: true })
}
/**
* Resubscribe user (remove all unsubscribe flags)
*/
export async function resubscribe(email: string): Promise<boolean> {
return updateEmailPreferences(email, {
unsubscribeAll: false,
unsubscribeMarketing: false,
unsubscribeUpdates: false,
unsubscribeNotifications: false,
})
}
@@ -0,0 +1,71 @@
import { createEnvMock, urlsMock, urlsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EMAIL_HEADER_CONTROL_CHARS_REGEX,
getFromEmailAddress,
hasEmailHeaderControlChars,
NO_EMAIL_HEADER_CONTROL_CHARS_REGEX,
} from './utils'
/**
* Tests for getFromEmailAddress utility function.
*
* These tests verify the function correctly handles different
* environment configurations for email addresses.
*/
// Set up mocks at module level - these will be used for all tests in this file
vi.mock('@/lib/core/config/env', () =>
createEnvMock({
FROM_EMAIL_ADDRESS: 'Sim <noreply@sim.ai>',
EMAIL_DOMAIN: 'example.com',
})
)
vi.mock('@/lib/core/utils/urls', () => urlsMock)
beforeEach(() => {
urlsMockFns.mockGetEmailDomain.mockReturnValue('fallback.com')
})
describe('getFromEmailAddress', () => {
it('should return the configured FROM_EMAIL_ADDRESS', () => {
const result = getFromEmailAddress()
expect(result).toBe('Sim <noreply@sim.ai>')
})
it('should return a valid email format', () => {
const result = getFromEmailAddress()
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('should contain an @ symbol in the email', () => {
const result = getFromEmailAddress()
expect(result.includes('@')).toBe(true)
})
it('should be consistent across multiple calls', () => {
const result1 = getFromEmailAddress()
const result2 = getFromEmailAddress()
expect(result1).toBe(result2)
})
})
describe('email header safety', () => {
it('rejects CRLF characters consistently', () => {
const injectedHeader = 'Hello\r\nBcc: attacker@example.com'
expect(EMAIL_HEADER_CONTROL_CHARS_REGEX.test(injectedHeader)).toBe(true)
expect(hasEmailHeaderControlChars(injectedHeader)).toBe(true)
expect(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX.test(injectedHeader)).toBe(false)
})
it('allows plain header content', () => {
const safeHeader = 'Product feedback'
expect(EMAIL_HEADER_CONTROL_CHARS_REGEX.test(safeHeader)).toBe(false)
expect(hasEmailHeaderControlChars(safeHeader)).toBe(false)
expect(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX.test(safeHeader)).toBe(true)
})
})
+60
View File
@@ -0,0 +1,60 @@
import { env } from '@/lib/core/config/env'
import { getEmailDomain } from '@/lib/core/utils/urls'
export const EMAIL_HEADER_CONTROL_CHARS_REGEX = /[\r\n]/
export const NO_EMAIL_HEADER_CONTROL_CHARS_REGEX = /^[^\r\n]*$/
export function hasEmailHeaderControlChars(value: string): boolean {
return EMAIL_HEADER_CONTROL_CHARS_REGEX.test(value)
}
/**
* Get the from email address, preferring FROM_EMAIL_ADDRESS over EMAIL_DOMAIN
*/
export function getFromEmailAddress(): string {
if (env.FROM_EMAIL_ADDRESS?.trim()) {
return env.FROM_EMAIL_ADDRESS
}
// Fallback to constructing from EMAIL_DOMAIN
return `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
}
/**
* Extract the email address from a "Name <email>" formatted string"
*/
export function extractEmailFromAddress(fromAddress: string): string | undefined {
const match = fromAddress.match(/<([^>]+)>/)
if (match) {
return match[1]
}
if (fromAddress.includes('@') && !fromAddress.includes('<')) {
return fromAddress.trim()
}
return undefined
}
/**
* Get the personal email from address and reply-to. Lifecycle and billing notification
* emails should use `getHelpEmailAddress()` for reply-to instead of the value returned here.
*/
export function getPersonalEmailFrom(): { from: string; replyTo: string | undefined } {
const personalFrom = env.PERSONAL_EMAIL_FROM
if (personalFrom) {
return {
from: personalFrom,
replyTo: extractEmailFromAddress(personalFrom),
}
}
return {
from: getFromEmailAddress(),
replyTo: undefined,
}
}
/**
* Get the shared help inbox address, used as reply-to so replies reach the team rather than an individual
*/
export function getHelpEmailAddress(): string {
return `help@${env.EMAIL_DOMAIN || getEmailDomain()}`
}
@@ -0,0 +1,81 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockResolveMx } = vi.hoisted(() => ({
mockResolveMx: vi.fn(),
}))
vi.mock('dns/promises', () => ({
default: { resolveMx: mockResolveMx },
}))
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
const mx = (...hosts: string[]) =>
hosts.map((exchange, i) => ({ exchange, priority: (i + 1) * 10 }))
describe('validateSignupEmailMx', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('blocks a domain whose MX backend is on the configured denylist', async () => {
mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example'))
const result = await validateSignupEmailMx('user@rotated-domain.test', [
'blocked-backend.example',
])
expect(result.allowed).toBe(false)
expect(result.reason).toBe('blocked_mx_backend')
})
it('matches the denylist as a case-insensitive substring of the MX exchange', async () => {
mockResolveMx.mockResolvedValue(mx('MX1.Blocked-Backend.Example'))
const result = await validateSignupEmailMx('user@another-domain.test', [
'blocked-backend.example',
])
expect(result.allowed).toBe(false)
expect(result.reason).toBe('blocked_mx_backend')
})
it('does not block any backend when the denylist is empty (no hardcoded defaults)', async () => {
mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example'))
const result = await validateSignupEmailMx('user@rotated-domain.test', [])
expect(result.allowed).toBe(true)
})
it('allows a legitimate domain (gmail)', async () => {
mockResolveMx.mockResolvedValue(
mx('gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com')
)
const result = await validateSignupEmailMx('real.person@gmail.com', ['blocked-backend.example'])
expect(result.allowed).toBe(true)
})
it('blocks a domain with no MX records (ENOTFOUND)', async () => {
mockResolveMx.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }))
const result = await validateSignupEmailMx('x@no-such-domain.invalid', [])
expect(result.allowed).toBe(false)
expect(result.reason).toBe('no_mx')
})
it('blocks a domain that resolves to an empty MX set', async () => {
mockResolveMx.mockResolvedValue([])
const result = await validateSignupEmailMx('x@empty.example', [])
expect(result.allowed).toBe(false)
expect(result.reason).toBe('no_mx')
})
it('fails open on a transient DNS error (does not block legit users)', async () => {
mockResolveMx.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
const result = await validateSignupEmailMx('user@some-real-domain.com', [])
expect(result.allowed).toBe(true)
})
it('allows when the email has no domain (defers to other validation)', async () => {
const result = await validateSignupEmailMx('not-an-email', [])
expect(result.allowed).toBe(true)
expect(mockResolveMx).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,87 @@
import type { MxRecord } from 'dns'
import dns from 'dns/promises'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
const logger = createLogger('EmailValidationServer')
const MX_LOOKUP_TIMEOUT_MS = 3000
export interface SignupEmailCheck {
/** Whether the email may proceed to signup. */
allowed: boolean
/** Machine-readable block reason, present only when `allowed` is false. */
reason?: 'no_mx' | 'blocked_mx_backend'
}
/**
* Server-side signup email validation backed by an MX lookup.
*
* Rejects domains that resolve to no mail server (`no_mx`) or to a denylisted
* catch-all backend (`blocked_mx_backend`). Designed to be fail-open: any DNS
* timeout or transient resolver error allows the signup through so legitimate
* users are never blocked by an infrastructure blip. Only a definitive
* "domain has no MX" answer (`ENOTFOUND` / `ENODATA`) blocks.
*
* `blockedMxHosts` are case-insensitive substrings matched against each resolved
* MX exchange — signup-spam botnets rotate throwaway domains but funnel them
* through a few shared catch-all backends, so the MX host is a more stable signal
* than the domain. Sourced from access-control config (AppConfig or env fallback).
*
* Server-only — imports `dns/promises`. Never import from client code. Gated by the caller
* behind `isSignupMxValidationEnabled`; this function performs the check unconditionally.
*/
export async function validateSignupEmailMx(
email: string,
blockedMxHosts: string[]
): Promise<SignupEmailCheck> {
const domain = email.split('@')[1]?.toLowerCase()
if (!domain) return { allowed: true }
let records: MxRecord[]
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
try {
records = await Promise.race([
dns.resolveMx(domain),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error('mx_lookup_timeout')),
MX_LOOKUP_TIMEOUT_MS
)
}),
])
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOTFOUND' || code === 'ENODATA') {
logger.info('Blocked signup: domain has no MX record', { domain })
return { allowed: false, reason: 'no_mx' }
}
logger.warn('MX lookup failed; allowing signup (fail-open)', {
domain,
error: getErrorMessage(error),
})
return { allowed: true }
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle)
}
if (!records || records.length === 0) {
logger.info('Blocked signup: domain has no MX record', { domain })
return { allowed: false, reason: 'no_mx' }
}
const match = records.find((record) => {
const exchange = record.exchange.toLowerCase()
return blockedMxHosts.some((host) => exchange.includes(host))
})
if (match) {
logger.info('Blocked signup: denylisted MX backend', {
domain,
exchange: match.exchange,
})
return { allowed: false, reason: 'blocked_mx_backend' }
}
return { allowed: true }
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
describe('quickValidateEmail', () => {
it.concurrent('should validate a correct email', () => {
const result = quickValidateEmail('user@example.com')
expect(result.isValid).toBe(true)
expect(result.checks.syntax).toBe(true)
expect(result.checks.disposable).toBe(true)
expect(result.checks.mxRecord).toBe(true)
expect(result.confidence).toBe('medium')
})
it.concurrent('should reject invalid syntax', () => {
const result = quickValidateEmail('invalid-email')
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Invalid email format')
})
it.concurrent('should reject disposable email addresses', () => {
const disposableDomains = [
'mailinator.com',
'yopmail.com',
'guerrillamail.com',
'temp-mail.org',
'throwaway.email',
'getnada.com',
'sharklasers.com',
'spam4.me',
'sharebot.net',
'oakon.com',
'catchmail.io',
'salt.email',
'mail.gw',
'tempmail.org',
]
for (const domain of disposableDomains) {
const result = quickValidateEmail(`test@${domain}`)
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Disposable email addresses are not allowed')
expect(result.checks.disposable).toBe(false)
}
})
it.concurrent('should reject consecutive dots (RFC violation)', () => {
const result = quickValidateEmail('user..name@example.com')
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Email contains suspicious patterns')
expect(result.confidence).toBe('medium')
})
it.concurrent('should reject very long local parts (RFC violation)', () => {
const longLocalPart = 'a'.repeat(65)
const result = quickValidateEmail(`${longLocalPart}@example.com`)
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Email contains suspicious patterns')
})
it.concurrent('should reject email with missing domain', () => {
const result = quickValidateEmail('user@')
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Invalid email format')
})
it.concurrent('should reject email with domain starting with dot', () => {
const result = quickValidateEmail('user@.example.com')
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Invalid email format')
})
it.concurrent('should reject email with domain ending with dot', () => {
const result = quickValidateEmail('user@example.')
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Invalid email format')
})
it.concurrent('should reject email with domain missing TLD', () => {
const result = quickValidateEmail('user@localhost')
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Invalid domain format')
})
it.concurrent('should reject email longer than 254 characters', () => {
const longLocal = 'a'.repeat(64)
const longDomain = `${'b'.repeat(180)}.com`
const result = quickValidateEmail(`${longLocal}@${longDomain}`)
expect(result.isValid).toBe(false)
})
it.concurrent('should accept valid email formats', () => {
const validEmails = [
'simple@example.com',
'very.common@example.com',
'disposable.style.email.with+symbol@example.com',
'other.email-with-hyphen@example.com',
'user.name+tag+sorting@example.com',
'x@example.com',
'example-indeed@strange-example.com',
'example@s.example',
]
for (const email of validEmails) {
const result = quickValidateEmail(email)
expect(result.isValid).toBe(true)
expect(result.checks.syntax).toBe(true)
expect(result.checks.disposable).toBe(true)
}
})
it.concurrent('should return high confidence for syntax errors', () => {
const result = quickValidateEmail('not-valid-email')
expect(result.confidence).toBe('high')
})
it.concurrent('should handle special characters in local part', () => {
const result = quickValidateEmail("user!#$%&'*+/=?^_`{|}~@example.com")
expect(result.checks.syntax).toBe(true)
})
it.concurrent('should handle empty string', () => {
const result = quickValidateEmail('')
expect(result.isValid).toBe(false)
expect(result.reason).toBe('Invalid email format')
})
it.concurrent('should handle email with only @ symbol', () => {
const result = quickValidateEmail('@')
expect(result.isValid).toBe(false)
})
it.concurrent('should handle email with spaces', () => {
const result = quickValidateEmail('user name@example.com')
expect(result.isValid).toBe(false)
})
it.concurrent('should handle email with multiple @ symbols', () => {
const result = quickValidateEmail('user@domain@example.com')
expect(result.isValid).toBe(false)
})
it.concurrent('should validate subdomains', () => {
const result = quickValidateEmail('user@mail.subdomain.example.com')
expect(result.isValid).toBe(true)
expect(result.checks.domain).toBe(true)
})
})
+135
View File
@@ -0,0 +1,135 @@
export interface EmailValidationResult {
isValid: boolean
reason?: string
confidence: 'high' | 'medium' | 'low'
checks: {
syntax: boolean
domain: boolean
mxRecord: boolean
disposable: boolean
}
}
/** Common disposable domains for fast client-side feedback */
const DISPOSABLE_DOMAINS = new Set([
'10minutemail.com',
'10minutemail.net',
'catchmail.io',
'dispostable.com',
'emailondeck.com',
'fakemailgenerator.com',
'getnada.com',
'guerrillamail.com',
'guerrillamailblock.com',
'mail.gw',
'mailinator.com',
'oakon.com',
'pokemail.net',
'salt.email',
'sharebot.net',
'sharklasers.com',
'spam4.me',
'temp-mail.org',
'tempail.com',
'tempmail.org',
'tempr.email',
'temporary-mail.net',
'throwaway.email',
'yopmail.com',
])
/**
* Validates email syntax using RFC 5322 compliant regex
*/
function validateEmailSyntax(email: string): boolean {
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
return emailRegex.test(email) && email.length <= 254
}
/**
* Checks if email is from a known disposable email provider
*/
function isDisposableEmail(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase()
return domain ? DISPOSABLE_DOMAINS.has(domain) : false
}
/**
* Checks for obvious patterns that indicate invalid emails
*/
function hasInvalidPatterns(email: string): boolean {
if (email.includes('..')) return true
const localPart = email.split('@')[0]
if (localPart && localPart.length > 64) return true
return false
}
/**
* Quick email validation for client-side form feedback.
*/
export function quickValidateEmail(email: string): EmailValidationResult {
const checks = {
syntax: false,
domain: false,
mxRecord: true,
disposable: false,
}
checks.syntax = validateEmailSyntax(email)
if (!checks.syntax) {
return {
isValid: false,
reason: 'Invalid email format',
confidence: 'high',
checks,
}
}
const domain = email.split('@')[1]?.toLowerCase()
if (!domain) {
return {
isValid: false,
reason: 'Missing domain',
confidence: 'high',
checks,
}
}
checks.disposable = !isDisposableEmail(email)
if (!checks.disposable) {
return {
isValid: false,
reason: 'Disposable email addresses are not allowed',
confidence: 'high',
checks,
}
}
if (hasInvalidPatterns(email)) {
return {
isValid: false,
reason: 'Email contains suspicious patterns',
confidence: 'medium',
checks,
}
}
checks.domain = domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.')
if (!checks.domain) {
return {
isValid: false,
reason: 'Invalid domain format',
confidence: 'high',
checks,
}
}
return {
isValid: true,
confidence: 'medium',
checks,
}
}
+50
View File
@@ -0,0 +1,50 @@
import { createLogger } from '@sim/logger'
import { tasks } from '@trigger.dev/sdk'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import { env } from '@/lib/core/config/env'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('LifecycleEmail')
export const LIFECYCLE_EMAIL_TASK_ID = 'lifecycle-email' as const
/** Supported lifecycle email types. Add new types here as the sequence grows. */
export type LifecycleEmailType = 'onboarding-followup'
interface ScheduleLifecycleEmailOptions {
userId: string
type: LifecycleEmailType
delayDays: number
}
/**
* Schedules a lifecycle email to be sent after a delay.
* Uses Trigger.dev's built-in delay scheduling — no polling or cron needed.
*/
export async function scheduleLifecycleEmail({
userId,
type,
delayDays,
}: ScheduleLifecycleEmailOptions): Promise<void> {
if (!isTriggerDevEnabled || !env.TRIGGER_SECRET_KEY) {
logger.info('[lifecycle] Trigger.dev not configured, skipping lifecycle email', {
userId,
type,
})
return
}
const delayUntil = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000)
await tasks.trigger(
LIFECYCLE_EMAIL_TASK_ID,
{ userId, type },
{
delay: delayUntil,
idempotencyKey: `lifecycle-${type}-${userId}`,
region: await resolveTriggerRegion(),
}
)
logger.info('[lifecycle] Scheduled lifecycle email', { userId, type, delayDays })
}
+185
View File
@@ -0,0 +1,185 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { Twilio } from 'twilio'
import { env } from '@/lib/core/config/env'
const logger = createLogger('SMSService')
export interface SMSOptions {
to: string | string[]
body: string
from?: string
}
interface BatchSMSOptions {
messages: SMSOptions[]
}
interface SMSResponseData {
sid?: string
status?: string
to?: string
from?: string
id?: string
results?: SendSMSResult[]
count?: number
}
export interface SendSMSResult {
success: boolean
message: string
data?: SMSResponseData
}
interface BatchSendSMSResult {
success: boolean
message: string
results: SendSMSResult[]
data?: SMSResponseData
}
const twilioAccountSid = env.TWILIO_ACCOUNT_SID
const twilioAuthToken = env.TWILIO_AUTH_TOKEN
const twilioPhoneNumber = env.TWILIO_PHONE_NUMBER
const twilioClient =
twilioAccountSid &&
twilioAuthToken &&
twilioAccountSid.trim() !== '' &&
twilioAuthToken.trim() !== ''
? new Twilio(twilioAccountSid, twilioAuthToken)
: null
export async function sendSMS(options: SMSOptions): Promise<SendSMSResult> {
try {
const { to, body, from } = options
const fromNumber = from || twilioPhoneNumber
if (!fromNumber || fromNumber.trim() === '') {
logger.error('No Twilio phone number configured')
return {
success: false,
message: 'SMS sending failed: No from phone number configured',
}
}
if (!twilioClient) {
logger.error('SMS sending failed: Twilio not configured', {
to,
body: truncate(body, 50),
from: fromNumber,
})
return {
success: false,
message:
'SMS sending failed: Twilio credentials not configured. Please set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER in your environment variables.',
}
}
if (typeof to === 'string') {
return await sendSingleSMS(to, body, fromNumber)
}
const results: SendSMSResult[] = []
for (const phoneNumber of to) {
try {
const result = await sendSingleSMS(phoneNumber, body, fromNumber)
results.push(result)
} catch (error) {
results.push({
success: false,
message: getErrorMessage(error, 'Failed to send SMS'),
})
}
}
const successCount = results.filter((r) => r.success).length
return {
success: successCount === results.length,
message:
successCount === results.length
? 'All SMS messages sent successfully'
: `${successCount}/${results.length} SMS messages sent successfully`,
data: { results, count: successCount },
}
} catch (error) {
logger.error('Error sending SMS:', error)
return {
success: false,
message: 'Failed to send SMS',
}
}
}
async function sendSingleSMS(to: string, body: string, from: string): Promise<SendSMSResult> {
if (!twilioClient) {
throw new Error('Twilio client not configured')
}
try {
const message = await twilioClient.messages.create({
body,
from,
to,
})
logger.info('SMS sent successfully:', {
to,
from,
messageSid: message.sid,
status: message.status,
})
return {
success: true,
message: 'SMS sent successfully via Twilio',
data: {
sid: message.sid,
status: message.status,
to: message.to,
from: message.from,
},
}
} catch (error) {
logger.error('Failed to send SMS via Twilio:', error)
throw error
}
}
async function sendBatchSMS(options: BatchSMSOptions): Promise<BatchSendSMSResult> {
try {
const results: SendSMSResult[] = []
logger.info('Sending batch SMS messages')
for (const smsOptions of options.messages) {
try {
const result = await sendSMS(smsOptions)
results.push(result)
} catch (error) {
results.push({
success: false,
message: getErrorMessage(error, 'Failed to send SMS'),
})
}
}
const successCount = results.filter((r) => r.success).length
return {
success: successCount === results.length,
message:
successCount === results.length
? 'All batch SMS messages sent successfully'
: `${successCount}/${results.length} SMS messages sent successfully`,
results,
data: { count: successCount },
}
} catch (error) {
logger.error('Error in batch SMS sending:', error)
return {
success: false,
message: 'Failed to send batch SMS messages',
results: [],
}
}
}