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:
+9
-5
File diff suppressed because one or more lines are too long
@@ -24,6 +24,8 @@ export * from './runtime/apps/echo.js';
|
|||||||
export * from './runtime/apps/web.js';
|
export * from './runtime/apps/web.js';
|
||||||
export * from './runtime/apps/routerPages.js';
|
export * from './runtime/apps/routerPages.js';
|
||||||
export * from './runtime/apps/firewallSettings.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 './runtime/http.js';
|
||||||
export * from './util/equal.js';
|
export * from './util/equal.js';
|
||||||
export * from './util/javaBase64.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;
|
protected abstract answer(message: string): string;
|
||||||
|
|
||||||
/** ServerMitarbeiter.run: each message answered (verarbeiteNachricht, sendeNachricht), until the connection ends. */
|
/** 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 (;;) {
|
for (;;) {
|
||||||
const received = yield* this.engine.receiveTcpSteps(socketId);
|
const received = yield* this.engine.receiveTcpSteps(socketId);
|
||||||
if (received.type !== 'message') break;
|
if (received.type !== 'message') return;
|
||||||
const text = decode(received.payload);
|
const text = decode(received.payload);
|
||||||
this.log.write({ kind: 'received', text });
|
this.log.write({ kind: 'received', text });
|
||||||
const answer = this.answer(text);
|
const answer = this.answer(text);
|
||||||
const result = yield* this.engine.sendTcpSteps(socketId, encode(answer));
|
const result = yield* this.engine.sendTcpSteps(socketId, encode(answer));
|
||||||
this.log.write(result === 'sent' ? { kind: 'sent', text: answer } : { kind: 'sendFailed', failure: result });
|
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.engine.requestTcpClose(socketId);
|
||||||
this.connections.delete(socketId);
|
this.connections.delete(socketId);
|
||||||
this.log.write({ kind: 'connectionClosed', address, port });
|
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 {
|
import type {
|
||||||
DeviceConfiguration,
|
DeviceConfiguration,
|
||||||
DhcpServerConfiguration,
|
DhcpServerConfiguration,
|
||||||
|
EmailServerAccount,
|
||||||
FirewallConfiguration,
|
FirewallConfiguration,
|
||||||
GatewayPortForwardingRow,
|
GatewayPortForwardingRow,
|
||||||
FirewallRule,
|
FirewallRule,
|
||||||
@@ -115,6 +116,7 @@ import { RuntimeSpeed } from './speed.js';
|
|||||||
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
||||||
import { EchoServer, SimpleClient } from './apps/echo.js';
|
import { EchoServer, SimpleClient } from './apps/echo.js';
|
||||||
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.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 { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
|
||||||
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
|
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
|
||||||
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||||
@@ -910,6 +912,9 @@ export class NetworkRuntimeEngine {
|
|||||||
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
||||||
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
||||||
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
|
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. */
|
/** 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[]>();
|
private readonly firewallLogsByNodeId = new Map<Id, FirewallLogEntry[]>();
|
||||||
/** Accepted connections a program has taken (acceptTcpSteps). */
|
/** Accepted connections a program has taken (acceptTcpSteps). */
|
||||||
@@ -929,6 +934,9 @@ export class NetworkRuntimeEngine {
|
|||||||
* web administration answers without a socket), likewise.
|
* web administration answers without a socket), likewise.
|
||||||
*
|
*
|
||||||
* `captureRetention`: how many frames each card's packet capture keeps.
|
* `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(
|
constructor(
|
||||||
seed: bigint | number = DEFAULT_SEED,
|
seed: bigint | number = DEFAULT_SEED,
|
||||||
@@ -939,8 +947,10 @@ export class NetworkRuntimeEngine {
|
|||||||
readonly swiftSends?: boolean;
|
readonly swiftSends?: boolean;
|
||||||
readonly swiftRouters?: boolean;
|
readonly swiftRouters?: boolean;
|
||||||
readonly captureRetention?: CaptureRetentionPolicy;
|
readonly captureRetention?: CaptureRetentionPolicy;
|
||||||
|
readonly wallClock?: () => number;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
|
this.wallClock = options.wallClock ?? Date.now;
|
||||||
this.packetCapture = new PacketCapture(options.captureRetention ?? PRODUCTION_CAPTURE_RETENTION);
|
this.packetCapture = new PacketCapture(options.captureRetention ?? PRODUCTION_CAPTURE_RETENTION);
|
||||||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||||||
this.swiftArp = options.swiftArp === true;
|
this.swiftArp = options.swiftArp === true;
|
||||||
@@ -1032,6 +1042,7 @@ export class NetworkRuntimeEngine {
|
|||||||
this.simpleClientsByNodeId.clear();
|
this.simpleClientsByNodeId.clear();
|
||||||
this.webServersByNodeId.clear();
|
this.webServersByNodeId.clear();
|
||||||
this.webBrowsersByNodeId.clear();
|
this.webBrowsersByNodeId.clear();
|
||||||
|
this.emailServersByNodeId.clear();
|
||||||
this.arpLookups = [];
|
this.arpLookups = [];
|
||||||
this.protocolThreads.clear();
|
this.protocolThreads.clear();
|
||||||
this.dnsQueriesBySocketId.clear();
|
this.dnsQueriesBySocketId.clear();
|
||||||
@@ -1117,6 +1128,20 @@ export class NetworkRuntimeEngine {
|
|||||||
return this.webBrowsersByNodeId.get(nodeId);
|
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),
|
* Each node's programs start with the simulation (Betriebssystem.starten),
|
||||||
* from the snapshot; what they show starts empty.
|
* from the snapshot; what they show starts empty.
|
||||||
@@ -1127,6 +1152,7 @@ export class NetworkRuntimeEngine {
|
|||||||
this.simpleClientsByNodeId.clear();
|
this.simpleClientsByNodeId.clear();
|
||||||
this.webServersByNodeId.clear();
|
this.webServersByNodeId.clear();
|
||||||
this.webBrowsersByNodeId.clear();
|
this.webBrowsersByNodeId.clear();
|
||||||
|
this.emailServersByNodeId.clear();
|
||||||
for (const node of snapshot.nodes) {
|
for (const node of snapshot.nodes) {
|
||||||
const echoServer = snapshot.echoServersByNodeId.get(node.id);
|
const echoServer = snapshot.echoServersByNodeId.get(node.id);
|
||||||
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
|
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)));
|
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));
|
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);
|
if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
SimpleClientConfiguration,
|
SimpleClientConfiguration,
|
||||||
SwitchConfiguration,
|
SwitchConfiguration,
|
||||||
WebServerConfiguration,
|
WebServerConfiguration,
|
||||||
|
EmailServerAccount,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||||
import type { PageLanguage } from './apps/routerPages.js';
|
import type { PageLanguage } from './apps/routerPages.js';
|
||||||
@@ -30,6 +31,17 @@ export interface WebServerSnapshot extends WebServerConfiguration {
|
|||||||
readonly isActive: boolean;
|
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). */
|
/** An installed echo server: its port, and whether it is switched on (Java: ServerBaustein.aktiv). */
|
||||||
export interface EchoServerSnapshot extends EchoServerConfiguration {
|
export interface EchoServerSnapshot extends EchoServerConfiguration {
|
||||||
readonly isActive: boolean;
|
readonly isActive: boolean;
|
||||||
@@ -88,6 +100,8 @@ export interface TopologySnapshot {
|
|||||||
readonly webServersByNodeId: ReadonlyMap<Id, WebServerSnapshot>;
|
readonly webServersByNodeId: ReadonlyMap<Id, WebServerSnapshot>;
|
||||||
/** The nodes with the web browser installed (WebBrowser). */
|
/** The nodes with the web browser installed (WebBrowser). */
|
||||||
readonly webBrowserNodeIds: ReadonlySet<Id>;
|
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). */
|
/** Each node's files, which its programs read (a web server its pages). */
|
||||||
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
|
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. */
|
/** 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>;
|
simpleClientsByNodeId?: ReadonlyMap<Id, SimpleClientConfiguration>;
|
||||||
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
|
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
|
||||||
webBrowserNodeIds?: ReadonlySet<Id>;
|
webBrowserNodeIds?: ReadonlySet<Id>;
|
||||||
|
emailServersByNodeId?: ReadonlyMap<Id, EmailServerSnapshot>;
|
||||||
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
||||||
language?: PageLanguage;
|
language?: PageLanguage;
|
||||||
}
|
}
|
||||||
@@ -135,6 +150,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
|
|||||||
simpleClientsByNodeId: input.simpleClientsByNodeId ?? new Map(),
|
simpleClientsByNodeId: input.simpleClientsByNodeId ?? new Map(),
|
||||||
webServersByNodeId: input.webServersByNodeId ?? new Map(),
|
webServersByNodeId: input.webServersByNodeId ?? new Map(),
|
||||||
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
||||||
|
emailServersByNodeId: input.emailServersByNodeId ?? new Map(),
|
||||||
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
||||||
language: input.language ?? 'de',
|
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
|
// message bundle says them: for comparing the core's structured lines with
|
||||||
// what the Java oracle recorded (echoJava.test.ts, webJava.test.ts).
|
// 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. */
|
/** 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;
|
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
|
// 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).
|
// 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
|
// 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
|
// switched at run time). The programs read the nodes' files (a web server its
|
||||||
// pages).
|
// pages).
|
||||||
//
|
//
|
||||||
// Back the other way: what the simulation changes that Java keeps in the
|
// 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
|
// 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';
|
import type { ImportedProject } from './importProject.js';
|
||||||
|
|
||||||
export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
||||||
@@ -29,6 +31,11 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
|||||||
for (const [nodeId, configuration] of project.webServerConfigurations) {
|
for (const [nodeId, configuration] of project.webServerConfigurations) {
|
||||||
webServers.set(nodeId, { ...configuration, isActive: project.activePrograms.get(nodeId)?.has('webServer') === true });
|
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));
|
const webBrowsers = new Set([...project.installedPrograms].filter(([, programs]) => programs.has('webBrowser')).map(([nodeId]) => nodeId));
|
||||||
return topologySnapshot({
|
return topologySnapshot({
|
||||||
nodes: project.graph.nodes.map((node) => ({
|
nodes: project.graph.nodes.map((node) => ({
|
||||||
@@ -52,11 +59,22 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
|||||||
simpleClientsByNodeId: project.simpleClientConfigurations,
|
simpleClientsByNodeId: project.simpleClientConfigurations,
|
||||||
webServersByNodeId: webServers,
|
webServersByNodeId: webServers,
|
||||||
webBrowserNodeIds: webBrowsers,
|
webBrowserNodeIds: webBrowsers,
|
||||||
|
emailServersByNodeId: emailServers,
|
||||||
fileSystemsByNodeId: project.virtualFileSystems,
|
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 {
|
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);
|
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", () => {
|
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 system = (ipForwardingEnabled: boolean) => ({ manualRoutes: [], ipForwardingEnabled });
|
||||||
const project = importProject(
|
const project = importProject(
|
||||||
|
|||||||
@@ -81,3 +81,7 @@ packages/core/src/runtime/apps/routerPages.ts::titel
|
|||||||
packages/core/test/routerWebJava.test.ts::titel
|
packages/core/test/routerWebJava.test.ts::titel
|
||||||
# konfig: the path of Java's firewall settings plug-in (FirewallWebKonfig); the stories that use it are named after it, as in RouterWebRun.
|
# konfig: the path of Java's firewall settings plug-in (FirewallWebKonfig); the stories that use it are named after it, as in RouterWebRun.
|
||||||
packages/core/test/routerWebJava.test.ts::konfig
|
packages/core/test/routerWebJava.test.ts::konfig
|
||||||
|
# Java FILIUS's e-mail classes and message keys (SMTPMitarbeiter, POP3Mitarbeiter, sw_smtpmitarbeiter_msg12 …),
|
||||||
|
# cited where the core's e-mail server says which of Java's classes and lines it stands for.
|
||||||
|
packages/core/src/runtime/apps/emailServer.ts::smtpmitarbeiter
|
||||||
|
packages/core/src/runtime/apps/emailServer.ts::pop3mitarbeiter
|
||||||
|
|||||||
@@ -684,3 +684,44 @@ Gemessen (01.10.2026):
|
|||||||
Regel „TCP 80 verwerfen“ über die erlaubende geschoben, und das SYN des
|
Regel „TCP 80 verwerfen“ über die erlaubende geschoben, und das SYN des
|
||||||
Browsers bleibt an der Firewall hängen. Javas Router-Server lauschte auch
|
Browsers bleibt an der Firewall hängen. Javas Router-Server lauschte auch
|
||||||
hier in einem Lauf nie (`run.sh` wiederholt).
|
hier in einem Lauf nie (`run.sh` wiederholt).
|
||||||
|
|
||||||
|
## E-Mail-Server (F3.3a)
|
||||||
|
|
||||||
|
`EmailRun` lässt Javas einfachen Client (`ClientBaustein`) auf `mail-lan`
|
||||||
|
rohe SMTP- und POP3-Befehle an einen E-Mail-Server schicken (Domain
|
||||||
|
`filius.de`, Konten aus `/mailserver/konten.txt`: anna mit zwei Mails, bob
|
||||||
|
ohne). Aufgezeichnet werden die Frames, die Fensterzeilen von Server und
|
||||||
|
Client und am Ende die Konten (jede Mail als `Email.toString`, neu,
|
||||||
|
gelöscht). `MailCases` füttert Javas `SMTPMitarbeiter` und
|
||||||
|
`POP3Mitarbeiter` ohne Netz über einen Socket, der nur mitschreibt, mit den
|
||||||
|
Fällen aus `packages/core/test/email-server.cases.txt`, auf Deutsch und auf
|
||||||
|
Englisch. Die Netz-Abläufe bleiben bei dem, was Java richtig macht; Javas
|
||||||
|
Fehler zeigen die Fälle.
|
||||||
|
|
||||||
|
Gemessen (01.10.2026):
|
||||||
|
|
||||||
|
* Der Server startet POP3 (Port 110) vor SMTP (Port 25); POP3s Socket nimmt
|
||||||
|
die erste TCP-Startnummer. Die SMTP-Antworten sind übersetzt („220
|
||||||
|
Willkommen bei“), die POP3-Antworten englisch. SMTP schreibt seine
|
||||||
|
Fensterzeile nach dem Senden, POP3 davor.
|
||||||
|
* Eine angenommene Mail speichert Java so, wie es ihren Text neu liest
|
||||||
|
(`new Email(email.toString())`): Zeilenenden ohne `\r`, kein Bcc.
|
||||||
|
* SMTP-Fehler: Nach einer Mail beantwortet der Server den nächsten Befehl mit
|
||||||
|
503 und behält Absender, Empfänger und Text (die zweite Mail ging auch an
|
||||||
|
die erste Empfängerin, mit dem ersten Text); unbekannte oder verfrühte
|
||||||
|
Befehle bleiben ohne Antwort; ein `quit` im Mailtext beendet die
|
||||||
|
Verbindung; die Domain wird genau verglichen (`FILIUS.DE` ist fremd); ein
|
||||||
|
leerer Absender mit unbekanntem Empfänger und ein Empfänger ohne `@`
|
||||||
|
werfen im Mitarbeiter, die Verbindung endet; eine unbekannte Kopfzeile
|
||||||
|
beendet den Kopf (der Betreff wird Text), ohne Leerzeile fehlt die erste
|
||||||
|
Zeile; eine Benachrichtigung an eine Mail ohne From hat `To: null`.
|
||||||
|
* Die Benachrichtigung über unbekannte Empfänger trägt Javas Datum im
|
||||||
|
Gebietsschema der JVM (hier `10/1/26, 6:30 PM`) und die Adresse des
|
||||||
|
Servers.
|
||||||
|
* POP3 zählt Mails ab 0. Fehler: eine Mail lässt sich nur einmal abrufen;
|
||||||
|
`LIST` ohne Mails gibt `-ERR`, `LIST 0` listet alle; vor der Anmeldung
|
||||||
|
kommen leere Antworten; eine fehlende Mail kommt als leere `+OK`-Mail,
|
||||||
|
`DELE` darauf leer; ein fehlendes oder falsches Argument wirft, die
|
||||||
|
Verbindung endet; nach `DELE` nummeriert `LIST` neu, `RETR` nicht.
|
||||||
|
* Die Größe in `STAT` und `LIST` ist Javas eigene: die Länge von Text,
|
||||||
|
Absender, Betreff, Empfängerliste (`[a, b]`) und Datum.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
|||||||
fi
|
fi
|
||||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||||
mkdir -p "$here/.build"
|
mkdir -p "$here/.build"
|
||||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java"
|
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java"
|
||||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||||
mkdir -p "$here/.build/home"
|
mkdir -p "$here/.build/home"
|
||||||
# Removed first: a crashed run must not leave the previous result standing.
|
# Removed first: a crashed run must not leave the previous result standing.
|
||||||
@@ -247,6 +247,24 @@ for language in de en; do
|
|||||||
test -s "$output"
|
test -s "$output"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The e-mail server as Java FILIUS runs it (EmailRun on mail-lan), and its
|
||||||
|
# SMTP and POP3 workers fed without a network, in both languages (MailCases).
|
||||||
|
for run in smtp:16000 smtpErrors:25000 pop3:36000 pop3Errors:20000; do
|
||||||
|
story="${run%%:*}"
|
||||||
|
output="$root/packages/core/test/scenarios/email-java-$story.java.json"
|
||||||
|
rm -f "$output"
|
||||||
|
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" EmailRun \
|
||||||
|
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$story" "${run##*:}" "$output" 2>&1 | grep -v 'logback\|^$' || true
|
||||||
|
test -s "$output"
|
||||||
|
done
|
||||||
|
for language in de en; do
|
||||||
|
output="$root/packages/core/test/email-server.$language.java.json"
|
||||||
|
rm -f "$output"
|
||||||
|
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" MailCases "$language" \
|
||||||
|
"$root/packages/core/test/email-server.cases.txt" "$output" 2>&1 | grep -v 'logback\|^$' || true
|
||||||
|
test -s "$output"
|
||||||
|
done
|
||||||
|
|
||||||
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
||||||
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts).
|
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts).
|
||||||
for run in internetwork_komplett:routing:12000 einfaches_rechnernetz_komplett:dhcp:20000 dns_server:dns:12000 \
|
for run in internetwork_komplett:routing:12000 einfaches_rechnernetz_komplett:dhcp:20000 dns_server:dns:12000 \
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Runs a story of FILIUS' e-mail server (EmailServer with its SMTPServer and
|
||||||
|
// POP3Server) on a core scenario's network in Java FILIUS and records what
|
||||||
|
// really happens: every frame each card saw, every line the server wrote
|
||||||
|
// into its window, every line of the generic client that talks to it, and
|
||||||
|
// the server's accounts at the end (each message as Email.toString, whether
|
||||||
|
// it is new, whether it is marked for deletion).
|
||||||
|
//
|
||||||
|
// EmailRun <scenario.json> <story> <milliseconds> <out.json>
|
||||||
|
//
|
||||||
|
// The stories use mail-lan (client on 192.168.0.20, e-mail server on .30,
|
||||||
|
// domain filius.de). The server's accounts come from /mailserver/konten.txt
|
||||||
|
// as below (anna with two messages, from bob and from carla; bob without),
|
||||||
|
// switched on.
|
||||||
|
// FILIUS' generic client (ClientBaustein) on .20 sends the commands as they
|
||||||
|
// are, one message each, after a pause for the answer.
|
||||||
|
// smtp to anna, then QUIT
|
||||||
|
// smtpErrors commands out of order, then a mail to a recipient the
|
||||||
|
// domain does not know (a notice goes to the local sender)
|
||||||
|
// pop3 log in, STAT, LIST, RETR and DELE both messages, QUIT;
|
||||||
|
// again: log in, STAT, QUIT
|
||||||
|
// pop3Errors unknown user, PASS before USER, wrong password, NOOP before
|
||||||
|
// and after log-in, USER after log-in
|
||||||
|
// Java's errors in these dialogues are measured without a network
|
||||||
|
// (MailCases), so the stories keep to what Java does right.
|
||||||
|
|
||||||
|
import filius.software.clientserver.ClientBaustein;
|
||||||
|
import filius.software.email.Email;
|
||||||
|
import filius.software.email.EmailKonto;
|
||||||
|
import filius.software.email.EmailServer;
|
||||||
|
import filius.software.system.Datei;
|
||||||
|
import filius.software.system.Dateisystem;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class EmailRun {
|
||||||
|
static final String CLIENT = "00000000-0000-0000-0000-000000000201";
|
||||||
|
static final String SERVER = "00000000-0000-0000-0000-000000000203";
|
||||||
|
|
||||||
|
/** The server's accounts before the run, in EmailServer's file format. */
|
||||||
|
static final String ACCOUNTS = "anna;filius.de;geheim;Muster;Anna;#Bob Beispiel <bob@filius.de>$<anna@filius.de>$$$$Hallo Anna$Erste Zeile|Zweite Zeile"
|
||||||
|
+ "#Carla <carla@andere.de>$Anna Muster <anna@filius.de>,<bob@filius.de>$<chef@filius.de>$$$Termin$Morgen um 10?\n"
|
||||||
|
+ "bob;filius.de;passwort;Beispiel;Bob;\n";
|
||||||
|
|
||||||
|
static ScenarioNetwork network;
|
||||||
|
static ClientBaustein client;
|
||||||
|
static final List<Object[]> events = new ArrayList<>();
|
||||||
|
|
||||||
|
static synchronized void event(String who, String what) {
|
||||||
|
events.add(new Object[] { System.currentTimeMillis() - network.start, who, what });
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleepUntil(long milliseconds) throws InterruptedException {
|
||||||
|
long rest = network.start + milliseconds - System.currentTimeMillis();
|
||||||
|
if (rest > 0) Thread.sleep(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
static long at;
|
||||||
|
|
||||||
|
static void connect(int port) throws InterruptedException {
|
||||||
|
sleepUntil(at);
|
||||||
|
client.setZielIPAdresse("192.168.0.30");
|
||||||
|
client.setZielPort(port);
|
||||||
|
event("client", "call verbinden " + port);
|
||||||
|
client.verbinden();
|
||||||
|
at += 2500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each command after the answer to the one before. */
|
||||||
|
static void send(String... messages) throws InterruptedException {
|
||||||
|
for (String message : messages) {
|
||||||
|
sleepUntil(at);
|
||||||
|
event("client", "call senden " + message);
|
||||||
|
client.senden(message);
|
||||||
|
at += 1500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
network = ScenarioNetwork.read(args[0]);
|
||||||
|
String story = args[1];
|
||||||
|
long end = Long.parseLong(args[2]);
|
||||||
|
|
||||||
|
network.system(SERVER).installApp("filius.software.email.EmailServer", false);
|
||||||
|
EmailServer server = (EmailServer) network.system(SERVER).holeSoftware("filius.software.email.EmailServer");
|
||||||
|
Dateisystem files = network.system(SERVER).getDateisystem();
|
||||||
|
files.speicherDatei("/mailserver", new Datei("konten.txt", "txt", ACCOUNTS));
|
||||||
|
server.setMailDomain("filius.de");
|
||||||
|
server.setAktiv(true);
|
||||||
|
server.hinzuBeobachter((observable, data) -> event("server", String.valueOf(data)));
|
||||||
|
network.system(CLIENT).installApp("filius.software.clientserver.ClientBaustein", false);
|
||||||
|
client = (ClientBaustein) network.system(CLIENT).holeSoftware("filius.software.clientserver.ClientBaustein");
|
||||||
|
client.hinzuBeobachter((observable, data) -> event("client", String.valueOf(data)));
|
||||||
|
network.start();
|
||||||
|
at = 500;
|
||||||
|
|
||||||
|
switch (story) {
|
||||||
|
case "smtp":
|
||||||
|
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");
|
||||||
|
break;
|
||||||
|
case "smtpErrors":
|
||||||
|
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");
|
||||||
|
break;
|
||||||
|
case "pop3":
|
||||||
|
connect(110);
|
||||||
|
send("USER anna", "PASS geheim", "STAT", "LIST", "RETR 0", "RETR 1", "DELE 0", "DELE 1", "QUIT");
|
||||||
|
at += 1000;
|
||||||
|
connect(110);
|
||||||
|
send("USER anna", "PASS geheim", "STAT", "QUIT");
|
||||||
|
break;
|
||||||
|
case "pop3Errors":
|
||||||
|
connect(110);
|
||||||
|
send("NOOP", "USER niemand", "PASS x", "USER anna", "PASS falsch", "PASS geheim", "NOOP", "USER bob", "QUIT");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("unknown story " + story);
|
||||||
|
}
|
||||||
|
sleepUntil(Math.max(end, at + 3000));
|
||||||
|
|
||||||
|
ScenarioNetwork.Json out = new ScenarioNetwork.Json();
|
||||||
|
out.open('{');
|
||||||
|
network.writeFrames(out);
|
||||||
|
synchronized (EmailRun.class) {
|
||||||
|
out.key("events").open('[');
|
||||||
|
for (Object[] each : events) out.open('[').number((Long) each[0]).string((String) each[1]).string((String) each[2]).close(']');
|
||||||
|
out.close(']');
|
||||||
|
}
|
||||||
|
out.key("accounts").open('[');
|
||||||
|
for (EmailKonto account : server.getListeBenutzerkonten()) {
|
||||||
|
out.open('[').string(account.getBenutzername());
|
||||||
|
for (Email email : account.getNachrichten()) {
|
||||||
|
out.open('[').string(email.toString()).string(String.valueOf(email.getNeu())).string(String.valueOf(email.getDelete())).close(']');
|
||||||
|
}
|
||||||
|
out.close(']');
|
||||||
|
}
|
||||||
|
out.close(']');
|
||||||
|
out.key("file").string(files.holeDatei("/mailserver/konten.txt").getDateiInhalt());
|
||||||
|
out.close('}');
|
||||||
|
network.stop();
|
||||||
|
ScenarioNetwork.write(args[3], out);
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Feeds FILIUS' SMTP and POP3 workers (SMTPMitarbeiter, POP3Mitarbeiter of
|
||||||
|
// an EmailServer) with messages from a client, one after the other, without
|
||||||
|
// a network: the worker's socket only writes down what it is to send. After
|
||||||
|
// each message it writes what the worker sent, the lines the e-mail server
|
||||||
|
// wrote into its window, whether the socket was closed, and the accounts
|
||||||
|
// (each message as Email.toString, new, marked for deletion). The reference
|
||||||
|
// for the core's e-mail server (packages/core/src/runtime/apps/emailServer.ts).
|
||||||
|
// Each case starts from a new server with the accounts of EmailRun (domain
|
||||||
|
// filius.de, its address 192.168.0.30, the client 192.168.0.20). The cases
|
||||||
|
// file is ASCII, with Java-style unicode escapes (backslash, u, four hex
|
||||||
|
// digits) for anything else, a message per line:
|
||||||
|
//
|
||||||
|
// case <name> smtp|pop3
|
||||||
|
// <message>
|
||||||
|
//
|
||||||
|
// Usage: java -cp filius.jar:lib/*:classes MailCases <de|en> <cases txt> <output json>
|
||||||
|
|
||||||
|
import filius.hardware.knoten.Rechner;
|
||||||
|
import filius.rahmenprogramm.Information;
|
||||||
|
import filius.software.email.Email;
|
||||||
|
import filius.software.email.EmailKonto;
|
||||||
|
import filius.software.email.EmailServer;
|
||||||
|
import filius.software.email.POP3Mitarbeiter;
|
||||||
|
import filius.software.email.POP3Server;
|
||||||
|
import filius.software.email.SMTPMitarbeiter;
|
||||||
|
import filius.software.email.SMTPServer;
|
||||||
|
import filius.software.system.Datei;
|
||||||
|
import filius.software.system.InternetKnotenBetriebssystem;
|
||||||
|
import filius.software.transportschicht.TCPSocket;
|
||||||
|
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class MailCases {
|
||||||
|
|
||||||
|
/** A socket that only writes down what is sent, and whether it was closed. */
|
||||||
|
static class Recorder extends TCPSocket {
|
||||||
|
final List<String> sent = new ArrayList<>();
|
||||||
|
boolean closed = false;
|
||||||
|
|
||||||
|
Recorder(InternetKnotenBetriebssystem system) throws Exception {
|
||||||
|
super(system, "192.168.0.20", 50000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void senden(String nachricht) {
|
||||||
|
sent.add(nachricht);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void schliessen() {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beenden() {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean istVerbunden() {
|
||||||
|
return !closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String holeZielIPAdresse() {
|
||||||
|
return "192.168.0.20";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
// Before any class reads the message bundle (I18n.messages is read once).
|
||||||
|
Information.getInformation().setLocale(args[0].equals("en") ? new Locale("en", "GB") : Locale.GERMANY);
|
||||||
|
List<Object> cases = new ArrayList<>();
|
||||||
|
List<Object> steps = null;
|
||||||
|
EmailServer server = null;
|
||||||
|
Recorder socket = null;
|
||||||
|
Object worker = null;
|
||||||
|
Method process = null;
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
for (String line : Files.readAllLines(Path.of(args[1]), StandardCharsets.US_ASCII)) {
|
||||||
|
if (line.isEmpty() || line.startsWith("#")) continue;
|
||||||
|
if (line.startsWith("case ")) {
|
||||||
|
String[] words = line.split(" ");
|
||||||
|
Map<String, Object> current = new LinkedHashMap<>();
|
||||||
|
steps = new ArrayList<>();
|
||||||
|
current.put("name", words[1]);
|
||||||
|
current.put("protocol", words[2]);
|
||||||
|
current.put("steps", steps);
|
||||||
|
cases.add(current);
|
||||||
|
Rechner host = new Rechner();
|
||||||
|
InternetKnotenBetriebssystem system = (InternetKnotenBetriebssystem) host.getSystemSoftware();
|
||||||
|
system.setzeIPAdresse("192.168.0.30");
|
||||||
|
system.installApp("filius.software.email.EmailServer", false);
|
||||||
|
server = (EmailServer) system.holeSoftware("filius.software.email.EmailServer");
|
||||||
|
system.getDateisystem().speicherDatei("/mailserver", new Datei("konten.txt", "txt", EmailRun.ACCOUNTS));
|
||||||
|
server.setMailDomain("filius.de");
|
||||||
|
server.kontenLaden();
|
||||||
|
lines.clear();
|
||||||
|
List<String> window = lines;
|
||||||
|
server.hinzuBeobachter((observable, data) -> {
|
||||||
|
if (data != null) window.add(String.valueOf(data));
|
||||||
|
});
|
||||||
|
socket = new Recorder(system);
|
||||||
|
if (words[2].equals("smtp")) {
|
||||||
|
worker = new SMTPMitarbeiter(socket, new SMTPServer(25, server));
|
||||||
|
process = SMTPMitarbeiter.class.getDeclaredMethod("verarbeiteNachricht", String.class);
|
||||||
|
} else {
|
||||||
|
worker = new POP3Mitarbeiter(socket, new POP3Server(110, server));
|
||||||
|
process = POP3Mitarbeiter.class.getDeclaredMethod("verarbeiteNachricht", String.class);
|
||||||
|
}
|
||||||
|
process.setAccessible(true);
|
||||||
|
// The greeting the worker sends when it begins.
|
||||||
|
steps.add(step(null, socket, lines, server, null));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String message = IpCheckCases.unescape(line);
|
||||||
|
String thrown = null;
|
||||||
|
try {
|
||||||
|
process.invoke(worker, message);
|
||||||
|
} catch (InvocationTargetException error) {
|
||||||
|
thrown = error.getCause().getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
steps.add(step(message, socket, lines, server, thrown));
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(args[2])) {
|
||||||
|
out.write((FlsOracle.Json.write(cases, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
System.out.println("mail cases (" + args[0] + "): " + cases.size());
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object> step(String message, Recorder socket, List<String> lines, EmailServer server, String thrown) {
|
||||||
|
Map<String, Object> step = new LinkedHashMap<>();
|
||||||
|
step.put("message", message);
|
||||||
|
step.put("sent", new ArrayList<>(socket.sent));
|
||||||
|
step.put("lines", new ArrayList<>(lines));
|
||||||
|
step.put("closed", socket.closed);
|
||||||
|
step.put("thrown", thrown);
|
||||||
|
List<Object> accounts = new ArrayList<>();
|
||||||
|
for (EmailKonto account : server.getListeBenutzerkonten()) {
|
||||||
|
List<Object> messages = new ArrayList<>();
|
||||||
|
for (Email email : account.getNachrichten()) messages.add(Arrays.asList(email.toString(), email.getNeu(), email.getDelete()));
|
||||||
|
accounts.add(Arrays.asList(account.getBenutzername(), messages));
|
||||||
|
}
|
||||||
|
step.put("accounts", accounts);
|
||||||
|
socket.sent.clear();
|
||||||
|
lines.clear();
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user