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>
This commit is contained in:
2026-10-01 18:58:26 +00:00
co-authored by Claude Opus 5.5
parent 8438b42018
commit 015635926f
26 changed files with 45188 additions and 14 deletions
+185
View File
@@ -0,0 +1,185 @@
# Cases for tools/java-oracle's MailCases: FILIUS' SMTP and POP3 workers fed
# with a client's messages, one per line. Read by emailServer.test.ts.
case smtpPlain smtp
HELO 192.168.0.20
MAIL FROM: <bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
From: Bob Beispiel <bob@filius.de>\u000aTo: <anna@filius.de>\u000aSubject: Gru\u00df\u000a\u000aHallo Anna,\u000awie geht's?\u000d\u000a.\u000d\u000a
QUIT
case smtpTwice smtp
EHLO client
MAIL FROM: <bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
From: <bob@filius.de>\u000aTo: <anna@filius.de>\u000aSubject: Eins\u000a\u000aerster Teil\u000a
zweiter Teil\u000a.\u000a
MAIL FROM: <anna@filius.de>
MAIL FROM: <anna@filius.de>
RCPT TO:<bob@filius.de>
DATA
From: <anna@filius.de>\u000aTo: <bob@filius.de>\u000aSubject: Zwei\u000a\u000aan Bob\u000a.\u000a
QUIT
case smtpStart smtp
DATA
MAIL FROM:<bob@filius.de>
noop
helo
Quit
case smtpHelo smtp
EHLO viele Leerzeichen
NOOP
HELO wieder
case smtpEnvelope smtp
HELO x
MAIL FROM:bob
MAIL FROM:<bob@filius.de>
mail from : <bob@filius.de>
RCPT TO:anna
rcpt to: <ANNA@filius.de>
RCPT TO:<anna@filius.de> <bob@filius.de>
HELO noch einmal
DATA
Subject: Kopf\u000a\u000aText\u000a.\u000a
case smtpDomainCase smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@FILIUS.DE>
DATA
Subject: Gross\u000a\u000aText\u000a.\u000a
case smtpHeaders smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
FROM: Bob <bob@filius.de>\u000ato: a@b.de, Carla <carla@andere.de>,\u000aCc: <chef@filius.de>\u000aBcc: geheim@filius.de\u000aSubject: Zeit: 10 Uhr\u000a\u000aText mit: Doppelpunkt\u000a\u000aAbsatz\u000a.\u000a
case smtpOddHeaders smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
To: x>y@z.de\u000aCc: ,\u000aSubject : mit Leerzeichen\u000a\u000aText\u000a.\u000a
case smtpColonFirst smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
Subject: vorn\u000a: leer\u000a\u000aText\u000a.\u000a
case smtpUnknownHeader smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
From: <bob@filius.de>\u000aX-Mailer: Filius\u000aSubject: Betreff\u000a\u000aText\u000a.\u000a
case smtpNoHeader smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
Hallo\u000aWelt\u000a.\u000a
case smtpDataPieces smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
Subject: Teile\u000a\u000aeins
zwei\u000a
.
case smtpQuitInData smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna@filius.de>
DATA
Subject: Abbruch\u000a\u000avorher\u000a
quit
case smtpUnknownRecipient smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<niemand@filius.de>
RCPT TO:<anna@filius.de>
RCPT TO:<Keiner@filius.de>
DATA
From: Bob Beispiel <bob@filius.de>\u000aTo: <niemand@filius.de>\u000aSubject: Wer?\u000a\u000aIst da jemand?\u000a.\u000a
case smtpEmptySender smtp
HELO x
MAIL FROM:<>
RCPT TO:<niemand@filius.de>
DATA
Subject: Leer\u000a\u000aText\u000a.\u000a
case smtpWithoutAt smtp
HELO x
MAIL FROM:<bob@filius.de>
RCPT TO:<anna>
DATA
Subject: Ohne\u000a\u000aText\u000a.\u000a
case pop3Plain pop3
USER anna
PASS geheim
STAT
LIST
RETR 0
RETR 1
DELE 0
STAT
RSET
DELE 0
QUIT
case pop3Again pop3
USER anna
PASS geheim
RETR 1
RETR 1
case pop3Numbers pop3
USER anna
PASS geheim
DELE 0
LIST
RETR 0
RETR 1
QUIT
case pop3EmptyList pop3
USER bob
PASS passwort
STAT
LIST
QUIT
case pop3Before pop3
STAT
LIST
RETR 0
DELE 0
RSET
NOOP
QUIT
case pop3Users pop3
USER niemand
PASS x
user ANNA
PASS falsch
PASS geheim
USER bob
PASS passwort
NOOP
case pop3UserSwitch pop3
USER anna
USER niemand
PASS geheim
USER anna
PASS GEHEIM
PASS geheim
case pop3BadArguments pop3
USER anna
PASS geheim
RETR 7
DELE 7
RETR -1
LIST 0
STAT extra
NOOP
RETR x
case pop3NoArgument pop3
USER
case pop3TwoSpaces pop3
USER anna
PASS geheim
RETR 0
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
// The e-mail server as Java FILIUS runs it. tools/java-oracle's EmailRun let
// FILIUS' generic client talk SMTP and POP3 to an e-mail server on
// mail-lan's network and wrote every frame each card saw, Java's tables of
// them, the lines of the server's and the client's windows, and the
// server's accounts at the end (email-java-<story>.java.json). The engine
// plays the same stories with its programs (apps/emailServer.ts, the
// generic client of apps/echo.ts): each card's frames in the same order,
// with the same content and rows, at about the same time; each window's
// lines as Java's, at about the same time; the same accounts at the end.
// Java's errors in these dialogues are measured without a network
// (emailServer.test.ts); these stories keep to what Java does right.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { formatMail, withSnapshot, type LogLine, type NetworkRuntimeEngine } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
import { MAIL_ACCOUNTS } from './mailAccounts.js';
import { clientText, emailServerText, isPolled, POLL_MILLISECONDS } from './programLines.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const CLIENT = '00000000-0000-0000-0000-000000000201';
const SERVER = '00000000-0000-0000-0000-000000000203';
type Program = 'server' | 'client';
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
events: [number, Program, string][];
/** Each account's name and its messages: Email.toString, new, marked for deletion. */
accounts: [string, ...[string, string, string][]][];
}
type Act = (engine: NetworkRuntimeEngine) => void;
/** EmailRun's calls at their times: connecting takes 2.5 s, each message 1.5 s, from 500 ms on. */
function story(...parts: (readonly ['connect', number] | readonly ['send', ...string[]] | readonly ['wait', number])[]): [number, Act][] {
const acts: [number, Act][] = [];
let at = 500;
for (const [kind, ...rest] of parts) {
if (kind === 'connect') {
const port = rest[0] as number;
acts.push([at, (engine) => engine.simpleClient(CLIENT)!.connect('192.168.0.30', port)]);
at += 2_500;
} else if (kind === 'send') {
for (const text of rest as string[]) {
acts.push([at, (engine) => engine.simpleClient(CLIENT)!.send(text)]);
at += 1_500;
}
} else {
at += rest[0] as number;
}
}
return acts;
}
const STORIES: Record<string, [number, Act][]> = {
smtp: story(
['connect', 25],
[
'send',
'HELO 192.168.0.20',
'MAIL FROM: <bob@filius.de>',
'RCPT TO:<anna@filius.de>',
'DATA',
"From: Bob Beispiel <bob@filius.de>\nTo: <anna@filius.de>\nSubject: Gruß\n\nHallo Anna,\nwie geht's?\r\n.\r\n",
'QUIT',
],
),
smtpErrors: story(
['connect', 25],
[
'send',
'NOOP',
'EHLO',
'DATA',
'MAIL FROM: bob@filius.de',
'RCPT TO:<niemand@filius.de>',
'DATA',
'MAIL FROM:<bob@filius.de>',
'RCPT TO: anna',
'NOOP',
'DATA',
'From: <bob@filius.de>\nTo: <niemand@filius.de>\nSubject: Wer?\n\nIst da jemand?\n.\n',
'QUIT',
],
),
pop3: story(
['connect', 110],
['send', 'USER anna', 'PASS geheim', 'STAT', 'LIST', 'RETR 0', 'RETR 1', 'DELE 0', 'DELE 1', 'QUIT'],
['wait', 1_000],
['connect', 110],
['send', 'USER anna', 'PASS geheim', 'STAT', 'QUIT'],
),
pop3Errors: story(['connect', 110], ['send', 'NOOP', 'USER niemand', 'PASS x', 'USER anna', 'PASS falsch', 'PASS geheim', 'NOOP', 'USER bob', 'QUIT']),
};
/** A notice's date: Java's in its locale and the time it ran. */
const withoutDate = (text: string) => text.replace(/\nDate: [^\n]*\n/g, '\nDate: <date>\n');
type Lines = Record<Program, [number, string][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = { server: [], client: [] };
for (const [time, program, text] of java.events) {
// Anwendung.starten and the server's refreshes tell their observers nothing (null), which the windows skip.
if (text === 'null' || text.startsWith('call ')) continue;
lines[program].push([time, withClientPortText(text)]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine): Lines {
const lines = <E>(log: readonly LogLine<E>[], text: (entry: E) => string): [number, string][] =>
log.map((line) => [line.timeMilliseconds, withClientPortText(text(line))]);
return { server: lines(engine.emailServer(SERVER)!.log.all, (entry) => emailServerText(entry)), client: lines(engine.simpleClient(CLIENT)!.log.all, clientText) };
}
describe('the e-mail server as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'mail-lan.scenario.json'), 'utf8')) as Scenario;
const snapshot = withSnapshot(snapshotOf(scenario), {
emailServersByNodeId: new Map([[SERVER, { domain: 'filius.de', accounts: MAIL_ACCOUNTS, isActive: true }]]),
simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 25 }]]),
});
for (const [name, acts] of Object.entries(STORIES)) {
it(name, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `email-java-${name}.java.json`), 'utf8')) as JavaRun;
const { engine } = watchedEngine(scenario, snapshot);
for (const [time, act] of acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act(engine);
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
expectSameCaptures(engine, scenario, java.frames, { text: (text) => withoutDate(withClientPortText(text)), tables: java, cell: withoutDate });
const expected = javaLines(java);
const actual = engineLines(engine);
for (const program of ['server', 'client'] as const) {
expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text));
for (const [index, [javaTime, text]] of expected[program].entries()) {
const late = javaTime - actual[program][index]![0];
const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + GAP_TOLERANCE_MILLISECONDS;
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeGreaterThanOrEqual(-GAP_TOLERANCE_MILLISECONDS);
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeLessThanOrEqual(most);
}
}
const accounts = engine.emailServer(SERVER)!.state.accounts.map((account) => [
account.username,
...account.mailbox.map((entry) => [withoutDate(formatMail(entry.mail)), String(entry.isNew), String(entry.isMarkedForDeletion)]),
]);
expect(accounts).toEqual(java.accounts.map(([username, ...mails]) => [username, ...mails.map(([text, isNew, deleted]) => [withoutDate(text), isNew, deleted])]));
});
}
});
+389
View File
@@ -0,0 +1,389 @@
// 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');
});
});
+44
View File
@@ -0,0 +1,44 @@
// The accounts of tools/java-oracle's EmailRun and MailCases (EmailRun.ACCOUNTS,
// Java's /mailserver/konten.txt) as the model holds them: anna with a mail
// from bob and one from carla, bob without mail.
import type { EmailServerAccount } from '../src/index.js';
const message = (fields: Partial<EmailServerAccount['mailbox'][number]>): EmailServerAccount['mailbox'][number] => ({
id: 0,
from: { name: null, mailAddress: null },
to: [],
cc: [],
bcc: [],
subject: '',
body: '',
isNew: true,
isMarkedForDeletion: false,
isSent: false,
...fields,
});
export const MAIL_ACCOUNTS: readonly EmailServerAccount[] = [
{
username: 'anna',
password: 'geheim',
firstName: 'Anna',
lastName: 'Muster',
mailbox: [
message({ id: 1, from: { name: 'Bob Beispiel', mailAddress: 'bob@filius.de' }, to: [{ name: null, mailAddress: 'anna@filius.de' }], subject: 'Hallo Anna', body: 'Erste Zeile\nZweite Zeile' }),
message({
id: 2,
from: { name: 'Carla', mailAddress: 'carla@andere.de' },
to: [
{ name: 'Anna Muster', mailAddress: 'anna@filius.de' },
{ name: null, mailAddress: 'bob@filius.de' },
],
cc: [{ name: null, mailAddress: 'chef@filius.de' }],
subject: 'Termin',
body: 'Morgen um 10?',
}),
],
},
{ username: 'bob', password: 'passwort', firstName: 'Bob', lastName: 'Beispiel', mailbox: [] },
];
+17 -1
View File
@@ -2,7 +2,7 @@
// message bundle says them: for comparing the core's structured lines with
// what the Java oracle recorded (echoJava.test.ts, webJava.test.ts).
import type { EchoServerLogEntry, SendFailure, SimpleClientLogEntry, WebBrowserLogEntry } from '../src/index.js';
import type { EchoServerLogEntry, EmailServerLogEntry, PageLanguage, SendFailure, SimpleClientLogEntry, WebBrowserLogEntry } from '../src/index.js';
/** Java's client and server look every 100 ms whether they are connected, or switched off; the core at once. */
export const POLL_MILLISECONDS = 100;
@@ -88,3 +88,19 @@ export function browserText(entry: WebBrowserLogEntry): string | undefined {
}
}
}
/** The e-mail server's line as Java's window shows it (sw_smtpmitarbeiter_msg12 to msg14 in both languages). */
export function emailServerText(entry: EmailServerLogEntry, language: PageLanguage = 'de'): string {
switch (entry.kind) {
case 'connectionOpened':
return '----';
case 'received':
return `${entry.address}< ${entry.text}`;
case 'sent':
return `${entry.address}> ${entry.text}`;
case 'sendFailed':
return language === 'de' ? `Fehler beim Senden an ${entry.address}` : `Error while sending ${entry.address}`;
case 'stored':
return language === 'de' ? `E-Mail für ${entry.username} im Postfach gespeichert.` : `E-mail for ${entry.username} saved in mailbox.`;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
{
"seed": 61,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000201",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000211",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000202",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000212",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000203",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000213",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000204",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000214",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000205",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000221",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000222",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000223",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000224",
"label": "sw4"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F201",
"sourceNode": "00000000-0000-0000-0000-000000000201",
"sourcePort": "00000000-0000-0000-0000-000000000211",
"targetNode": "00000000-0000-0000-0000-000000000205",
"targetPort": "00000000-0000-0000-0000-000000000221"
},
{
"id": "00000000-0000-0000-0000-00000000F202",
"sourceNode": "00000000-0000-0000-0000-000000000202",
"sourcePort": "00000000-0000-0000-0000-000000000212",
"targetNode": "00000000-0000-0000-0000-000000000205",
"targetPort": "00000000-0000-0000-0000-000000000222"
},
{
"id": "00000000-0000-0000-0000-00000000F203",
"sourceNode": "00000000-0000-0000-0000-000000000203",
"sourcePort": "00000000-0000-0000-0000-000000000213",
"targetNode": "00000000-0000-0000-0000-000000000205",
"targetPort": "00000000-0000-0000-0000-000000000223"
},
{
"id": "00000000-0000-0000-0000-00000000F204",
"sourceNode": "00000000-0000-0000-0000-000000000204",
"sourcePort": "00000000-0000-0000-0000-000000000214",
"targetNode": "00000000-0000-0000-0000-000000000205",
"targetPort": "00000000-0000-0000-0000-000000000224"
}
],
"devices": {
"00000000-0000-0000-0000-000000000201": {
"ip": "192.168.0.20",
"mask": "255.255.255.0",
"dns": "192.168.0.10"
},
"00000000-0000-0000-0000-000000000202": {
"ip": "192.168.0.10",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000203": {
"ip": "192.168.0.30",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000204": {
"ip": "192.168.0.31",
"mask": "255.255.255.0"
}
},
"dnsServers": {
"00000000-0000-0000-0000-000000000202": {
"records": [
"www.filius.de A 192.168.0.30",
"seite.filius.de A 192.168.0.30",
"andere.filius.de A 192.168.0.30",
"leer.filius.de A 192.168.0.31"
]
}
}
}