240 lines
6.2 KiB
TypeScript
240 lines
6.2 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 { SmtpState } from '../ts/mail/delivery/interfaces.js';
|
||
|
|
|
||
|
|
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 bridge: RustSecurityBridge;
|
||
|
|
let bridgeAvailable = false;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build a minimal SMTP session object for processEmailByMode().
|
||
|
|
*/
|
||
|
|
function buildSession(email: Email): any {
|
||
|
|
return {
|
||
|
|
id: `test-${Date.now()}-${Math.random().toString(36).substring(2)}`,
|
||
|
|
state: SmtpState.FINISHED,
|
||
|
|
mailFrom: email.from,
|
||
|
|
rcptTo: email.to,
|
||
|
|
emailData: '',
|
||
|
|
useTLS: false,
|
||
|
|
connectionEnded: false,
|
||
|
|
remoteAddress: '127.0.0.1',
|
||
|
|
clientHostname: 'test-client.local',
|
||
|
|
secure: false,
|
||
|
|
authenticated: false,
|
||
|
|
envelope: {
|
||
|
|
mailFrom: { address: email.from, args: {} },
|
||
|
|
rcptTo: email.to.map((addr: string) => ({ address: addr, args: {} })),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
tap.test('setup - start server with routing rules on port 10225', async () => {
|
||
|
|
RustSecurityBridge.resetInstance();
|
||
|
|
bridge = RustSecurityBridge.getInstance();
|
||
|
|
|
||
|
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||
|
|
ports: [10225],
|
||
|
|
hostname: 'test.routing.local',
|
||
|
|
domains: [
|
||
|
|
{ domain: 'process.com', dnsMode: 'forward' },
|
||
|
|
{ domain: 'local.com', dnsMode: 'forward' },
|
||
|
|
{ domain: 'external.com', dnsMode: 'forward' },
|
||
|
|
],
|
||
|
|
routes: [
|
||
|
|
{
|
||
|
|
name: 'reject-route',
|
||
|
|
priority: 40,
|
||
|
|
match: {
|
||
|
|
senders: '*@spammer.com',
|
||
|
|
},
|
||
|
|
action: {
|
||
|
|
type: 'reject',
|
||
|
|
reject: {
|
||
|
|
code: 550,
|
||
|
|
message: 'Spam rejected',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'process-route',
|
||
|
|
priority: 30,
|
||
|
|
match: {
|
||
|
|
recipients: '*@process.com',
|
||
|
|
},
|
||
|
|
action: {
|
||
|
|
type: 'process',
|
||
|
|
process: {
|
||
|
|
scan: true,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'deliver-route',
|
||
|
|
priority: 20,
|
||
|
|
match: {
|
||
|
|
recipients: '*@local.com',
|
||
|
|
},
|
||
|
|
action: {
|
||
|
|
type: 'deliver',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'forward-route',
|
||
|
|
priority: 10,
|
||
|
|
match: {
|
||
|
|
recipients: '*@external.com',
|
||
|
|
},
|
||
|
|
action: {
|
||
|
|
type: 'forward',
|
||
|
|
forward: {
|
||
|
|
host: '127.0.0.1',
|
||
|
|
port: 59999, // No server listening — expected failure
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('process action - queues email for processing', async () => {
|
||
|
|
if (!bridgeAvailable) {
|
||
|
|
console.log('SKIP: bridge not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'sender@example.com',
|
||
|
|
to: 'user@process.com',
|
||
|
|
subject: 'Process test',
|
||
|
|
text: 'This email should be queued for processing.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = buildSession(email);
|
||
|
|
const result = await server.processEmailByMode(email, session);
|
||
|
|
expect(result).toBeTruthy();
|
||
|
|
|
||
|
|
const stats = server.deliveryQueue.getStats();
|
||
|
|
expect(stats.modes.process).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('deliver action - queues email for MTA delivery', async () => {
|
||
|
|
if (!bridgeAvailable) {
|
||
|
|
console.log('SKIP: bridge not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'sender@example.com',
|
||
|
|
to: 'user@local.com',
|
||
|
|
subject: 'Deliver test',
|
||
|
|
text: 'This email should be queued for local delivery.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = buildSession(email);
|
||
|
|
const result = await server.processEmailByMode(email, session);
|
||
|
|
expect(result).toBeTruthy();
|
||
|
|
|
||
|
|
const stats = server.deliveryQueue.getStats();
|
||
|
|
expect(stats.modes.mta).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('reject action - throws with correct code', async () => {
|
||
|
|
if (!bridgeAvailable) {
|
||
|
|
console.log('SKIP: bridge not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'bad@spammer.com',
|
||
|
|
to: 'user@process.com',
|
||
|
|
subject: 'Spam attempt',
|
||
|
|
text: 'This should be rejected.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = buildSession(email);
|
||
|
|
|
||
|
|
try {
|
||
|
|
await server.processEmailByMode(email, session);
|
||
|
|
throw new Error('Expected processEmailByMode to throw for rejected email');
|
||
|
|
} catch (err: any) {
|
||
|
|
expect(err.responseCode).toEqual(550);
|
||
|
|
expect(err.message).toInclude('Spam rejected');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('forward action - fails to unreachable host', async () => {
|
||
|
|
if (!bridgeAvailable) {
|
||
|
|
console.log('SKIP: bridge not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'sender@example.com',
|
||
|
|
to: 'user@external.com',
|
||
|
|
subject: 'Forward test',
|
||
|
|
text: 'This forward should fail — no server at port 59999.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = buildSession(email);
|
||
|
|
|
||
|
|
try {
|
||
|
|
await server.processEmailByMode(email, session);
|
||
|
|
throw new Error('Expected processEmailByMode to throw for unreachable forward host');
|
||
|
|
} catch (err: any) {
|
||
|
|
// We expect an error from the failed SMTP connection
|
||
|
|
expect(err).toBeTruthy();
|
||
|
|
expect(err.message).toBeTruthy();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('no matching route - throws error', async () => {
|
||
|
|
if (!bridgeAvailable) {
|
||
|
|
console.log('SKIP: bridge not running');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const email = new Email({
|
||
|
|
from: 'sender@example.com',
|
||
|
|
to: 'nobody@unmatched.com',
|
||
|
|
subject: 'Unmatched route test',
|
||
|
|
text: 'No route matches this recipient.',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = buildSession(email);
|
||
|
|
|
||
|
|
try {
|
||
|
|
await server.processEmailByMode(email, session);
|
||
|
|
throw new Error('Expected processEmailByMode to throw for no matching route');
|
||
|
|
} catch (err: any) {
|
||
|
|
expect(err.message).toInclude('No matching route');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('cleanup - stop server', async () => {
|
||
|
|
if (bridgeAvailable) {
|
||
|
|
await server.stop();
|
||
|
|
}
|
||
|
|
await tap.stopForcefully();
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|