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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { EmailOptions, EmailType, ProcessedEmailData } from '@/lib/messaging/email/types'
|
||||
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
|
||||
import { getFromEmailAddress, hasEmailHeaderControlChars } from '@/lib/messaging/email/utils'
|
||||
|
||||
function sanitizeEmailSubject(subject: string): string {
|
||||
return subject.replace(/[\r\n]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
interface UnsubscribeInjection {
|
||||
headers: Record<string, string>
|
||||
html?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function buildUnsubscribeInjection(
|
||||
recipientEmail: string,
|
||||
emailType: EmailType,
|
||||
html?: string,
|
||||
text?: string
|
||||
): UnsubscribeInjection {
|
||||
const token = generateUnsubscribeToken(recipientEmail, emailType)
|
||||
const baseUrl = getBaseUrl()
|
||||
const encodedEmail = encodeURIComponent(recipientEmail)
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${token}&email=${encodedEmail}`
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
html: html
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, token)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
text: text
|
||||
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, token)
|
||||
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
|
||||
}
|
||||
}
|
||||
|
||||
function validateAndSanitize(options: EmailOptions): {
|
||||
senderEmail: string
|
||||
subject: string
|
||||
replyTo?: string
|
||||
} {
|
||||
const senderEmail = options.from || getFromEmailAddress()
|
||||
const recipients = Array.isArray(options.to) ? options.to : [options.to]
|
||||
|
||||
if (recipients.some(hasEmailHeaderControlChars)) {
|
||||
throw new Error('Invalid recipient email header')
|
||||
}
|
||||
if (hasEmailHeaderControlChars(senderEmail)) {
|
||||
throw new Error('Invalid from email header')
|
||||
}
|
||||
if (options.replyTo && hasEmailHeaderControlChars(options.replyTo)) {
|
||||
throw new Error('Invalid reply-to email header')
|
||||
}
|
||||
|
||||
const subject = sanitizeEmailSubject(options.subject)
|
||||
if (subject.length === 0) {
|
||||
throw new Error('Email subject cannot be empty')
|
||||
}
|
||||
|
||||
return { senderEmail, subject, replyTo: options.replyTo }
|
||||
}
|
||||
|
||||
export function processEmailData(options: EmailOptions): ProcessedEmailData {
|
||||
const { senderEmail, subject, replyTo } = validateAndSanitize(options)
|
||||
const {
|
||||
to,
|
||||
html,
|
||||
text,
|
||||
emailType = 'transactional',
|
||||
includeUnsubscribe = true,
|
||||
attachments,
|
||||
} = options
|
||||
|
||||
let finalHtml = html
|
||||
let finalText = text
|
||||
let headers: Record<string, string> = {}
|
||||
|
||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||
const primaryEmail = Array.isArray(to) ? to[0] : to
|
||||
const injection = buildUnsubscribeInjection(primaryEmail, emailType, html, text)
|
||||
headers = injection.headers
|
||||
finalHtml = injection.html
|
||||
finalText = injection.text
|
||||
}
|
||||
|
||||
return {
|
||||
to,
|
||||
subject,
|
||||
html: finalHtml,
|
||||
text: finalText,
|
||||
senderEmail,
|
||||
headers,
|
||||
attachments,
|
||||
replyTo,
|
||||
}
|
||||
}
|
||||
|
||||
export async function shouldSkipForUnsubscribe(options: EmailOptions): Promise<boolean> {
|
||||
const { emailType = 'transactional', to } = options
|
||||
if (emailType === 'transactional') return false
|
||||
const primaryEmail = Array.isArray(to) ? to[0] : to
|
||||
return isUnsubscribed(primaryEmail, emailType)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import type {
|
||||
MailProviderName,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
export async function sendViaNodemailer(
|
||||
transporter: Transporter,
|
||||
data: ProcessedEmailData,
|
||||
provider: MailProviderName
|
||||
): Promise<SendEmailResult> {
|
||||
const info = await transporter.sendMail({
|
||||
from: data.senderEmail,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
html: data.html,
|
||||
text: data.text,
|
||||
replyTo: data.replyTo,
|
||||
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
|
||||
attachments: data.attachments?.map((att) => ({
|
||||
filename: att.filename,
|
||||
content: att.content,
|
||||
contentType: att.contentType,
|
||||
contentDisposition: att.disposition || 'attachment',
|
||||
})),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Email sent successfully via ${provider}`,
|
||||
data: { id: info.messageId },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { EmailClient, type EmailMessage } from '@azure/communication-email'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { MailProvider, ProcessedEmailData, SendEmailResult } from '@/lib/messaging/email/types'
|
||||
|
||||
function extractBareAddress(addressOrFormatted: string): string {
|
||||
if (!addressOrFormatted.includes('<')) return addressOrFormatted
|
||||
return addressOrFormatted.match(/<(.+)>/)?.[1] ?? addressOrFormatted
|
||||
}
|
||||
|
||||
export function createAzureProvider(): MailProvider | null {
|
||||
const connectionString = env.AZURE_ACS_CONNECTION_STRING
|
||||
if (!connectionString || connectionString.trim() === '') return null
|
||||
const client = new EmailClient(connectionString)
|
||||
|
||||
return {
|
||||
name: 'azure',
|
||||
async send(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
if (!data.html && !data.text) {
|
||||
throw new Error('Azure Communication Services requires either HTML or text content')
|
||||
}
|
||||
|
||||
const message: EmailMessage = {
|
||||
senderAddress: extractBareAddress(data.senderEmail),
|
||||
content: data.html
|
||||
? { subject: data.subject, html: data.html }
|
||||
: { subject: data.subject, plainText: data.text as string },
|
||||
recipients: {
|
||||
to: (Array.isArray(data.to) ? data.to : [data.to]).map((address) => ({ address })),
|
||||
},
|
||||
headers: data.headers,
|
||||
}
|
||||
|
||||
const poller = await client.beginSend(message)
|
||||
const result = await poller.pollUntilDone()
|
||||
if (result.status !== 'Succeeded') {
|
||||
throw new Error(`Azure Communication Services failed with status: ${result.status}`)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Azure Communication Services',
|
||||
data: { id: result.id },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { createAzureProvider } from '@/lib/messaging/email/providers/azure'
|
||||
import { createResendProvider } from '@/lib/messaging/email/providers/resend'
|
||||
import { createSesProvider } from '@/lib/messaging/email/providers/ses'
|
||||
import { createSmtpProvider } from '@/lib/messaging/email/providers/smtp'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
const logger = createLogger('MailProviders')
|
||||
|
||||
const factories = [
|
||||
createResendProvider,
|
||||
createSesProvider,
|
||||
createSmtpProvider,
|
||||
createAzureProvider,
|
||||
] as const
|
||||
|
||||
function safeCreate(factory: () => MailProvider | null): MailProvider | null {
|
||||
try {
|
||||
return factory()
|
||||
} catch (error) {
|
||||
logger.error('Mail provider factory threw at startup; skipping', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const activeProviders: readonly MailProvider[] = factories
|
||||
.map((factory) => safeCreate(factory))
|
||||
.filter((provider): provider is MailProvider => provider !== null)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Resend } from 'resend'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type {
|
||||
BatchSendEmailResult,
|
||||
MailProvider,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
function isConfigured(key: string | undefined): key is string {
|
||||
return !!key && key !== 'placeholder' && key.trim() !== ''
|
||||
}
|
||||
|
||||
function toResendPayload(data: ProcessedEmailData) {
|
||||
return {
|
||||
from: data.senderEmail,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
html: data.html,
|
||||
text: data.text,
|
||||
replyTo: data.replyTo,
|
||||
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
|
||||
attachments: data.attachments?.map((att) => ({
|
||||
filename: att.filename,
|
||||
content: typeof att.content === 'string' ? att.content : att.content.toString('base64'),
|
||||
contentType: att.contentType,
|
||||
disposition: att.disposition || 'attachment',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createResendProvider(): MailProvider | null {
|
||||
if (!isConfigured(env.RESEND_API_KEY)) return null
|
||||
const client = new Resend(env.RESEND_API_KEY)
|
||||
|
||||
return {
|
||||
name: 'resend',
|
||||
async send(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
const payload = toResendPayload(data)
|
||||
const { data: responseData, error } = await client.emails.send(payload as never)
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to send email via Resend')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend',
|
||||
data: responseData,
|
||||
}
|
||||
},
|
||||
async sendBatch(emails: ProcessedEmailData[]): Promise<BatchSendEmailResult> {
|
||||
const payloads = emails.map(toResendPayload)
|
||||
const response = await client.batch.send(payloads as never)
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || 'Resend batch API error')
|
||||
}
|
||||
|
||||
const results: SendEmailResult[] = emails.map((_, index) => ({
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend batch',
|
||||
data: { id: `batch-${index}` },
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'All batch emails sent successfully via Resend',
|
||||
results,
|
||||
data: { count: emails.length },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
|
||||
import nodemailer from 'nodemailer'
|
||||
import type SESTransport from 'nodemailer/lib/ses-transport'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
/**
|
||||
* AWS SES via nodemailer's SES transport using the AWS SDK v3 client.
|
||||
* Credentials resolve through the SDK's default provider chain (env vars,
|
||||
* shared config, ECS/EKS task role, EC2 instance profile, SSO).
|
||||
*/
|
||||
export function createSesProvider(): MailProvider | null {
|
||||
const region = env.AWS_SES_REGION
|
||||
if (!region) return null
|
||||
|
||||
const sesClient = new SESv2Client({ region })
|
||||
const sesOptions: SESTransport.Options = {
|
||||
// double-cast-allowed: @types/nodemailer bundles a nested @aws-sdk/client-sesv2 whose nominal class types do not unify with the top-level install
|
||||
SES: { sesClient, SendEmailCommand } as unknown as SESTransport.Options['SES'],
|
||||
}
|
||||
const transporter = nodemailer.createTransport(sesOptions)
|
||||
|
||||
return {
|
||||
name: 'ses',
|
||||
send: (data) => sendViaNodemailer(transporter, data, 'ses'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { env, envBoolean, envNumber } from '@/lib/core/config/env'
|
||||
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
const logger = createLogger('SmtpMailProvider')
|
||||
|
||||
export function createSmtpProvider(): MailProvider | null {
|
||||
const host = env.SMTP_HOST
|
||||
if (!host) return null
|
||||
|
||||
const port = envNumber(env.SMTP_PORT, 0, { min: 1 })
|
||||
if (port === 0) {
|
||||
logger.warn(
|
||||
'SMTP_HOST is set but SMTP_PORT is missing or invalid; skipping SMTP provider. Set SMTP_PORT to 465 (TLS), 587 (STARTTLS), or 25 (plain).'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const user = env.SMTP_USER
|
||||
const pass = env.SMTP_PASS
|
||||
if ((user && !pass) || (!user && pass)) {
|
||||
logger.warn(
|
||||
'SMTP_USER and SMTP_PASS must both be set for authenticated relays; proceeding without auth.'
|
||||
)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: envBoolean(env.SMTP_SECURE) ?? port === 465,
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
name: 'smtp',
|
||||
send: (data) => sendViaNodemailer(transporter, data, 'smtp'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type EmailType = 'transactional' | 'marketing' | 'updates' | 'notifications'
|
||||
|
||||
export interface EmailAttachment {
|
||||
filename: string
|
||||
content: string | Buffer
|
||||
contentType: string
|
||||
disposition?: 'attachment' | 'inline'
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string | string[]
|
||||
subject: string
|
||||
html?: string
|
||||
text?: string
|
||||
from?: string
|
||||
emailType?: EmailType
|
||||
includeUnsubscribe?: boolean
|
||||
attachments?: EmailAttachment[]
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
export interface BatchEmailOptions {
|
||||
emails: EmailOptions[]
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface BatchSendEmailResult {
|
||||
success: boolean
|
||||
message: string
|
||||
results: SendEmailResult[]
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface ProcessedEmailData {
|
||||
to: string | string[]
|
||||
subject: string
|
||||
html?: string
|
||||
text?: string
|
||||
senderEmail: string
|
||||
headers: Record<string, string>
|
||||
attachments?: EmailAttachment[]
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
export type MailProviderName = 'resend' | 'ses' | 'smtp' | 'azure'
|
||||
|
||||
export interface MailProvider {
|
||||
readonly name: MailProviderName
|
||||
send(data: ProcessedEmailData): Promise<SendEmailResult>
|
||||
sendBatch?(emails: ProcessedEmailData[]): Promise<BatchSendEmailResult>
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user