Files
mdg-filius/packages/core/test/emailServer.test.ts
T
julian.kiedaischandClaude Opus 5.5 015635926f feat(core): the e-mail server after Java, measured against Java
SMTP on port 25 stores mails for the domain's accounts as Java reads
them anew and tells a local sender of unknown recipients; POP3 on port
110 hands them out. The mailboxes are in the snapshot and go back into
the project. EmailRun plays four stories over the network; MailCases
feeds Java's workers without one (26 cases, de and en). 18 cases name
Java bugs that the core does not take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:58:26 +00:00

390 lines
19 KiB
TypeScript

// The e-mail server's SMTP and POP3 dialogues (apps/emailServer.ts) against
// Java FILIUS' own: tools/java-oracle's MailCases fed Java's workers with the
// messages of email-server.cases.txt (email-server.<de|en>.java.json). After
// each message: what was sent, the window's lines, whether the connection
// was closed, and the accounts must be Java's, in both languages, except
// where Java errs.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
addressEntry,
deviceConfiguration,
NetworkRuntimeEngine,
topologySnapshot,
type TopologySnapshotInput,
addressText,
formatMail,
mailboxEntry,
MailServerState,
mailSize,
parseMail,
Pop3Session,
SmtpSession,
type EmailServerLogEntry,
type MailAccount,
type PageLanguage,
type Steps,
} from '../src/index.js';
import { link, pc, uuid } from './fixtures.js';
import { MAIL_ACCOUNTS } from './mailAccounts.js';
import { emailServerText } from './programLines.js';
const here = dirname(fileURLToPath(import.meta.url));
type JavaAccounts = [string, [string, boolean, boolean][]][];
interface JavaStep {
message: string | null;
sent: string[];
lines: string[];
closed: boolean;
thrown: string | null;
accounts: JavaAccounts;
}
interface JavaCase {
name: string;
protocol: 'smtp' | 'pop3';
steps: JavaStep[];
}
const CLIENT = '192.168.0.20';
/** Runs a dialogue's steps to their end: nothing waits here, the connection sends at once. */
function run(steps: Steps): void {
for (let next = steps.next(); next.done !== true; next = steps.next()) throw new Error('a step waited');
}
/** The session's own run of a case: each step as MailCases writes Java's. */
function play(javaCase: JavaCase, language: PageLanguage): JavaStep[] {
const lines: EmailServerLogEntry[] = [];
const accounts: MailAccount[] = MAIL_ACCOUNTS.map((account) => ({ ...account, mailbox: account.mailbox.map(mailboxEntry) }));
const state = new MailServerState(
'filius.de',
accounts,
language,
(entry) => lines.push(entry),
() => undefined,
() => '192.168.0.30',
() => 0,
);
const sent: string[] = [];
const connection = {
address: CLIENT,
*send(text: string): Steps<boolean> {
sent.push(text);
return true;
},
};
const session = javaCase.protocol === 'smtp' ? new SmtpSession(state, connection) : new Pop3Session(state, connection);
const step = (text: string | null): JavaStep => {
const result: JavaStep = {
message: text,
sent: [...sent],
lines: lines.map((entry) => emailServerText(entry, language)),
closed: session.closed,
thrown: null,
accounts: accounts.map((account) => [account.username, account.mailbox.map((entry) => [formatMail(entry.mail), entry.isNew, entry.isMarkedForDeletion])]),
};
sent.length = 0;
lines.length = 0;
return result;
};
run(session.begin());
const steps = [step(null)];
for (const each of javaCase.steps.slice(1)) {
run(session.receive(each.message!));
steps.push(step(each.message));
}
return steps;
}
/** Java's date of a notice, in its locale and time: a placeholder. */
const withoutDate = (text: string) => text.replace(/\nDate: [^\n]*\n/g, '\nDate: <date>\n');
const stored = (username: string, language: PageLanguage) => emailServerText({ kind: 'stored', username }, language);
/**
* A step as Java means it: the answers given, the window's lines rebuilt
* from them (received, sent, then the mails stored), the accounts as given.
*/
function fixed(
step: JavaStep,
language: PageLanguage,
changes: { sent?: string[]; stored?: string[]; accounts?: JavaAccounts; closed?: boolean },
): JavaStep {
const sent = changes.sent ?? step.sent;
const storedLines = changes.stored?.map((username) => stored(username, language)) ?? step.lines.filter((line) => !line.startsWith(`${CLIENT}`));
return {
...step,
sent,
lines: [`${CLIENT}< ${step.message}`, ...sent.map((text) => `${CLIENT}> ${text}`), ...storedLines],
closed: changes.closed ?? step.closed,
thrown: null,
accounts: changes.accounts ?? step.accounts,
};
}
type Fix = (steps: JavaStep[], language: PageLanguage) => JavaStep[];
/** Each step from `from` on with the accounts given (a mail Java stores differently changes every later step). */
const accountsFrom = (steps: JavaStep[], from: number, accounts: JavaAccounts) => steps.map((step, index) => (index >= from ? { ...step, accounts } : step));
const order = (language: PageLanguage) => (language === 'de' ? '503 Falsche Reihenfolge der Kommandos.' : '503 Wrong order of commands.');
const queued = '250 Mail queued for delivery';
const noSuchMessage = '-ERR no such message';
const unknownCommand = '-ERR unknown command';
function addTo(accounts: JavaAccounts, username: string, ...mails: [string, boolean, boolean][]): JavaAccounts {
return accounts.map(([name, mailbox]) => [name, name === username ? [...mailbox, ...mails] : mailbox]);
}
/**
* Where Java errs, the case as Java means it (the core's departures, see
* emailServer.ts).
*/
const JAVA_BUGS: Record<string, Fix> = {
// After a mail Java answers the next command 503 and keeps sender,
// recipients and text: the second mail went to anna too, with the first's text.
smtpTwice: (steps, language) => {
const start = steps[0]!.accounts;
const first = steps[6]!.accounts.find(([name]) => name === 'anna')![1].at(-1)!;
const second: [string, boolean, boolean] = ['From: <anna@filius.de>\nTo: <bob@filius.de>\nSubject: Zwei\n\nan Bob', true, false];
const result = steps.map((step, index) => (index === 7 ? fixed(step, language, { sent: ['250 Sender OK'] }) : step));
result[11] = fixed(steps[11]!, language, { stored: ['bob'] });
return accountsFrom(result, 11, addTo(addTo(start, 'anna', first), 'bob', second));
},
// A command the state does not take: Java answers nothing.
smtpStart: (steps, language) => steps.map((step, index) => (index === 1 || index === 2 ? fixed(step, language, { sent: [order(language)] }) : step)),
// HELO again: Java answers nothing; the core begins anew.
smtpHelo: (steps, language) => steps.map((step, index) => (index === 3 ? fixed(step, language, { sent: ['250 Hello wieder'] }) : step)),
// "MAIL FROM" (two spaces) and HELO again: Java answers nothing. After
// HELO the envelope is new: DATA and the text are out of order, nothing is stored.
smtpEnvelope: (steps, language) => {
const result = steps.map((step, index) => {
if (index === 3) return fixed(step, language, { sent: [order(language)] });
if (index === 8) return fixed(step, language, { sent: ['250 Hello noch einmal'] });
if (index === 9 || index === 10) return fixed(step, language, { sent: [order(language)], stored: [] });
return step;
});
return accountsFrom(result, 10, steps[0]!.accounts);
},
// Java compares the domain exactly and sends the mail on; anna@FILIUS.DE is anna's.
smtpDomainCase: (steps, language) => {
const mail: [string, boolean, boolean] = ['Subject: Gross\n\nText', true, false];
const result = steps.map((step, index) => (index === 5 ? fixed(step, language, { stored: ['anna'] }) : step));
return accountsFrom(result, 5, addTo(steps[0]!.accounts, 'anna', mail));
},
// A field Java does not know ends its header, and Subject became text.
smtpUnknownHeader: (steps) => accountsFrom(steps, 5, addTo(steps[0]!.accounts, 'anna', ['From: <bob@filius.de>\nSubject: Betreff\n\nText', true, false])),
// A line without a field name (": leer") ends Java's header and is dropped; the core's text begins with it.
smtpColonFirst: (steps) => accountsFrom(steps, 5, addTo(steps[0]!.accounts, 'anna', ['Subject: vorn\n\n: leer\n\nText', true, false])),
// Without a header Java drops the first line.
smtpNoHeader: (steps) => accountsFrom(steps, 5, addTo(steps[0]!.accounts, 'anna', ['\nHallo\nWelt', true, false])),
// "quit" during DATA: Java ends the connection; it is the mail's text.
smtpQuitInData: (steps, language) => steps.map((step, index) => (index === 6 ? { ...fixed(step, language, { sent: [], closed: false }), lines: [`${CLIENT}< quit`] } : step)),
// An empty sender: Java's notice throws (NullPointerException); none is sent.
smtpEmptySender: (steps, language) => steps.map((step, index) => (index === 5 ? fixed(step, language, {}) : step)),
// A recipient without "@": Java's delivery throws; the core sends the sender a notice, to the
// envelope's sender, as the mail has no From.
smtpWithoutAt: (steps, language) => {
const notice: [string, boolean, boolean] = [
'From: <noreply@filius.de>\nTo: <bob@filius.de>\nSubject: Non Delivery Notification\n\n' +
'The mail could not be delivered either because user or mail domain is unknown.\nMessage could not be delivered to the following recipients:\n\tanna\n\n' +
'Date: <date>\nMail-Server: 192.168.0.30\n\n______________________________________________________________________\nSubject: Ohne\n\nText',
true,
false,
];
const result = steps.map((step, index) => (index === 5 ? fixed(step, language, { sent: [queued], stored: ['bob'] }) : step));
return accountsFrom(result, 5, addTo(steps[0]!.accounts, 'bob', notice));
},
// RETR of a message retrieved before: Java refuses.
pop3Again: (steps, language) => steps.map((step, index) => (index === 4 ? fixed(step, language, { sent: steps[3]!.sent }) : step)),
// After DELE 0, Java's LIST numbers the other message 0; RETR 0 still means the deleted one.
pop3Numbers: (steps, language) => steps.map((step, index) => (index === 4 ? fixed(step, language, { sent: ['+OK 1 89\n1 89\n'] }) : step)),
// LIST without messages: Java's list fails.
pop3EmptyList: (steps, language) => steps.map((step, index) => (index === 4 ? fixed(step, language, { sent: ['+OK 0 0\n'] }) : step)),
// Before log-in Java answers empty.
pop3Before: (steps, language) =>
steps.map((step, index) => (index >= 1 && index <= 5 ? fixed(step, language, { sent: [unknownCommand] }) : index === 7 ? fixed(step, language, { sent: ['+OK'] }) : step)),
// A message that is not there: Java sends an empty mail or an empty
// answer; LIST 0 lists all; RETR x throws and ends the connection.
pop3BadArguments: (steps, language) =>
steps.map((step, index) => {
if (index === 3 || index === 4 || index === 5 || index === 9) return fixed(step, language, { sent: [noSuchMessage] });
if (index === 6) return fixed(step, language, { sent: ['+OK 0 80'] });
return step;
}),
// USER without a name: Java throws and ends the connection.
pop3NoArgument: (steps, language) => steps.map((step, index) => (index === 1 ? fixed(step, language, { sent: ['-ERR user or password wrong'] }) : step)),
// Two spaces before the number: Java throws and ends the connection.
pop3TwoSpaces: (steps, language) => steps.map((step, index) => (index === 3 ? fixed(step, language, { sent: [noSuchMessage] }) : step)),
};
function javaCases(language: PageLanguage): JavaCase[] {
return JSON.parse(readFileSync(join(here, `email-server.${language}.java.json`), 'utf8')) as JavaCase[];
}
const placeholders = (step: JavaStep): JavaStep => ({
...step,
accounts: step.accounts.map(([name, mailbox]) => [name, mailbox.map(([text, isNew, deleted]) => [withoutDate(text), isNew, deleted])]),
});
describe("the e-mail server's dialogues as Java FILIUS answers them", () => {
for (const language of ['de', 'en'] as const) {
const cases = javaCases(language);
it(`corrects Java only where it errs (${language})`, () => {
for (const [name, fix] of Object.entries(JAVA_BUGS)) {
const javaCase = cases.find((each) => each.name === name)!;
expect(JSON.stringify(fix(javaCase.steps, language)), name).not.toBe(JSON.stringify(javaCase.steps));
}
});
for (const javaCase of cases) {
it(`${javaCase.name} (${language})`, () => {
const expected = (JAVA_BUGS[javaCase.name]?.(javaCase.steps, language) ?? javaCase.steps).map(placeholders);
const actual = play(javaCase, language).map(placeholders);
for (const [index, step] of expected.entries()) expect(actual[index], `step ${index}: ${JSON.stringify(step.message)}`).toEqual(step);
});
}
}
});
describe('e-mail as Java writes it', () => {
it('reads addresses as AddressEntry does', () => {
expect(addressEntry(' Anna Muster <anna@filius.de> ')).toEqual({ name: 'Anna Muster', mailAddress: 'anna@filius.de' });
expect(addressEntry('<anna@filius.de>')).toEqual({ name: null, mailAddress: 'anna@filius.de' });
expect(addressEntry('anna@filius.de')).toEqual({ name: null, mailAddress: 'anna@filius.de' });
expect(addressEntry('Anna')).toEqual({ name: 'Anna', mailAddress: null });
expect(addressEntry('a>b<c')).toEqual({ name: 'a>b<c', mailAddress: null });
expect(addressText({ name: null, mailAddress: null })).toBeNull();
});
it('writes no Bcc, and counts a size as Java does', () => {
const mail = parseMail('From: A <a@b.de>\nTo: x@y.de, Y <y@z.de>\nBcc: geheim@b.de\nSubject: Hallo \n\nText\n');
expect(mail.bcc).toEqual([{ name: null, mailAddress: 'geheim@b.de' }]);
expect(formatMail(mail)).toBe('From: A <a@b.de>\nTo: <x@y.de>, Y <y@z.de>\nSubject: Hallo\n\nText');
expect(mailSize(mail)).toBe('Text\n'.length + 'A <a@b.de>'.length + 'Hallo'.length + '[<x@y.de>, Y <y@z.de>]'.length);
expect(mailSize(parseMail('\nx'))).toBe('x\n'.length + 'null'.length * 2 + '[]'.length);
});
});
const SERVER = uuid(0x5001);
const HOST = uuid(0x5002);
function mailNetwork(): TopologySnapshotInput {
return {
nodes: [pc(SERVER, uuid(0x5011)), pc(HOST, uuid(0x5012))],
links: [link(SERVER, uuid(0x5011), HOST, uuid(0x5012))],
deviceConfigurations: new Map([
[SERVER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
[HOST, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '')],
]),
emailServersByNodeId: new Map([[SERVER, { domain: 'filius.de', accounts: MAIL_ACCOUNTS, isActive: true }]]),
};
}
/** Connects to the server's port and sends each message, each after the answer to the one before; the answers, the greeting first. */
function talk(engine: NetworkRuntimeEngine, port: number, ...messages: string[]): string[] {
const socket = engine.openTcpClientSocket(HOST, '10.0.0.1', port)!;
if (engine.connectTcpWithResult(socket) !== 'connected') return [];
const answers: string[] = [];
const receive = () => {
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 2_000 });
const answer = engine.receiveTcp(socket);
if (answer !== undefined) answers.push(new TextDecoder().decode(answer));
};
receive();
for (const message of messages) {
engine.sendTcp(socket, new TextEncoder().encode(message));
receive();
}
return answers;
}
describe('the e-mail server beyond the cases', () => {
it('writes a failed answer as Java does (sw_smtpmitarbeiter_msg14)', () => {
const lines: EmailServerLogEntry[] = [];
const state = new MailServerState('filius.de', [], 'de', (entry) => lines.push(entry), () => undefined, () => '10.0.0.1', () => 0);
const session = new SmtpSession(state, {
address: '10.0.0.2',
*send(): Steps<boolean> {
return false;
},
});
run(session.begin());
expect(lines.map((entry) => emailServerText(entry))).toEqual(['----', 'Fehler beim Senden an 10.0.0.2']);
});
it("takes a model's message as Java loads it from its file: the date, the subject trimmed when written, no sender as an empty name", () => {
const entry = mailboxEntry({ ...MAIL_ACCOUNTS[0]!.mailbox[0]!, subject: ' Termin ', receivedAtMilliseconds: 1_000, from: { name: null, mailAddress: null } });
expect(formatMail(entry.mail)).toBe('From: \nTo: <anna@filius.de>\nSubject: Termin\nDate Received: 1000\n\nErste Zeile\nZweite Zeile');
expect(entry.mail.subject).toBe(' Termin ');
});
});
describe('the e-mail server in the engine', () => {
it('switches SMTP and POP3 on and off together', () => {
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot: topologySnapshot(mailNetwork()), seed: 14, initialTimeMilliseconds: 0 });
const server = engine.emailServer(SERVER)!;
expect(server.isActive).toBe(true);
server.setActive(false);
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
expect(server.isActive).toBe(false);
expect(talk(engine, 25)).toEqual([]);
expect(talk(engine, 110)).toEqual([]);
server.setActive(true);
expect(talk(engine, 25, 'QUIT')).toEqual(['220 Willkommen bei filius.de', '221 Server beendet Verbindung.']);
expect(talk(engine, 110, 'QUIT')).toEqual(['+OK POP3 server ready', '+OK']);
});
it('keeps its mailboxes in the snapshot: a mail taken, a mail deleted; a new start begins from there', () => {
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot: topologySnapshot({ ...mailNetwork(), language: 'en' }), seed: 14, initialTimeMilliseconds: 0 });
talk(engine, 25, 'HELO x', 'MAIL FROM:<anna@filius.de>', 'RCPT TO:<bob@filius.de>', 'DATA', 'Bcc: <bob@filius.de>\nSubject: Neu\n\nText\r\n.\r\n', 'QUIT');
talk(engine, 25, 'HELO x', 'MAIL FROM:<anna@filius.de>', 'RCPT TO:<anna@filius.de>', 'DATA', 'Ohne Kopf\n.\n', 'QUIT');
const mailbox = (username = 'bob') => engine.state.topologySnapshot.emailServersByNodeId.get(SERVER)!.accounts.find((account) => account.username === username)!.mailbox;
// Stored as Java reads the mail anew: no Bcc, no "\r".
expect(mailbox()).toEqual([expect.objectContaining({ id: 4, bcc: [], subject: 'Neu', body: 'Text\n', isNew: true, isMarkedForDeletion: false })]);
expect(mailbox('anna').at(-1)).toEqual(expect.objectContaining({ from: { name: null, mailAddress: null }, subject: '', body: 'Ohne Kopf\n' }));
expect(talk(engine, 110, 'USER bob', 'PASS passwort', 'RETR 0', 'DELE 0')).toEqual([
'+OK POP3 server ready',
'+OK enter password',
'+OK Mailbox locked and ready',
'+OK message follows \nSubject: Neu\n\nText',
'+OK message marked for delete',
]);
expect(mailbox()).toEqual([expect.objectContaining({ isNew: false, isMarkedForDeletion: true })]);
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: 14, initialTimeMilliseconds: 0 });
// As Java loads its file: every message new, none deleted; no sender is an empty name ("Text\n", "", "Neu", "[]").
expect(talk(engine, 110, 'USER bob', 'PASS passwort', 'STAT', 'DELE 0', 'QUIT')).toEqual([
'+OK POP3 server ready',
'+OK enter password',
'+OK Mailbox locked and ready',
'+OK 1 10',
'+OK message marked for delete',
'+OK',
]);
expect(mailbox()).toEqual([]);
});
it("writes a notice's date in the project's language, at the wall clock's time", () => {
const at = new Date(2026, 9, 1, 18, 5).getTime();
const notice = (language: PageLanguage) => {
const accounts: MailAccount[] = MAIL_ACCOUNTS.map((account) => ({ ...account, mailbox: [] }));
const state = new MailServerState('filius.de', accounts, language, () => undefined, () => undefined, () => '10.0.0.1', () => at);
state.deliver(parseMail('Subject: x\n\ny\n'), 'anna@filius.de', ['niemand@filius.de']);
return formatMail(accounts[0]!.mailbox[0]!.mail);
};
expect(notice('de')).toContain('\nDate: 01.10.26, 18:05\nMail-Server: 10.0.0.1\n');
expect(notice('en')).toContain('\nDate: 01/10/2026, 18:05\n');
expect(notice('de')).toContain('To: <anna@filius.de>\n');
});
});