update
This commit is contained in:
@ -0,0 +1,522 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from './plugins.js';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
tap.test('CRFC-03: should comply with SMTP command syntax (RFC 5321)', async (tools) => {
|
||||
const testId = 'CRFC-03-command-syntax';
|
||||
console.log(`\n${testId}: Testing SMTP command syntax compliance...`);
|
||||
|
||||
let scenarioCount = 0;
|
||||
|
||||
// Scenario 1: EHLO/HELO command syntax
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing EHLO/HELO command syntax`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 syntax.example.com ESMTP\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.match(/^EHLO\s+[^\s]+$/i)) {
|
||||
const domain = command.split(' ')[1];
|
||||
console.log(` [Server] Valid EHLO with domain: ${domain}`);
|
||||
|
||||
// Validate domain format (basic check)
|
||||
if (domain.includes('.') || domain === 'localhost' || domain.match(/^\[[\d\.]+\]$/)) {
|
||||
socket.write('250-syntax.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else {
|
||||
socket.write('501 5.5.4 Invalid domain name\r\n');
|
||||
}
|
||||
} else if (command.match(/^HELO\s+[^\s]+$/i)) {
|
||||
const domain = command.split(' ')[1];
|
||||
console.log(` [Server] Valid HELO with domain: ${domain}`);
|
||||
socket.write('250 syntax.example.com\r\n');
|
||||
} else if (command === 'EHLO' || command === 'HELO') {
|
||||
console.log(' [Server] Missing domain parameter');
|
||||
socket.write('501 5.5.4 EHLO/HELO requires domain name\r\n');
|
||||
} else if (command.startsWith('EHLO ') && command.split(' ').length > 2) {
|
||||
console.log(' [Server] Too many parameters');
|
||||
socket.write('501 5.5.4 EHLO syntax error\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('500 5.5.1 Command not recognized\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
name: 'client.example.com' // Valid domain
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'EHLO syntax test',
|
||||
text: 'Testing proper EHLO syntax'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Valid EHLO syntax accepted');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 2: MAIL FROM command syntax
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing MAIL FROM command syntax`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 syntax.example.com ESMTP\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-syntax.example.com\r\n');
|
||||
socket.write('250-SIZE 10485760\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.match(/^MAIL FROM:\s*<[^>]*>(\s+[A-Z0-9]+=\S*)*\s*$/i)) {
|
||||
// Valid MAIL FROM syntax with optional parameters
|
||||
const address = command.match(/<([^>]*)>/)?.[1] || '';
|
||||
console.log(` [Server] Valid MAIL FROM: ${address}`);
|
||||
|
||||
// Validate email address format
|
||||
if (address === '' || address.includes('@') || address === 'postmaster') {
|
||||
// Check for ESMTP parameters
|
||||
const params = command.substring(command.indexOf('>') + 1).trim();
|
||||
if (params) {
|
||||
console.log(` [Server] ESMTP parameters: ${params}`);
|
||||
|
||||
// Validate parameter syntax
|
||||
const validParams = /^(\s+[A-Z0-9]+=\S*)*\s*$/i.test(params);
|
||||
if (validParams) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else {
|
||||
socket.write('501 5.5.4 Invalid MAIL FROM parameters\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('553 5.1.8 Invalid sender address\r\n');
|
||||
}
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
console.log(' [Server] Invalid MAIL FROM syntax');
|
||||
if (!command.includes('<') || !command.includes('>')) {
|
||||
socket.write('501 5.5.4 MAIL FROM requires <address>\r\n');
|
||||
} else {
|
||||
socket.write('501 5.5.4 Syntax error in MAIL FROM\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with various sender formats
|
||||
const testCases = [
|
||||
{ from: 'sender@example.com', desc: 'normal address' },
|
||||
{ from: '', desc: 'null sender (bounce)' },
|
||||
{ from: 'postmaster', desc: 'postmaster without domain' },
|
||||
{ from: 'user+tag@example.com', desc: 'address with plus extension' }
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
console.log(` Testing ${testCase.desc}...`);
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: testCase.from || 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `MAIL FROM syntax test: ${testCase.desc}`,
|
||||
text: `Testing MAIL FROM with ${testCase.desc}`
|
||||
});
|
||||
|
||||
// For null sender, modify the envelope
|
||||
if (testCase.from === '') {
|
||||
email.envelope = { from: '', to: ['recipient@example.com'] };
|
||||
}
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 3: RCPT TO command syntax
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing RCPT TO command syntax`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 syntax.example.com ESMTP\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-syntax.example.com\r\n');
|
||||
socket.write('250-DSN\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.match(/^RCPT TO:\s*<[^>]*>(\s+[A-Z0-9]+=\S*)*\s*$/i)) {
|
||||
// Valid RCPT TO syntax with optional parameters
|
||||
const address = command.match(/<([^>]*)>/)?.[1] || '';
|
||||
console.log(` [Server] Valid RCPT TO: ${address}`);
|
||||
|
||||
// Validate recipient address
|
||||
if (address.includes('@') && address.split('@').length === 2) {
|
||||
// Check for DSN parameters
|
||||
const params = command.substring(command.indexOf('>') + 1).trim();
|
||||
if (params) {
|
||||
console.log(` [Server] DSN parameters: ${params}`);
|
||||
|
||||
// Validate NOTIFY and ORCPT parameters
|
||||
if (params.includes('NOTIFY=') || params.includes('ORCPT=')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else {
|
||||
socket.write('501 5.5.4 Invalid RCPT TO parameters\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('553 5.1.3 Invalid recipient address\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
console.log(' [Server] Invalid RCPT TO syntax');
|
||||
if (!command.includes('<') || !command.includes('>')) {
|
||||
socket.write('501 5.5.4 RCPT TO requires <address>\r\n');
|
||||
} else {
|
||||
socket.write('501 5.5.4 Syntax error in RCPT TO\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with various recipient formats
|
||||
const recipients = [
|
||||
'user@example.com',
|
||||
'user.name@example.com',
|
||||
'user+tag@example.com',
|
||||
'user_name@sub.example.com'
|
||||
];
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: recipients,
|
||||
subject: 'RCPT TO syntax test',
|
||||
text: 'Testing RCPT TO command syntax'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(` Valid RCPT TO syntax for ${recipients.length} recipients`);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.accepted?.length).toBe(recipients.length);
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 4: DATA command and message termination
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing DATA command and message termination`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 syntax.example.com ESMTP\r\n');
|
||||
|
||||
let inDataMode = false;
|
||||
let messageData = '';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
if (inDataMode) {
|
||||
messageData += data.toString();
|
||||
|
||||
// Check for proper message termination
|
||||
if (messageData.includes('\r\n.\r\n')) {
|
||||
inDataMode = false;
|
||||
console.log(' [Server] Message terminated with CRLF.CRLF');
|
||||
|
||||
// Check for transparency (dot stuffing)
|
||||
const lines = messageData.split('\r\n');
|
||||
let hasDotStuffing = false;
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('..')) {
|
||||
hasDotStuffing = true;
|
||||
console.log(' [Server] Found dot stuffing in line');
|
||||
}
|
||||
});
|
||||
|
||||
socket.write('250 OK: Message accepted\r\n');
|
||||
messageData = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-syntax.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
console.log(' [Server] Entering DATA mode');
|
||||
socket.write('354 Start mail input; end with <CRLF>.<CRLF>\r\n');
|
||||
inDataMode = true;
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with message containing dots at line start (transparency test)
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'DATA transparency test',
|
||||
text: 'Line 1\n.This line starts with a dot\n..This line starts with two dots\nLine 4'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' DATA command and transparency handled correctly');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 5: RSET command syntax
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing RSET command syntax`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 syntax.example.com ESMTP\r\n');
|
||||
|
||||
let transactionState = 'initial';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command} (state: ${transactionState})`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-syntax.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
transactionState = 'ready';
|
||||
} else if (command.startsWith('MAIL FROM:') && transactionState === 'ready') {
|
||||
socket.write('250 OK\r\n');
|
||||
transactionState = 'mail';
|
||||
} else if (command.startsWith('RCPT TO:') && transactionState === 'mail') {
|
||||
socket.write('250 OK\r\n');
|
||||
transactionState = 'rcpt';
|
||||
} else if (command === 'RSET') {
|
||||
console.log(' [Server] RSET - resetting transaction state');
|
||||
socket.write('250 OK\r\n');
|
||||
transactionState = 'ready';
|
||||
} else if (command.match(/^RSET\s+/)) {
|
||||
console.log(' [Server] RSET with parameters - syntax error');
|
||||
socket.write('501 5.5.4 RSET does not accept parameters\r\n');
|
||||
} else if (command === 'DATA' && transactionState === 'rcpt') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
transactionState = 'ready';
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Start a transaction then reset it
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'RSET test',
|
||||
text: 'Testing RSET command'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' RSET command syntax validated');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 6: Command line length limits
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing command line length limits`);
|
||||
|
||||
const maxLineLength = 512; // RFC 5321 limit
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 syntax.example.com ESMTP\r\n');
|
||||
|
||||
let lineBuffer = '';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
lineBuffer += data.toString();
|
||||
|
||||
const lines = lineBuffer.split('\r\n');
|
||||
lineBuffer = lines.pop() || ''; // Keep incomplete line
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.length === 0) return;
|
||||
|
||||
console.log(` [Server] Line length: ${line.length} chars`);
|
||||
|
||||
if (line.length > maxLineLength) {
|
||||
console.log(' [Server] Line too long');
|
||||
socket.write('500 5.5.1 Line too long\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('EHLO')) {
|
||||
socket.write('250-syntax.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (line.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (line.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (line === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (line === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (line === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with normal length commands
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Line length test',
|
||||
text: 'Testing command line length limits'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Normal command lengths accepted');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Test with very long recipient address
|
||||
const longRecipient = 'very-long-username-that-exceeds-normal-limits@' + 'x'.repeat(400) + '.com';
|
||||
|
||||
const longEmail = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: [longRecipient],
|
||||
subject: 'Long recipient test',
|
||||
text: 'Testing very long recipient address'
|
||||
});
|
||||
|
||||
try {
|
||||
await smtpClient.sendMail(longEmail);
|
||||
console.log(' Long command handled (possibly folded)');
|
||||
} catch (error) {
|
||||
console.log(' Long command rejected as expected');
|
||||
expect(error.message).toContain('too long');
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
console.log(`\n${testId}: All ${scenarioCount} command syntax scenarios tested ✓`);
|
||||
});
|
@ -0,0 +1,511 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from './plugins.js';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
tap.test('CRFC-04: should handle SMTP response codes correctly (RFC 5321)', async (tools) => {
|
||||
const testId = 'CRFC-04-response-codes';
|
||||
console.log(`\n${testId}: Testing SMTP response code compliance...`);
|
||||
|
||||
let scenarioCount = 0;
|
||||
|
||||
// Scenario 1: 2xx success response codes
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing 2xx success response codes`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 responses.example.com Service ready\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
// 250 - Requested mail action okay, completed
|
||||
socket.write('250-responses.example.com\r\n');
|
||||
socket.write('250-SIZE 10485760\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// 250 - Requested mail action okay, completed
|
||||
socket.write('250 2.1.0 Sender OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
// 250 - Requested mail action okay, completed
|
||||
socket.write('250 2.1.5 Recipient OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
// 354 - Start mail input; end with <CRLF>.<CRLF>
|
||||
socket.write('354 Start mail input; end with <CRLF>.<CRLF>\r\n');
|
||||
} else if (command === '.') {
|
||||
// 250 - Requested mail action okay, completed
|
||||
socket.write('250 2.0.0 Message accepted for delivery\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
// 221 - Service closing transmission channel
|
||||
socket.write('221 2.0.0 Service closing transmission channel\r\n');
|
||||
socket.end();
|
||||
} else if (command === 'NOOP') {
|
||||
// 250 - Requested mail action okay, completed
|
||||
socket.write('250 2.0.0 OK\r\n');
|
||||
} else if (command === 'RSET') {
|
||||
// 250 - Requested mail action okay, completed
|
||||
socket.write('250 2.0.0 Reset OK\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: '2xx response test',
|
||||
text: 'Testing 2xx success response codes'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' All 2xx success codes handled correctly');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 2: 4xx temporary failure response codes
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing 4xx temporary failure response codes`);
|
||||
|
||||
let attemptCount = 0;
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
attemptCount++;
|
||||
console.log(` [Server] Client connected (attempt ${attemptCount})`);
|
||||
socket.write('220 responses.example.com Service ready\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-responses.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
if (attemptCount === 1) {
|
||||
// 451 - Requested action aborted: local error in processing
|
||||
socket.write('451 4.3.0 Temporary system failure, try again later\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
|
||||
if (address.includes('full')) {
|
||||
// 452 - Requested action not taken: insufficient system storage
|
||||
socket.write('452 4.2.2 Mailbox full, try again later\r\n');
|
||||
} else if (address.includes('busy')) {
|
||||
// 450 - Requested mail action not taken: mailbox unavailable
|
||||
socket.write('450 4.2.1 Mailbox busy, try again later\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
if (attemptCount === 1) {
|
||||
// 421 - Service not available, closing transmission channel
|
||||
socket.write('421 4.3.2 System shutting down, try again later\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test temporary failures with retry
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// First attempt with temporary failure
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: '4xx response test',
|
||||
text: 'Testing 4xx temporary failure codes'
|
||||
});
|
||||
|
||||
try {
|
||||
await smtpClient.sendMail(email);
|
||||
console.log(' Unexpected: First attempt succeeded');
|
||||
} catch (error) {
|
||||
console.log(' Expected: Temporary failure on first attempt');
|
||||
expect(error.responseCode).toBeGreaterThanOrEqual(400);
|
||||
expect(error.responseCode).toBeLessThan(500);
|
||||
}
|
||||
|
||||
// Second attempt should succeed
|
||||
const retryResult = await smtpClient.sendMail(email);
|
||||
console.log(' Retry after temporary failure succeeded');
|
||||
expect(retryResult).toBeDefined();
|
||||
|
||||
// Test specific 4xx codes
|
||||
const tempFailureEmail = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['full@example.com', 'busy@example.com'],
|
||||
subject: 'Specific 4xx test',
|
||||
text: 'Testing specific temporary failure codes'
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await smtpClient.sendMail(tempFailureEmail);
|
||||
console.log(` Partial delivery: ${result.rejected?.length || 0} rejected`);
|
||||
expect(result.rejected?.length).toBeGreaterThan(0);
|
||||
} catch (error) {
|
||||
console.log(' Multiple 4xx failures handled');
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 3: 5xx permanent failure response codes
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing 5xx permanent failure response codes`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 responses.example.com Service ready\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-responses.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
|
||||
if (address.includes('blocked')) {
|
||||
// 550 - Requested action not taken: mailbox unavailable
|
||||
socket.write('550 5.1.1 Sender blocked\r\n');
|
||||
} else if (address.includes('invalid')) {
|
||||
// 553 - Requested action not taken: mailbox name not allowed
|
||||
socket.write('553 5.1.8 Invalid sender address format\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
|
||||
if (address.includes('unknown')) {
|
||||
// 550 - Requested action not taken: mailbox unavailable
|
||||
socket.write('550 5.1.1 User unknown\r\n');
|
||||
} else if (address.includes('disabled')) {
|
||||
// 551 - User not local; please try <forward-path>
|
||||
socket.write('551 5.1.6 User account disabled\r\n');
|
||||
} else if (address.includes('relay')) {
|
||||
// 554 - Transaction failed
|
||||
socket.write('554 5.7.1 Relay access denied\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else if (command.startsWith('INVALID')) {
|
||||
// 500 - Syntax error, command unrecognized
|
||||
socket.write('500 5.5.1 Command not recognized\r\n');
|
||||
} else if (command === 'MAIL') {
|
||||
// 501 - Syntax error in parameters or arguments
|
||||
socket.write('501 5.5.4 Syntax error in MAIL command\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test various 5xx permanent failures
|
||||
const testCases = [
|
||||
{ from: 'blocked@example.com', to: 'recipient@example.com', desc: 'blocked sender' },
|
||||
{ from: 'sender@example.com', to: 'unknown@example.com', desc: 'unknown recipient' },
|
||||
{ from: 'sender@example.com', to: 'disabled@example.com', desc: 'disabled user' },
|
||||
{ from: 'sender@example.com', to: 'relay@external.com', desc: 'relay denied' }
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
console.log(` Testing ${testCase.desc}...`);
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: testCase.from,
|
||||
to: [testCase.to],
|
||||
subject: `5xx test: ${testCase.desc}`,
|
||||
text: `Testing 5xx permanent failure: ${testCase.desc}`
|
||||
});
|
||||
|
||||
try {
|
||||
await smtpClient.sendMail(email);
|
||||
console.log(` Unexpected: ${testCase.desc} succeeded`);
|
||||
} catch (error) {
|
||||
console.log(` Expected: ${testCase.desc} failed with 5xx`);
|
||||
expect(error.responseCode).toBeGreaterThanOrEqual(500);
|
||||
expect(error.responseCode).toBeLessThan(600);
|
||||
}
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 4: Multi-line response handling
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing multi-line response handling`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220-responses.example.com ESMTP Service Ready\r\n');
|
||||
socket.write('220-This server supports multiple extensions\r\n');
|
||||
socket.write('220 Please proceed with EHLO\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
// Multi-line EHLO response
|
||||
socket.write('250-responses.example.com Hello client\r\n');
|
||||
socket.write('250-SIZE 10485760\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-STARTTLS\r\n');
|
||||
socket.write('250-ENHANCEDSTATUSCODES\r\n');
|
||||
socket.write('250-PIPELINING\r\n');
|
||||
socket.write('250-DSN\r\n');
|
||||
socket.write('250 HELP\r\n'); // Last line ends with space
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input; end with <CRLF>.<CRLF>\r\n');
|
||||
} else if (command === '.') {
|
||||
// Multi-line success response
|
||||
socket.write('250-Message accepted for delivery\r\n');
|
||||
socket.write('250-Queue ID: ABC123\r\n');
|
||||
socket.write('250 Thank you\r\n');
|
||||
} else if (command === 'HELP') {
|
||||
// Multi-line help response
|
||||
socket.write('214-This server supports the following commands:\r\n');
|
||||
socket.write('214-EHLO HELO MAIL RCPT DATA\r\n');
|
||||
socket.write('214-RSET NOOP QUIT HELP\r\n');
|
||||
socket.write('214 For more info visit http://example.com/help\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221-Thank you for using our service\r\n');
|
||||
socket.write('221 Goodbye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Multi-line response test',
|
||||
text: 'Testing multi-line SMTP response handling'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Multi-line responses handled correctly');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.response).toContain('Queue ID');
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 5: Response code format validation
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing response code format validation`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 responses.example.com ESMTP\r\n');
|
||||
|
||||
let commandCount = 0;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
commandCount++;
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-responses.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Test various response code formats
|
||||
if (commandCount === 2) {
|
||||
// Valid 3-digit code
|
||||
socket.write('250 OK\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
|
||||
if (address.includes('enhanced')) {
|
||||
// Enhanced status code format (RFC 3463)
|
||||
socket.write('250 2.1.5 Recipient OK\r\n');
|
||||
} else if (address.includes('detailed')) {
|
||||
// Detailed response with explanation
|
||||
socket.write('250 OK: Recipient accepted for delivery to local mailbox\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input; end with <CRLF>.<CRLF>\r\n');
|
||||
} else if (command === '.') {
|
||||
// Response with timestamp
|
||||
const timestamp = new Date().toISOString();
|
||||
socket.write(`250 OK: Message accepted at ${timestamp}\r\n`);
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Service closing transmission channel\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with recipients that trigger different response formats
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['enhanced@example.com', 'detailed@example.com', 'normal@example.com'],
|
||||
subject: 'Response format test',
|
||||
text: 'Testing SMTP response code format compliance'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Various response code formats handled');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.response).toContain('Message accepted');
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 6: Error recovery and continuation
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing error recovery and continuation`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 responses.example.com ESMTP\r\n');
|
||||
|
||||
let errorCount = 0;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-responses.example.com\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
|
||||
if (address.includes('error1')) {
|
||||
errorCount++;
|
||||
socket.write('550 5.1.1 First error - user unknown\r\n');
|
||||
} else if (address.includes('error2')) {
|
||||
errorCount++;
|
||||
socket.write('551 5.1.6 Second error - user not local\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
if (errorCount > 0) {
|
||||
console.log(` [Server] ${errorCount} errors occurred, but continuing`);
|
||||
}
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write(`250 OK: Message accepted despite ${errorCount} recipient errors\r\n`);
|
||||
} else if (command === 'RSET') {
|
||||
console.log(' [Server] Transaction reset');
|
||||
errorCount = 0;
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
// Unknown command
|
||||
socket.write('500 5.5.1 Command not recognized\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with mix of valid and invalid recipients
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['error1@example.com', 'valid@example.com', 'error2@example.com', 'another-valid@example.com'],
|
||||
subject: 'Error recovery test',
|
||||
text: 'Testing error handling and recovery'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(` Partial delivery: ${result.accepted?.length || 0} accepted, ${result.rejected?.length || 0} rejected`);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.accepted?.length).toBeGreaterThan(0);
|
||||
expect(result.rejected?.length).toBeGreaterThan(0);
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
console.log(`\n${testId}: All ${scenarioCount} response code scenarios tested ✓`);
|
||||
});
|
@ -0,0 +1,701 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from './plugins.js';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
tap.test('CRFC-05: should comply with SMTP state machine (RFC 5321)', async (tools) => {
|
||||
const testId = 'CRFC-05-state-machine';
|
||||
console.log(`\n${testId}: Testing SMTP state machine compliance...`);
|
||||
|
||||
let scenarioCount = 0;
|
||||
|
||||
// Scenario 1: Initial state and greeting
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing initial state and greeting`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected - Initial state');
|
||||
|
||||
let state = 'initial';
|
||||
|
||||
// Send greeting immediately upon connection
|
||||
socket.write('220 statemachine.example.com ESMTP Service ready\r\n');
|
||||
state = 'greeting-sent';
|
||||
console.log(' [Server] State: initial -> greeting-sent');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] State: ${state}, Received: ${command}`);
|
||||
|
||||
if (state === 'greeting-sent') {
|
||||
if (command.startsWith('EHLO') || command.startsWith('HELO')) {
|
||||
socket.write('250 statemachine.example.com\r\n');
|
||||
state = 'ready';
|
||||
console.log(' [Server] State: greeting-sent -> ready');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
} else if (state === 'ready') {
|
||||
if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'mail';
|
||||
console.log(' [Server] State: ready -> mail');
|
||||
} else if (command.startsWith('EHLO') || command.startsWith('HELO')) {
|
||||
socket.write('250 statemachine.example.com\r\n');
|
||||
// Stay in ready state
|
||||
} else if (command === 'RSET' || command === 'NOOP') {
|
||||
socket.write('250 OK\r\n');
|
||||
// Stay in ready state
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Just establish connection and send EHLO
|
||||
try {
|
||||
await smtpClient.verify();
|
||||
console.log(' Initial state transition (connect -> EHLO) successful');
|
||||
} catch (error) {
|
||||
console.log(` Connection/EHLO failed: ${error.message}`);
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 2: Transaction state machine
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing transaction state machine`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 statemachine.example.com ESMTP\r\n');
|
||||
|
||||
let state = 'ready';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] State: ${state}, Command: ${command}`);
|
||||
|
||||
switch (state) {
|
||||
case 'ready':
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250 statemachine.example.com\r\n');
|
||||
// Stay in ready
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'mail';
|
||||
console.log(' [Server] State: ready -> mail');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mail':
|
||||
if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'rcpt';
|
||||
console.log(' [Server] State: mail -> rcpt');
|
||||
} else if (command === 'RSET') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
console.log(' [Server] State: mail -> ready (RSET)');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rcpt':
|
||||
if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
// Stay in rcpt (can have multiple recipients)
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
state = 'data';
|
||||
console.log(' [Server] State: rcpt -> data');
|
||||
} else if (command === 'RSET') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
console.log(' [Server] State: rcpt -> ready (RSET)');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'data':
|
||||
if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
console.log(' [Server] State: data -> ready (message complete)');
|
||||
} else if (command === 'QUIT') {
|
||||
// QUIT is not allowed during DATA
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
// All other input during DATA is message content
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient1@example.com', 'recipient2@example.com'],
|
||||
subject: 'State machine test',
|
||||
text: 'Testing SMTP transaction state machine'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Complete transaction state sequence successful');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 3: Invalid state transitions
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing invalid state transitions`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 statemachine.example.com ESMTP\r\n');
|
||||
|
||||
let state = 'ready';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] State: ${state}, Command: ${command}`);
|
||||
|
||||
// Strictly enforce state machine
|
||||
switch (state) {
|
||||
case 'ready':
|
||||
if (command.startsWith('EHLO') || command.startsWith('HELO')) {
|
||||
socket.write('250 statemachine.example.com\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'mail';
|
||||
} else if (command === 'RSET' || command === 'NOOP') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
console.log(' [Server] RCPT TO without MAIL FROM');
|
||||
socket.write('503 5.5.1 Need MAIL command first\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
console.log(' [Server] DATA without MAIL FROM and RCPT TO');
|
||||
socket.write('503 5.5.1 Need MAIL and RCPT commands first\r\n');
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mail':
|
||||
if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'rcpt';
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
console.log(' [Server] Second MAIL FROM without RSET');
|
||||
socket.write('503 5.5.1 Sender already specified\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
console.log(' [Server] DATA without RCPT TO');
|
||||
socket.write('503 5.5.1 Need RCPT command first\r\n');
|
||||
} else if (command === 'RSET') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rcpt':
|
||||
if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
state = 'data';
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
console.log(' [Server] MAIL FROM after RCPT TO without RSET');
|
||||
socket.write('503 5.5.1 Sender already specified\r\n');
|
||||
} else if (command === 'RSET') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'data':
|
||||
if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
} else if (command.startsWith('MAIL FROM:') ||
|
||||
command.startsWith('RCPT TO:') ||
|
||||
command === 'RSET') {
|
||||
console.log(' [Server] SMTP command during DATA mode');
|
||||
socket.write('503 5.5.1 Commands not allowed during data transfer\r\n');
|
||||
}
|
||||
// During DATA, most input is treated as message content
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// We'll create a custom client to send invalid command sequences
|
||||
const testCases = [
|
||||
{
|
||||
name: 'RCPT without MAIL',
|
||||
commands: ['EHLO client.example.com', 'RCPT TO:<test@example.com>'],
|
||||
expectError: true
|
||||
},
|
||||
{
|
||||
name: 'DATA without RCPT',
|
||||
commands: ['EHLO client.example.com', 'MAIL FROM:<sender@example.com>', 'DATA'],
|
||||
expectError: true
|
||||
},
|
||||
{
|
||||
name: 'Double MAIL FROM',
|
||||
commands: ['EHLO client.example.com', 'MAIL FROM:<sender1@example.com>', 'MAIL FROM:<sender2@example.com>'],
|
||||
expectError: true
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
console.log(` Testing: ${testCase.name}`);
|
||||
|
||||
try {
|
||||
// Create simple socket connection for manual command testing
|
||||
const net = await import('net');
|
||||
const client = net.createConnection(testServer.port, testServer.hostname);
|
||||
|
||||
let responseCount = 0;
|
||||
let errorReceived = false;
|
||||
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString();
|
||||
console.log(` Response: ${response.trim()}`);
|
||||
|
||||
if (response.startsWith('5')) {
|
||||
errorReceived = true;
|
||||
}
|
||||
|
||||
responseCount++;
|
||||
|
||||
if (responseCount <= testCase.commands.length) {
|
||||
const command = testCase.commands[responseCount - 1];
|
||||
if (command) {
|
||||
setTimeout(() => {
|
||||
console.log(` Sending: ${command}`);
|
||||
client.write(command + '\r\n');
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
client.write('QUIT\r\n');
|
||||
client.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.on('end', () => {
|
||||
if (testCase.expectError && errorReceived) {
|
||||
console.log(` ✓ Expected error received`);
|
||||
} else if (!testCase.expectError && !errorReceived) {
|
||||
console.log(` ✓ No error as expected`);
|
||||
} else {
|
||||
console.log(` ✗ Unexpected result`);
|
||||
}
|
||||
resolve(void 0);
|
||||
});
|
||||
|
||||
client.on('error', reject);
|
||||
|
||||
// Start with greeting response
|
||||
setTimeout(() => {
|
||||
if (testCase.commands.length > 0) {
|
||||
console.log(` Sending: ${testCase.commands[0]}`);
|
||||
client.write(testCase.commands[0] + '\r\n');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log(` Error testing ${testCase.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 4: RSET command state transitions
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing RSET command state transitions`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 statemachine.example.com ESMTP\r\n');
|
||||
|
||||
let state = 'ready';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] State: ${state}, Command: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250 statemachine.example.com\r\n');
|
||||
state = 'ready';
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'mail';
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
if (state === 'mail' || state === 'rcpt') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'rcpt';
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
} else if (command === 'RSET') {
|
||||
console.log(` [Server] RSET from state: ${state} -> ready`);
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
} else if (command === 'DATA') {
|
||||
if (state === 'rcpt') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
state = 'data';
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||||
}
|
||||
} else if (command === '.') {
|
||||
if (state === 'data') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
}
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else if (command === 'NOOP') {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test RSET at various points in transaction
|
||||
console.log(' Testing RSET from different states...');
|
||||
|
||||
// We'll manually test RSET behavior
|
||||
const net = await import('net');
|
||||
const client = net.createConnection(testServer.port, testServer.hostname);
|
||||
|
||||
const commands = [
|
||||
'EHLO client.example.com', // -> ready
|
||||
'MAIL FROM:<sender@example.com>', // -> mail
|
||||
'RSET', // -> ready (reset from mail state)
|
||||
'MAIL FROM:<sender2@example.com>', // -> mail
|
||||
'RCPT TO:<rcpt1@example.com>', // -> rcpt
|
||||
'RCPT TO:<rcpt2@example.com>', // -> rcpt (multiple recipients)
|
||||
'RSET', // -> ready (reset from rcpt state)
|
||||
'MAIL FROM:<sender3@example.com>', // -> mail (fresh transaction)
|
||||
'RCPT TO:<rcpt3@example.com>', // -> rcpt
|
||||
'DATA', // -> data
|
||||
'.', // -> ready (complete transaction)
|
||||
'QUIT'
|
||||
];
|
||||
|
||||
let commandIndex = 0;
|
||||
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString().trim();
|
||||
console.log(` Response: ${response}`);
|
||||
|
||||
if (commandIndex < commands.length) {
|
||||
setTimeout(() => {
|
||||
const command = commands[commandIndex];
|
||||
console.log(` Sending: ${command}`);
|
||||
if (command === 'DATA') {
|
||||
client.write(command + '\r\n');
|
||||
// Send message content immediately after DATA
|
||||
setTimeout(() => {
|
||||
client.write('Subject: RSET test\r\n\r\nTesting RSET state transitions.\r\n.\r\n');
|
||||
}, 100);
|
||||
} else {
|
||||
client.write(command + '\r\n');
|
||||
}
|
||||
commandIndex++;
|
||||
}, 100);
|
||||
} else {
|
||||
client.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.on('end', () => {
|
||||
console.log(' RSET state transitions completed successfully');
|
||||
resolve(void 0);
|
||||
});
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 5: Connection state persistence
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing connection state persistence`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 statemachine.example.com ESMTP\r\n');
|
||||
|
||||
let state = 'ready';
|
||||
let messageCount = 0;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-statemachine.example.com\r\n');
|
||||
socket.write('250 PIPELINING\r\n');
|
||||
state = 'ready';
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
if (state === 'ready') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'mail';
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
if (state === 'mail' || state === 'rcpt') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'rcpt';
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
if (state === 'rcpt') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
state = 'data';
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence\r\n');
|
||||
}
|
||||
} else if (command === '.') {
|
||||
if (state === 'data') {
|
||||
messageCount++;
|
||||
console.log(` [Server] Message ${messageCount} completed`);
|
||||
socket.write(`250 OK: Message ${messageCount} accepted\r\n`);
|
||||
state = 'ready';
|
||||
}
|
||||
} else if (command === 'QUIT') {
|
||||
console.log(` [Server] Session ended after ${messageCount} messages`);
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
pool: true,
|
||||
maxConnections: 1
|
||||
});
|
||||
|
||||
// Send multiple emails through same connection
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i}@example.com`],
|
||||
subject: `Persistence test ${i}`,
|
||||
text: `Testing connection state persistence - message ${i}`
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(` Message ${i} sent successfully`);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.response).toContain(`Message ${i}`);
|
||||
}
|
||||
|
||||
// Close the pooled connection
|
||||
await smtpClient.close();
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 6: Error state recovery
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing error state recovery`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 statemachine.example.com ESMTP\r\n');
|
||||
|
||||
let state = 'ready';
|
||||
let errorCount = 0;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] State: ${state}, Command: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250 statemachine.example.com\r\n');
|
||||
state = 'ready';
|
||||
errorCount = 0; // Reset error count on new session
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
if (address.includes('error')) {
|
||||
errorCount++;
|
||||
console.log(` [Server] Error ${errorCount} - invalid sender`);
|
||||
socket.write('550 5.1.8 Invalid sender address\r\n');
|
||||
// State remains ready after error
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'mail';
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
if (state === 'mail' || state === 'rcpt') {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
if (address.includes('error')) {
|
||||
errorCount++;
|
||||
console.log(` [Server] Error ${errorCount} - invalid recipient`);
|
||||
socket.write('550 5.1.1 User unknown\r\n');
|
||||
// State remains the same after recipient error
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'rcpt';
|
||||
}
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
if (state === 'rcpt') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
state = 'data';
|
||||
} else {
|
||||
socket.write('503 5.5.1 Bad sequence\r\n');
|
||||
}
|
||||
} else if (command === '.') {
|
||||
if (state === 'data') {
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
}
|
||||
} else if (command === 'RSET') {
|
||||
console.log(` [Server] RSET - recovering from errors (${errorCount} errors so far)`);
|
||||
socket.write('250 OK\r\n');
|
||||
state = 'ready';
|
||||
} else if (command === 'QUIT') {
|
||||
console.log(` [Server] Session ended with ${errorCount} total errors`);
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
socket.write('500 5.5.1 Command not recognized\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test recovery from various errors
|
||||
const testEmails = [
|
||||
{
|
||||
from: 'error@example.com', // Will cause sender error
|
||||
to: ['valid@example.com'],
|
||||
desc: 'invalid sender'
|
||||
},
|
||||
{
|
||||
from: 'valid@example.com',
|
||||
to: ['error@example.com', 'valid@example.com'], // Mixed valid/invalid recipients
|
||||
desc: 'mixed recipients'
|
||||
},
|
||||
{
|
||||
from: 'valid@example.com',
|
||||
to: ['valid@example.com'],
|
||||
desc: 'valid email after errors'
|
||||
}
|
||||
];
|
||||
|
||||
for (const testEmail of testEmails) {
|
||||
console.log(` Testing ${testEmail.desc}...`);
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: testEmail.from,
|
||||
to: testEmail.to,
|
||||
subject: `Error recovery test: ${testEmail.desc}`,
|
||||
text: `Testing error state recovery with ${testEmail.desc}`
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(` ${testEmail.desc}: Success`);
|
||||
if (result.rejected && result.rejected.length > 0) {
|
||||
console.log(` Rejected: ${result.rejected.length} recipients`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ${testEmail.desc}: Failed as expected - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
console.log(`\n${testId}: All ${scenarioCount} state machine scenarios tested ✓`);
|
||||
});
|
@ -0,0 +1,686 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from './plugins.js';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
tap.test('CRFC-06: should handle protocol negotiation correctly (RFC 5321)', async (tools) => {
|
||||
const testId = 'CRFC-06-protocol-negotiation';
|
||||
console.log(`\n${testId}: Testing SMTP protocol negotiation compliance...`);
|
||||
|
||||
let scenarioCount = 0;
|
||||
|
||||
// Scenario 1: EHLO capability announcement and selection
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing EHLO capability announcement`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 negotiation.example.com ESMTP Service Ready\r\n');
|
||||
|
||||
let negotiatedCapabilities: string[] = [];
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
// Announce available capabilities
|
||||
socket.write('250-negotiation.example.com\r\n');
|
||||
socket.write('250-SIZE 52428800\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-STARTTLS\r\n');
|
||||
socket.write('250-ENHANCEDSTATUSCODES\r\n');
|
||||
socket.write('250-PIPELINING\r\n');
|
||||
socket.write('250-CHUNKING\r\n');
|
||||
socket.write('250-SMTPUTF8\r\n');
|
||||
socket.write('250-DSN\r\n');
|
||||
socket.write('250-AUTH PLAIN LOGIN CRAM-MD5\r\n');
|
||||
socket.write('250 HELP\r\n');
|
||||
|
||||
negotiatedCapabilities = [
|
||||
'SIZE', '8BITMIME', 'STARTTLS', 'ENHANCEDSTATUSCODES',
|
||||
'PIPELINING', 'CHUNKING', 'SMTPUTF8', 'DSN', 'AUTH', 'HELP'
|
||||
];
|
||||
console.log(` [Server] Announced capabilities: ${negotiatedCapabilities.join(', ')}`);
|
||||
} else if (command.startsWith('HELO')) {
|
||||
// Basic SMTP mode - no capabilities
|
||||
socket.write('250 negotiation.example.com\r\n');
|
||||
negotiatedCapabilities = [];
|
||||
console.log(' [Server] Basic SMTP mode (no capabilities)');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Check for SIZE parameter
|
||||
const sizeMatch = command.match(/SIZE=(\d+)/i);
|
||||
if (sizeMatch && negotiatedCapabilities.includes('SIZE')) {
|
||||
const size = parseInt(sizeMatch[1]);
|
||||
console.log(` [Server] SIZE parameter used: ${size} bytes`);
|
||||
if (size > 52428800) {
|
||||
socket.write('552 5.3.4 Message size exceeds maximum\r\n');
|
||||
} else {
|
||||
socket.write('250 2.1.0 Sender OK\r\n');
|
||||
}
|
||||
} else if (sizeMatch && !negotiatedCapabilities.includes('SIZE')) {
|
||||
console.log(' [Server] SIZE parameter used without capability');
|
||||
socket.write('501 5.5.4 SIZE not supported\r\n');
|
||||
} else {
|
||||
socket.write('250 2.1.0 Sender OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
// Check for DSN parameters
|
||||
if (command.includes('NOTIFY=') && negotiatedCapabilities.includes('DSN')) {
|
||||
console.log(' [Server] DSN NOTIFY parameter used');
|
||||
} else if (command.includes('NOTIFY=') && !negotiatedCapabilities.includes('DSN')) {
|
||||
console.log(' [Server] DSN parameter used without capability');
|
||||
socket.write('501 5.5.4 DSN not supported\r\n');
|
||||
return;
|
||||
}
|
||||
socket.write('250 2.1.5 Recipient OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 2.0.0 Message accepted\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 2.0.0 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test EHLO negotiation
|
||||
const esmtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Capability negotiation test',
|
||||
text: 'Testing EHLO capability announcement and usage'
|
||||
});
|
||||
|
||||
const result = await esmtpClient.sendMail(email);
|
||||
console.log(' EHLO capability negotiation successful');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 2: Capability-based feature usage
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing capability-based feature usage`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 features.example.com ESMTP\r\n');
|
||||
|
||||
let supportsUTF8 = false;
|
||||
let supportsPipelining = false;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-features.example.com\r\n');
|
||||
socket.write('250-SMTPUTF8\r\n');
|
||||
socket.write('250-PIPELINING\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250 SIZE 10485760\r\n');
|
||||
|
||||
supportsUTF8 = true;
|
||||
supportsPipelining = true;
|
||||
console.log(' [Server] UTF8 and PIPELINING capabilities announced');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Check for SMTPUTF8 parameter
|
||||
if (command.includes('SMTPUTF8') && supportsUTF8) {
|
||||
console.log(' [Server] SMTPUTF8 parameter accepted');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.includes('SMTPUTF8') && !supportsUTF8) {
|
||||
console.log(' [Server] SMTPUTF8 used without capability');
|
||||
socket.write('555 5.6.7 SMTPUTF8 not supported\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with UTF-8 content
|
||||
const utf8Email = new plugins.smartmail.Email({
|
||||
from: 'sénder@example.com', // Non-ASCII sender
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'UTF-8 test: café, naïve, 你好',
|
||||
text: 'Testing SMTPUTF8 capability with international characters: émojis 🎉'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(utf8Email);
|
||||
console.log(' UTF-8 email sent using SMTPUTF8 capability');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 3: Extension parameter validation
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing extension parameter validation`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 validation.example.com ESMTP\r\n');
|
||||
|
||||
const supportedExtensions = new Set(['SIZE', 'BODY', 'DSN', '8BITMIME']);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-validation.example.com\r\n');
|
||||
socket.write('250-SIZE 5242880\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-DSN\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Validate all ESMTP parameters
|
||||
const params = command.substring(command.indexOf('>') + 1).trim();
|
||||
if (params) {
|
||||
console.log(` [Server] Validating parameters: ${params}`);
|
||||
|
||||
const paramPairs = params.split(/\s+/).filter(p => p.length > 0);
|
||||
let allValid = true;
|
||||
|
||||
for (const param of paramPairs) {
|
||||
const [key, value] = param.split('=');
|
||||
|
||||
if (key === 'SIZE') {
|
||||
const size = parseInt(value || '0');
|
||||
if (isNaN(size) || size < 0) {
|
||||
socket.write('501 5.5.4 Invalid SIZE value\r\n');
|
||||
allValid = false;
|
||||
break;
|
||||
} else if (size > 5242880) {
|
||||
socket.write('552 5.3.4 Message size exceeds limit\r\n');
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
console.log(` [Server] SIZE=${size} validated`);
|
||||
} else if (key === 'BODY') {
|
||||
if (value !== '7BIT' && value !== '8BITMIME') {
|
||||
socket.write('501 5.5.4 Invalid BODY value\r\n');
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
console.log(` [Server] BODY=${value} validated`);
|
||||
} else if (key === 'RET') {
|
||||
if (value !== 'FULL' && value !== 'HDRS') {
|
||||
socket.write('501 5.5.4 Invalid RET value\r\n');
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
console.log(` [Server] RET=${value} validated`);
|
||||
} else if (key === 'ENVID') {
|
||||
// ENVID can be any string, just check format
|
||||
if (!value) {
|
||||
socket.write('501 5.5.4 ENVID requires value\r\n');
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
console.log(` [Server] ENVID=${value} validated`);
|
||||
} else {
|
||||
console.log(` [Server] Unknown parameter: ${key}`);
|
||||
socket.write(`555 5.5.4 Unsupported parameter: ${key}\r\n`);
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allValid) {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
// Validate DSN parameters
|
||||
const params = command.substring(command.indexOf('>') + 1).trim();
|
||||
if (params) {
|
||||
const paramPairs = params.split(/\s+/).filter(p => p.length > 0);
|
||||
let allValid = true;
|
||||
|
||||
for (const param of paramPairs) {
|
||||
const [key, value] = param.split('=');
|
||||
|
||||
if (key === 'NOTIFY') {
|
||||
const notifyValues = value.split(',');
|
||||
const validNotify = ['NEVER', 'SUCCESS', 'FAILURE', 'DELAY'];
|
||||
|
||||
for (const nv of notifyValues) {
|
||||
if (!validNotify.includes(nv)) {
|
||||
socket.write('501 5.5.4 Invalid NOTIFY value\r\n');
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allValid) {
|
||||
console.log(` [Server] NOTIFY=${value} validated`);
|
||||
}
|
||||
} else if (key === 'ORCPT') {
|
||||
// ORCPT format: addr-type;addr-value
|
||||
if (!value.includes(';')) {
|
||||
socket.write('501 5.5.4 Invalid ORCPT format\r\n');
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
console.log(` [Server] ORCPT=${value} validated`);
|
||||
} else {
|
||||
socket.write(`555 5.5.4 Unsupported RCPT parameter: ${key}\r\n`);
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allValid) {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with various valid parameters
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Parameter validation test',
|
||||
text: 'Testing ESMTP parameter validation',
|
||||
dsn: {
|
||||
notify: ['SUCCESS', 'FAILURE'],
|
||||
envid: 'test-envelope-id-123',
|
||||
ret: 'FULL'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' ESMTP parameter validation successful');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 4: Service extension discovery
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing service extension discovery`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 discovery.example.com ESMTP Ready\r\n');
|
||||
|
||||
let clientName = '';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO ')) {
|
||||
clientName = command.substring(5);
|
||||
console.log(` [Server] Client identified as: ${clientName}`);
|
||||
|
||||
// Announce extensions in order of preference
|
||||
socket.write('250-discovery.example.com\r\n');
|
||||
|
||||
// Security extensions first
|
||||
socket.write('250-STARTTLS\r\n');
|
||||
socket.write('250-AUTH PLAIN LOGIN CRAM-MD5 DIGEST-MD5\r\n');
|
||||
|
||||
// Core functionality extensions
|
||||
socket.write('250-SIZE 104857600\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-SMTPUTF8\r\n');
|
||||
|
||||
// Delivery extensions
|
||||
socket.write('250-DSN\r\n');
|
||||
socket.write('250-DELIVERBY 86400\r\n');
|
||||
|
||||
// Performance extensions
|
||||
socket.write('250-PIPELINING\r\n');
|
||||
socket.write('250-CHUNKING\r\n');
|
||||
socket.write('250-BINARYMIME\r\n');
|
||||
|
||||
// Enhanced status and debugging
|
||||
socket.write('250-ENHANCEDSTATUSCODES\r\n');
|
||||
socket.write('250-NO-SOLICITING\r\n');
|
||||
socket.write('250-MTRK\r\n');
|
||||
|
||||
// End with help
|
||||
socket.write('250 HELP\r\n');
|
||||
} else if (command.startsWith('HELO ')) {
|
||||
clientName = command.substring(5);
|
||||
console.log(` [Server] Basic SMTP client: ${clientName}`);
|
||||
socket.write('250 discovery.example.com\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Client should use discovered capabilities appropriately
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'HELP') {
|
||||
// Detailed help for discovered extensions
|
||||
socket.write('214-This server supports the following features:\r\n');
|
||||
socket.write('214-STARTTLS - Start TLS negotiation\r\n');
|
||||
socket.write('214-AUTH - SMTP Authentication\r\n');
|
||||
socket.write('214-SIZE - Message size declaration\r\n');
|
||||
socket.write('214-8BITMIME - 8-bit MIME transport\r\n');
|
||||
socket.write('214-SMTPUTF8 - UTF-8 support\r\n');
|
||||
socket.write('214-DSN - Delivery Status Notifications\r\n');
|
||||
socket.write('214-PIPELINING - Command pipelining\r\n');
|
||||
socket.write('214-CHUNKING - BDAT chunking\r\n');
|
||||
socket.write('214 For more information, visit our website\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Thank you for using our service\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
name: 'test-client.example.com'
|
||||
});
|
||||
|
||||
// Test service discovery
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Service discovery test',
|
||||
text: 'Testing SMTP service extension discovery'
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Service extension discovery completed');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 5: Backward compatibility negotiation
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing backward compatibility negotiation`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 compat.example.com ESMTP\r\n');
|
||||
|
||||
let isESMTP = false;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
isESMTP = true;
|
||||
console.log(' [Server] ESMTP mode enabled');
|
||||
socket.write('250-compat.example.com\r\n');
|
||||
socket.write('250-SIZE 10485760\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250 ENHANCEDSTATUSCODES\r\n');
|
||||
} else if (command.startsWith('HELO')) {
|
||||
isESMTP = false;
|
||||
console.log(' [Server] Basic SMTP mode (RFC 821 compatibility)');
|
||||
socket.write('250 compat.example.com\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
if (isESMTP) {
|
||||
// Accept ESMTP parameters
|
||||
if (command.includes('SIZE=') || command.includes('BODY=')) {
|
||||
console.log(' [Server] ESMTP parameters accepted');
|
||||
}
|
||||
socket.write('250 2.1.0 Sender OK\r\n');
|
||||
} else {
|
||||
// Basic SMTP - reject ESMTP parameters
|
||||
if (command.includes('SIZE=') || command.includes('BODY=')) {
|
||||
console.log(' [Server] ESMTP parameters rejected in basic mode');
|
||||
socket.write('501 5.5.4 Syntax error in parameters\r\n');
|
||||
} else {
|
||||
socket.write('250 Sender OK\r\n');
|
||||
}
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
if (isESMTP) {
|
||||
socket.write('250 2.1.5 Recipient OK\r\n');
|
||||
} else {
|
||||
socket.write('250 Recipient OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
if (isESMTP) {
|
||||
socket.write('354 2.0.0 Start mail input\r\n');
|
||||
} else {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
}
|
||||
} else if (command === '.') {
|
||||
if (isESMTP) {
|
||||
socket.write('250 2.0.0 Message accepted\r\n');
|
||||
} else {
|
||||
socket.write('250 Message accepted\r\n');
|
||||
}
|
||||
} else if (command === 'QUIT') {
|
||||
if (isESMTP) {
|
||||
socket.write('221 2.0.0 Service closing\r\n');
|
||||
} else {
|
||||
socket.write('221 Service closing\r\n');
|
||||
}
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test ESMTP mode
|
||||
const esmtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
const esmtpEmail = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'ESMTP compatibility test',
|
||||
text: 'Testing ESMTP mode with extensions'
|
||||
});
|
||||
|
||||
const esmtpResult = await esmtpClient.sendMail(esmtpEmail);
|
||||
console.log(' ESMTP mode negotiation successful');
|
||||
expect(esmtpResult.response).toContain('2.0.0');
|
||||
|
||||
// Test basic SMTP mode (fallback)
|
||||
const basicClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
disableESMTP: true // Force HELO instead of EHLO
|
||||
});
|
||||
|
||||
const basicEmail = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Basic SMTP compatibility test',
|
||||
text: 'Testing basic SMTP mode without extensions'
|
||||
});
|
||||
|
||||
const basicResult = await basicClient.sendMail(basicEmail);
|
||||
console.log(' Basic SMTP mode fallback successful');
|
||||
expect(basicResult.response).not.toContain('2.0.0'); // No enhanced status codes
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 6: Extension interdependencies
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing extension interdependencies`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 interdep.example.com ESMTP\r\n');
|
||||
|
||||
let tlsEnabled = false;
|
||||
let authenticated = false;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command} (TLS: ${tlsEnabled}, Auth: ${authenticated})`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-interdep.example.com\r\n');
|
||||
|
||||
if (!tlsEnabled) {
|
||||
// Before TLS
|
||||
socket.write('250-STARTTLS\r\n');
|
||||
socket.write('250-SIZE 1048576\r\n'); // Limited size before TLS
|
||||
} else {
|
||||
// After TLS
|
||||
socket.write('250-SIZE 52428800\r\n'); // Larger size after TLS
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-SMTPUTF8\r\n');
|
||||
socket.write('250-AUTH PLAIN LOGIN CRAM-MD5\r\n');
|
||||
|
||||
if (authenticated) {
|
||||
// Additional capabilities after authentication
|
||||
socket.write('250-DSN\r\n');
|
||||
socket.write('250-DELIVERBY 86400\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
socket.write('250 ENHANCEDSTATUSCODES\r\n');
|
||||
} else if (command === 'STARTTLS') {
|
||||
if (!tlsEnabled) {
|
||||
socket.write('220 2.0.0 Ready to start TLS\r\n');
|
||||
tlsEnabled = true;
|
||||
console.log(' [Server] TLS enabled (simulated)');
|
||||
// In real implementation, would upgrade to TLS here
|
||||
} else {
|
||||
socket.write('503 5.5.1 TLS already active\r\n');
|
||||
}
|
||||
} else if (command.startsWith('AUTH')) {
|
||||
if (tlsEnabled) {
|
||||
authenticated = true;
|
||||
console.log(' [Server] Authentication successful (simulated)');
|
||||
socket.write('235 2.7.0 Authentication successful\r\n');
|
||||
} else {
|
||||
console.log(' [Server] AUTH rejected - TLS required');
|
||||
socket.write('538 5.7.11 Encryption required for authentication\r\n');
|
||||
}
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
if (command.includes('SMTPUTF8') && !tlsEnabled) {
|
||||
console.log(' [Server] SMTPUTF8 requires TLS');
|
||||
socket.write('530 5.7.0 Must issue STARTTLS first\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
if (command.includes('NOTIFY=') && !authenticated) {
|
||||
console.log(' [Server] DSN requires authentication');
|
||||
socket.write('530 5.7.0 Authentication required for DSN\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test extension dependencies
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
requireTLS: true, // This will trigger STARTTLS
|
||||
auth: {
|
||||
user: 'testuser',
|
||||
pass: 'testpass'
|
||||
}
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Extension interdependency test',
|
||||
text: 'Testing SMTP extension interdependencies',
|
||||
dsn: {
|
||||
notify: ['SUCCESS'],
|
||||
envid: 'interdep-test-123'
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Extension interdependency handling successful');
|
||||
expect(result).toBeDefined();
|
||||
} catch (error) {
|
||||
console.log(` Extension dependency error (expected in test): ${error.message}`);
|
||||
// In test environment, STARTTLS won't actually work
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
console.log(`\n${testId}: All ${scenarioCount} protocol negotiation scenarios tested ✓`);
|
||||
});
|
@ -0,0 +1,726 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from './plugins.js';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
tap.test('CRFC-07: should ensure SMTP interoperability (RFC 5321)', async (tools) => {
|
||||
const testId = 'CRFC-07-interoperability';
|
||||
console.log(`\n${testId}: Testing SMTP interoperability compliance...`);
|
||||
|
||||
let scenarioCount = 0;
|
||||
|
||||
// Scenario 1: Different server implementations compatibility
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing different server implementations`);
|
||||
|
||||
const serverImplementations = [
|
||||
{
|
||||
name: 'Sendmail-style',
|
||||
greeting: '220 mail.example.com ESMTP Sendmail 8.15.2/8.15.2; Date Time',
|
||||
ehloResponse: [
|
||||
'250-mail.example.com Hello client.example.com [192.168.1.100]',
|
||||
'250-ENHANCEDSTATUSCODES',
|
||||
'250-PIPELINING',
|
||||
'250-8BITMIME',
|
||||
'250-SIZE 36700160',
|
||||
'250-DSN',
|
||||
'250-ETRN',
|
||||
'250-DELIVERBY',
|
||||
'250 HELP'
|
||||
],
|
||||
quirks: { verboseResponses: true, includesTimestamp: true }
|
||||
},
|
||||
{
|
||||
name: 'Postfix-style',
|
||||
greeting: '220 mail.example.com ESMTP Postfix',
|
||||
ehloResponse: [
|
||||
'250-mail.example.com',
|
||||
'250-PIPELINING',
|
||||
'250-SIZE 10240000',
|
||||
'250-VRFY',
|
||||
'250-ETRN',
|
||||
'250-STARTTLS',
|
||||
'250-ENHANCEDSTATUSCODES',
|
||||
'250-8BITMIME',
|
||||
'250-DSN',
|
||||
'250 SMTPUTF8'
|
||||
],
|
||||
quirks: { shortResponses: true, strictSyntax: true }
|
||||
},
|
||||
{
|
||||
name: 'Exchange-style',
|
||||
greeting: '220 mail.example.com Microsoft ESMTP MAIL Service ready',
|
||||
ehloResponse: [
|
||||
'250-mail.example.com Hello [192.168.1.100]',
|
||||
'250-SIZE 37748736',
|
||||
'250-PIPELINING',
|
||||
'250-DSN',
|
||||
'250-ENHANCEDSTATUSCODES',
|
||||
'250-STARTTLS',
|
||||
'250-8BITMIME',
|
||||
'250-BINARYMIME',
|
||||
'250-CHUNKING',
|
||||
'250 OK'
|
||||
],
|
||||
quirks: { windowsLineEndings: true, detailedErrors: true }
|
||||
}
|
||||
];
|
||||
|
||||
for (const impl of serverImplementations) {
|
||||
console.log(`\n Testing with ${impl.name} server...`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(` [${impl.name}] Client connected`);
|
||||
socket.write(impl.greeting + '\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [${impl.name}] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
impl.ehloResponse.forEach(line => {
|
||||
socket.write(line + '\r\n');
|
||||
});
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
if (impl.quirks.strictSyntax && !command.includes('<')) {
|
||||
socket.write('501 5.5.4 Syntax error in MAIL command\r\n');
|
||||
} else {
|
||||
const response = impl.quirks.verboseResponses ?
|
||||
'250 2.1.0 Sender OK' : '250 OK';
|
||||
socket.write(response + '\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
const response = impl.quirks.verboseResponses ?
|
||||
'250 2.1.5 Recipient OK' : '250 OK';
|
||||
socket.write(response + '\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
const response = impl.quirks.detailedErrors ?
|
||||
'354 Start mail input; end with <CRLF>.<CRLF>' :
|
||||
'354 Enter message, ending with "." on a line by itself';
|
||||
socket.write(response + '\r\n');
|
||||
} else if (command === '.') {
|
||||
const timestamp = impl.quirks.includesTimestamp ?
|
||||
` at ${new Date().toISOString()}` : '';
|
||||
socket.write(`250 2.0.0 Message accepted for delivery${timestamp}\r\n`);
|
||||
} else if (command === 'QUIT') {
|
||||
const response = impl.quirks.verboseResponses ?
|
||||
'221 2.0.0 Service closing transmission channel' :
|
||||
'221 Bye';
|
||||
socket.write(response + '\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: `Interoperability test with ${impl.name}`,
|
||||
text: `Testing compatibility with ${impl.name} server implementation`
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(` ${impl.name} compatibility: Success`);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
}
|
||||
})();
|
||||
|
||||
// Scenario 2: Character encoding and internationalization
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing character encoding interoperability`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 international.example.com ESMTP\r\n');
|
||||
|
||||
let supportsUTF8 = false;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString();
|
||||
console.log(` [Server] Received (${data.length} bytes): ${command.trim()}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-international.example.com\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-SMTPUTF8\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
supportsUTF8 = true;
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Check for non-ASCII characters
|
||||
const hasNonASCII = /[^\x00-\x7F]/.test(command);
|
||||
const hasUTF8Param = command.includes('SMTPUTF8');
|
||||
|
||||
console.log(` [Server] Non-ASCII: ${hasNonASCII}, UTF8 param: ${hasUTF8Param}`);
|
||||
|
||||
if (hasNonASCII && !hasUTF8Param) {
|
||||
socket.write('553 5.6.7 Non-ASCII addresses require SMTPUTF8\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.trim() === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command.trim() === '.') {
|
||||
socket.write('250 OK: International message accepted\r\n');
|
||||
} else if (command.trim() === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test various international character sets
|
||||
const internationalTests = [
|
||||
{
|
||||
desc: 'Latin characters with accents',
|
||||
from: 'sénder@éxample.com',
|
||||
to: 'récipient@éxample.com',
|
||||
subject: 'Tëst with açcénts',
|
||||
text: 'Café, naïve, résumé, piñata'
|
||||
},
|
||||
{
|
||||
desc: 'Cyrillic characters',
|
||||
from: 'отправитель@пример.com',
|
||||
to: 'получатель@пример.com',
|
||||
subject: 'Тест с кириллицей',
|
||||
text: 'Привет мир! Это тест с русскими буквами.'
|
||||
},
|
||||
{
|
||||
desc: 'Chinese characters',
|
||||
from: 'sender@example.com', // ASCII for compatibility
|
||||
to: 'recipient@example.com',
|
||||
subject: '测试中文字符',
|
||||
text: '你好世界!这是一个中文测试。'
|
||||
},
|
||||
{
|
||||
desc: 'Arabic characters',
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'اختبار النص العربي',
|
||||
text: 'مرحبا بالعالم! هذا اختبار باللغة العربية.'
|
||||
},
|
||||
{
|
||||
desc: 'Emoji and symbols',
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: '🎉 Test with emojis 🌟',
|
||||
text: 'Hello 👋 World 🌍! Testing emojis: 🚀 📧 ✨'
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of internationalTests) {
|
||||
console.log(` Testing: ${test.desc}`);
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: test.from,
|
||||
to: [test.to],
|
||||
subject: test.subject,
|
||||
text: test.text
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(` ${test.desc}: Success`);
|
||||
expect(result).toBeDefined();
|
||||
} catch (error) {
|
||||
console.log(` ${test.desc}: Failed - ${error.message}`);
|
||||
// Some may fail if server doesn't support international addresses
|
||||
}
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 3: Message format compatibility
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing message format compatibility`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 formats.example.com ESMTP\r\n');
|
||||
|
||||
let inData = false;
|
||||
let messageContent = '';
|
||||
|
||||
socket.on('data', (data) => {
|
||||
if (inData) {
|
||||
messageContent += data.toString();
|
||||
if (messageContent.includes('\r\n.\r\n')) {
|
||||
inData = false;
|
||||
|
||||
// Analyze message format
|
||||
const headers = messageContent.substring(0, messageContent.indexOf('\r\n\r\n'));
|
||||
const body = messageContent.substring(messageContent.indexOf('\r\n\r\n') + 4);
|
||||
|
||||
console.log(' [Server] Message analysis:');
|
||||
console.log(` Header count: ${(headers.match(/\r\n/g) || []).length + 1}`);
|
||||
console.log(` Body size: ${body.length} bytes`);
|
||||
|
||||
// Check for proper header folding
|
||||
const longHeaders = headers.split('\r\n').filter(h => h.length > 78);
|
||||
if (longHeaders.length > 0) {
|
||||
console.log(` Long headers detected: ${longHeaders.length}`);
|
||||
}
|
||||
|
||||
// Check for MIME structure
|
||||
if (headers.includes('Content-Type:')) {
|
||||
console.log(' MIME message detected');
|
||||
}
|
||||
|
||||
socket.write('250 OK: Message format validated\r\n');
|
||||
messageContent = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-formats.example.com\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-BINARYMIME\r\n');
|
||||
socket.write('250 SIZE 52428800\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
inData = true;
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test different message formats
|
||||
const formatTests = [
|
||||
{
|
||||
desc: 'Plain text message',
|
||||
email: new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Plain text test',
|
||||
text: 'This is a simple plain text message.'
|
||||
})
|
||||
},
|
||||
{
|
||||
desc: 'HTML message',
|
||||
email: new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'HTML test',
|
||||
html: '<h1>HTML Message</h1><p>This is an <strong>HTML</strong> message.</p>'
|
||||
})
|
||||
},
|
||||
{
|
||||
desc: 'Multipart alternative',
|
||||
email: new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Multipart test',
|
||||
text: 'Plain text version',
|
||||
html: '<p>HTML version</p>'
|
||||
})
|
||||
},
|
||||
{
|
||||
desc: 'Message with attachment',
|
||||
email: new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Attachment test',
|
||||
text: 'Message with attachment',
|
||||
attachments: [{
|
||||
filename: 'test.txt',
|
||||
content: 'This is a test attachment'
|
||||
}]
|
||||
})
|
||||
},
|
||||
{
|
||||
desc: 'Message with custom headers',
|
||||
email: new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Custom headers test',
|
||||
text: 'Message with custom headers',
|
||||
headers: {
|
||||
'X-Custom-Header': 'Custom value',
|
||||
'X-Mailer': 'Test Mailer 1.0',
|
||||
'Message-ID': '<test123@example.com>',
|
||||
'References': '<ref1@example.com> <ref2@example.com>'
|
||||
}
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of formatTests) {
|
||||
console.log(` Testing: ${test.desc}`);
|
||||
|
||||
const result = await smtpClient.sendMail(test.email);
|
||||
console.log(` ${test.desc}: Success`);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 4: Error handling interoperability
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing error handling interoperability`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 errors.example.com ESMTP\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-errors.example.com\r\n');
|
||||
socket.write('250-ENHANCEDSTATUSCODES\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
|
||||
if (address.includes('temp-fail')) {
|
||||
// Temporary failure - client should retry
|
||||
socket.write('451 4.7.1 Temporary system problem, try again later\r\n');
|
||||
} else if (address.includes('perm-fail')) {
|
||||
// Permanent failure - client should not retry
|
||||
socket.write('550 5.1.8 Invalid sender address format\r\n');
|
||||
} else if (address.includes('syntax-error')) {
|
||||
// Syntax error
|
||||
socket.write('501 5.5.4 Syntax error in MAIL command\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
const address = command.match(/<(.+)>/)?.[1] || '';
|
||||
|
||||
if (address.includes('unknown')) {
|
||||
socket.write('550 5.1.1 User unknown in local recipient table\r\n');
|
||||
} else if (address.includes('temp-reject')) {
|
||||
socket.write('450 4.2.1 Mailbox temporarily unavailable\r\n');
|
||||
} else if (address.includes('quota-exceeded')) {
|
||||
socket.write('552 5.2.2 Mailbox over quota\r\n');
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
} else {
|
||||
// Unknown command
|
||||
socket.write('500 5.5.1 Command unrecognized\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test various error scenarios
|
||||
const errorTests = [
|
||||
{
|
||||
desc: 'Temporary sender failure',
|
||||
from: 'temp-fail@example.com',
|
||||
to: 'valid@example.com',
|
||||
expectError: true,
|
||||
errorType: '4xx'
|
||||
},
|
||||
{
|
||||
desc: 'Permanent sender failure',
|
||||
from: 'perm-fail@example.com',
|
||||
to: 'valid@example.com',
|
||||
expectError: true,
|
||||
errorType: '5xx'
|
||||
},
|
||||
{
|
||||
desc: 'Unknown recipient',
|
||||
from: 'valid@example.com',
|
||||
to: 'unknown@example.com',
|
||||
expectError: true,
|
||||
errorType: '5xx'
|
||||
},
|
||||
{
|
||||
desc: 'Mixed valid/invalid recipients',
|
||||
from: 'valid@example.com',
|
||||
to: ['valid@example.com', 'unknown@example.com', 'temp-reject@example.com'],
|
||||
expectError: false, // Partial success
|
||||
errorType: 'mixed'
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of errorTests) {
|
||||
console.log(` Testing: ${test.desc}`);
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: test.from,
|
||||
to: Array.isArray(test.to) ? test.to : [test.to],
|
||||
subject: `Error test: ${test.desc}`,
|
||||
text: `Testing error handling for ${test.desc}`
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await smtpClient.sendMail(email);
|
||||
|
||||
if (test.expectError && test.errorType !== 'mixed') {
|
||||
console.log(` Unexpected success for ${test.desc}`);
|
||||
} else {
|
||||
console.log(` ${test.desc}: Handled correctly`);
|
||||
if (result.rejected && result.rejected.length > 0) {
|
||||
console.log(` Rejected: ${result.rejected.length} recipients`);
|
||||
}
|
||||
if (result.accepted && result.accepted.length > 0) {
|
||||
console.log(` Accepted: ${result.accepted.length} recipients`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (test.expectError) {
|
||||
console.log(` ${test.desc}: Failed as expected (${error.responseCode})`);
|
||||
if (test.errorType === '4xx') {
|
||||
expect(error.responseCode).toBeGreaterThanOrEqual(400);
|
||||
expect(error.responseCode).toBeLessThan(500);
|
||||
} else if (test.errorType === '5xx') {
|
||||
expect(error.responseCode).toBeGreaterThanOrEqual(500);
|
||||
expect(error.responseCode).toBeLessThan(600);
|
||||
}
|
||||
} else {
|
||||
console.log(` Unexpected error for ${test.desc}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 5: Connection management interoperability
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing connection management interoperability`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
|
||||
let commandCount = 0;
|
||||
let idleTime = Date.now();
|
||||
const maxIdleTime = 5000; // 5 seconds for testing
|
||||
const maxCommands = 10;
|
||||
|
||||
socket.write('220 connection.example.com ESMTP\r\n');
|
||||
|
||||
// Set up idle timeout
|
||||
const idleCheck = setInterval(() => {
|
||||
if (Date.now() - idleTime > maxIdleTime) {
|
||||
console.log(' [Server] Idle timeout - closing connection');
|
||||
socket.write('421 4.4.2 Idle timeout, closing connection\r\n');
|
||||
socket.end();
|
||||
clearInterval(idleCheck);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
commandCount++;
|
||||
idleTime = Date.now();
|
||||
|
||||
console.log(` [Server] Command ${commandCount}: ${command}`);
|
||||
|
||||
if (commandCount > maxCommands) {
|
||||
console.log(' [Server] Too many commands - closing connection');
|
||||
socket.write('421 4.7.0 Too many commands, closing connection\r\n');
|
||||
socket.end();
|
||||
clearInterval(idleCheck);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-connection.example.com\r\n');
|
||||
socket.write('250-PIPELINING\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'RSET') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'NOOP') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
clearInterval(idleCheck);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(idleCheck);
|
||||
console.log(` [Server] Connection closed after ${commandCount} commands`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
pool: true,
|
||||
maxConnections: 1
|
||||
});
|
||||
|
||||
// Test connection reuse
|
||||
console.log(' Testing connection reuse...');
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: [`recipient${i}@example.com`],
|
||||
subject: `Connection test ${i}`,
|
||||
text: `Testing connection management - email ${i}`
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(` Email ${i} sent successfully`);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Small delay to test connection persistence
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Test NOOP for keeping connection alive
|
||||
console.log(' Testing connection keep-alive...');
|
||||
|
||||
await smtpClient.verify(); // This might send NOOP
|
||||
console.log(' Connection verified (keep-alive)');
|
||||
|
||||
await smtpClient.close();
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 6: Legacy SMTP compatibility
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing legacy SMTP compatibility`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Legacy SMTP server');
|
||||
|
||||
// Old-style greeting without ESMTP
|
||||
socket.write('220 legacy.example.com Simple Mail Transfer Service Ready\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
// Legacy server doesn't understand EHLO
|
||||
socket.write('500 Command unrecognized\r\n');
|
||||
} else if (command.startsWith('HELO')) {
|
||||
socket.write('250 legacy.example.com\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Very strict syntax checking
|
||||
if (!command.match(/^MAIL FROM:\s*<[^>]+>\s*$/)) {
|
||||
socket.write('501 Syntax error\r\n');
|
||||
} else {
|
||||
socket.write('250 Sender OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
if (!command.match(/^RCPT TO:\s*<[^>]+>\s*$/)) {
|
||||
socket.write('501 Syntax error\r\n');
|
||||
} else {
|
||||
socket.write('250 Recipient OK\r\n');
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Enter mail, end with "." on a line by itself\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 Message accepted for delivery\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Service closing transmission channel\r\n');
|
||||
socket.end();
|
||||
} else if (command === 'HELP') {
|
||||
socket.write('214-Commands supported:\r\n');
|
||||
socket.write('214-HELO MAIL RCPT DATA QUIT HELP\r\n');
|
||||
socket.write('214 End of HELP info\r\n');
|
||||
} else {
|
||||
socket.write('500 Command unrecognized\r\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test with client that can fall back to basic SMTP
|
||||
const legacyClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
disableESMTP: true // Force HELO mode
|
||||
});
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Legacy compatibility test',
|
||||
text: 'Testing compatibility with legacy SMTP servers'
|
||||
});
|
||||
|
||||
const result = await legacyClient.sendMail(email);
|
||||
console.log(' Legacy SMTP compatibility: Success');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
console.log(`\n${testId}: All ${scenarioCount} interoperability scenarios tested ✓`);
|
||||
});
|
@ -0,0 +1,654 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from './plugins.js';
|
||||
import { createTestServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
tap.test('CRFC-08: should handle SMTP extensions correctly (Various RFCs)', async (tools) => {
|
||||
const testId = 'CRFC-08-smtp-extensions';
|
||||
console.log(`\n${testId}: Testing SMTP extensions compliance...`);
|
||||
|
||||
let scenarioCount = 0;
|
||||
|
||||
// Scenario 1: CHUNKING extension (RFC 3030)
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing CHUNKING extension (RFC 3030)`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 chunking.example.com ESMTP\r\n');
|
||||
|
||||
let chunkingMode = false;
|
||||
let totalChunks = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
if (chunkingMode) {
|
||||
// In chunking mode, all data is message content
|
||||
totalBytes += data.length;
|
||||
console.log(` [Server] Received chunk: ${data.length} bytes`);
|
||||
return;
|
||||
}
|
||||
|
||||
const command = text.trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-chunking.example.com\r\n');
|
||||
socket.write('250-CHUNKING\r\n');
|
||||
socket.write('250-8BITMIME\r\n');
|
||||
socket.write('250-BINARYMIME\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
if (command.includes('BODY=BINARYMIME')) {
|
||||
console.log(' [Server] Binary MIME body declared');
|
||||
}
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('BDAT ')) {
|
||||
// BDAT command format: BDAT <size> [LAST]
|
||||
const parts = command.split(' ');
|
||||
const chunkSize = parseInt(parts[1]);
|
||||
const isLast = parts.includes('LAST');
|
||||
|
||||
totalChunks++;
|
||||
console.log(` [Server] BDAT chunk ${totalChunks}: ${chunkSize} bytes${isLast ? ' (LAST)' : ''}`);
|
||||
|
||||
if (isLast) {
|
||||
socket.write(`250 OK: Message accepted, ${totalChunks} chunks, ${totalBytes} total bytes\r\n`);
|
||||
chunkingMode = false;
|
||||
totalChunks = 0;
|
||||
totalBytes = 0;
|
||||
} else {
|
||||
socket.write('250 OK: Chunk accepted\r\n');
|
||||
chunkingMode = true;
|
||||
}
|
||||
} else if (command === 'DATA') {
|
||||
// DATA not allowed when CHUNKING is available
|
||||
socket.write('503 5.5.1 Use BDAT instead of DATA\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with binary content that would benefit from chunking
|
||||
const binaryContent = Buffer.alloc(1024);
|
||||
for (let i = 0; i < binaryContent.length; i++) {
|
||||
binaryContent[i] = i % 256;
|
||||
}
|
||||
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'CHUNKING test',
|
||||
text: 'Testing CHUNKING extension with binary data',
|
||||
attachments: [{
|
||||
filename: 'binary-data.bin',
|
||||
content: binaryContent
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' CHUNKING extension handled (if supported by client)');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 2: DELIVERBY extension (RFC 2852)
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing DELIVERBY extension (RFC 2852)`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 deliverby.example.com ESMTP\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-deliverby.example.com\r\n');
|
||||
socket.write('250-DELIVERBY 86400\r\n'); // 24 hours max
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Check for DELIVERBY parameter
|
||||
const deliverByMatch = command.match(/DELIVERBY=(\d+)([RN]?)/i);
|
||||
if (deliverByMatch) {
|
||||
const seconds = parseInt(deliverByMatch[1]);
|
||||
const mode = deliverByMatch[2] || 'R'; // R=return, N=notify
|
||||
|
||||
console.log(` [Server] DELIVERBY: ${seconds} seconds, mode: ${mode}`);
|
||||
|
||||
if (seconds > 86400) {
|
||||
socket.write('501 5.5.4 DELIVERBY time exceeds maximum\r\n');
|
||||
} else if (seconds < 0) {
|
||||
socket.write('501 5.5.4 Invalid DELIVERBY time\r\n');
|
||||
} else {
|
||||
socket.write('250 OK: Delivery deadline accepted\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('250 OK\r\n');
|
||||
}
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK: Message queued with delivery deadline\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test with delivery deadline
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['urgent@example.com'],
|
||||
subject: 'Urgent delivery test',
|
||||
text: 'This message has a delivery deadline',
|
||||
// Note: Most SMTP clients don't expose DELIVERBY directly
|
||||
// but we can test server handling
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' DELIVERBY extension supported by server');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 3: ETRN extension (RFC 1985)
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing ETRN extension (RFC 1985)`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 etrn.example.com ESMTP\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-etrn.example.com\r\n');
|
||||
socket.write('250-ETRN\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('ETRN ')) {
|
||||
const domain = command.substring(5);
|
||||
console.log(` [Server] ETRN request for domain: ${domain}`);
|
||||
|
||||
if (domain === '@example.com') {
|
||||
socket.write('250 OK: Queue processing started for example.com\r\n');
|
||||
} else if (domain === '#urgent') {
|
||||
socket.write('250 OK: Urgent queue processing started\r\n');
|
||||
} else if (domain.includes('unknown')) {
|
||||
socket.write('458 Unable to queue messages for node\r\n');
|
||||
} else {
|
||||
socket.write('250 OK: Queue processing started\r\n');
|
||||
}
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ETRN is typically used by mail servers, not clients
|
||||
// We'll test the server's ETRN capability manually
|
||||
const net = await import('net');
|
||||
const client = net.createConnection(testServer.port, testServer.hostname);
|
||||
|
||||
const commands = [
|
||||
'EHLO client.example.com',
|
||||
'ETRN @example.com', // Request queue processing for domain
|
||||
'ETRN #urgent', // Request urgent queue processing
|
||||
'ETRN unknown.domain.com', // Test error handling
|
||||
'QUIT'
|
||||
];
|
||||
|
||||
let commandIndex = 0;
|
||||
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString().trim();
|
||||
console.log(` [Client] Response: ${response}`);
|
||||
|
||||
if (commandIndex < commands.length) {
|
||||
setTimeout(() => {
|
||||
const command = commands[commandIndex];
|
||||
console.log(` [Client] Sending: ${command}`);
|
||||
client.write(command + '\r\n');
|
||||
commandIndex++;
|
||||
}, 100);
|
||||
} else {
|
||||
client.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.on('end', () => {
|
||||
console.log(' ETRN extension testing completed');
|
||||
resolve(void 0);
|
||||
});
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 4: VRFY and EXPN extensions (RFC 5321)
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing VRFY and EXPN extensions`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 verify.example.com ESMTP\r\n');
|
||||
|
||||
// Simulated user database
|
||||
const users = new Map([
|
||||
['admin', { email: 'admin@example.com', fullName: 'Administrator' }],
|
||||
['john', { email: 'john.doe@example.com', fullName: 'John Doe' }],
|
||||
['support', { email: 'support@example.com', fullName: 'Support Team' }]
|
||||
]);
|
||||
|
||||
const mailingLists = new Map([
|
||||
['staff', ['admin@example.com', 'john.doe@example.com']],
|
||||
['support-team', ['support@example.com', 'admin@example.com']]
|
||||
]);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-verify.example.com\r\n');
|
||||
socket.write('250-VRFY\r\n');
|
||||
socket.write('250-EXPN\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('VRFY ')) {
|
||||
const query = command.substring(5);
|
||||
console.log(` [Server] VRFY query: ${query}`);
|
||||
|
||||
// Look up user
|
||||
const user = users.get(query.toLowerCase());
|
||||
if (user) {
|
||||
socket.write(`250 ${user.fullName} <${user.email}>\r\n`);
|
||||
} else {
|
||||
// Check if it's an email address
|
||||
const emailMatch = Array.from(users.values()).find(u =>
|
||||
u.email.toLowerCase() === query.toLowerCase()
|
||||
);
|
||||
if (emailMatch) {
|
||||
socket.write(`250 ${emailMatch.fullName} <${emailMatch.email}>\r\n`);
|
||||
} else {
|
||||
socket.write('550 5.1.1 User unknown\r\n');
|
||||
}
|
||||
}
|
||||
} else if (command.startsWith('EXPN ')) {
|
||||
const listName = command.substring(5);
|
||||
console.log(` [Server] EXPN query: ${listName}`);
|
||||
|
||||
const list = mailingLists.get(listName.toLowerCase());
|
||||
if (list) {
|
||||
socket.write(`250-Mailing list ${listName}:\r\n`);
|
||||
list.forEach((email, index) => {
|
||||
const prefix = index < list.length - 1 ? '250-' : '250 ';
|
||||
socket.write(`${prefix}${email}\r\n`);
|
||||
});
|
||||
} else {
|
||||
socket.write('550 5.1.1 Mailing list not found\r\n');
|
||||
}
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test VRFY and EXPN commands
|
||||
const net = await import('net');
|
||||
const client = net.createConnection(testServer.port, testServer.hostname);
|
||||
|
||||
const commands = [
|
||||
'EHLO client.example.com',
|
||||
'VRFY admin', // Verify user by username
|
||||
'VRFY john.doe@example.com', // Verify user by email
|
||||
'VRFY nonexistent', // Test unknown user
|
||||
'EXPN staff', // Expand mailing list
|
||||
'EXPN nonexistent-list', // Test unknown list
|
||||
'QUIT'
|
||||
];
|
||||
|
||||
let commandIndex = 0;
|
||||
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString().trim();
|
||||
console.log(` [Client] Response: ${response}`);
|
||||
|
||||
if (commandIndex < commands.length) {
|
||||
setTimeout(() => {
|
||||
const command = commands[commandIndex];
|
||||
console.log(` [Client] Sending: ${command}`);
|
||||
client.write(command + '\r\n');
|
||||
commandIndex++;
|
||||
}, 200);
|
||||
} else {
|
||||
client.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.on('end', () => {
|
||||
console.log(' VRFY and EXPN testing completed');
|
||||
resolve(void 0);
|
||||
});
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 5: HELP extension
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing HELP extension`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 help.example.com ESMTP\r\n');
|
||||
|
||||
const helpTopics = new Map([
|
||||
['commands', [
|
||||
'Available commands:',
|
||||
'EHLO <domain> - Extended HELLO',
|
||||
'MAIL FROM:<addr> - Specify sender',
|
||||
'RCPT TO:<addr> - Specify recipient',
|
||||
'DATA - Start message text',
|
||||
'QUIT - Close connection'
|
||||
]],
|
||||
['extensions', [
|
||||
'Supported extensions:',
|
||||
'SIZE - Message size declaration',
|
||||
'8BITMIME - 8-bit MIME transport',
|
||||
'STARTTLS - Start TLS negotiation',
|
||||
'AUTH - SMTP Authentication',
|
||||
'DSN - Delivery Status Notifications'
|
||||
]],
|
||||
['syntax', [
|
||||
'Command syntax:',
|
||||
'Commands are case-insensitive',
|
||||
'Lines end with CRLF',
|
||||
'Email addresses must be in <> brackets',
|
||||
'Parameters are space-separated'
|
||||
]]
|
||||
]);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-help.example.com\r\n');
|
||||
socket.write('250-HELP\r\n');
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'HELP' || command === 'HELP HELP') {
|
||||
socket.write('214-This server provides HELP for the following topics:\r\n');
|
||||
socket.write('214-COMMANDS - List of available commands\r\n');
|
||||
socket.write('214-EXTENSIONS - List of supported extensions\r\n');
|
||||
socket.write('214-SYNTAX - Command syntax rules\r\n');
|
||||
socket.write('214 Use HELP <topic> for specific information\r\n');
|
||||
} else if (command.startsWith('HELP ')) {
|
||||
const topic = command.substring(5).toLowerCase();
|
||||
const helpText = helpTopics.get(topic);
|
||||
|
||||
if (helpText) {
|
||||
helpText.forEach((line, index) => {
|
||||
const prefix = index < helpText.length - 1 ? '214-' : '214 ';
|
||||
socket.write(`${prefix}${line}\r\n`);
|
||||
});
|
||||
} else {
|
||||
socket.write('504 5.3.0 HELP topic not available\r\n');
|
||||
}
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
} else if (command === '.') {
|
||||
socket.write('250 OK\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test HELP command
|
||||
const net = await import('net');
|
||||
const client = net.createConnection(testServer.port, testServer.hostname);
|
||||
|
||||
const commands = [
|
||||
'EHLO client.example.com',
|
||||
'HELP', // General help
|
||||
'HELP COMMANDS', // Specific topic
|
||||
'HELP EXTENSIONS', // Another topic
|
||||
'HELP NONEXISTENT', // Unknown topic
|
||||
'QUIT'
|
||||
];
|
||||
|
||||
let commandIndex = 0;
|
||||
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString().trim();
|
||||
console.log(` [Client] Response: ${response}`);
|
||||
|
||||
if (commandIndex < commands.length) {
|
||||
setTimeout(() => {
|
||||
const command = commands[commandIndex];
|
||||
console.log(` [Client] Sending: ${command}`);
|
||||
client.write(command + '\r\n');
|
||||
commandIndex++;
|
||||
}, 200);
|
||||
} else {
|
||||
client.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.on('end', () => {
|
||||
console.log(' HELP extension testing completed');
|
||||
resolve(void 0);
|
||||
});
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
// Scenario 6: Extension combination and interaction
|
||||
await (async () => {
|
||||
scenarioCount++;
|
||||
console.log(`\nScenario ${scenarioCount}: Testing extension combinations`);
|
||||
|
||||
const testServer = await createTestServer({
|
||||
onConnection: async (socket) => {
|
||||
console.log(' [Server] Client connected');
|
||||
socket.write('220 combined.example.com ESMTP\r\n');
|
||||
|
||||
let activeExtensions: string[] = [];
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
console.log(` [Server] Received: ${command}`);
|
||||
|
||||
if (command.startsWith('EHLO')) {
|
||||
socket.write('250-combined.example.com\r\n');
|
||||
|
||||
// Announce multiple extensions
|
||||
const extensions = [
|
||||
'SIZE 52428800',
|
||||
'8BITMIME',
|
||||
'SMTPUTF8',
|
||||
'ENHANCEDSTATUSCODES',
|
||||
'PIPELINING',
|
||||
'DSN',
|
||||
'DELIVERBY 86400',
|
||||
'CHUNKING',
|
||||
'BINARYMIME',
|
||||
'HELP'
|
||||
];
|
||||
|
||||
extensions.forEach(ext => {
|
||||
socket.write(`250-${ext}\r\n`);
|
||||
activeExtensions.push(ext.split(' ')[0]);
|
||||
});
|
||||
|
||||
socket.write('250 OK\r\n');
|
||||
console.log(` [Server] Active extensions: ${activeExtensions.join(', ')}`);
|
||||
} else if (command.startsWith('MAIL FROM:')) {
|
||||
// Check for multiple extension parameters
|
||||
const params = [];
|
||||
|
||||
if (command.includes('SIZE=')) {
|
||||
const sizeMatch = command.match(/SIZE=(\d+)/);
|
||||
if (sizeMatch) params.push(`SIZE=${sizeMatch[1]}`);
|
||||
}
|
||||
|
||||
if (command.includes('BODY=')) {
|
||||
const bodyMatch = command.match(/BODY=(\w+)/);
|
||||
if (bodyMatch) params.push(`BODY=${bodyMatch[1]}`);
|
||||
}
|
||||
|
||||
if (command.includes('SMTPUTF8')) {
|
||||
params.push('SMTPUTF8');
|
||||
}
|
||||
|
||||
if (command.includes('DELIVERBY=')) {
|
||||
const deliverByMatch = command.match(/DELIVERBY=(\d+)/);
|
||||
if (deliverByMatch) params.push(`DELIVERBY=${deliverByMatch[1]}`);
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
console.log(` [Server] Extension parameters: ${params.join(', ')}`);
|
||||
}
|
||||
|
||||
socket.write('250 2.1.0 Sender OK\r\n');
|
||||
} else if (command.startsWith('RCPT TO:')) {
|
||||
// Check for DSN parameters
|
||||
if (command.includes('NOTIFY=')) {
|
||||
const notifyMatch = command.match(/NOTIFY=([^,\s]+)/);
|
||||
if (notifyMatch) {
|
||||
console.log(` [Server] DSN NOTIFY: ${notifyMatch[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
socket.write('250 2.1.5 Recipient OK\r\n');
|
||||
} else if (command === 'DATA') {
|
||||
if (activeExtensions.includes('CHUNKING')) {
|
||||
socket.write('503 5.5.1 Use BDAT when CHUNKING is available\r\n');
|
||||
} else {
|
||||
socket.write('354 Start mail input\r\n');
|
||||
}
|
||||
} else if (command.startsWith('BDAT ')) {
|
||||
if (activeExtensions.includes('CHUNKING')) {
|
||||
const parts = command.split(' ');
|
||||
const size = parts[1];
|
||||
const isLast = parts.includes('LAST');
|
||||
console.log(` [Server] BDAT chunk: ${size} bytes${isLast ? ' (LAST)' : ''}`);
|
||||
|
||||
if (isLast) {
|
||||
socket.write('250 2.0.0 Message accepted\r\n');
|
||||
} else {
|
||||
socket.write('250 2.0.0 Chunk accepted\r\n');
|
||||
}
|
||||
} else {
|
||||
socket.write('500 5.5.1 CHUNKING not available\r\n');
|
||||
}
|
||||
} else if (command === '.') {
|
||||
socket.write('250 2.0.0 Message accepted\r\n');
|
||||
} else if (command === 'QUIT') {
|
||||
socket.write('221 2.0.0 Bye\r\n');
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false
|
||||
});
|
||||
|
||||
// Test email that could use multiple extensions
|
||||
const email = new plugins.smartmail.Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'Extension combination test with UTF-8: 测试',
|
||||
text: 'Testing multiple SMTP extensions together',
|
||||
dsn: {
|
||||
notify: ['SUCCESS', 'FAILURE'],
|
||||
envid: 'multi-ext-test-123'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
console.log(' Multiple extension combination handled');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
await testServer.server.close();
|
||||
})();
|
||||
|
||||
console.log(`\n${testId}: All ${scenarioCount} SMTP extension scenarios tested ✓`);
|
||||
});
|
Reference in New Issue
Block a user