feat(fls): import the e-mail programs into the editor model

The e-mail client and server of every host come into the model (core:
EmailClientConfiguration, EmailServerConfiguration, EmailMessage), from
Java's view:

- the server's accounts are those of /mailserver/konten.txt, as Java reads
  them at every simulation start; without the file those of the bean list,
  which Java would drop;
- the client's account is that of /konten.txt; without it the first of an
  older file's kontoListe, which FILIUS 2.14 no longer reads;
- messages are numbered per program in folder order, as Swift does; the
  sent folder's are sent; a date is taken only when the file gives one.

Departures from Swift: accounts and messages stay where Swift drops what
fails its checks; first and last name stay apart; a port that is no number
is the default, with a warning. On the corpus the model matches Swift's on
all 13 files; the one Swift error is named (it numbers a mailbox's messages
as their received time and writes that into the konten.txt it rewrites).

The Swift dump now carries the e-mail state, and the file system
comparison leaves out only Swift's hidden state files (and the directories
it makes for them) instead of all of /mail, /mailserver and /peer2peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:36:05 +00:00
co-authored by Claude Opus 5.5
parent 8f9930f1ff
commit 1efbb8a660
11 changed files with 1404 additions and 27 deletions
+133
View File
@@ -0,0 +1,133 @@
// The e-mail programs of a host in the editor model (Swift's
// registerJavaEmailConfigurations), from Java's view (javaEmail.ts). Java
// decides what the programs hold; its errors are not copied:
// - the server's accounts are those of /mailserver/konten.txt, which Java
// reads at every simulation start; without the file, those of the bean
// list, which Java would drop there;
// - the client's account is that of /konten.txt; without it, the first of
// an older file's kontoListe, which FILIUS 2.14 no longer reads;
// - a recipient an older file keeps as a string is read as an address.
// Departures from Swift (named in importProject.test.ts): accounts and
// messages stay as Java has them, where Swift drops what fails its checks;
// first and last name stay apart; a port that is no number is the default
// with a warning (Swift drops the account); a message's date is taken only
// when the file gives one (Swift numbers a mailbox's messages instead).
import {
DEFAULT_POP3_PORT,
DEFAULT_SMTP_PORT,
type EmailAddress,
type EmailClientAccount,
type EmailClientConfiguration,
type EmailContact,
type EmailMessage,
type EmailServerConfiguration,
} from '@mdg-filius/core';
import { addressEntry, type JavaEmail, type JavaEmailAccount, type JavaRecipient } from './javaEmail.js';
import { javaParseInt } from './javaIp.js';
import type { FlsApplication } from './systemSoftware.js';
type Warn = (message: string) => void;
export function emailClientConfiguration(application: FlsApplication, warn: Warn): EmailClientConfiguration {
const source = application.fileAccount ?? application.legacyAccounts?.[0] ?? null;
const numbering = { next: 1 };
const inbox = emailMessages(application.received ?? [], numbering, undefined, warn);
const sent = emailMessages(application.sent ?? [], numbering, true, warn);
const drafts = emailMessages(application.drafts ?? [], numbering, false, warn);
return {
account: source === null ? null : clientAccount(source, warn),
inbox,
sent,
drafts,
addressBook: (application.addressBook ?? []).map(
(contact): EmailContact => ({
name: contact.name ?? '',
firstName: contact.firstName ?? '',
street: contact.street ?? '',
houseNumber: contact.houseNumber,
postalCode: contact.postalCode,
city: contact.city ?? '',
email: contact.email ?? '',
phone: contact.phone ?? '',
}),
),
nextMessageID: numbering.next,
};
}
export function emailServerConfiguration(application: FlsApplication, warn: Warn): EmailServerConfiguration {
const accounts = Array.isArray(application.fileAccounts) ? application.fileAccounts : (application.beanAccounts ?? []);
const numbering = { next: 1 };
return {
domain: application.mailDomain ?? 'filius.de',
pop3Port: DEFAULT_POP3_PORT,
accounts: accounts.map((account) => ({
username: account.username ?? '',
password: account.password ?? '',
firstName: account.firstName,
lastName: account.lastName,
mailbox: emailMessages(account.messages, numbering, undefined, warn),
})),
nextMessageID: numbering.next,
};
}
function clientAccount(account: JavaEmailAccount, warn: Warn): EmailClientAccount {
return {
pop3Host: account.pop3Server ?? '',
pop3Port: port(account.pop3Port, DEFAULT_POP3_PORT, 'POP3', warn),
smtpHost: account.smtpServer ?? '',
smtpPort: port(account.smtpPort, DEFAULT_SMTP_PORT, 'SMTP', warn),
username: account.username ?? '',
password: account.password ?? '',
firstName: account.firstName,
lastName: account.lastName,
email: account.emailAddress ?? '',
};
}
/** A port as Java's client reads it (Integer.parseInt); none or no port is the default. */
function port(text: string | null, fallback: number, protocol: string, warn: Warn): number {
if (text === null) return fallback;
const value = javaParseInt(text);
if (value !== undefined && value >= 0 && value <= 65_535) return value;
warn(`e-mail client: ${protocol} port '${text}' is not a port, used ${fallback}`);
return fallback;
}
/** Numbered on from `numbering.next`; `isSent` is the folder's when it has one. */
function emailMessages(
messages: readonly JavaEmail[],
numbering: { next: number },
isSent: boolean | undefined,
warn: Warn,
): EmailMessage[] {
return messages.map((message) => {
const received = message.dateReceived !== null && /^[0-9]+$/.test(message.dateReceived) ? Number(message.dateReceived) : undefined;
return {
id: numbering.next++,
from: { name: message.sender.name, mailAddress: message.sender.mailAddress },
to: recipients(message.to, warn),
cc: recipients(message.cc, warn),
bcc: recipients(message.bcc, warn),
subject: message.subject ?? '',
body: message.text ?? '',
...(received === undefined ? {} : { receivedAtMilliseconds: received }),
isNew: message.isNew,
isMarkedForDeletion: message.isDeleted,
isSent: isSent ?? message.isSent,
};
});
}
function recipients(entries: readonly JavaRecipient[], warn: Warn): EmailAddress[] {
return entries.flatMap((entry) => {
if (entry === null) {
warn('an e-mail recipient is null, left out');
return [];
}
const address = typeof entry === 'string' ? addressEntry(entry) : entry;
return [{ name: address.name, mailAddress: address.mailAddress }];
});
}
+21 -4
View File
@@ -33,10 +33,10 @@
// - a web server's files stay in /webserver (its document root), where Swift
// moves them to /www; and no program state is written into a host's files
// (Swift keeps Gnutella and e-mail state there as hidden JSON files).
// - e-mail: see importEmail.ts;
// - a DNS server's records are what Java's server answers from /dns/hosts
// (see dnsHosts.ts); Swift reads the file its own way — tabs, comments,
// "IN", names with a leading digit — and drops Java's duplicates.
// Not here yet: e-mail accounts.
import {
canonicalMacAddress,
@@ -65,6 +65,8 @@ import {
type DhcpClientConfiguration,
type DhcpServerConfiguration,
type DnsServerConfiguration,
type EmailClientConfiguration,
type EmailServerConfiguration,
type EchoServerConfiguration,
type GnutellaConfiguration,
type InstallableProgram,
@@ -92,6 +94,7 @@ import {
type VirtualFileContent,
} from '@mdg-filius/core';
import { dnsRecordsOf, javaHostsFileContent, readDnsHosts } from './dnsHosts.js';
import { emailClientConfiguration, emailServerConfiguration } from './importEmail.js';
import type { FlsApplicationClass, FlsFileTree, FlsFirewall } from './systemSoftware.js';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
@@ -119,6 +122,8 @@ export interface ImportedProject {
webServerConfigurations: Map<Id, WebServerConfiguration>;
gnutellaConfigurations: Map<Id, GnutellaConfiguration>;
dnsServerConfigurations: Map<Id, DnsServerConfiguration>;
emailClientConfigurations: Map<Id, EmailClientConfiguration>;
emailServerConfigurations: Map<Id, EmailServerConfiguration>;
echoServerConfigurations: Map<Id, EchoServerConfiguration>;
simpleClientConfigurations: Map<Id, SimpleClientConfiguration>;
/** In render order: rectangles below text, then by order. */
@@ -162,6 +167,8 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
webServerConfigurations: new Map(),
gnutellaConfigurations: new Map(),
dnsServerConfigurations: new Map(),
emailClientConfigurations: new Map(),
emailServerConfigurations: new Map(),
echoServerConfigurations: new Map(),
simpleClientConfigurations: new Map(),
};
@@ -369,6 +376,8 @@ type SystemConfigurations = Pick<
| 'webServerConfigurations'
| 'gnutellaConfigurations'
| 'dnsServerConfigurations'
| 'emailClientConfigurations'
| 'emailServerConfigurations'
| 'echoServerConfigurations'
| 'simpleClientConfigurations'
>;
@@ -393,6 +402,10 @@ export const PROGRAM_OF_CLASS: Readonly<Record<FlsApplicationClass, InstallableP
/** The web server's document root: where Java keeps its files. */
export const JAVA_WEB_ROOT = '/webserver';
function warnAt(node: TopologyNode, warnings: string[]): (message: string) => void {
return (message) => warnings.push(`node '${node.displayName}': ${message}`);
}
/** The program parts of Swift's finalizeCurrentNodeCandidate, for a PC or notebook. */
function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemConfigurations, warnings: string[]): void {
const installed = new Set<InstallableProgram>();
@@ -420,9 +433,7 @@ function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemCon
const primary = trimmedOrEmpty(source.interfaces[0]?.ip);
const forwarding = recursive && forwarder !== '' && forwarder !== primary;
const hosts = javaHostsFileContent(source.system.fileSystem);
const records = dnsRecordsOf(readDnsHosts(hosts ?? ''), (message) =>
warnings.push(`node '${node.displayName}': ${message}`),
);
const records = dnsRecordsOf(readDnsHosts(hosts ?? ''), warnAt(node, warnings));
system.dnsServerConfigurations.set(
node.id,
forwarding
@@ -431,6 +442,12 @@ function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemCon
);
break;
}
case 'emailClient':
system.emailClientConfigurations.set(node.id, emailClientConfiguration(application, warnAt(node, warnings)));
break;
case 'emailServer':
system.emailServerConfigurations.set(node.id, emailServerConfiguration(application, warnAt(node, warnings)));
break;
case 'firewall':
if (application.firewall !== undefined) {
system.firewallConfigurationsByNodeId.set(node.id, firewallConfigurationOf(application.firewall, `node '${node.displayName}'`, warnings));
+22 -6
View File
@@ -11,7 +11,7 @@
// the account files of test/email-*.cases.txt, where Java's errors in reading
// them are not copied (see readEmailServerFile, readEmailClientFile).
import { isJavaObject, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { isJavaObject, listItems, mapEntries, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaSplit, javaTrim } from './javaString.js';
/** AddressEntry: a display name, an address, or both. */
@@ -73,7 +73,23 @@ type Warn = (message: string) => void;
// JavaBeans
export function javaEmailAccounts(list: JavaValue | undefined, warn: Warn): JavaEmailAccount[] {
return objectsOf(list, 'EmailKonto', warn).map((account) => {
return emailAccountsOf(listItems(list), warn);
}
/**
* The client accounts of older files (EmailAnwendung's kontoListe, a map by
* address). FILIUS 2.14 has no such property and loses them; they are read
* for files without /konten.txt.
*/
export function javaLegacyEmailAccounts(map: JavaValue | undefined, warn: Warn): JavaEmailAccount[] {
return emailAccountsOf(
mapEntries(map).map(([, account]) => account),
warn,
);
}
function emailAccountsOf(items: JavaValue[], warn: Warn): JavaEmailAccount[] {
return objectsOf(items, 'EmailKonto', warn).map((account) => {
const at = (property: string) => text(account.properties.get(property), `EmailKonto ${property}`, warn);
return {
username: at('benutzername'),
@@ -92,7 +108,7 @@ export function javaEmailAccounts(list: JavaValue | undefined, warn: Warn): Java
}
export function javaEmails(list: JavaValue | undefined, warn: Warn): JavaEmail[] {
return objectsOf(list, 'Email', warn).map((email) => {
return objectsOf(listItems(list), 'Email', warn).map((email) => {
const at = (property: string) => email.properties.get(property);
const flag = (property: string, fallback: boolean) => {
const value = at(property);
@@ -117,7 +133,7 @@ export function javaEmails(list: JavaValue | undefined, warn: Warn): JavaEmail[]
}
export function javaContacts(list: JavaValue | undefined, warn: Warn): JavaContact[] {
return objectsOf(list, 'Kontakt', warn).map((contact) => {
return objectsOf(listItems(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);
@@ -172,8 +188,8 @@ function beanAddressEntry(entry: JavaObject, warn: Warn): JavaAddressEntry {
};
}
function objectsOf(list: JavaValue | undefined, simpleClass: string, warn: Warn): JavaObject[] {
return listItems(list).flatMap((item) => {
function objectsOf(items: JavaValue[], simpleClass: string, warn: Warn): JavaObject[] {
return items.flatMap((item) => {
if (isJavaObject(item) && simpleClassName(item.className) === simpleClass) return [item];
warn(`a list of ${simpleClass} holds something else, left out`);
return [];
+4
View File
@@ -34,6 +34,7 @@ import {
javaContacts,
javaEmailAccounts,
javaEmails,
javaLegacyEmailAccounts,
readEmailClientFile,
readEmailServerFile,
type JavaContact,
@@ -167,6 +168,8 @@ export interface FlsApplication {
addressBook?: JavaContact[];
/** EmailAnwendung: the account of /konten.txt, null without one. */
fileAccount?: JavaEmailAccount | null;
/** EmailAnwendung: the accounts of an older file's kontoListe, which FILIUS 2.14 ignores. */
legacyAccounts?: JavaEmailAccount[];
/** The application's JavaBean, for the e-mail layer. */
source: JavaObject;
}
@@ -325,6 +328,7 @@ function applications(map: JavaValue | undefined, root: FlsFileTree, warn: Warn)
entry.addressBook = javaContacts(at('adressbuch'), warn);
const file = javaFileContent(root, EMAIL_CLIENT_FILE);
entry.fileAccount = file === undefined ? null : readEmailClientFile(file, warn);
entry.legacyAccounts = javaLegacyEmailAccounts(at('kontoListe'), warn);
break;
}
}