Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ Send emails with a variety of options:
```typescript
import { Buffer } from 'node:buffer'
import fs from 'node:fs'

const result = await emailService.sendEmail({
// Required fields
from: { email: 'sender@example.com', name: 'Sender Name' },
Expand Down
172 changes: 117 additions & 55 deletions src/providers/smtp/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =
rejectUnauthorized: opts.rejectUnauthorized ?? true,
pool: opts.pool ?? false,
maxConnections: opts.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
timeout: opts.timeout ?? DEFAULT_TIMEOUT,
authMethod: opts.authMethod || 'LOGIN', // Assign default to avoid undefined
oauth2: opts.oauth2,
dkim: opts.dkim,
Expand Down Expand Up @@ -94,8 +95,26 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =
const expectedCodes = Array.isArray(expectedCode) ? expectedCode : [expectedCode]
let responseBuffer = ''
let lastLineCode = ''
let timeoutHandle: NodeJS.Timeout

const onData = (data: Buffer) => {
// Declare functions before use
let onData: (data: Buffer) => void
let onError: (err: Error) => void

const cleanup = () => {
socket.removeListener('data', onData)
socket.removeListener('error', onError)
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
}

onError = (err: Error) => {
cleanup()
reject(createError(PROVIDER_NAME, `Socket error: ${err.message}`, { cause: err }))
}

onData = (data: Buffer) => {
responseBuffer += data.toString()
// SMTP çok satırlı yanıtlar: 250-...\r\n, son satır 250 ...\r\n
// Her satırı kontrol et
Expand All @@ -107,7 +126,7 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =
lastLineCode = match[1]
// Son satırda boşluk varsa (multi-line bitti)
if (lastLine[3] === ' ') {
socket.removeListener('data', onData)
cleanup()
if (expectedCodes.includes(lastLineCode)) {
resolve(responseBuffer)
}
Expand All @@ -119,7 +138,14 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =
}
}

// Set up timeout
timeoutHandle = setTimeout(() => {
cleanup()
reject(createError(PROVIDER_NAME, `Command timeout after ${options.timeout}ms: ${command?.substring(0, 50)}...`))
}, options.timeout)

socket.on('data', onData)
socket.on('error', onError)

if (command) {
socket.write(`${command}\r\n`)
Expand Down Expand Up @@ -174,12 +200,12 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =
: net.createConnection(options.port, options.host)

// Set timeout
socket.setTimeout(DEFAULT_TIMEOUT)
socket.setTimeout(options.timeout)

// Handle connection timeout
socket.on('timeout', () => {
socket.destroy()
reject(createError(PROVIDER_NAME, `Connection timeout to ${options.host}:${options.port}`))
reject(createError(PROVIDER_NAME, `Connection timeout to ${options.host}:${options.port} after ${options.timeout}ms`))
})

// Handle errors
Expand Down Expand Up @@ -224,7 +250,7 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =
const tlsSocket = tls.connect(tlsOptions)

// Set timeout
tlsSocket.setTimeout(DEFAULT_TIMEOUT)
tlsSocket.setTimeout(options.timeout)

// Handle TLS connection errors
tlsSocket.on('error', (err) => {
Expand All @@ -234,7 +260,7 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =
// Handle timeout
tlsSocket.on('timeout', () => {
tlsSocket.destroy()
reject(createError(PROVIDER_NAME, 'TLS connection timeout'))
reject(createError(PROVIDER_NAME, `TLS connection timeout after ${options.timeout}ms`))
})

// Resolve when secure connection is established
Expand Down Expand Up @@ -339,68 +365,104 @@ export const smtpProvider: ProviderFactory<SmtpConfig, any, SmtpEmailOptions> =

// Handle OAUTH2 authentication if configured
if (authMethod === 'OAUTH2' && options.oauth2) {
const { user, accessToken } = options.oauth2
const auth = `user=${user}\x01auth=Bearer ${accessToken}\x01\x01`
const authBase64 = Buffer.from(auth).toString('base64')
try {
const { user, accessToken } = options.oauth2
const auth = `user=${user}\x01auth=Bearer ${accessToken}\x01\x01`
const authBase64 = Buffer.from(auth).toString('base64')

await sendSmtpCommand(socket, `AUTH XOAUTH2 ${authBase64}`, '235')
return
await sendSmtpCommand(socket, `AUTH XOAUTH2 ${authBase64}`, '235')
return
}
catch (error) {
const errorMessage = (error as Error).message
if (errorMessage.includes('535') || errorMessage.includes('Authentication failed')) {
throw createError(PROVIDER_NAME, 'Authentication failed: Invalid OAuth2 credentials')
}
throw error
}
}

// Handle CRAM-MD5 authentication
if (authMethod === 'CRAM-MD5' && options.password) {
// Request challenge from server
const response = await sendSmtpCommand(socket, 'AUTH CRAM-MD5', '334')

// Decode challenge
const challenge = Buffer.from(response.split(' ')[1], 'base64').toString('utf-8')

// Calculate HMAC digest
const hmac = crypto.createHmac('md5', options.password)
hmac.update(challenge)
const digest = hmac.digest('hex')

// Respond with username and digest
const cramResponse = `${options.user} ${digest}`
await sendSmtpCommand(
socket,
Buffer.from(cramResponse).toString('base64'),
'235',
)
return
try {
// Request challenge from server
const response = await sendSmtpCommand(socket, 'AUTH CRAM-MD5', '334')

// Decode challenge
const challenge = Buffer.from(response.split(' ')[1], 'base64').toString('utf-8')

// Calculate HMAC digest
const hmac = crypto.createHmac('md5', options.password)
hmac.update(challenge)
const digest = hmac.digest('hex')

// Respond with username and digest
const cramResponse = `${options.user} ${digest}`
await sendSmtpCommand(
socket,
Buffer.from(cramResponse).toString('base64'),
'235',
)
return
}
catch (error) {
const errorMessage = (error as Error).message
if (errorMessage.includes('535') || errorMessage.includes('Authentication failed')) {
throw createError(PROVIDER_NAME, 'Authentication failed: Invalid username or password')
}
throw error
}
}

// Handle LOGIN authentication
if (authMethod === 'LOGIN' && options.password) {
// Send AUTH command
await sendSmtpCommand(socket, 'AUTH LOGIN', '334')

// Send username (base64 encoded)
await sendSmtpCommand(
socket,
Buffer.from(options.user).toString('base64'),
'334',
)

// Send password (base64 encoded)
await sendSmtpCommand(
socket,
Buffer.from(options.password).toString('base64'),
'235',
)
return
try {
// Send AUTH command
await sendSmtpCommand(socket, 'AUTH LOGIN', '334')

// Send username (base64 encoded)
await sendSmtpCommand(
socket,
Buffer.from(options.user).toString('base64'),
'334',
)

// Send password (base64 encoded)
await sendSmtpCommand(
socket,
Buffer.from(options.password).toString('base64'),
'235',
)
return
}
catch (error) {
const errorMessage = (error as Error).message
if (errorMessage.includes('535') || errorMessage.includes('Authentication failed')) {
throw createError(PROVIDER_NAME, 'Authentication failed: Invalid username or password')
}
throw error
}
}

// Handle PLAIN authentication (fallback)
if (authMethod === 'PLAIN' && options.password) {
// Send AUTH PLAIN command with credentials
const authPlain = Buffer.from(`\0${options.user}\0${options.password}`).toString('base64')
await sendSmtpCommand(
socket,
`AUTH PLAIN ${authPlain}`,
'235',
)
return
try {
// Send AUTH PLAIN command with credentials
const authPlain = Buffer.from(`\0${options.user}\0${options.password}`).toString('base64')
await sendSmtpCommand(
socket,
`AUTH PLAIN ${authPlain}`,
'235',
)
return
}
catch (error) {
const errorMessage = (error as Error).message
if (errorMessage.includes('535') || errorMessage.includes('Authentication failed')) {
throw createError(PROVIDER_NAME, 'Authentication failed: Invalid username or password')
}
throw error
}
}

throw createError(PROVIDER_NAME, 'Authentication failed - no valid credentials or method')
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export interface SmtpConfig {
rejectUnauthorized?: boolean // Whether to verify TLS certificate
pool?: boolean // Enable connection pooling
maxConnections?: number // Maximum connections for pooling
timeout?: number // Connection and command timeout in milliseconds
dkim?: { // DKIM signing configuration
domainName: string
keySelector: string
Expand Down
58 changes: 58 additions & 0 deletions test/smtp-auth-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { createEmailService } from '../src/core.ts'
import smtpProvider from '../src/providers/smtp/index.ts'

describe('smtp Authentication Timeout', () => {
it('should timeout and return error when using incorrect credentials', async () => {
// Create email service with wrong credentials and short timeout
const emailService = createEmailService({
provider: smtpProvider({
host: 'smtp.office365.com',
port: 587,
secure: false,
user: 'test@example.com',
password: 'wrongpassword',
timeout: 2000, // 1 second timeout
}),
})

// Try to send an email
const result = await emailService.sendEmail({
from: { email: 'test@example.com' },
to: { email: 'recipient@example.com' },
subject: 'Test Email',
text: 'This should fail due to wrong credentials',
})

// Verify that it returns an error
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
expect(result.error?.message).toMatch(/(Authentication failed|timeout|Connection)/i)
}, 4000) // Test timeout of 4 seconds

it('should handle authentication errors gracefully', async () => {
// Create email service with invalid credentials to a test SMTP server
const emailService = createEmailService({
provider: smtpProvider({
host: 'localhost',
port: 1025,
secure: false,
user: 'testuser',
password: 'wrongpassword',
timeout: 3000, // 3 second timeout
}),
})

// Try to send an email
const result = await emailService.sendEmail({
from: { email: 'test@example.com' },
to: { email: 'recipient@example.com' },
subject: 'Test Email',
text: 'Testing authentication error handling',
})

// If the SMTP server is not available, it should return an error
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
}, 6000)
})
Loading