Files
julian.kiedaischandClaude Opus 5.5 f622959ecc feat(core): the e-mail server keeps /mailserver/konten.txt up to date at run time, measured against Java
The account file's writer moves from the export (packages/fls) into the
core, so that the export and the running server write the same. The
server writes the file at its start and whenever its accounts change.
EmailRun now records Java's file as it left it and as Java writes the
accounts it ended with; Java leaves the notice to a local sender out until
its next write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:16 +00:00

438 lines
22 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,
EMAIL_SERVER_ACCOUNTS_FILE,
serverAccountsFileText,
VirtualFileSystem,
deviceConfiguration,
NetworkRuntimeEngine,
topologySnapshot,
type TopologySnapshotInput,
addressText,
formatMail,
isMailAddress,
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';
/** Without a network, no mail goes to another domain (MailCases: Java's server thread never runs). */
const NO_OUTBOX = {
forward: () => undefined,
*lookUpAndSend(): Steps<string[]> {
return [];
},
};
/** 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,
NO_OUTBOX,
);
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("checks an address as Java's pattern does: an address, or a name and an address in angle brackets", () => {
expect(isMailAddress('anna@filius.de')).toBe(true);
expect(isMailAddress('Anna Muster <anna.m-x_1@filius.de.>')).toBe(true);
expect(isMailAddress('anna@filius.de x')).toBe(false);
expect(isMailAddress('Anna <anna@filius.de> x')).toBe(false);
expect(isMailAddress('anna@')).toBe(false);
expect(isMailAddress('anna..b@filius.de')).toBe(false);
});
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, NO_OUTBOX);
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('writes its account file from its accounts when it starts, over one that says otherwise', () => {
const files = new VirtualFileSystem();
files.createDirectory('/mailserver');
files.writeTextFile(EMAIL_SERVER_ACCOUNTS_FILE, 'alt;filius.de;alt;Alt;Alt;\n');
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot: topologySnapshot({ ...mailNetwork(), fileSystemsByNodeId: new Map([[SERVER, files]]) }), seed: 14, initialTimeMilliseconds: 0 });
expect(files.textFile(EMAIL_SERVER_ACCOUNTS_FILE)).toBe(serverAccountsFileText('filius.de', MAIL_ACCOUNTS));
});
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('sends no notice to an empty sender, also when the recipient is unknown (Java: <> gets none)', () => {
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot: topologySnapshot(mailNetwork()), seed: 14, initialTimeMilliseconds: 0 });
expect(talk(engine, 25, 'HELO x', 'MAIL FROM:<>', 'RCPT TO:<niemand@filius.de>', 'DATA', 'Subject: x\n\ny\n.\n', 'QUIT').at(-1)).toBe('221 Server beendet Verbindung.');
expect(engine.emailServer(SERVER)!.log.all.filter((line) => line.kind === 'stored')).toEqual([]);
});
it("sends a notice to another domain's sender with an empty sender, and none back when that fails", () => {
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot: topologySnapshot(mailNetwork()), seed: 14, initialTimeMilliseconds: 0 });
// nirgendwo.de has no mail exchange (no DNS server here): the notice goes nowhere, and its own failure tells no one.
expect(talk(engine, 25, 'HELO x', 'MAIL FROM:<x@nirgendwo.de>', 'RCPT TO:<niemand@filius.de>', 'DATA', 'Subject: x\n\ny\n.\n', 'QUIT').at(-1)).toBe(
'221 Server beendet Verbindung.',
);
expect(engine.emailServer(SERVER)!.log.all.filter((line) => line.kind === 'stored' || line.kind === 'connectionStarting')).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, NO_OUTBOX);
run(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');
});
});