feat(fls): Java's view of the e-mail programs

The Java view now reads what FILIUS 2.14 keeps for its e-mail programs:
the server's domain, switch and bean accounts with their mailboxes, and the
accounts of /mailserver/konten.txt, which Java reads at every simulation
start and which so win over the bean list; the client's three folders and
address book, and its account from /konten.txt (no bean holds it).

javaEmail.ts ports the bean setters and both file readers
(stringZuListeBenutzerkonten, EmailKonto(String)); javaString.ts Java's
trim, split and StringTokenizer; javaFileSystem.ts the file lookup, now
shared with the DNS server, with the four non-ASCII units Java's
equalsIgnoreCase also takes for an ASCII letter (measured over the BMP).

Checked against Java on the corpus (the oracle dumps the e-mail state) and
on 43 account files read by EmailServer.kontenLaden and
EmailAnwendung.laden (EmailFileCases). Found: older files keep recipients
as strings in the typed lists; a getter copy of the sender is filled and
set back; one short server line makes Java throw for the whole file; a
message with an empty text is lost on reading; a broken last client line
empties the account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:06:55 +00:00
co-authored by Claude Opus 5.5
parent c80d212c90
commit ec5523d7c7
22 changed files with 2900 additions and 94 deletions
+5 -40
View File
@@ -11,7 +11,9 @@
// - duplicates stay.
import type { DnsResourceRecord } from '@mdg-filius/core';
import { javaFileContent } from './javaFileSystem.js';
import { javaIpCheck, javaParseInt } from './javaIp.js';
import { javaTrim } from './javaString.js';
import type { FlsFileTree } from './systemSoftware.js';
/** A record as Java's ResourceRecord holds it (the getters). */
@@ -27,51 +29,14 @@ export const DNS_HOSTS_PATH = '/dns/hosts';
// EingabenUeberpruefung.musterDomain, including its quirks: labels start with
// a letter, labels after the first may hold a backslash, and the empty
// string is a valid domain.
const JAVA_DOMAIN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)?\.?$/;
export const JAVA_DOMAIN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)?\.?$/;
// EingabenUeberpruefung.musterIpAdresse.
const JAVA_IP_ADDRESS =
/^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
/** `String.trim()`: every UTF-16 unit up to U+0020 off both ends. */
function javaTrim(text: string): string {
return text.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '');
}
/**
* The content of /dns/hosts as `Dateisystem.holeDatei` finds it: the first
* node in preorder whose path equals it ignoring case, and only if that node
* is a file. Undefined when there is none; a file without content reads as
* empty (Java would fail on it).
*/
/** The content of /dns/hosts as Java's DNS server finds it (see javaFileSystem.ts). */
export function javaHostsFileContent(root: FlsFileTree | undefined): string | undefined {
if (root === undefined) return undefined;
const found = firstInPreorder(root, '', (path) => javaEqualsIgnoreCase(path, DNS_HOSTS_PATH));
return found?.kind === 'file' ? (found.content ?? '') : undefined;
}
/** Preorder below the root; a node's path is "/" plus each name after the root's. */
function firstInPreorder(node: FlsFileTree, path: string, matches: (path: string) => boolean): FlsFileTree | undefined {
for (const child of node.children) {
const childPath = `${path}/${child.kind === 'other' ? child.className : String(child.name)}`;
if (matches(childPath)) return child;
const found = firstInPreorder(child, childPath, matches);
if (found !== undefined) return found;
}
return undefined;
}
/**
* `String.equalsIgnoreCase` against an ASCII path, unit by unit: equal after
* Character.toUpperCase, so the long s (U+017F) is an "s". Java also
* compares the lower case of both; for the letters of /dns/hosts that adds
* no unit (measured over the BMP).
*/
function javaEqualsIgnoreCase(text: string, asciiPath: string): boolean {
if (text.length !== asciiPath.length) return false;
for (let index = 0; index < text.length; index++) {
if (text[index]!.toUpperCase() !== asciiPath[index]!.toUpperCase()) return false;
}
return true;
return javaFileContent(root, DNS_HOSTS_PATH);
}
/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */
+3
View File
@@ -2,7 +2,10 @@
export * from './archive.js';
export * from './dnsHosts.js';
export * from './importProject.js';
export * from './javaEmail.js';
export * from './javaFileSystem.js';
export * from './javaIp.js';
export * from './javaString.js';
export * from './javabeans.js';
export * from './systemSoftware.js';
export * from './topology.js';
+304
View File
@@ -0,0 +1,304 @@
// Java's e-mail programs as FILIUS 2.14 loads them. Two places hold their
// state:
// - the file's JavaBeans: the server's accounts (listeBenutzerkonten, with
// their mailboxes), the client's three folders and address book;
// - text files in the host's file system: /mailserver/konten.txt, which the
// server reads (kontenLaden) whenever a simulation starts and so wins over
// the bean list, and /konten.txt, the client's account, read (laden) when
// its window opens. The client's account is in no bean: its accessors
// (holeKontoListe, setzeKontoListe) are not bean properties.
// Checked against Java on the corpus (corpus.java.json, applications).
import { isJavaObject, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaSplit, javaTokens, javaTrim } from './javaString.js';
/** AddressEntry: a display name, an address, or both. */
export interface JavaAddressEntry {
readonly name: string | null;
readonly mailAddress: string | null;
}
/**
* A recipient as the list holds it: older files keep recipients as plain
* strings, which the decoder puts into the typed list unchecked.
*/
export type JavaRecipient = JavaAddressEntry | string | null;
export interface JavaEmail {
readonly sender: JavaAddressEntry;
readonly to: readonly JavaRecipient[];
readonly cc: readonly JavaRecipient[];
readonly bcc: readonly JavaRecipient[];
readonly subject: string | null;
readonly text: string | null;
readonly dateReceived: string | null;
readonly isNew: boolean;
readonly isDeleted: boolean;
readonly isSent: boolean;
}
/** EmailKonto: on the server an account with its mailbox, in the client the account it uses. */
export interface JavaEmailAccount {
readonly username: string | null;
readonly password: string | null;
readonly lastName: string;
readonly firstName: string;
readonly emailAddress: string | null;
readonly pop3Server: string | null;
readonly pop3Port: string | null;
readonly smtpServer: string | null;
readonly smtpPort: string | null;
readonly messages: readonly JavaEmail[];
}
/** Kontakt: an address book entry. */
export interface JavaContact {
readonly name: string | null;
readonly firstName: string | null;
readonly street: string | null;
readonly houseNumber: number;
readonly postalCode: number;
readonly city: string | null;
readonly email: string | null;
readonly phone: string | null;
}
/** An exception Java throws while reading, by its simple class name. */
export interface JavaFailure {
readonly exception: string;
}
export const EMAIL_SERVER_FILE = '/mailserver/konten.txt';
export const EMAIL_CLIENT_FILE = '/konten.txt';
type Warn = (message: string) => void;
// JavaBeans
export function javaEmailAccounts(list: JavaValue | undefined, warn: Warn): JavaEmailAccount[] {
return objectsOf(list, 'EmailKonto', warn).map((account) => {
const at = (property: string) => text(account.properties.get(property), `EmailKonto ${property}`, warn);
return {
username: at('benutzername'),
password: at('passwort'),
// setNachname and setVorname keep "" for null.
lastName: at('nachname') ?? '',
firstName: at('vorname') ?? '',
emailAddress: at('emailAdresse'),
pop3Server: at('pop3server'),
pop3Port: at('pop3port'),
smtpServer: at('smtpserver'),
smtpPort: at('smtpport'),
messages: javaEmails(account.properties.get('nachrichten'), warn),
};
});
}
export function javaEmails(list: JavaValue | undefined, warn: Warn): JavaEmail[] {
return objectsOf(list, 'Email', warn).map((email) => {
const at = (property: string) => email.properties.get(property);
const flag = (property: string, fallback: boolean) => {
const value = at(property);
if (value === undefined) return fallback;
if (typeof value === 'boolean') return value;
warn(`Email ${property} is not a boolean, used ${fallback}`);
return fallback;
};
return {
sender: senderOf(at('absender'), warn),
to: recipientsOf(at('empfaenger'), warn),
cc: recipientsOf(at('cc'), warn),
bcc: recipientsOf(at('bcc'), warn),
subject: text(at('betreff'), 'Email betreff', warn),
text: at('text') === undefined ? '' : text(at('text'), 'Email text', warn),
dateReceived: at('dateReceived') === undefined ? '' : text(at('dateReceived'), 'Email dateReceived', warn),
isNew: flag('neu', true),
isDeleted: flag('delete', false),
isSent: flag('versendet', false),
};
});
}
export function javaContacts(list: JavaValue | undefined, warn: Warn): JavaContact[] {
return objectsOf(list, 'Kontakt', warn).map((contact) => {
const at = (property: string) => text(contact.properties.get(property), `Kontakt ${property}`, warn);
const number = (property: string) => {
const value = contact.properties.get(property);
if (value === undefined) return 0;
if (typeof value === 'number' && Number.isInteger(value)) return value;
warn(`Kontakt ${property} is not an int, used 0`);
return 0;
};
return {
name: at('name'),
firstName: at('vorname'),
street: at('strasse'),
houseNumber: number('hausnr'),
postalCode: number('plz'),
city: at('wohnort'),
email: at('email'),
phone: at('telefon'),
};
});
}
/** Email.getAbsender(): a copy, so never null — a missing sender has neither name nor address. */
function senderOf(value: JavaValue | undefined, warn: Warn): JavaAddressEntry {
if (typeof value === 'string') return addressEntry(value);
if (isAddressEntry(value)) return beanAddressEntry(value, warn);
if (value !== undefined && value !== null) warn('Email absender is neither a string nor an AddressEntry, ignored');
return { name: null, mailAddress: null };
}
function recipientsOf(list: JavaValue | undefined, warn: Warn): JavaRecipient[] {
return listItems(list).flatMap((item): JavaRecipient[] => {
if (item === null || typeof item === 'string') return [item];
if (isAddressEntry(item)) return [beanAddressEntry(item, warn)];
warn('an e-mail recipient is neither a string nor an AddressEntry, left out');
return [];
});
}
/**
* An AddressEntry — also the copy a getter returns, which the file fills and
* then sets (`<void id="AddressEntry0" property="absender">`): the decoder
* names such an object by its id.
*/
function isAddressEntry(value: JavaValue | undefined): value is JavaObject {
return isJavaObject(value) && simpleClassName(value.className) === 'AddressEntry';
}
function beanAddressEntry(entry: JavaObject, warn: Warn): JavaAddressEntry {
return {
name: text(entry.properties.get('name'), 'AddressEntry name', warn),
mailAddress: text(entry.properties.get('mailAddress'), 'AddressEntry mailAddress', warn),
};
}
function objectsOf(list: JavaValue | undefined, simpleClass: string, warn: Warn): JavaObject[] {
return listItems(list).flatMap((item) => {
if (isJavaObject(item) && simpleClassName(item.className) === simpleClass) return [item];
warn(`a list of ${simpleClass} holds something else, left out`);
return [];
});
}
function text(value: JavaValue | undefined, name: string, warn: Warn): string | null {
if (value === undefined || value === null) return null;
if (typeof value === 'string') return value;
warn(`${name} is not a string, ignored`);
return null;
}
// Text files
/** `new AddressEntry(text)`: "Name <address>", "<address>", a bare address with "@", else a name. */
export function addressEntry(raw: string): JavaAddressEntry {
const entry = javaTrim(raw);
const start = entry.indexOf('<');
const end = entry.indexOf('>');
if (start >= 0 && end > start) {
return { name: start > 0 ? javaTrim(entry.slice(0, start)) : null, mailAddress: javaTrim(entry.slice(start + 1, end)) };
}
if (!entry.includes('<') && !entry.includes('>') && entry.includes('@')) return { name: null, mailAddress: entry };
return { name: entry, mailAddress: null };
}
/** `EmailUtils.stringToAddressEntryList`: comma-separated, blank parts skipped. */
function addressEntries(raw: string): JavaAddressEntry[] {
return javaSplit(raw, ',')
.filter((part) => javaTrim(part) !== '')
.map(addressEntry);
}
/**
* `EmailServer.stringZuListeBenutzerkonten`: one account a line —
* user;domain;password;last name;first name;messages — split by a
* StringTokenizer, which skips empty fields; an account with fewer than five
* fields makes Java throw. Messages are "#"-separated, seven "$"-separated
* fields each: sender, to, cc, bcc, date, subject, text. Java's split drops
* trailing empty fields, so a message with an empty text is lost.
*/
export function readJavaEmailServerFile(content: string): JavaEmailAccount[] | JavaFailure {
const accounts: JavaEmailAccount[] = [];
for (const line of javaTokens(content, '\n')) {
const fields = javaTokens(line, ';');
if (fields.length < 5) return { exception: 'NoSuchElementException' };
const [username, , password, lastName, firstName, messages] = fields as [string, string, string, string, string, string?];
accounts.push({
username,
password,
lastName,
firstName,
emailAddress: null,
pop3Server: null,
pop3Port: null,
smtpServer: null,
smtpPort: null,
messages: messages === undefined ? [] : fileMessages(messages),
});
}
return accounts;
}
function fileMessages(messages: string): JavaEmail[] {
return javaSplit(messages, '#').flatMap((message) => {
const fields = javaSplit(message, '$');
if (fields.length !== 7) return [];
const [sender, to, cc, bcc, dateReceived, subject, body] = fields as [string, string, string, string, string, string, string];
return [
{
sender: addressEntry(sender),
to: addressEntries(to),
cc: addressEntries(cc),
bcc: addressEntries(bcc),
subject,
text: unescapedBody(body),
dateReceived,
isNew: true,
isDeleted: false,
isSent: false,
},
];
});
}
/** brtonl, then undoSpecialChar: "|" is a line break, "&&00124&&" a "|". */
function unescapedBody(body: string): string {
return body
.replaceAll('|', '\n')
.replaceAll('&&00036&&', '$')
.replaceAll('&&00059&&', ';')
.replaceAll('&&00124&&', '|')
.replaceAll('&&00035&&', '#');
}
/**
* `EmailAnwendung.laden`: every line makes an account, the last one stays.
* `new EmailKonto(line)` takes nine ";"-separated fields — POP3 server, SMTP
* server, POP3 port, SMTP port, user, password, last name, first name,
* address — and with fewer keeps none (trailing empty fields do not count).
* Null when the file has no line at all.
*/
export function readJavaEmailClientFile(content: string): JavaEmailAccount | null {
const lines = javaSplit(content, '\n');
const line = lines[lines.length - 1];
if (line === undefined) return null;
const fields = javaSplit(line, ';');
const empty = { username: null, password: null, lastName: '', firstName: '', emailAddress: null };
const servers = { pop3Server: null, pop3Port: null, smtpServer: null, smtpPort: null };
if (fields.length <= 8) return { ...empty, ...servers, messages: [] };
const [pop3Server, smtpServer, pop3Port, smtpPort, username, password, lastName, firstName, emailAddress] = fields as string[];
return {
username: username!,
password: password!,
lastName: lastName!,
firstName: firstName!,
emailAddress: emailAddress!,
pop3Server: pop3Server!,
pop3Port: pop3Port!,
smtpServer: smtpServer!,
smtpPort: smtpPort!,
messages: [],
};
}
+51
View File
@@ -0,0 +1,51 @@
// How Java FILIUS finds a file by path (Dateisystem.holeDatei): the first
// node in preorder whose path equals the path ignoring case, and only if that
// node is a file.
import type { FlsFileTree } from './systemSoftware.js';
/**
* The content of the file at an ASCII path as `Dateisystem.holeDatei` finds
* it. Undefined when there is none; a file without content reads as empty
* (Java would fail on it).
*/
export function javaFileContent(root: FlsFileTree | undefined, asciiPath: string): string | undefined {
if (root === undefined) return undefined;
const found = firstInPreorder(root, '', (path) => javaEqualsIgnoreCase(path, asciiPath));
return found?.kind === 'file' ? (found.content ?? '') : undefined;
}
/** Preorder below the root; a node's path is "/" plus each name after the root's. */
function firstInPreorder(node: FlsFileTree, path: string, matches: (path: string) => boolean): FlsFileTree | undefined {
for (const child of node.children) {
const childPath = `${path}/${child.kind === 'other' ? child.className : String(child.name)}`;
if (matches(childPath)) return child;
const found = firstInPreorder(child, childPath, matches);
if (found !== undefined) return found;
}
return undefined;
}
// Besides ASCII case, String.equalsIgnoreCase takes these BMP units for an
// ASCII letter (measured on the JDK over every unit): dotted and dotless I,
// the Kelvin sign and the long s.
const JAVA_ASCII_CASE_FOLDS = new Map([
['\u0130', 'i'],
['\u0131', 'i'],
['\u212A', 'k'],
['\u017F', 's'],
]);
/** `String.equalsIgnoreCase` against an ASCII path, unit by unit. */
function javaEqualsIgnoreCase(text: string, asciiPath: string): boolean {
if (text.length !== asciiPath.length) return false;
for (let index = 0; index < text.length; index++) {
if (asciiFold(text[index]!) !== asciiFold(asciiPath[index]!)) return false;
}
return true;
}
function asciiFold(unit: string): string {
if (unit >= 'A' && unit <= 'Z') return unit.toLowerCase();
return JAVA_ASCII_CASE_FOLDS.get(unit) ?? unit;
}
+24
View File
@@ -0,0 +1,24 @@
// java.lang.String and java.util.StringTokenizer as FILIUS's text formats
// use them — each differs from its JavaScript namesake.
/** `String.trim()`: every UTF-16 unit up to U+0020 off both ends. */
export function javaTrim(text: string): string {
return text.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '');
}
/**
* `text.split(separator)` for a one-character separator: trailing empty
* parts are dropped — "a;b;;".split(";") is two parts, ";;" none — but a
* text without the separator is one part, even the empty text.
*/
export function javaSplit(text: string, separator: string): string[] {
if (!text.includes(separator)) return [text];
const parts = text.split(separator);
while (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
return parts;
}
/** `new StringTokenizer(text, separator)`: the non-empty runs between separators. */
export function javaTokens(text: string, separator: string): string[] {
return text.split(separator).filter((token) => token !== '');
}
+54 -4
View File
@@ -27,6 +27,21 @@
// not know Java's constructors and sees a placeholder per path; they are
// merged here.
import { JAVA_DOMAIN } from './dnsHosts.js';
import {
EMAIL_CLIENT_FILE,
EMAIL_SERVER_FILE,
javaContacts,
javaEmailAccounts,
javaEmails,
readJavaEmailClientFile,
readJavaEmailServerFile,
type JavaContact,
type JavaEmail,
type JavaEmailAccount,
type JavaFailure,
} from './javaEmail.js';
import { javaFileContent } from './javaFileSystem.js';
import {
isJavaObject,
isUnresolved,
@@ -114,12 +129,12 @@ const SERVER_APPLICATIONS = new Set<string>([
'filius.software.www.WebServer',
]);
/** An installed application with the settings the model reads; e-mail contents are a later layer. */
/** An installed application with the settings the model reads. */
export interface FlsApplication {
/** The key in installierteAnwendungen — the class name in every corpus file. */
key: string | null;
javaClass: FlsApplicationClass;
/** Server applications: switched on (Java's aktiv) and the port. */
/** Server applications: switched on (Java's aktiv) and the port; EmailServer: switched on. */
isActive?: boolean;
port?: number;
/** ClientBaustein: the server it talks to (Java's zielIPAdresse, zielPort). */
@@ -136,6 +151,23 @@ export interface FlsApplication {
maxPeers?: number | null;
/** Firewall: the personal firewall. */
firewall?: FlsFirewall;
/** EmailServer: its domain (Java keeps "filius.de" for one its pattern rejects). */
mailDomain?: string;
/** EmailServer: the accounts of the bean list. */
beanAccounts?: JavaEmailAccount[];
/**
* EmailServer: the accounts of /mailserver/konten.txt, which Java reads at
* every simulation start — null without the file (Java then starts with
* none), a failure where Java throws.
*/
fileAccounts?: JavaEmailAccount[] | JavaFailure | null;
/** EmailAnwendung: its folders (Java's empfangene, gesendete, erstellte Nachrichten). */
received?: JavaEmail[];
sent?: JavaEmail[];
drafts?: JavaEmail[];
addressBook?: JavaContact[];
/** EmailAnwendung: the account of /konten.txt, null without one. */
fileAccount?: JavaEmailAccount | null;
/** The application's JavaBean, for the e-mail layer. */
source: JavaObject;
}
@@ -188,7 +220,7 @@ export function readSystemSoftware(device: JavaObject, javaClass: FlsNodeClass,
case 'Notebook':
result.ssid = optionalString(value('ssid'), 'ssid', warn) ?? null;
result.fileSystem = fileSystem(value('dateisystem'), warn);
result.applications = applications(value('installierteAnwendungen'), warn);
result.applications = applications(value('installierteAnwendungen'), result.fileSystem, warn);
break;
case 'Vermittlungsrechner':
result.ripEnabled = boolean(value('ripEnabled'), false, 'ripEnabled', warn);
@@ -234,7 +266,7 @@ function modemAddress(value: string | null | undefined): string | null {
* a project's own application, compiled into projekt/anwendungen — is left
* out: this reader cannot run Java classes.
*/
function applications(map: JavaValue | undefined, warn: Warn): FlsApplication[] {
function applications(map: JavaValue | undefined, root: FlsFileTree, warn: Warn): FlsApplication[] {
const result: FlsApplication[] = [];
for (const [key, application] of mapEntries(map)) {
if (!isJavaObject(application)) {
@@ -278,6 +310,24 @@ function applications(map: JavaValue | undefined, warn: Warn): FlsApplication[]
case 'filius.software.firewall.Firewall':
entry.firewall = firewallOf([application], true, warn);
break;
case 'filius.software.email.EmailServer': {
entry.isActive = boolean(at('aktiv'), false, 'EmailServer aktiv', warn);
const domain = optionalString(at('mailDomain'), 'EmailServer mailDomain', warn);
entry.mailDomain = typeof domain === 'string' && JAVA_DOMAIN.test(domain) ? domain : 'filius.de';
entry.beanAccounts = javaEmailAccounts(at('listeBenutzerkonten'), warn);
const file = javaFileContent(root, EMAIL_SERVER_FILE);
entry.fileAccounts = file === undefined ? null : readJavaEmailServerFile(file);
break;
}
case 'filius.software.email.EmailAnwendung': {
entry.received = javaEmails(at('empfangeneNachrichten'), warn);
entry.sent = javaEmails(at('gesendeteNachrichten'), warn);
entry.drafts = javaEmails(at('erstellteNachrichten'), warn);
entry.addressBook = javaContacts(at('adressbuch'), warn);
const file = javaFileContent(root, EMAIL_CLIENT_FILE);
entry.fileAccount = file === undefined ? null : readJavaEmailClientFile(file);
break;
}
}
result.push(entry);
}