dcrouter/test/suite/smtpclient_rfc-compliance/test.crfc-08.smtp-extensions.ts
2025-05-26 14:50:55 +00:00

656 lines
23 KiB
TypeScript

import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createTestServer } from '../../helpers/server.loader.js';
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
import { Email } from '../../../ts/index.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 = createTestSmtpClient({
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 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 = createTestSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false
});
// Test with delivery deadline
const email = new 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 = createTestSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false
});
// Test email that could use multiple extensions
const email = new 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 ✓`);
});
tap.start();