feat(core): the e-mail server after Java, measured against Java

SMTP on port 25 stores mails for the domain's accounts as Java reads
them anew and tells a local sender of unknown recipients; POP3 on port
110 hands them out. The mailboxes are in the snapshot and go back into
the project. EmailRun plays four stories over the network; MailCases
feeds Java's workers without one (26 cases, de and en). 18 cases name
Java bugs that the core does not take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 18:58:26 +00:00
co-authored by Claude Opus 5.5
parent 8438b42018
commit 015635926f
26 changed files with 45188 additions and 14 deletions
+2
View File
@@ -24,6 +24,8 @@ export * from './runtime/apps/echo.js';
export * from './runtime/apps/web.js';
export * from './runtime/apps/routerPages.js';
export * from './runtime/apps/firewallSettings.js';
export * from './runtime/apps/emailServer.js';
export * from './runtime/email.js';
export * from './runtime/http.js';
export * from './util/equal.js';
export * from './util/javaBase64.js';
@@ -0,0 +1,551 @@
// FILIUS' e-mail server (filius.software.email: EmailServer, SMTPServer,
// SMTPMitarbeiter, POP3Server, POP3Mitarbeiter): accounts of one domain,
// SMTP on port 25 takes mails and stores those for its accounts, POP3 on
// port 110 hands an account its mails. Switched on and off together. The
// window shows each connection's messages both ways and each mail stored.
//
// Departures from Java (emailServer.test.ts names each):
// SMTP
// - after a mail, the envelope is new: the next command counts, and the
// next mail goes only to its own recipients with only its own text (Java
// answers the next command 503 and keeps sender, recipients and text);
// - a command the state does not take gets 503 (Java answers nothing);
// HELO again begins anew;
// - during DATA everything is the mail's text, also "quit" (Java ends);
// - a domain is the server's in any case (Java compares exactly and sends
// such a mail on);
// - a recipient without "@" is unknown, and an empty sender gets no notice
// (Java's worker throws, and the connection ends); a notice to a mail
// without From goes to the envelope's sender (Java writes "To: null").
// POP3
// - a message can be retrieved again (Java: only once);
// - LIST without messages, and LIST, RETR, DELE for a message that is not
// there: "-ERR no such message" (Java lists an error, sends an empty mail
// or an empty answer); LIST with a number lists that message (Java lists
// all); a deleted message keeps the other messages' numbers (Java's LIST
// counts them anew, RETR does not);
// - a command before log-in gets "-ERR unknown command", QUIT "+OK", an
// unknown command "-ERR unknown command" (Java answers empty or nothing);
// - a missing or wrong argument is an error (Java's worker throws, and the
// connection ends).
import type { Id } from '../../ids.js';
import type { EmailMessage, EmailServerAccount } from '../config.js';
import { addressEntry, addressList, addressText, formatMail, javaSplit, mailSize, parseMail, type Mail } from '../email.js';
import type { NetworkRuntimeEngine } from '../engine.js';
import { javaTrim } from '../http.js';
import type { Steps } from '../threads.js';
import { ProgramLog } from './program.js';
import type { PageLanguage } from './routerPages.js';
import type { EmailServerSnapshot } from '../snapshot.js';
import { TcpServerProgram } from './server.js';
export const SMTP_PORT = 25;
export const POP3_PORT = 110;
/** A line of the e-mail server's window. */
export type EmailServerLogEntry =
/** EmailServer.LINE_SEPARATOR: a connection began. */
| { readonly kind: 'connectionOpened' }
/** "<address>< " and the message. */
| { readonly kind: 'received'; readonly address: string; readonly text: string }
/** "<address>> " and the answer. */
| { readonly kind: 'sent'; readonly address: string; readonly text: string }
/** sw_smtpmitarbeiter_msg14: the answer could not be sent. */
| { readonly kind: 'sendFailed'; readonly address: string }
/** sw_smtpmitarbeiter_msg12, msg13: a mail stored in the account's mailbox. */
| { readonly kind: 'stored'; readonly username: string };
/** A message in a mailbox, as POP3 sees it. */
export interface MailboxEntry {
mail: Mail;
isNew: boolean;
isMarkedForDeletion: boolean;
}
export interface MailAccount {
readonly username: string;
readonly password: string;
readonly firstName: string;
readonly lastName: string;
mailbox: MailboxEntry[];
}
/** sw_smtpmitarbeiter_msg1 to msg11, the SMTP answers. */
const SMTP_TEXTS: Record<PageLanguage, Record<'welcome' | 'bye' | 'ok' | 'senderOk' | 'syntax' | 'recipientOk' | 'data' | 'order' | 'queued', string>> = {
de: {
welcome: '220 Willkommen bei',
bye: '221 Server beendet Verbindung.',
ok: '250 OK',
senderOk: '250 Sender OK',
syntax: '501 Syntax-Fehler - Parameter oder Argument falsch.',
recipientOk: '250 Recipient OK',
data: '354 End data with <CR><LF>.<CR><LF>',
order: '503 Falsche Reihenfolge der Kommandos.',
queued: '250 Mail queued for delivery',
},
en: {
welcome: '220 Welcome to',
bye: '221 Server closed connection.',
ok: '250 OK',
senderOk: '250 Sender OK',
syntax: '501 Syntax error - Parameter or argument wrong.',
recipientOk: '250 Recipient OK',
data: '354 End data with <CR><LF>.<CR><LF>',
order: '503 Wrong order of commands.',
queued: '250 Mail queued for delivery',
},
};
/** DateFormat.getInstance: short date and time in the project's language. */
function shortDateTime(milliseconds: number, language: PageLanguage): string {
const date = new Date(milliseconds);
const two = (value: number) => String(value).padStart(2, '0');
const day = two(date.getDate());
const month = two(date.getMonth() + 1);
const time = `${two(date.getHours())}:${two(date.getMinutes())}`;
return language === 'de' ? `${day}.${month}.${two(date.getFullYear() % 100)}, ${time}` : `${day}/${month}/${date.getFullYear()}, ${time}`;
}
/** The connection a session talks over. */
export interface MailConnection {
readonly address: string;
/** Sends and waits for the acknowledgement; false when it failed. */
send(text: string): Steps<boolean>;
}
/** What the server's sessions share: its domain, accounts and window. */
export class MailServerState {
constructor(
readonly domain: string,
readonly accounts: MailAccount[],
readonly language: PageLanguage,
readonly log: (entry: EmailServerLogEntry) => void,
/** After each change of a mailbox (EmailServer.kontenSpeichern). */
readonly changed: () => void,
/** The server's first address (primaryIPAdresse) and the wall clock, for a notice. */
private readonly ipAddress: () => string,
private readonly now: () => number,
) {}
/** EmailServer.sucheKonto: by name in any case. */
account(username: string): MailAccount | undefined {
const lower = username.toLowerCase();
return this.accounts.find((each) => each.username.toLowerCase() === lower);
}
/** EmailServer.pruefeAufSelbeDomain, in any case; an address without "@" has no domain. */
isOwnDomain(address: string): boolean {
const at = address.indexOf('@');
return at >= 0 && address.slice(at + 1).toLowerCase() === this.domain.toLowerCase();
}
/** SMTPMitarbeiter.verarbeiteEmail: each of the domain's recipients gets the mail as Java reads it anew. */
deliver(mail: Mail, sender: string, recipients: readonly string[]): void {
const unknown: string[] = [];
for (const recipient of recipients) {
const account = this.isOwnDomain(recipient) ? this.account(javaSplit(recipient, '@')[0] ?? '') : undefined;
if (account !== undefined) {
account.mailbox.push({ mail: parseMail(formatMail(mail)), isNew: true, isMarkedForDeletion: false });
this.log({ kind: 'stored', username: account.username });
} else if (this.isOwnDomain(recipient) || !recipient.includes('@')) {
unknown.push(recipient);
}
// Other domains: sent on with the e-mail program (F3.3b).
}
this.changed();
if (unknown.length > 0) this.notifyUnknown(mail, sender, unknown);
}
/** EmailServer.sendUnknownReceiverResponse: a notice to a sender of the domain; none for an empty sender. */
private notifyUnknown(mail: Mail, sender: string, unknown: readonly string[]): void {
const address = addressEntry(sender).mailAddress;
// An empty sender (<>) has no address of the domain.
if (address === null || !this.isOwnDomain(address)) return;
const account = this.account(javaSplit(address, '@')[0] ?? '');
if (account === undefined) return;
const text =
'The mail could not be delivered either because user or mail domain is unknown.\n' +
'Message could not be delivered to the following recipients:\n' +
unknown.map((each) => `\t${each}\n`).join('') +
`\nDate: ${shortDateTime(this.now(), this.language)}\n` +
`Mail-Server: ${this.ipAddress()}\n` +
'\n______________________________________________________________________\n' +
formatMail(mail);
const notice: Mail = {
sender: addressEntry(`noreply@${this.domain}`),
to: [mail.sender ?? { name: null, mailAddress: address }],
cc: [],
bcc: [],
subject: 'Non Delivery Notification',
text,
dateReceived: '',
};
account.mailbox.push({ mail: notice, isNew: true, isMarkedForDeletion: false });
this.log({ kind: 'stored', username: account.username });
this.changed();
}
}
type SmtpState = 'start' | 'envelope' | 'data';
/** SMTPMitarbeiter: one connection's SMTP dialogue. */
export class SmtpSession {
private state: SmtpState = 'start';
private sender: string | undefined;
private recipients: string[] = [];
private source = '';
closed = false;
constructor(
private readonly server: MailServerState,
private readonly connection: MailConnection,
) {}
private get texts() {
return SMTP_TEXTS[this.server.language];
}
/** Sends, then writes the line (Java's senden). */
private *reply(text: string): Steps {
const sent = yield* this.connection.send(text);
this.server.log(sent ? { kind: 'sent', address: this.connection.address, text } : { kind: 'sendFailed', address: this.connection.address });
}
/** The constructor's greeting. */
*begin(): Steps {
this.server.log({ kind: 'connectionOpened' });
yield* this.reply(`${this.texts.welcome} ${this.server.domain}`);
}
private resetEnvelope(): void {
this.sender = undefined;
this.recipients = [];
this.source = '';
}
/** SMTPMitarbeiter.verarbeiteNachricht. */
*receive(message: string): Steps {
this.server.log({ kind: 'received', address: this.connection.address, text: message });
if (this.state === 'data') {
const lines = javaSplit(message, '\n');
if (lines.length > 0 && javaTrim(lines[lines.length - 1]!) === '.') {
const mail = parseMail(this.source + message.slice(0, message.lastIndexOf('.')));
yield* this.reply(this.texts.queued);
const sender = this.sender ?? '';
const recipients = this.recipients;
this.state = 'envelope';
this.resetEnvelope();
this.server.deliver(mail, sender, recipients);
} else {
this.source += message;
}
return;
}
if (javaTrim(message).toLowerCase() === 'quit') {
yield* this.reply(this.texts.bye);
this.closed = true;
return;
}
const space = message.indexOf(' ');
const word = javaTrim(space > 0 ? message.slice(0, space) : message).toLowerCase();
if (word === 'helo' || word === 'ehlo') {
yield* this.reply(`250 Hello ${space > 0 ? javaTrim(message.slice(space)) : ''}`);
this.state = 'envelope';
this.resetEnvelope();
return;
}
if (word === 'noop') {
yield* this.reply(this.texts.ok);
return;
}
if (this.state === 'envelope') {
const colon = message.indexOf(':');
const command = javaTrim(colon > 0 ? message.slice(0, colon) : message).toLowerCase();
const start = message.indexOf('<');
const end = message.indexOf('>');
const address = start > 0 && end > start ? javaTrim(message.slice(start + 1, end)) : undefined;
if (command === 'mail from') {
if (address !== undefined) this.sender = address;
yield* this.reply(address !== undefined ? this.texts.senderOk : this.texts.syntax);
return;
}
if (command === 'rcpt to') {
if (address !== undefined) this.recipients.push(address);
yield* this.reply(address !== undefined ? this.texts.recipientOk : this.texts.syntax);
return;
}
if (command === 'data' && this.sender !== undefined && this.recipients.length > 0) {
yield* this.reply(this.texts.data);
this.state = 'data';
return;
}
}
yield* this.reply(this.texts.order);
}
}
/** POP3Mitarbeiter: one connection's POP3 dialogue; messages numbered from 0, as Java numbers them. */
export class Pop3Session {
private username: string | undefined;
private userKnown = false;
private account: MailAccount | undefined;
closed = false;
constructor(
private readonly server: MailServerState,
private readonly connection: MailConnection,
) {}
/** Writes the line, then sends (Java's sendeAntwort). */
private *reply(text: string): Steps {
this.server.log({ kind: 'sent', address: this.connection.address, text });
yield* this.connection.send(text);
}
*begin(): Steps {
this.server.log({ kind: 'connectionOpened' });
yield* this.reply('+OK POP3 server ready');
}
/** The message with a number, if there and not deleted. */
private entry(argument: string | undefined): MailboxEntry | undefined {
if (argument === undefined || !/^[+-]?[0-9]+$/.test(argument)) return undefined;
const entry = this.account?.mailbox[Number.parseInt(argument, 10)];
return entry === undefined || entry.isMarkedForDeletion ? undefined : entry;
}
private status(): string {
const kept = this.account!.mailbox.filter((each) => !each.isMarkedForDeletion);
return `+OK ${kept.length} ${kept.reduce((sum, each) => sum + mailSize(each.mail), 0)}`;
}
/** POP3Mitarbeiter.verarbeiteNachricht. */
*receive(message: string): Steps {
this.server.log({ kind: 'received', address: this.connection.address, text: message });
const words = javaSplit(message, ' ');
const command = (words[0] ?? '').toLowerCase();
const argument = words[1];
const loggedIn = this.account !== undefined;
const noSuchMessage = '-ERR no such message';
switch (command) {
case 'user':
if (loggedIn) {
yield* this.reply('-ERR Please enter USER');
} else if (argument !== undefined && this.server.account(argument) !== undefined) {
this.userKnown = true;
this.username = argument;
yield* this.reply('+OK enter password');
} else {
// Java takes the name once a user was known, so that PASS fails.
if (this.userKnown) this.username = argument ?? '';
yield* this.reply('-ERR user or password wrong');
}
return;
case 'pass': {
if (loggedIn || !this.userKnown) {
yield* this.reply('-ERR Please enter PASS');
return;
}
const account = this.server.account(this.username!);
if (argument !== undefined && account !== undefined && account.password === argument) {
this.account = account;
yield* this.reply('+OK Mailbox locked and ready');
} else {
yield* this.reply('-ERR user or password wrong');
}
return;
}
case 'quit':
if (loggedIn) {
this.account!.mailbox = this.account!.mailbox.filter((each) => !each.isMarkedForDeletion);
this.server.changed();
}
yield* this.reply('+OK');
this.closed = true;
return;
case 'noop':
yield* this.reply(loggedIn ? '+OK' : '-ERR unknown command');
return;
}
if (!loggedIn || !['stat', 'list', 'retr', 'dele', 'rset'].includes(command)) {
yield* this.reply('-ERR unknown command');
return;
}
const mailbox = this.account!.mailbox;
switch (command) {
case 'stat':
yield* this.reply(this.status());
return;
case 'list':
if (argument === undefined) {
const lines = mailbox.flatMap((each, index) => (each.isMarkedForDeletion ? [] : [`${index} ${mailSize(each.mail)}\n`]));
yield* this.reply(`${this.status()}\n${lines.join('')}`);
} else {
const entry = this.entry(argument);
yield* this.reply(entry === undefined ? noSuchMessage : `+OK ${Number.parseInt(argument, 10)} ${mailSize(entry.mail)}`);
}
return;
case 'retr': {
const entry = this.entry(argument);
if (entry === undefined) {
yield* this.reply(noSuchMessage);
} else {
entry.isNew = false;
this.server.changed();
yield* this.reply(`+OK message follows \n${formatMail(entry.mail)}`);
}
return;
}
case 'dele': {
const index = argument !== undefined && /^[+-]?[0-9]+$/.test(argument) ? Number.parseInt(argument, 10) : -1;
const entry = mailbox[index];
if (entry === undefined) {
yield* this.reply(noSuchMessage);
} else {
entry.isMarkedForDeletion = true;
this.server.changed();
yield* this.reply('+OK message marked for delete');
}
return;
}
case 'rset':
for (const each of mailbox) each.isMarkedForDeletion = false;
this.server.changed();
yield* this.reply('+OK');
return;
}
}
}
/** A message of the model as Java has it after loading the server's file: new, not deleted. */
export function mailboxEntry(message: EmailMessage): MailboxEntry {
const reread = (entries: EmailMessage['to']) => addressList(entries.map((entry) => addressText(entry) ?? '').join(','));
return {
mail: {
sender: addressEntry(addressText(message.from) ?? ''),
to: reread(message.to),
cc: reread(message.cc),
bcc: reread(message.bcc),
subject: message.subject,
text: message.body,
dateReceived: message.receivedAtMilliseconds === undefined ? '' : String(message.receivedAtMilliseconds),
},
isNew: true,
isMarkedForDeletion: false,
};
}
/** A mailbox's message back in the model, numbered by the caller. */
export function emailMessage(entry: MailboxEntry, id: number): EmailMessage {
const { mail } = entry;
const received = /^[0-9]+$/.test(mail.dateReceived) ? Number(mail.dateReceived) : undefined;
return {
id,
from: mail.sender ?? { name: null, mailAddress: null },
to: mail.to,
cc: mail.cc,
bcc: mail.bcc,
subject: mail.subject ?? '',
body: mail.text,
...(received === undefined ? {} : { receivedAtMilliseconds: received }),
isNew: entry.isNew,
isMarkedForDeletion: entry.isMarkedForDeletion,
isSent: false,
};
}
const encode = (text: string) => new TextEncoder().encode(text);
/** SMTPServer, POP3Server: a server whose connections each hold a session. */
class MailProtocolServer extends TcpServerProgram {
constructor(
engine: NetworkRuntimeEngine,
nodeId: Id,
port: number,
isActive: boolean,
private readonly open: (connection: MailConnection) => SmtpSession | Pop3Session,
) {
super(engine, nodeId, { port, isActive });
}
/** Never asked: each connection holds a session (converse). */
protected answer(): string {
return '';
}
protected override *converse(socketId: Id, address: string): Steps {
const engine = this.engine;
const session = this.open({
address,
*send(text: string): Steps<boolean> {
return (yield* engine.sendTcpSteps(socketId, encode(text))) === 'sent';
},
});
yield* session.begin();
while (!session.closed) {
const received = yield* engine.receiveTcpSteps(socketId);
if (received.type !== 'message') return;
yield* session.receive(new TextDecoder().decode(received.payload));
}
}
}
/** EmailServer: its SMTP and POP3 servers, switched on and off together. */
export class EmailServer {
readonly log: ProgramLog<EmailServerLogEntry>;
readonly state: MailServerState;
private readonly smtp: MailProtocolServer;
private readonly pop3: MailProtocolServer;
constructor(
engine: NetworkRuntimeEngine,
nodeId: Id,
configuration: EmailServerSnapshot,
changed: (accounts: readonly EmailServerAccount[]) => void,
ipAddress: () => string,
now: () => number,
) {
this.log = new ProgramLog(engine);
const accounts: MailAccount[] = configuration.accounts.map((account) => ({
username: account.username,
password: account.password,
firstName: account.firstName,
lastName: account.lastName,
mailbox: account.mailbox.map(mailboxEntry),
}));
this.state = new MailServerState(
configuration.domain,
accounts,
engine.state.topologySnapshot.language,
(entry) => this.log.write(entry),
() => changed(this.accounts()),
ipAddress,
now,
);
// POP3 first, as EmailServer.starten (its socket draws the first initial sequence number).
this.pop3 = new MailProtocolServer(engine, nodeId, POP3_PORT, configuration.isActive, (connection) => new Pop3Session(this.state, connection));
this.smtp = new MailProtocolServer(engine, nodeId, SMTP_PORT, configuration.isActive, (connection) => new SmtpSession(this.state, connection));
}
get isActive(): boolean {
return this.smtp.isActive;
}
setActive(active: boolean): void {
this.pop3.setActive(active);
this.smtp.setActive(active);
}
/** The accounts as the model keeps them. */
accounts(): EmailServerAccount[] {
let id = 1;
return this.state.accounts.map((account) => ({
username: account.username,
password: account.password,
firstName: account.firstName,
lastName: account.lastName,
mailbox: account.mailbox.map((entry) => emailMessage(entry, id++)),
}));
}
}
+7 -2
View File
@@ -121,16 +121,21 @@ export abstract class TcpServerProgram {
protected abstract answer(message: string): string;
/** ServerMitarbeiter.run: each message answered (verarbeiteNachricht, sendeNachricht), until the connection ends. */
private *serve(socketId: Id, address: string, port: number): Steps {
protected *converse(socketId: Id, _address: string): Steps {
for (;;) {
const received = yield* this.engine.receiveTcpSteps(socketId);
if (received.type !== 'message') break;
if (received.type !== 'message') return;
const text = decode(received.payload);
this.log.write({ kind: 'received', text });
const answer = this.answer(text);
const result = yield* this.engine.sendTcpSteps(socketId, encode(answer));
this.log.write(result === 'sent' ? { kind: 'sent', text: answer } : { kind: 'sendFailed', failure: result });
}
}
/** A connection's thread: the dialogue, then the connection closed. */
private *serve(socketId: Id, address: string, port: number): Steps {
yield* this.converse(socketId, address);
this.engine.requestTcpClose(socketId);
this.connections.delete(socketId);
this.log.write({ kind: 'connectionClosed', address, port });
+117
View File
@@ -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;
}
+41
View File
@@ -60,6 +60,7 @@ import { forwardsIPv4ByDefault, isPcClassEndpoint, stableGeneratedMacAddress } f
import type {
DeviceConfiguration,
DhcpServerConfiguration,
EmailServerAccount,
FirewallConfiguration,
GatewayPortForwardingRow,
FirewallRule,
@@ -115,6 +116,7 @@ import { RuntimeSpeed } from './speed.js';
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
import { EchoServer, SimpleClient } from './apps/echo.js';
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
import { EmailServer } from './apps/emailServer.js';
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
@@ -910,6 +912,9 @@ export class NetworkRuntimeEngine {
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
private readonly webServersByNodeId = new Map<Id, WebServer>();
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
private readonly emailServersByNodeId = new Map<Id, EmailServer>();
/** The time of day a program writes into a mail (Java's new Date()). */
private readonly wallClock: () => number;
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
private readonly firewallLogsByNodeId = new Map<Id, FirewallLogEntry[]>();
/** Accepted connections a program has taken (acceptTcpSteps). */
@@ -929,6 +934,9 @@ export class NetworkRuntimeEngine {
* web administration answers without a socket), likewise.
*
* `captureRetention`: how many frames each card's packet capture keeps.
*
* `wallClock`: the time of day in milliseconds, which a program writes
* into a mail (the e-mail server's notice); the host's clock by default.
*/
constructor(
seed: bigint | number = DEFAULT_SEED,
@@ -939,8 +947,10 @@ export class NetworkRuntimeEngine {
readonly swiftSends?: boolean;
readonly swiftRouters?: boolean;
readonly captureRetention?: CaptureRetentionPolicy;
readonly wallClock?: () => number;
} = {},
) {
this.wallClock = options.wallClock ?? Date.now;
this.packetCapture = new PacketCapture(options.captureRetention ?? PRODUCTION_CAPTURE_RETENTION);
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
this.swiftArp = options.swiftArp === true;
@@ -1032,6 +1042,7 @@ export class NetworkRuntimeEngine {
this.simpleClientsByNodeId.clear();
this.webServersByNodeId.clear();
this.webBrowsersByNodeId.clear();
this.emailServersByNodeId.clear();
this.arpLookups = [];
this.protocolThreads.clear();
this.dnsQueriesBySocketId.clear();
@@ -1117,6 +1128,20 @@ export class NetworkRuntimeEngine {
return this.webBrowsersByNodeId.get(nodeId);
}
/** The node's e-mail server while the simulation runs, if it has one installed. */
emailServer(nodeId: Id): EmailServer | undefined {
return this.emailServersByNodeId.get(nodeId);
}
/** An e-mail server's accounts as a run changed them, into the snapshot (and so into the project saved from it). */
private setEmailServerAccounts(nodeId: Id, accounts: readonly EmailServerAccount[]): void {
const servers = new Map(this.stateValue.topologySnapshot.emailServersByNodeId);
const server = servers.get(nodeId);
if (server === undefined) return;
servers.set(nodeId, { ...server, accounts });
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { emailServersByNodeId: servers });
}
/**
* Each node's programs start with the simulation (Betriebssystem.starten),
* from the snapshot; what they show starts empty.
@@ -1127,6 +1152,7 @@ export class NetworkRuntimeEngine {
this.simpleClientsByNodeId.clear();
this.webServersByNodeId.clear();
this.webBrowsersByNodeId.clear();
this.emailServersByNodeId.clear();
for (const node of snapshot.nodes) {
const echoServer = snapshot.echoServersByNodeId.get(node.id);
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
@@ -1137,6 +1163,21 @@ export class NetworkRuntimeEngine {
this.webServersByNodeId.set(node.id, new WebServer(this, node.id, webServer, snapshot.fileSystemsByNodeId.get(node.id)));
}
if (snapshot.webBrowserNodeIds.has(node.id)) this.webBrowsersByNodeId.set(node.id, new WebBrowser(this, node.id));
const emailServer = snapshot.emailServersByNodeId.get(node.id);
if (emailServer !== undefined) {
const nodeId = node.id;
this.emailServersByNodeId.set(
nodeId,
new EmailServer(
this,
nodeId,
emailServer,
(accounts) => this.setEmailServerAccounts(nodeId, accounts),
() => this.networkInterfaces(nodeId)[0]?.ipAddress ?? 'null',
this.wallClock,
),
);
}
if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind);
}
}
+16
View File
@@ -21,6 +21,7 @@ import type {
SimpleClientConfiguration,
SwitchConfiguration,
WebServerConfiguration,
EmailServerAccount,
} from './config.js';
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
import type { PageLanguage } from './apps/routerPages.js';
@@ -30,6 +31,17 @@ export interface WebServerSnapshot extends WebServerConfiguration {
readonly isActive: boolean;
}
/**
* An installed e-mail server (EmailServer): its domain, its accounts with
* their mailboxes, and whether it is switched on. Java's server keeps its
* accounts in /mailserver/konten.txt; the model's are what Java loads from it.
*/
export interface EmailServerSnapshot {
readonly domain: string;
readonly accounts: readonly EmailServerAccount[];
readonly isActive: boolean;
}
/** An installed echo server: its port, and whether it is switched on (Java: ServerBaustein.aktiv). */
export interface EchoServerSnapshot extends EchoServerConfiguration {
readonly isActive: boolean;
@@ -88,6 +100,8 @@ export interface TopologySnapshot {
readonly webServersByNodeId: ReadonlyMap<Id, WebServerSnapshot>;
/** The nodes with the web browser installed (WebBrowser). */
readonly webBrowserNodeIds: ReadonlySet<Id>;
/** The installed e-mail servers, by node; a run's deliveries change their mailboxes here. */
readonly emailServersByNodeId: ReadonlyMap<Id, EmailServerSnapshot>;
/** Each node's files, which its programs read (a web server its pages). */
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
/** The language of the pages the devices write themselves (a router's), as Java's in the locale it runs in. */
@@ -112,6 +126,7 @@ export interface TopologySnapshotInput {
simpleClientsByNodeId?: ReadonlyMap<Id, SimpleClientConfiguration>;
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
webBrowserNodeIds?: ReadonlySet<Id>;
emailServersByNodeId?: ReadonlyMap<Id, EmailServerSnapshot>;
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
language?: PageLanguage;
}
@@ -135,6 +150,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
simpleClientsByNodeId: input.simpleClientsByNodeId ?? new Map(),
webServersByNodeId: input.webServersByNodeId ?? new Map(),
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
emailServersByNodeId: input.emailServersByNodeId ?? new Map(),
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
language: input.language ?? 'de',
};