296 lines
9.6 KiB
TypeScript
296 lines
9.6 KiB
TypeScript
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||
|
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||
|
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||
|
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||
|
|
import * as net from 'net';
|
||
|
|
import * as dns from 'dns';
|
||
|
|
|
||
|
|
const storageMap = new Map<string, string>();
|
||
|
|
const mockDcRouter = {
|
||
|
|
storageManager: {
|
||
|
|
get: async (key: string) => storageMap.get(key) || null,
|
||
|
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
let server: UnifiedEmailServer;
|
||
|
|
let bridgeAvailable = false;
|
||
|
|
let mockSmtpServer: net.Server;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a minimal mock SMTP server that accepts any email.
|
||
|
|
*/
|
||
|
|
function createMockSmtpServer(port: number): Promise<net.Server> {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const srv = net.createServer((socket) => {
|
||
|
|
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
|
||
|
|
|
||
|
|
let inData = false;
|
||
|
|
let dataBuffer = '';
|
||
|
|
|
||
|
|
socket.on('data', (chunk) => {
|
||
|
|
const input = chunk.toString();
|
||
|
|
|
||
|
|
if (inData) {
|
||
|
|
dataBuffer += input;
|
||
|
|
if (dataBuffer.includes('\r\n.\r\n')) {
|
||
|
|
inData = false;
|
||
|
|
dataBuffer = '';
|
||
|
|
socket.write('250 2.0.0 Ok: queued\r\n');
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
|
||
|
|
for (const line of lines) {
|
||
|
|
const cmd = line.toUpperCase();
|
||
|
|
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||
|
|
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
|
||
|
|
} else if (cmd.startsWith('MAIL FROM')) {
|
||
|
|
socket.write('250 2.1.0 Ok\r\n');
|
||
|
|
} else if (cmd.startsWith('RCPT TO')) {
|
||
|
|
socket.write('250 2.1.5 Ok\r\n');
|
||
|
|
} else if (cmd === 'DATA') {
|
||
|
|
inData = true;
|
||
|
|
dataBuffer = '';
|
||
|
|
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||
|
|
} else if (cmd === 'QUIT') {
|
||
|
|
socket.write('221 2.0.0 Bye\r\n');
|
||
|
|
socket.end();
|
||
|
|
} else if (cmd === 'RSET') {
|
||
|
|
socket.write('250 2.0.0 Ok\r\n');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
srv.listen(port, '127.0.0.1', () => {
|
||
|
|
resolve(srv);
|
||
|
|
});
|
||
|
|
|
||
|
|
srv.on('error', reject);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store original resolveMx so we can restore it
|
||
|
|
const originalResolveMx = dns.promises.Resolver.prototype.resolveMx;
|
||
|
|
|
||
|
|
tap.test('setup - start server and mock SMTP', async () => {
|
||
|
|
RustSecurityBridge.resetInstance();
|
||
|
|
|
||
|
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||
|
|
ports: [10425],
|
||
|
|
hostname: 'test.mta.local',
|
||
|
|
domains: [
|
||
|
|
{ domain: 'mta-test.com', dnsMode: 'forward' },
|
||
|
|
],
|
||
|
|
routes: [
|
||
|
|
{
|
||
|
|
name: 'mta-route',
|
||
|
|
priority: 10,
|
||
|
|
match: { recipients: '*@mta-test.com' },
|
||
|
|
action: { type: 'deliver' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'process-route',
|
||
|
|
priority: 20,
|
||
|
|
match: { recipients: '*@process-test.com' },
|
||
|
|
action: {
|
||
|
|
type: 'process',
|
||
|
|
options: {
|
||
|
|
contentScanning: true,
|
||
|
|
scanners: [{ type: 'spam' }],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await server.start();
|
||
|
|
bridgeAvailable = true;
|
||
|
|
} catch (err) {
|
||
|
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||
|
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
mockSmtpServer = await createMockSmtpServer(10426);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('MX resolution for a public domain', async () => {
|
||
|
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||
|
|
|
||
|
|
// Use the delivery system's resolveMxForDomain via a quick DNS lookup
|
||
|
|
const resolver = new dns.promises.Resolver();
|
||
|
|
try {
|
||
|
|
const records = await resolver.resolveMx('gmail.com');
|
||
|
|
expect(records).toBeTruthy();
|
||
|
|
expect(records.length).toBeGreaterThan(0);
|
||
|
|
// Each record should have exchange and priority
|
||
|
|
for (const rec of records) {
|
||
|
|
expect(typeof rec.exchange).toEqual('string');
|
||
|
|
expect(typeof rec.priority).toEqual('number');
|
||
|
|
}
|
||
|
|
console.log(`Resolved ${records.length} MX records for gmail.com`);
|
||
|
|
} catch (err) {
|
||
|
|
console.log(`SKIP: DNS resolution failed (network may be unavailable): ${(err as Error).message}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('group recipients by domain', async () => {
|
||
|
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||
|
|
|
||
|
|
// Test the grouping logic directly
|
||
|
|
const recipients = [
|
||
|
|
'alice@example.com',
|
||
|
|
'bob@example.com',
|
||
|
|
'carol@other.org',
|
||
|
|
'dave@example.com',
|
||
|
|
'eve@other.org',
|
||
|
|
];
|
||
|
|
|
||
|
|
const groups = new Map<string, string[]>();
|
||
|
|
for (const rcpt of recipients) {
|
||
|
|
const domain = rcpt.split('@')[1]?.toLowerCase();
|
||
|
|
if (!domain) continue;
|
||
|
|
const list = groups.get(domain) || [];
|
||
|
|
list.push(rcpt);
|
||
|
|
groups.set(domain, list);
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(groups.size).toEqual(2);
|
||
|
|
expect(groups.get('example.com')!.length).toEqual(3);
|
||
|
|
expect(groups.get('other.org')!.length).toEqual(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('MTA delivery to mock SMTP server via mocked MX', async () => {
|
||
|
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||
|
|
|
||
|
|
// Mock dns.promises.Resolver.resolveMx to return 127.0.0.1
|
||
|
|
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||
|
|
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||
|
|
};
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'sender@mta-test.com',
|
||
|
|
to: 'recipient@target-domain.com',
|
||
|
|
subject: 'MTA Delivery Test',
|
||
|
|
text: 'Testing MTA delivery with MX resolution.',
|
||
|
|
});
|
||
|
|
|
||
|
|
// Use sendOutboundEmail at the resolved MX host (which is mocked to 127.0.0.1)
|
||
|
|
// But the real test is the delivery system's handleMtaDelivery, which we test
|
||
|
|
// by sending through the server's outbound path with the mock MX.
|
||
|
|
|
||
|
|
// Direct test: resolve MX then send
|
||
|
|
const resolver = new dns.promises.Resolver();
|
||
|
|
const mxRecords = await resolver.resolveMx('target-domain.com');
|
||
|
|
expect(mxRecords[0].exchange).toEqual('127.0.0.1');
|
||
|
|
|
||
|
|
// Send via the resolved MX host to the mock SMTP server on port 10425
|
||
|
|
// Note: MTA delivery uses port 25 by default, but our mock is on 10425.
|
||
|
|
// We test the sendOutboundEmail path directly with the mock MX host.
|
||
|
|
const result = await server.sendOutboundEmail('127.0.0.1', 10426, email);
|
||
|
|
expect(result).toBeTruthy();
|
||
|
|
expect(result.accepted.length).toBeGreaterThan(0);
|
||
|
|
expect(result.response).toInclude('2.0.0');
|
||
|
|
|
||
|
|
// Restore original resolveMx
|
||
|
|
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('MTA delivery - connection refused to unreachable MX', async () => {
|
||
|
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'sender@mta-test.com',
|
||
|
|
to: 'recipient@unreachable-domain.com',
|
||
|
|
subject: 'Connection Refused MX Test',
|
||
|
|
text: 'This should fail — no server at the target.',
|
||
|
|
});
|
||
|
|
|
||
|
|
// Send to a port that nothing is listening on
|
||
|
|
try {
|
||
|
|
await server.sendOutboundEmail('127.0.0.1', 59789, email);
|
||
|
|
throw new Error('Expected sendOutboundEmail to fail');
|
||
|
|
} catch (err: any) {
|
||
|
|
expect(err).toBeTruthy();
|
||
|
|
expect(err.message.length).toBeGreaterThan(0);
|
||
|
|
console.log(`Got expected error: ${err.message}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('MTA delivery with multiple recipients across domains', async () => {
|
||
|
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||
|
|
|
||
|
|
// Mock MX to return 127.0.0.1 for all domains
|
||
|
|
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||
|
|
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||
|
|
};
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'sender@mta-test.com',
|
||
|
|
to: ['alice@domain-a.com', 'bob@domain-b.com'],
|
||
|
|
subject: 'Multi-Domain MTA Test',
|
||
|
|
text: 'Testing delivery to multiple domains.',
|
||
|
|
});
|
||
|
|
|
||
|
|
// Send to each recipient's domain individually (simulating MTA behavior)
|
||
|
|
for (const recipient of email.to) {
|
||
|
|
const singleEmail = new Email({
|
||
|
|
from: email.from,
|
||
|
|
to: recipient,
|
||
|
|
subject: email.subject,
|
||
|
|
text: email.text,
|
||
|
|
});
|
||
|
|
const result = await server.sendOutboundEmail('127.0.0.1', 10426, singleEmail);
|
||
|
|
expect(result.accepted.length).toEqual(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restore original resolveMx
|
||
|
|
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('E2E: send real email to hello@task.vc via MX resolution', async () => {
|
||
|
|
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||
|
|
|
||
|
|
// Resolve real MX records for task.vc
|
||
|
|
const resolver = new dns.promises.Resolver();
|
||
|
|
const mxRecords = await resolver.resolveMx('task.vc');
|
||
|
|
expect(mxRecords.length).toBeGreaterThan(0);
|
||
|
|
const mxHost = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange;
|
||
|
|
console.log(`Resolved MX for task.vc: ${mxHost} (priority ${mxRecords[0].priority})`);
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'test@mta-test.com',
|
||
|
|
to: 'hello@task.vc',
|
||
|
|
subject: `MTA E2E Test — ${new Date().toISOString()}`,
|
||
|
|
text: 'This is an automated E2E test from @serve.zone/mailer verifying real MX resolution and outbound SMTP delivery.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await server.sendOutboundEmail(mxHost, 25, email);
|
||
|
|
expect(result).toBeTruthy();
|
||
|
|
expect(result.accepted).toBeTruthy();
|
||
|
|
expect(result.accepted.length).toEqual(1);
|
||
|
|
expect(result.accepted[0]).toEqual('hello@task.vc');
|
||
|
|
expect(result.response).toInclude('2.0.0');
|
||
|
|
console.log(`Email delivered to hello@task.vc via ${mxHost}: ${result.response}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('cleanup - stop server and mock SMTP', async () => {
|
||
|
|
// Restore MX resolver in case it wasn't restored
|
||
|
|
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||
|
|
|
||
|
|
// Force-close mock server (destroy all open sockets)
|
||
|
|
if (mockSmtpServer) {
|
||
|
|
mockSmtpServer.close();
|
||
|
|
}
|
||
|
|
if (bridgeAvailable) {
|
||
|
|
await server.stop();
|
||
|
|
}
|
||
|
|
await tap.stopForcefully();
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|