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
+232 -14
View File
@@ -19,6 +19,8 @@ import {
portSnapshot,
topologySnapshot,
VirtualFileSystem,
type EmailAddress,
type EmailMessage,
type Id,
type NodeKind,
type VirtualFileEntry,
@@ -29,6 +31,8 @@ import {
readFlsArchive,
readTopology,
type FlsApplication,
type JavaEmail,
type JavaEmailAccount,
type FlsFileTree,
type FlsNode,
type FlsTopology,
@@ -148,6 +152,39 @@ function systemOf(project: ImportedProject, nodeId: Id) {
records: dns.records.map((record) => [record.name, record.type, record.ttlSeconds, record.target]),
};
}
const emailClient = project.emailClientConfigurations.get(nodeId);
// Swift keeps no client without an account.
if (emailClient?.account) {
const { account } = emailClient;
result.emailClient = {
pop3Host: account.pop3Host,
pop3Port: account.pop3Port,
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
username: account.username,
password: account.password,
name: fullName(account),
email: account.email,
inbox: emailClient.inbox.map(swiftMessage),
sent: emailClient.sent.map(swiftMessage),
drafts: emailClient.drafts.map(swiftMessage),
nextMessageID: emailClient.nextMessageID,
};
}
const emailServer = project.emailServerConfigurations.get(nodeId);
if (emailServer !== undefined) {
result.emailServer = {
domain: emailServer.domain,
pop3Port: emailServer.pop3Port,
accounts: emailServer.accounts.map((account) => ({
username: account.username,
password: account.password,
name: fullName(account),
mailbox: account.mailbox.map(swiftMessage),
})),
nextMessageID: emailServer.nextMessageID,
};
}
const remoteLink = project.remoteLinkConfigurations.get(nodeId);
if (remoteLink !== undefined) result.remoteLink = remoteLink;
const firewall = project.firewallConfigurationsByNodeId.get(nodeId);
@@ -172,6 +209,29 @@ function systemOf(project: ImportedProject, nodeId: Id) {
return result;
}
/** Swift keeps one name, first and last joined. */
function fullName(person: { firstName: string; lastName: string }): string {
return [person.firstName, person.lastName].filter((part) => part !== '').join(' ');
}
function swiftMessage(message: EmailMessage) {
const address = (entry: EmailAddress) => [entry.name, entry.mailAddress];
return {
id: message.id,
from: address(message.from),
to: message.to.map(address),
cc: message.cc.map(address),
bcc: message.bcc.map(address),
subject: message.subject,
body: message.body,
deliveryIdentity: null,
receivedAtMilliseconds: message.receivedAtMilliseconds ?? null,
isNew: message.isNew,
isMarkedForDeletion: message.isMarkedForDeletion,
isSent: message.isSent,
};
}
/** The shape describeImportedState (tools/swift-oracle) writes: indices instead of random ids. */
function asSwiftState(project: ImportedProject) {
const { nodes, links } = project.graph;
@@ -241,9 +301,37 @@ function withJavaDefaultNames(state: unknown): unknown {
* Where the Swift import is wrong and Java (corpus.java.json) is right, per
* file and node: the corrected node.
*/
type SwiftNode = { displayName: string; hostLabelPolicy: string; system: Record<string, unknown> };
type SwiftNode = { displayName: string; hostLabelPolicy: string; system: Record<string, unknown>; fileSystem: unknown };
const shownByIp = (displayName: string) => (node: SwiftNode) => ({ ...node, displayName, hostLabelPolicy: 'ipAddress' });
/**
* Swift numbers a server mailbox's messages as their received time when
* the file has none (Java never sets one), and writes the numbers into the
* konten.txt it rewrites.
*/
const unnumberedMailbox = (numbered: string[]) => (node: SwiftNode) => {
const server = node.system.emailServer as { accounts: { mailbox: { receivedAtMilliseconds: unknown }[] }[] };
const fileSystem = (node.fileSystem as { path: string; text?: string }[]).map((entry) =>
entry.path === '/mailserver/konten.txt'
? { ...entry, text: numbered.reduce((text, number) => text.replace(`$$$${number}$`, () => '$$$$'), entry.text!) }
: entry,
);
return {
...node,
fileSystem,
system: {
...node.system,
emailServer: {
...server,
accounts: server.accounts.map((account) => ({
...account,
mailbox: account.mailbox.map((message) => ({ ...message, receivedAtMilliseconds: null })),
})),
},
},
};
};
const SWIFT_ERRORS: Record<string, Record<number, (node: SwiftNode) => SwiftNode>> = {
'java-examples/man-in-the-middle.fls': { 5: unnumberedMailbox(['1', '2']) },
'java-examples/multicast_server.fls': {
// useIPAsName is `<object idref="Boolean0"/>`, the shared result of a
// button's isEnabled() — true. Swift's reader cannot follow it: it shows
@@ -263,20 +351,33 @@ function withSwiftErrorsCorrected(name: string, state: unknown): unknown {
}
/**
* What applications do to a file system, which the applications step ports:
* Swift keeps the e-mail and Gnutella state as files (left out on both
* sides here), and moves a web server's /webserver to /www (compared here
* by reading our /webserver as /www where Swift has one). Everything else
* in a file system must match.
* What Swift's programs do to a file system and ours do not: Swift keeps the
* e-mail and Gnutella state as hidden files too (left out here; the model
* holds that state), and moves a web server's /webserver to /www (compared
* here by reading our /webserver as /www where Swift has one). Everything
* else in a file system must match — also the /mail, /mailserver and
* /peer2peer directories Swift creates and the konten.txt files it rewrites.
*/
const APPLICATION_DIRECTORIES = ['/mail', '/mailserver', '/peer2peer'];
const SWIFT_STATE_FILES = [
'/mail/.filiuspad-email-client.json',
'/mailserver/.filiuspad-email-server.json',
'/peer2peer/.filiuspad-gnutella.json',
];
/**
* Directories Swift creates for those files: left out of Swift's state when
* they hold nothing else and the file had none (ours has none then).
*/
const SWIFT_STATE_DIRECTORIES = ['/mail', '/mailserver', '/peer2peer'];
type FileEntries = { path: string }[] | null;
function withoutApplicationDirectories(state: unknown, webRootOf?: (index: number) => boolean): unknown {
function withoutApplicationDirectories(
state: unknown,
webRootOf?: (index: number) => boolean,
ours?: unknown,
): unknown {
const { nodes, ...rest } = state as { nodes: { fileSystem: FileEntries }[] };
const outside = (entry: { path: string }) =>
!APPLICATION_DIRECTORIES.some((directory) => entry.path === directory || entry.path.startsWith(`${directory}/`));
const ourNodes = (ours as { nodes: { fileSystem: FileEntries }[] } | undefined)?.nodes;
const asWebRoot = (entry: { path: string }) =>
entry.path === '/webserver' || entry.path.startsWith('/webserver/')
? { ...entry, path: `/www${entry.path.slice('/webserver'.length)}` }
@@ -285,7 +386,12 @@ function withoutApplicationDirectories(state: unknown, webRootOf?: (index: numbe
...rest,
nodes: nodes.map((node, index) => {
if (node.fileSystem === null) return node;
const entries = node.fileSystem.filter(outside).map((entry) => (webRootOf?.(index) ? asWebRoot(entry) : entry));
const files = node.fileSystem.filter((entry) => !SWIFT_STATE_FILES.includes(entry.path));
const holdsMore = (directory: string) => files.some((entry) => entry.path.startsWith(`${directory}/`));
const ourPaths = new Set((ourNodes?.[index]?.fileSystem ?? []).map((entry) => entry.path));
const outside = (entry: { path: string }) =>
!(ourNodes !== undefined && SWIFT_STATE_DIRECTORIES.includes(entry.path) && !holdsMore(entry.path) && !ourPaths.has(entry.path));
const entries = files.filter(outside).map((entry) => (webRootOf?.(index) ? asWebRoot(entry) : entry));
return { ...node, fileSystem: entries.sort((lhs, rhs) => (lhs.path < rhs.path ? -1 : lhs.path > rhs.path ? 1 : 0)) };
}),
};
@@ -313,9 +419,8 @@ describe('corpus: the imported model as Swift builds it', () => {
const project = importFile(file);
const expected = withSwiftErrorsCorrected(name, withJavaDefaultNames(swiftView[name]!.state));
expect(withoutApplicationDirectories(asSwiftState(project), swiftWebRoots(expected))).toEqual(
withoutApplicationDirectories(expected),
);
const ours = withoutApplicationDirectories(asSwiftState(project), swiftWebRoots(expected));
expect(ours).toEqual(withoutApplicationDirectories(expected, undefined, ours));
expect(project.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []);
});
}
@@ -794,6 +899,119 @@ describe('importProject rules', () => {
]);
});
describe('e-mail', () => {
const source = flsNode('Rechner').source;
const account = (username: string, fields: Partial<JavaEmailAccount> = {}): JavaEmailAccount => ({
username,
password: `${username}-pw`,
lastName: 'Lang',
firstName: 'Anna Lena',
emailAddress: `${username}@filius.de`,
pop3Server: '10.0.0.1',
pop3Port: '110',
smtpServer: '10.0.0.1',
smtpPort: '25',
messages: [],
...fields,
});
const email = (subject: string, fields: Partial<JavaEmail> = {}): JavaEmail => ({
sender: { name: 'Alice', mailAddress: 'alice@senior.de' },
to: ['Bob <bob@filius.de>'],
cc: [],
bcc: [],
subject,
text: 'Hi',
dateReceived: '',
isNew: true,
isDeleted: false,
isSent: false,
...fields,
});
const host = (application: Partial<FlsApplication>) =>
flsNode('Rechner', {
name: 'Host',
interfaces: [nic('10.0.0.2')],
system: { applications: [{ key: 'mail', javaClass: 'filius.software.email.EmailAnwendung', source, ...application }] },
});
const server = (application: Partial<FlsApplication>) =>
flsNode('Rechner', {
name: 'Server',
interfaces: [nic('10.0.0.1')],
system: {
applications: [{ key: 'mail', javaClass: 'filius.software.email.EmailServer', source, mailDomain: 'filius.de', beanAccounts: [], fileAccounts: null, ...application }],
},
});
const imported = (node: FlsNode) => {
const project = importProject(topology({ nodes: [node] }), counter());
const id = project.graph.nodes[0]!.id;
return { project, client: project.emailClientConfigurations.get(id), server: project.emailServerConfigurations.get(id) };
};
it("takes a server's accounts from /mailserver/konten.txt, and from the bean list without the file", () => {
expect(imported(server({ beanAccounts: [account('bean')], fileAccounts: [account('file')] })).server!.accounts.map((a) => a.username)).toEqual(['file']);
expect(imported(server({ beanAccounts: [account('bean')], fileAccounts: null })).server!.accounts.map((a) => a.username)).toEqual(['bean']);
expect(imported(server({ beanAccounts: [account('bean')], fileAccounts: [] })).server!.accounts).toEqual([]);
});
it("numbers a server's messages across its accounts and keeps names apart", () => {
const { server: configuration } = imported(
server({ fileAccounts: [account('a', { messages: [email('1'), email('2')] }), account('b', { messages: [email('3')] })] }),
);
expect(configuration!.accounts.map((a) => a.mailbox.map((m) => [m.id, m.subject]))).toEqual([[[1, '1'], [2, '2']], [[3, '3']]]);
expect(configuration!.accounts[0]).toMatchObject({ firstName: 'Anna Lena', lastName: 'Lang', password: 'a-pw' });
expect(configuration!.nextMessageID).toBe(4);
});
it("takes a client's account from /konten.txt, else from an older file's kontoListe, else none", () => {
expect(imported(host({ fileAccount: account('file'), legacyAccounts: [account('legacy')] })).client!.account?.username).toBe('file');
expect(imported(host({ fileAccount: null, legacyAccounts: [account('legacy'), account('second')] })).client!.account?.username).toBe('legacy');
expect(imported(host({ fileAccount: null, legacyAccounts: [] })).client!.account).toBeNull();
});
it('uses the default for a port that is no port, and says so', () => {
const { project, client } = imported(host({ fileAccount: account('u', { pop3Port: 'x', smtpPort: '70000' }) }));
expect(client!.account).toMatchObject({ pop3Port: 110, smtpPort: 25 });
expect(project.warnings).toEqual([
"node 'Host': e-mail client: POP3 port 'x' is not a port, used 110",
"node 'Host': e-mail client: SMTP port '70000' is not a port, used 25",
]);
});
it('numbers the folders in order, marks the sent folder sent, and reads string recipients as addresses', () => {
const { project, client } = imported(
host({
fileAccount: account('u'),
received: [email('in', { isSent: true, dateReceived: '1700000000000' })],
sent: [email('out', { to: [null, { name: null, mailAddress: 'x@y' }] })],
drafts: [email('draft', { isSent: true, subject: null, text: null })],
}),
);
expect(client!.inbox[0]).toMatchObject({ id: 1, isSent: true, receivedAtMilliseconds: 1_700_000_000_000, to: [{ name: 'Bob', mailAddress: 'bob@filius.de' }] });
expect(client!.sent[0]).toMatchObject({ id: 2, isSent: true, to: [{ name: null, mailAddress: 'x@y' }] });
expect(client!.sent[0]).not.toHaveProperty('receivedAtMilliseconds');
expect(client!.drafts[0]).toMatchObject({ id: 3, isSent: false, subject: '', body: '' });
expect(client!.nextMessageID).toBe(4);
expect(project.warnings).toEqual(["node 'Host': an e-mail recipient is null, left out"]);
});
it("keeps a message's flags and a date only when it is a number, and the address book", () => {
const { client } = imported(
host({
fileAccount: account('u'),
received: [email('read', { isNew: false, isDeleted: true, dateReceived: 'Mon, 1 Jan' })],
addressBook: [
{ name: 'Lang', firstName: null, street: 'Weg', houseNumber: 3, postalCode: 20095, city: 'Hamburg', email: 'a@b', phone: null },
],
}),
);
expect(client!.inbox[0]).toMatchObject({ isNew: false, isMarkedForDeletion: true });
expect(client!.inbox[0]).not.toHaveProperty('receivedAtMilliseconds');
expect(client!.addressBook).toEqual([
{ name: 'Lang', firstName: '', street: 'Weg', houseNumber: 3, postalCode: 20095, city: 'Hamburg', email: 'a@b', phone: '' },
]);
});
});
it("takes a DNS server's records from its /dns/hosts, and names the node in a warning", () => {
const hosts = 'www.filius.de A 3600 141.99.2.10\nfilius.de MX 3600 mail.filius.de\nbroken.de A 3600 .1.2.3\n';
const server = flsNode('Rechner', {