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:
@@ -24,6 +24,8 @@ export * from './runtime/apps/echo.js';
|
||||
export * from './runtime/apps/web.js';
|
||||
export * from './runtime/apps/routerPages.js';
|
||||
export * from './runtime/apps/firewallSettings.js';
|
||||
export * from './runtime/apps/emailServer.js';
|
||||
export * from './runtime/email.js';
|
||||
export * from './runtime/http.js';
|
||||
export * from './util/equal.js';
|
||||
export * from './util/javaBase64.js';
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
// FILIUS' e-mail server (filius.software.email: EmailServer, SMTPServer,
|
||||
// SMTPMitarbeiter, POP3Server, POP3Mitarbeiter): accounts of one domain,
|
||||
// SMTP on port 25 takes mails and stores those for its accounts, POP3 on
|
||||
// port 110 hands an account its mails. Switched on and off together. The
|
||||
// window shows each connection's messages both ways and each mail stored.
|
||||
//
|
||||
// Departures from Java (emailServer.test.ts names each):
|
||||
// SMTP
|
||||
// - after a mail, the envelope is new: the next command counts, and the
|
||||
// next mail goes only to its own recipients with only its own text (Java
|
||||
// answers the next command 503 and keeps sender, recipients and text);
|
||||
// - a command the state does not take gets 503 (Java answers nothing);
|
||||
// HELO again begins anew;
|
||||
// - during DATA everything is the mail's text, also "quit" (Java ends);
|
||||
// - a domain is the server's in any case (Java compares exactly and sends
|
||||
// such a mail on);
|
||||
// - a recipient without "@" is unknown, and an empty sender gets no notice
|
||||
// (Java's worker throws, and the connection ends); a notice to a mail
|
||||
// without From goes to the envelope's sender (Java writes "To: null").
|
||||
// POP3
|
||||
// - a message can be retrieved again (Java: only once);
|
||||
// - LIST without messages, and LIST, RETR, DELE for a message that is not
|
||||
// there: "-ERR no such message" (Java lists an error, sends an empty mail
|
||||
// or an empty answer); LIST with a number lists that message (Java lists
|
||||
// all); a deleted message keeps the other messages' numbers (Java's LIST
|
||||
// counts them anew, RETR does not);
|
||||
// - a command before log-in gets "-ERR unknown command", QUIT "+OK", an
|
||||
// unknown command "-ERR unknown command" (Java answers empty or nothing);
|
||||
// - a missing or wrong argument is an error (Java's worker throws, and the
|
||||
// connection ends).
|
||||
|
||||
import type { Id } from '../../ids.js';
|
||||
import type { EmailMessage, EmailServerAccount } from '../config.js';
|
||||
import { addressEntry, addressList, addressText, formatMail, javaSplit, mailSize, parseMail, type Mail } from '../email.js';
|
||||
import type { NetworkRuntimeEngine } from '../engine.js';
|
||||
import { javaTrim } from '../http.js';
|
||||
import type { Steps } from '../threads.js';
|
||||
import { ProgramLog } from './program.js';
|
||||
import type { PageLanguage } from './routerPages.js';
|
||||
import type { EmailServerSnapshot } from '../snapshot.js';
|
||||
import { TcpServerProgram } from './server.js';
|
||||
|
||||
export const SMTP_PORT = 25;
|
||||
export const POP3_PORT = 110;
|
||||
|
||||
/** A line of the e-mail server's window. */
|
||||
export type EmailServerLogEntry =
|
||||
/** EmailServer.LINE_SEPARATOR: a connection began. */
|
||||
| { readonly kind: 'connectionOpened' }
|
||||
/** "<address>< " and the message. */
|
||||
| { readonly kind: 'received'; readonly address: string; readonly text: string }
|
||||
/** "<address>> " and the answer. */
|
||||
| { readonly kind: 'sent'; readonly address: string; readonly text: string }
|
||||
/** sw_smtpmitarbeiter_msg14: the answer could not be sent. */
|
||||
| { readonly kind: 'sendFailed'; readonly address: string }
|
||||
/** sw_smtpmitarbeiter_msg12, msg13: a mail stored in the account's mailbox. */
|
||||
| { readonly kind: 'stored'; readonly username: string };
|
||||
|
||||
/** A message in a mailbox, as POP3 sees it. */
|
||||
export interface MailboxEntry {
|
||||
mail: Mail;
|
||||
isNew: boolean;
|
||||
isMarkedForDeletion: boolean;
|
||||
}
|
||||
|
||||
export interface MailAccount {
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
readonly firstName: string;
|
||||
readonly lastName: string;
|
||||
mailbox: MailboxEntry[];
|
||||
}
|
||||
|
||||
/** sw_smtpmitarbeiter_msg1 to msg11, the SMTP answers. */
|
||||
const SMTP_TEXTS: Record<PageLanguage, Record<'welcome' | 'bye' | 'ok' | 'senderOk' | 'syntax' | 'recipientOk' | 'data' | 'order' | 'queued', string>> = {
|
||||
de: {
|
||||
welcome: '220 Willkommen bei',
|
||||
bye: '221 Server beendet Verbindung.',
|
||||
ok: '250 OK',
|
||||
senderOk: '250 Sender OK',
|
||||
syntax: '501 Syntax-Fehler - Parameter oder Argument falsch.',
|
||||
recipientOk: '250 Recipient OK',
|
||||
data: '354 End data with <CR><LF>.<CR><LF>',
|
||||
order: '503 Falsche Reihenfolge der Kommandos.',
|
||||
queued: '250 Mail queued for delivery',
|
||||
},
|
||||
en: {
|
||||
welcome: '220 Welcome to',
|
||||
bye: '221 Server closed connection.',
|
||||
ok: '250 OK',
|
||||
senderOk: '250 Sender OK',
|
||||
syntax: '501 Syntax error - Parameter or argument wrong.',
|
||||
recipientOk: '250 Recipient OK',
|
||||
data: '354 End data with <CR><LF>.<CR><LF>',
|
||||
order: '503 Wrong order of commands.',
|
||||
queued: '250 Mail queued for delivery',
|
||||
},
|
||||
};
|
||||
|
||||
/** DateFormat.getInstance: short date and time in the project's language. */
|
||||
function shortDateTime(milliseconds: number, language: PageLanguage): string {
|
||||
const date = new Date(milliseconds);
|
||||
const two = (value: number) => String(value).padStart(2, '0');
|
||||
const day = two(date.getDate());
|
||||
const month = two(date.getMonth() + 1);
|
||||
const time = `${two(date.getHours())}:${two(date.getMinutes())}`;
|
||||
return language === 'de' ? `${day}.${month}.${two(date.getFullYear() % 100)}, ${time}` : `${day}/${month}/${date.getFullYear()}, ${time}`;
|
||||
}
|
||||
|
||||
/** The connection a session talks over. */
|
||||
export interface MailConnection {
|
||||
readonly address: string;
|
||||
/** Sends and waits for the acknowledgement; false when it failed. */
|
||||
send(text: string): Steps<boolean>;
|
||||
}
|
||||
|
||||
/** What the server's sessions share: its domain, accounts and window. */
|
||||
export class MailServerState {
|
||||
constructor(
|
||||
readonly domain: string,
|
||||
readonly accounts: MailAccount[],
|
||||
readonly language: PageLanguage,
|
||||
readonly log: (entry: EmailServerLogEntry) => void,
|
||||
/** After each change of a mailbox (EmailServer.kontenSpeichern). */
|
||||
readonly changed: () => void,
|
||||
/** The server's first address (primaryIPAdresse) and the wall clock, for a notice. */
|
||||
private readonly ipAddress: () => string,
|
||||
private readonly now: () => number,
|
||||
) {}
|
||||
|
||||
/** EmailServer.sucheKonto: by name in any case. */
|
||||
account(username: string): MailAccount | undefined {
|
||||
const lower = username.toLowerCase();
|
||||
return this.accounts.find((each) => each.username.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
/** EmailServer.pruefeAufSelbeDomain, in any case; an address without "@" has no domain. */
|
||||
isOwnDomain(address: string): boolean {
|
||||
const at = address.indexOf('@');
|
||||
return at >= 0 && address.slice(at + 1).toLowerCase() === this.domain.toLowerCase();
|
||||
}
|
||||
|
||||
/** SMTPMitarbeiter.verarbeiteEmail: each of the domain's recipients gets the mail as Java reads it anew. */
|
||||
deliver(mail: Mail, sender: string, recipients: readonly string[]): void {
|
||||
const unknown: string[] = [];
|
||||
for (const recipient of recipients) {
|
||||
const account = this.isOwnDomain(recipient) ? this.account(javaSplit(recipient, '@')[0] ?? '') : undefined;
|
||||
if (account !== undefined) {
|
||||
account.mailbox.push({ mail: parseMail(formatMail(mail)), isNew: true, isMarkedForDeletion: false });
|
||||
this.log({ kind: 'stored', username: account.username });
|
||||
} else if (this.isOwnDomain(recipient) || !recipient.includes('@')) {
|
||||
unknown.push(recipient);
|
||||
}
|
||||
// Other domains: sent on with the e-mail program (F3.3b).
|
||||
}
|
||||
this.changed();
|
||||
if (unknown.length > 0) this.notifyUnknown(mail, sender, unknown);
|
||||
}
|
||||
|
||||
/** EmailServer.sendUnknownReceiverResponse: a notice to a sender of the domain; none for an empty sender. */
|
||||
private notifyUnknown(mail: Mail, sender: string, unknown: readonly string[]): void {
|
||||
const address = addressEntry(sender).mailAddress;
|
||||
// An empty sender (<>) has no address of the domain.
|
||||
if (address === null || !this.isOwnDomain(address)) return;
|
||||
const account = this.account(javaSplit(address, '@')[0] ?? '');
|
||||
if (account === undefined) return;
|
||||
const text =
|
||||
'The mail could not be delivered either because user or mail domain is unknown.\n' +
|
||||
'Message could not be delivered to the following recipients:\n' +
|
||||
unknown.map((each) => `\t${each}\n`).join('') +
|
||||
`\nDate: ${shortDateTime(this.now(), this.language)}\n` +
|
||||
`Mail-Server: ${this.ipAddress()}\n` +
|
||||
'\n______________________________________________________________________\n' +
|
||||
formatMail(mail);
|
||||
const notice: Mail = {
|
||||
sender: addressEntry(`noreply@${this.domain}`),
|
||||
to: [mail.sender ?? { name: null, mailAddress: address }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: 'Non Delivery Notification',
|
||||
text,
|
||||
dateReceived: '',
|
||||
};
|
||||
account.mailbox.push({ mail: notice, isNew: true, isMarkedForDeletion: false });
|
||||
this.log({ kind: 'stored', username: account.username });
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
|
||||
type SmtpState = 'start' | 'envelope' | 'data';
|
||||
|
||||
/** SMTPMitarbeiter: one connection's SMTP dialogue. */
|
||||
export class SmtpSession {
|
||||
private state: SmtpState = 'start';
|
||||
private sender: string | undefined;
|
||||
private recipients: string[] = [];
|
||||
private source = '';
|
||||
closed = false;
|
||||
|
||||
constructor(
|
||||
private readonly server: MailServerState,
|
||||
private readonly connection: MailConnection,
|
||||
) {}
|
||||
|
||||
private get texts() {
|
||||
return SMTP_TEXTS[this.server.language];
|
||||
}
|
||||
|
||||
/** Sends, then writes the line (Java's senden). */
|
||||
private *reply(text: string): Steps {
|
||||
const sent = yield* this.connection.send(text);
|
||||
this.server.log(sent ? { kind: 'sent', address: this.connection.address, text } : { kind: 'sendFailed', address: this.connection.address });
|
||||
}
|
||||
|
||||
/** The constructor's greeting. */
|
||||
*begin(): Steps {
|
||||
this.server.log({ kind: 'connectionOpened' });
|
||||
yield* this.reply(`${this.texts.welcome} ${this.server.domain}`);
|
||||
}
|
||||
|
||||
private resetEnvelope(): void {
|
||||
this.sender = undefined;
|
||||
this.recipients = [];
|
||||
this.source = '';
|
||||
}
|
||||
|
||||
/** SMTPMitarbeiter.verarbeiteNachricht. */
|
||||
*receive(message: string): Steps {
|
||||
this.server.log({ kind: 'received', address: this.connection.address, text: message });
|
||||
if (this.state === 'data') {
|
||||
const lines = javaSplit(message, '\n');
|
||||
if (lines.length > 0 && javaTrim(lines[lines.length - 1]!) === '.') {
|
||||
const mail = parseMail(this.source + message.slice(0, message.lastIndexOf('.')));
|
||||
yield* this.reply(this.texts.queued);
|
||||
const sender = this.sender ?? '';
|
||||
const recipients = this.recipients;
|
||||
this.state = 'envelope';
|
||||
this.resetEnvelope();
|
||||
this.server.deliver(mail, sender, recipients);
|
||||
} else {
|
||||
this.source += message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (javaTrim(message).toLowerCase() === 'quit') {
|
||||
yield* this.reply(this.texts.bye);
|
||||
this.closed = true;
|
||||
return;
|
||||
}
|
||||
const space = message.indexOf(' ');
|
||||
const word = javaTrim(space > 0 ? message.slice(0, space) : message).toLowerCase();
|
||||
if (word === 'helo' || word === 'ehlo') {
|
||||
yield* this.reply(`250 Hello ${space > 0 ? javaTrim(message.slice(space)) : ''}`);
|
||||
this.state = 'envelope';
|
||||
this.resetEnvelope();
|
||||
return;
|
||||
}
|
||||
if (word === 'noop') {
|
||||
yield* this.reply(this.texts.ok);
|
||||
return;
|
||||
}
|
||||
if (this.state === 'envelope') {
|
||||
const colon = message.indexOf(':');
|
||||
const command = javaTrim(colon > 0 ? message.slice(0, colon) : message).toLowerCase();
|
||||
const start = message.indexOf('<');
|
||||
const end = message.indexOf('>');
|
||||
const address = start > 0 && end > start ? javaTrim(message.slice(start + 1, end)) : undefined;
|
||||
if (command === 'mail from') {
|
||||
if (address !== undefined) this.sender = address;
|
||||
yield* this.reply(address !== undefined ? this.texts.senderOk : this.texts.syntax);
|
||||
return;
|
||||
}
|
||||
if (command === 'rcpt to') {
|
||||
if (address !== undefined) this.recipients.push(address);
|
||||
yield* this.reply(address !== undefined ? this.texts.recipientOk : this.texts.syntax);
|
||||
return;
|
||||
}
|
||||
if (command === 'data' && this.sender !== undefined && this.recipients.length > 0) {
|
||||
yield* this.reply(this.texts.data);
|
||||
this.state = 'data';
|
||||
return;
|
||||
}
|
||||
}
|
||||
yield* this.reply(this.texts.order);
|
||||
}
|
||||
}
|
||||
|
||||
/** POP3Mitarbeiter: one connection's POP3 dialogue; messages numbered from 0, as Java numbers them. */
|
||||
export class Pop3Session {
|
||||
private username: string | undefined;
|
||||
private userKnown = false;
|
||||
private account: MailAccount | undefined;
|
||||
closed = false;
|
||||
|
||||
constructor(
|
||||
private readonly server: MailServerState,
|
||||
private readonly connection: MailConnection,
|
||||
) {}
|
||||
|
||||
/** Writes the line, then sends (Java's sendeAntwort). */
|
||||
private *reply(text: string): Steps {
|
||||
this.server.log({ kind: 'sent', address: this.connection.address, text });
|
||||
yield* this.connection.send(text);
|
||||
}
|
||||
|
||||
*begin(): Steps {
|
||||
this.server.log({ kind: 'connectionOpened' });
|
||||
yield* this.reply('+OK POP3 server ready');
|
||||
}
|
||||
|
||||
/** The message with a number, if there and not deleted. */
|
||||
private entry(argument: string | undefined): MailboxEntry | undefined {
|
||||
if (argument === undefined || !/^[+-]?[0-9]+$/.test(argument)) return undefined;
|
||||
const entry = this.account?.mailbox[Number.parseInt(argument, 10)];
|
||||
return entry === undefined || entry.isMarkedForDeletion ? undefined : entry;
|
||||
}
|
||||
|
||||
private status(): string {
|
||||
const kept = this.account!.mailbox.filter((each) => !each.isMarkedForDeletion);
|
||||
return `+OK ${kept.length} ${kept.reduce((sum, each) => sum + mailSize(each.mail), 0)}`;
|
||||
}
|
||||
|
||||
/** POP3Mitarbeiter.verarbeiteNachricht. */
|
||||
*receive(message: string): Steps {
|
||||
this.server.log({ kind: 'received', address: this.connection.address, text: message });
|
||||
const words = javaSplit(message, ' ');
|
||||
const command = (words[0] ?? '').toLowerCase();
|
||||
const argument = words[1];
|
||||
const loggedIn = this.account !== undefined;
|
||||
const noSuchMessage = '-ERR no such message';
|
||||
switch (command) {
|
||||
case 'user':
|
||||
if (loggedIn) {
|
||||
yield* this.reply('-ERR Please enter USER');
|
||||
} else if (argument !== undefined && this.server.account(argument) !== undefined) {
|
||||
this.userKnown = true;
|
||||
this.username = argument;
|
||||
yield* this.reply('+OK enter password');
|
||||
} else {
|
||||
// Java takes the name once a user was known, so that PASS fails.
|
||||
if (this.userKnown) this.username = argument ?? '';
|
||||
yield* this.reply('-ERR user or password wrong');
|
||||
}
|
||||
return;
|
||||
case 'pass': {
|
||||
if (loggedIn || !this.userKnown) {
|
||||
yield* this.reply('-ERR Please enter PASS');
|
||||
return;
|
||||
}
|
||||
const account = this.server.account(this.username!);
|
||||
if (argument !== undefined && account !== undefined && account.password === argument) {
|
||||
this.account = account;
|
||||
yield* this.reply('+OK Mailbox locked and ready');
|
||||
} else {
|
||||
yield* this.reply('-ERR user or password wrong');
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'quit':
|
||||
if (loggedIn) {
|
||||
this.account!.mailbox = this.account!.mailbox.filter((each) => !each.isMarkedForDeletion);
|
||||
this.server.changed();
|
||||
}
|
||||
yield* this.reply('+OK');
|
||||
this.closed = true;
|
||||
return;
|
||||
case 'noop':
|
||||
yield* this.reply(loggedIn ? '+OK' : '-ERR unknown command');
|
||||
return;
|
||||
}
|
||||
if (!loggedIn || !['stat', 'list', 'retr', 'dele', 'rset'].includes(command)) {
|
||||
yield* this.reply('-ERR unknown command');
|
||||
return;
|
||||
}
|
||||
const mailbox = this.account!.mailbox;
|
||||
switch (command) {
|
||||
case 'stat':
|
||||
yield* this.reply(this.status());
|
||||
return;
|
||||
case 'list':
|
||||
if (argument === undefined) {
|
||||
const lines = mailbox.flatMap((each, index) => (each.isMarkedForDeletion ? [] : [`${index} ${mailSize(each.mail)}\n`]));
|
||||
yield* this.reply(`${this.status()}\n${lines.join('')}`);
|
||||
} else {
|
||||
const entry = this.entry(argument);
|
||||
yield* this.reply(entry === undefined ? noSuchMessage : `+OK ${Number.parseInt(argument, 10)} ${mailSize(entry.mail)}`);
|
||||
}
|
||||
return;
|
||||
case 'retr': {
|
||||
const entry = this.entry(argument);
|
||||
if (entry === undefined) {
|
||||
yield* this.reply(noSuchMessage);
|
||||
} else {
|
||||
entry.isNew = false;
|
||||
this.server.changed();
|
||||
yield* this.reply(`+OK message follows \n${formatMail(entry.mail)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'dele': {
|
||||
const index = argument !== undefined && /^[+-]?[0-9]+$/.test(argument) ? Number.parseInt(argument, 10) : -1;
|
||||
const entry = mailbox[index];
|
||||
if (entry === undefined) {
|
||||
yield* this.reply(noSuchMessage);
|
||||
} else {
|
||||
entry.isMarkedForDeletion = true;
|
||||
this.server.changed();
|
||||
yield* this.reply('+OK message marked for delete');
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'rset':
|
||||
for (const each of mailbox) each.isMarkedForDeletion = false;
|
||||
this.server.changed();
|
||||
yield* this.reply('+OK');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A message of the model as Java has it after loading the server's file: new, not deleted. */
|
||||
export function mailboxEntry(message: EmailMessage): MailboxEntry {
|
||||
const reread = (entries: EmailMessage['to']) => addressList(entries.map((entry) => addressText(entry) ?? '').join(','));
|
||||
return {
|
||||
mail: {
|
||||
sender: addressEntry(addressText(message.from) ?? ''),
|
||||
to: reread(message.to),
|
||||
cc: reread(message.cc),
|
||||
bcc: reread(message.bcc),
|
||||
subject: message.subject,
|
||||
text: message.body,
|
||||
dateReceived: message.receivedAtMilliseconds === undefined ? '' : String(message.receivedAtMilliseconds),
|
||||
},
|
||||
isNew: true,
|
||||
isMarkedForDeletion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** A mailbox's message back in the model, numbered by the caller. */
|
||||
export function emailMessage(entry: MailboxEntry, id: number): EmailMessage {
|
||||
const { mail } = entry;
|
||||
const received = /^[0-9]+$/.test(mail.dateReceived) ? Number(mail.dateReceived) : undefined;
|
||||
return {
|
||||
id,
|
||||
from: mail.sender ?? { name: null, mailAddress: null },
|
||||
to: mail.to,
|
||||
cc: mail.cc,
|
||||
bcc: mail.bcc,
|
||||
subject: mail.subject ?? '',
|
||||
body: mail.text,
|
||||
...(received === undefined ? {} : { receivedAtMilliseconds: received }),
|
||||
isNew: entry.isNew,
|
||||
isMarkedForDeletion: entry.isMarkedForDeletion,
|
||||
isSent: false,
|
||||
};
|
||||
}
|
||||
|
||||
const encode = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
/** SMTPServer, POP3Server: a server whose connections each hold a session. */
|
||||
class MailProtocolServer extends TcpServerProgram {
|
||||
constructor(
|
||||
engine: NetworkRuntimeEngine,
|
||||
nodeId: Id,
|
||||
port: number,
|
||||
isActive: boolean,
|
||||
private readonly open: (connection: MailConnection) => SmtpSession | Pop3Session,
|
||||
) {
|
||||
super(engine, nodeId, { port, isActive });
|
||||
}
|
||||
|
||||
/** Never asked: each connection holds a session (converse). */
|
||||
protected answer(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
protected override *converse(socketId: Id, address: string): Steps {
|
||||
const engine = this.engine;
|
||||
const session = this.open({
|
||||
address,
|
||||
*send(text: string): Steps<boolean> {
|
||||
return (yield* engine.sendTcpSteps(socketId, encode(text))) === 'sent';
|
||||
},
|
||||
});
|
||||
yield* session.begin();
|
||||
while (!session.closed) {
|
||||
const received = yield* engine.receiveTcpSteps(socketId);
|
||||
if (received.type !== 'message') return;
|
||||
yield* session.receive(new TextDecoder().decode(received.payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** EmailServer: its SMTP and POP3 servers, switched on and off together. */
|
||||
export class EmailServer {
|
||||
readonly log: ProgramLog<EmailServerLogEntry>;
|
||||
readonly state: MailServerState;
|
||||
private readonly smtp: MailProtocolServer;
|
||||
private readonly pop3: MailProtocolServer;
|
||||
|
||||
constructor(
|
||||
engine: NetworkRuntimeEngine,
|
||||
nodeId: Id,
|
||||
configuration: EmailServerSnapshot,
|
||||
changed: (accounts: readonly EmailServerAccount[]) => void,
|
||||
ipAddress: () => string,
|
||||
now: () => number,
|
||||
) {
|
||||
this.log = new ProgramLog(engine);
|
||||
const accounts: MailAccount[] = configuration.accounts.map((account) => ({
|
||||
username: account.username,
|
||||
password: account.password,
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
mailbox: account.mailbox.map(mailboxEntry),
|
||||
}));
|
||||
this.state = new MailServerState(
|
||||
configuration.domain,
|
||||
accounts,
|
||||
engine.state.topologySnapshot.language,
|
||||
(entry) => this.log.write(entry),
|
||||
() => changed(this.accounts()),
|
||||
ipAddress,
|
||||
now,
|
||||
);
|
||||
// POP3 first, as EmailServer.starten (its socket draws the first initial sequence number).
|
||||
this.pop3 = new MailProtocolServer(engine, nodeId, POP3_PORT, configuration.isActive, (connection) => new Pop3Session(this.state, connection));
|
||||
this.smtp = new MailProtocolServer(engine, nodeId, SMTP_PORT, configuration.isActive, (connection) => new SmtpSession(this.state, connection));
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.smtp.isActive;
|
||||
}
|
||||
|
||||
setActive(active: boolean): void {
|
||||
this.pop3.setActive(active);
|
||||
this.smtp.setActive(active);
|
||||
}
|
||||
|
||||
/** The accounts as the model keeps them. */
|
||||
accounts(): EmailServerAccount[] {
|
||||
let id = 1;
|
||||
return this.state.accounts.map((account) => ({
|
||||
username: account.username,
|
||||
password: account.password,
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
mailbox: account.mailbox.map((entry) => emailMessage(entry, id++)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -121,16 +121,21 @@ export abstract class TcpServerProgram {
|
||||
protected abstract answer(message: string): string;
|
||||
|
||||
/** ServerMitarbeiter.run: each message answered (verarbeiteNachricht, sendeNachricht), until the connection ends. */
|
||||
private *serve(socketId: Id, address: string, port: number): Steps {
|
||||
protected *converse(socketId: Id, _address: string): Steps {
|
||||
for (;;) {
|
||||
const received = yield* this.engine.receiveTcpSteps(socketId);
|
||||
if (received.type !== 'message') break;
|
||||
if (received.type !== 'message') return;
|
||||
const text = decode(received.payload);
|
||||
this.log.write({ kind: 'received', text });
|
||||
const answer = this.answer(text);
|
||||
const result = yield* this.engine.sendTcpSteps(socketId, encode(answer));
|
||||
this.log.write(result === 'sent' ? { kind: 'sent', text: answer } : { kind: 'sendFailed', failure: result });
|
||||
}
|
||||
}
|
||||
|
||||
/** A connection's thread: the dialogue, then the connection closed. */
|
||||
private *serve(socketId: Id, address: string, port: number): Steps {
|
||||
yield* this.converse(socketId, address);
|
||||
this.engine.requestTcpClose(socketId);
|
||||
this.connections.delete(socketId);
|
||||
this.log.write({ kind: 'connectionClosed', address, port });
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// E-mail as FILIUS writes and reads it (filius.software.email: Email,
|
||||
// AddressEntry, EmailUtils): the text a client sends after DATA and a
|
||||
// server sends after RETR — header lines From, To, Cc, Subject (and Date
|
||||
// Received), an empty line, the text — and the addresses in it, "Name
|
||||
// <address>", "<address>", an address or a name alone.
|
||||
//
|
||||
// Better than Java: the header ends at its empty line. A field Java does not
|
||||
// know (X-Mailer) is passed over (Java ends the header there, drops the line
|
||||
// and reads the fields after it as text); a line that is no field begins the
|
||||
// text (Java drops it).
|
||||
|
||||
import type { EmailAddress } from './config.js';
|
||||
import { javaTrim } from './http.js';
|
||||
|
||||
/** An e-mail as Java's Email holds it while the programs run. */
|
||||
export interface Mail {
|
||||
/** Null when the text had no From field. */
|
||||
readonly sender: EmailAddress | null;
|
||||
readonly to: readonly EmailAddress[];
|
||||
readonly cc: readonly EmailAddress[];
|
||||
readonly bcc: readonly EmailAddress[];
|
||||
/** Null when the text had no Subject field. */
|
||||
readonly subject: string | null;
|
||||
/** Each line ended by "\n", as Java reads it. */
|
||||
readonly text: string;
|
||||
/** Java's free text; "" when none (Java never sets it). */
|
||||
readonly dateReceived: string;
|
||||
}
|
||||
|
||||
export const EMPTY_MAIL: Mail = { sender: null, to: [], cc: [], bcc: [], subject: null, text: '', dateReceived: '' };
|
||||
|
||||
/** Java's String.split on a character: trailing empty pieces dropped ("" stays one piece). */
|
||||
export function javaSplit(text: string, separator: string): string[] {
|
||||
const pieces = text.split(separator);
|
||||
while (pieces.length > 1 && pieces[pieces.length - 1] === '') pieces.pop();
|
||||
return pieces.length === 1 && pieces[0] === '' && text !== '' ? [] : pieces;
|
||||
}
|
||||
|
||||
/** AddressEntry(String): "Name <address>", "<address>", an address (with "@"), or a name. */
|
||||
export function addressEntry(text: string): EmailAddress {
|
||||
const trimmed = javaTrim(text);
|
||||
const start = trimmed.indexOf('<');
|
||||
const end = trimmed.indexOf('>');
|
||||
if (start >= 0 && end > start) {
|
||||
return { name: start > 0 ? javaTrim(trimmed.slice(0, start)) : null, mailAddress: javaTrim(trimmed.slice(start + 1, end)) };
|
||||
}
|
||||
if (!trimmed.includes('<') && !trimmed.includes('>') && trimmed.includes('@')) return { name: null, mailAddress: trimmed };
|
||||
return { name: trimmed, mailAddress: null };
|
||||
}
|
||||
|
||||
/** AddressEntry.toString; Java's null as "null", where Java's strings show it. */
|
||||
export function addressText(entry: EmailAddress): string | null {
|
||||
if (entry.name === null) return entry.mailAddress === null ? null : `<${entry.mailAddress}>`;
|
||||
return entry.mailAddress === null ? entry.name : `${entry.name} <${entry.mailAddress}>`;
|
||||
}
|
||||
|
||||
const shown = (entry: EmailAddress) => addressText(entry) ?? 'null';
|
||||
|
||||
/** EmailUtils.stringToAddressEntryList: the addresses of a comma list, without empty ones. */
|
||||
export function addressList(text: string): EmailAddress[] {
|
||||
return javaSplit(text, ',')
|
||||
.filter((each) => javaTrim(each) !== '')
|
||||
.map(addressEntry);
|
||||
}
|
||||
|
||||
/** Email(String): the header fields Java knows, then the text, each line ended by "\n". */
|
||||
export function parseMail(source: string): Mail {
|
||||
let sender: EmailAddress | null = null;
|
||||
let to: EmailAddress[] = [];
|
||||
let cc: EmailAddress[] = [];
|
||||
let bcc: EmailAddress[] = [];
|
||||
let subject: string | null = null;
|
||||
let text = '';
|
||||
let header = true;
|
||||
const list = (data: string) => javaSplit(data, ',').map(addressEntry);
|
||||
for (const line of javaSplit(source, '\n')) {
|
||||
if (!header) {
|
||||
text += `${line}\n`;
|
||||
continue;
|
||||
}
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
header = false;
|
||||
if (javaTrim(line) !== '') text += `${line}\n`;
|
||||
continue;
|
||||
}
|
||||
const name = javaTrim(line.slice(0, colon)).toLowerCase();
|
||||
const data = javaTrim(line.slice(colon + 1));
|
||||
if (name === 'from') sender = addressEntry(data);
|
||||
else if (name === 'to') to = list(data);
|
||||
else if (name === 'cc') cc = list(data);
|
||||
else if (name === 'bcc') bcc = list(data);
|
||||
else if (name === 'subject') subject = data;
|
||||
}
|
||||
return { sender, to, cc, bcc, subject, text, dateReceived: '' };
|
||||
}
|
||||
|
||||
/** Email.toString: the header lines Java writes (no Bcc), an empty line, the text trimmed. */
|
||||
export function formatMail(mail: Mail): string {
|
||||
let result = '';
|
||||
if (mail.sender !== null) result += `From: ${shown(mail.sender)}\n`;
|
||||
if (mail.to.length > 0) result += `To: ${mail.to.map(shown).join(', ')}\n`;
|
||||
if (mail.cc.length > 0) result += `Cc: ${mail.cc.map(shown).join(', ')}\n`;
|
||||
if (mail.subject !== null) result += `Subject: ${javaTrim(mail.subject)}\n`;
|
||||
if (mail.dateReceived !== '') result += `Date Received: ${mail.dateReceived}\n`;
|
||||
return `${result}\n${javaTrim(mail.text)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The size POP3Mitarbeiter gives a message (STAT, LIST): the length of its
|
||||
* text, sender, subject, recipients (as Java prints a list) and date, in
|
||||
* Java's characters.
|
||||
*/
|
||||
export function mailSize(mail: Mail): number {
|
||||
const sender = mail.sender === null ? 'null' : shown(mail.sender);
|
||||
return (mail.text + sender + (mail.subject ?? 'null') + `[${mail.to.map(shown).join(', ')}]` + mail.dateReceived).length;
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { forwardsIPv4ByDefault, isPcClassEndpoint, stableGeneratedMacAddress } f
|
||||
import type {
|
||||
DeviceConfiguration,
|
||||
DhcpServerConfiguration,
|
||||
EmailServerAccount,
|
||||
FirewallConfiguration,
|
||||
GatewayPortForwardingRow,
|
||||
FirewallRule,
|
||||
@@ -115,6 +116,7 @@ import { RuntimeSpeed } from './speed.js';
|
||||
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
||||
import { EchoServer, SimpleClient } from './apps/echo.js';
|
||||
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
|
||||
import { EmailServer } from './apps/emailServer.js';
|
||||
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
|
||||
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
|
||||
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
@@ -910,6 +912,9 @@ export class NetworkRuntimeEngine {
|
||||
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
||||
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
||||
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
|
||||
private readonly emailServersByNodeId = new Map<Id, EmailServer>();
|
||||
/** The time of day a program writes into a mail (Java's new Date()). */
|
||||
private readonly wallClock: () => number;
|
||||
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
|
||||
private readonly firewallLogsByNodeId = new Map<Id, FirewallLogEntry[]>();
|
||||
/** Accepted connections a program has taken (acceptTcpSteps). */
|
||||
@@ -929,6 +934,9 @@ export class NetworkRuntimeEngine {
|
||||
* web administration answers without a socket), likewise.
|
||||
*
|
||||
* `captureRetention`: how many frames each card's packet capture keeps.
|
||||
*
|
||||
* `wallClock`: the time of day in milliseconds, which a program writes
|
||||
* into a mail (the e-mail server's notice); the host's clock by default.
|
||||
*/
|
||||
constructor(
|
||||
seed: bigint | number = DEFAULT_SEED,
|
||||
@@ -939,8 +947,10 @@ export class NetworkRuntimeEngine {
|
||||
readonly swiftSends?: boolean;
|
||||
readonly swiftRouters?: boolean;
|
||||
readonly captureRetention?: CaptureRetentionPolicy;
|
||||
readonly wallClock?: () => number;
|
||||
} = {},
|
||||
) {
|
||||
this.wallClock = options.wallClock ?? Date.now;
|
||||
this.packetCapture = new PacketCapture(options.captureRetention ?? PRODUCTION_CAPTURE_RETENTION);
|
||||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||||
this.swiftArp = options.swiftArp === true;
|
||||
@@ -1032,6 +1042,7 @@ export class NetworkRuntimeEngine {
|
||||
this.simpleClientsByNodeId.clear();
|
||||
this.webServersByNodeId.clear();
|
||||
this.webBrowsersByNodeId.clear();
|
||||
this.emailServersByNodeId.clear();
|
||||
this.arpLookups = [];
|
||||
this.protocolThreads.clear();
|
||||
this.dnsQueriesBySocketId.clear();
|
||||
@@ -1117,6 +1128,20 @@ export class NetworkRuntimeEngine {
|
||||
return this.webBrowsersByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
/** The node's e-mail server while the simulation runs, if it has one installed. */
|
||||
emailServer(nodeId: Id): EmailServer | undefined {
|
||||
return this.emailServersByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
/** An e-mail server's accounts as a run changed them, into the snapshot (and so into the project saved from it). */
|
||||
private setEmailServerAccounts(nodeId: Id, accounts: readonly EmailServerAccount[]): void {
|
||||
const servers = new Map(this.stateValue.topologySnapshot.emailServersByNodeId);
|
||||
const server = servers.get(nodeId);
|
||||
if (server === undefined) return;
|
||||
servers.set(nodeId, { ...server, accounts });
|
||||
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { emailServersByNodeId: servers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Each node's programs start with the simulation (Betriebssystem.starten),
|
||||
* from the snapshot; what they show starts empty.
|
||||
@@ -1127,6 +1152,7 @@ export class NetworkRuntimeEngine {
|
||||
this.simpleClientsByNodeId.clear();
|
||||
this.webServersByNodeId.clear();
|
||||
this.webBrowsersByNodeId.clear();
|
||||
this.emailServersByNodeId.clear();
|
||||
for (const node of snapshot.nodes) {
|
||||
const echoServer = snapshot.echoServersByNodeId.get(node.id);
|
||||
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
|
||||
@@ -1137,6 +1163,21 @@ export class NetworkRuntimeEngine {
|
||||
this.webServersByNodeId.set(node.id, new WebServer(this, node.id, webServer, snapshot.fileSystemsByNodeId.get(node.id)));
|
||||
}
|
||||
if (snapshot.webBrowserNodeIds.has(node.id)) this.webBrowsersByNodeId.set(node.id, new WebBrowser(this, node.id));
|
||||
const emailServer = snapshot.emailServersByNodeId.get(node.id);
|
||||
if (emailServer !== undefined) {
|
||||
const nodeId = node.id;
|
||||
this.emailServersByNodeId.set(
|
||||
nodeId,
|
||||
new EmailServer(
|
||||
this,
|
||||
nodeId,
|
||||
emailServer,
|
||||
(accounts) => this.setEmailServerAccounts(nodeId, accounts),
|
||||
() => this.networkInterfaces(nodeId)[0]?.ipAddress ?? 'null',
|
||||
this.wallClock,
|
||||
),
|
||||
);
|
||||
}
|
||||
if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
SimpleClientConfiguration,
|
||||
SwitchConfiguration,
|
||||
WebServerConfiguration,
|
||||
EmailServerAccount,
|
||||
} from './config.js';
|
||||
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import type { PageLanguage } from './apps/routerPages.js';
|
||||
@@ -30,6 +31,17 @@ export interface WebServerSnapshot extends WebServerConfiguration {
|
||||
readonly isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An installed e-mail server (EmailServer): its domain, its accounts with
|
||||
* their mailboxes, and whether it is switched on. Java's server keeps its
|
||||
* accounts in /mailserver/konten.txt; the model's are what Java loads from it.
|
||||
*/
|
||||
export interface EmailServerSnapshot {
|
||||
readonly domain: string;
|
||||
readonly accounts: readonly EmailServerAccount[];
|
||||
readonly isActive: boolean;
|
||||
}
|
||||
|
||||
/** An installed echo server: its port, and whether it is switched on (Java: ServerBaustein.aktiv). */
|
||||
export interface EchoServerSnapshot extends EchoServerConfiguration {
|
||||
readonly isActive: boolean;
|
||||
@@ -88,6 +100,8 @@ export interface TopologySnapshot {
|
||||
readonly webServersByNodeId: ReadonlyMap<Id, WebServerSnapshot>;
|
||||
/** The nodes with the web browser installed (WebBrowser). */
|
||||
readonly webBrowserNodeIds: ReadonlySet<Id>;
|
||||
/** The installed e-mail servers, by node; a run's deliveries change their mailboxes here. */
|
||||
readonly emailServersByNodeId: ReadonlyMap<Id, EmailServerSnapshot>;
|
||||
/** Each node's files, which its programs read (a web server its pages). */
|
||||
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
/** The language of the pages the devices write themselves (a router's), as Java's in the locale it runs in. */
|
||||
@@ -112,6 +126,7 @@ export interface TopologySnapshotInput {
|
||||
simpleClientsByNodeId?: ReadonlyMap<Id, SimpleClientConfiguration>;
|
||||
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
|
||||
webBrowserNodeIds?: ReadonlySet<Id>;
|
||||
emailServersByNodeId?: ReadonlyMap<Id, EmailServerSnapshot>;
|
||||
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
language?: PageLanguage;
|
||||
}
|
||||
@@ -135,6 +150,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
|
||||
simpleClientsByNodeId: input.simpleClientsByNodeId ?? new Map(),
|
||||
webServersByNodeId: input.webServersByNodeId ?? new Map(),
|
||||
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
||||
emailServersByNodeId: input.emailServersByNodeId ?? new Map(),
|
||||
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
||||
language: input.language ?? 'de',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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])]));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: [] },
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,17 @@
|
||||
// Only the cables count: a host's SSID connects nothing at run time, as in
|
||||
// Java (Swift joins a wireless host to the switch of its SSID; not taken).
|
||||
// A DNS server runs only when it is switched on (Java: DNSServer.aktiv); an
|
||||
// echo or web server is there either way, switched on or off (it can be
|
||||
// echo, web or e-mail server is there either way, switched on or off (it can be
|
||||
// switched at run time). The programs read the nodes' files (a web server its
|
||||
// pages).
|
||||
//
|
||||
// Back the other way: what the simulation changes that Java keeps in the
|
||||
// project (its objects are the project's), so a project saved after it holds
|
||||
// it. That is a router's or home router's firewall, set on its page konfig.
|
||||
// it. That is a router's or home router's firewall, set on its page konfig,
|
||||
// and an e-mail server's mailboxes (Java writes them into
|
||||
// /mailserver/konten.txt at once).
|
||||
|
||||
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core';
|
||||
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core';
|
||||
import type { ImportedProject } from './importProject.js';
|
||||
|
||||
export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
||||
@@ -29,6 +31,11 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
||||
for (const [nodeId, configuration] of project.webServerConfigurations) {
|
||||
webServers.set(nodeId, { ...configuration, isActive: project.activePrograms.get(nodeId)?.has('webServer') === true });
|
||||
}
|
||||
const emailServers = new Map<Id, EmailServerSnapshot>();
|
||||
for (const [nodeId, configuration] of project.emailServerConfigurations) {
|
||||
if (project.installedPrograms.get(nodeId)?.has('emailServer') !== true) continue;
|
||||
emailServers.set(nodeId, { domain: configuration.domain, accounts: configuration.accounts, isActive: project.activePrograms.get(nodeId)?.has('emailServer') === true });
|
||||
}
|
||||
const webBrowsers = new Set([...project.installedPrograms].filter(([, programs]) => programs.has('webBrowser')).map(([nodeId]) => nodeId));
|
||||
return topologySnapshot({
|
||||
nodes: project.graph.nodes.map((node) => ({
|
||||
@@ -52,11 +59,22 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
||||
simpleClientsByNodeId: project.simpleClientConfigurations,
|
||||
webServersByNodeId: webServers,
|
||||
webBrowserNodeIds: webBrowsers,
|
||||
emailServersByNodeId: emailServers,
|
||||
fileSystemsByNodeId: project.virtualFileSystems,
|
||||
});
|
||||
}
|
||||
|
||||
/** The project with what the simulation changed that Java keeps: the firewalls, as the run's snapshot holds them. */
|
||||
/**
|
||||
* The project with what the simulation changed that Java keeps, as the run's
|
||||
* snapshot holds it: the firewalls, and the e-mail servers' mailboxes.
|
||||
*/
|
||||
export function projectWithSimulationChanges(project: ImportedProject, snapshot: TopologySnapshot): ImportedProject {
|
||||
return { ...project, firewallConfigurationsByNodeId: new Map(snapshot.firewallConfigurationsByNodeId) };
|
||||
const emailServers = new Map(project.emailServerConfigurations);
|
||||
for (const [nodeId, server] of snapshot.emailServersByNodeId) {
|
||||
const configuration = emailServers.get(nodeId);
|
||||
if (configuration === undefined) continue;
|
||||
const messages = server.accounts.reduce((count, account) => count + account.mailbox.length, 0);
|
||||
emailServers.set(nodeId, { ...configuration, accounts: server.accounts, nextMessageID: messages + 1 });
|
||||
}
|
||||
return { ...project, firewallConfigurationsByNodeId: new Map(snapshot.firewallConfigurationsByNodeId), emailServerConfigurations: emailServers };
|
||||
}
|
||||
|
||||
@@ -801,6 +801,41 @@ describe('importProject rules', () => {
|
||||
expect(reopened.firewallConfigurationsByNodeId.get(reopenedRouter)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("keeps a mail the e-mail server took during the run in the project saved after it (projectWithSimulationChanges)", () => {
|
||||
const project = openProjectArchive(new Uint8Array(readFileSync(join(corpusRoot, 'java-examples/email_komplett.fls'))), counter());
|
||||
const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!.id;
|
||||
const server = node('Mail-Server (filius.de)');
|
||||
const sender = node('Rechner 1');
|
||||
const engine = new NetworkRuntimeEngine(14);
|
||||
const snapshot = projectSnapshot(project);
|
||||
expect(snapshot.emailServersByNodeId.get(server)).toEqual({ domain: 'filius.de', accounts: project.emailServerConfigurations.get(server)!.accounts, isActive: true });
|
||||
const switchedOff = { ...project, activePrograms: new Map([...project.activePrograms].map(([id, programs]) => [id, new Set([...programs].filter((each) => each !== 'emailServer'))])) };
|
||||
expect(projectSnapshot(switchedOff).emailServersByNodeId.get(server)?.isActive).toBe(false);
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: { ...snapshot, simpleClientsByNodeId: new Map([[sender, { targetAddress: '141.99.50.232', targetPort: 25 }]]) },
|
||||
seed: 14,
|
||||
initialTimeMilliseconds: 0,
|
||||
});
|
||||
const client = engine.simpleClient(sender)!;
|
||||
client.connect('141.99.50.232', 25);
|
||||
const commands = ['HELO 141.99.50.181', 'MAIL FROM:<rechner1@filius.de>', 'RCPT TO:<rechner2@filius.de>', 'DATA', 'From: <rechner1@filius.de>\nTo: <rechner2@filius.de>\nSubject: Hallo\n\nGespeichert?\n.\n', 'QUIT'];
|
||||
for (const [index, command] of commands.entries()) {
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 + index * 2_000 });
|
||||
client.send(command);
|
||||
}
|
||||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||||
// The project itself is as it was opened, until the run's changes go back into it.
|
||||
expect(project.emailServerConfigurations.get(server)!.accounts[1]!.mailbox).toEqual([]);
|
||||
|
||||
const saved = saveProjectArchive(projectWithSimulationChanges(project, engine.state.topologySnapshot)).archive;
|
||||
const reopened = openProjectArchive(saved, counter());
|
||||
const reopenedServer = reopened.graph.nodes.find((each) => each.displayName === 'Mail-Server (filius.de)')!.id;
|
||||
const [first, second] = reopened.emailServerConfigurations.get(reopenedServer)!.accounts;
|
||||
expect(first!.mailbox).toEqual([]);
|
||||
expect(second!.mailbox).toMatchObject([{ from: { name: null, mailAddress: 'rechner1@filius.de' }, to: [{ name: null, mailAddress: 'rechner2@filius.de' }], subject: 'Hallo', body: 'Gespeichert?\n' }]);
|
||||
});
|
||||
|
||||
it("keeps a node's IP forwarding where it differs from its kind's: a host that forwards, a router that does not", () => {
|
||||
const system = (ipForwardingEnabled: boolean) => ({ manualRoutes: [], ipForwardingEnabled });
|
||||
const project = importProject(
|
||||
|
||||
Reference in New Issue
Block a user