// 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..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 { 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 { 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: \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 = { // 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: \nTo: \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: \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: \nTo: \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: \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 ')).toEqual({ name: 'Anna Muster', mailAddress: 'anna@filius.de' }); expect(addressEntry('')).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>bb { expect(isMailAddress('anna@filius.de')).toBe(true); expect(isMailAddress('Anna Muster ')).toBe(true); expect(isMailAddress('anna@filius.de x')).toBe(false); expect(isMailAddress('Anna 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 \nTo: x@y.de, Y \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 \nTo: , Y \nSubject: Hallo\n\nText'); expect(mailSize(mail)).toBe('Text\n'.length + 'A '.length + 'Hallo'.length + '[, Y ]'.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 { 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: \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:', 'RCPT TO:', 'DATA', 'Bcc: \nSubject: Neu\n\nText\r\n.\r\n', 'QUIT'); talk(engine, 25, 'HELO x', 'MAIL FROM:', 'RCPT TO:', '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:', '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:', 'RCPT TO:', '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: \n'); }); });