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