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:
@@ -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 }];
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -478,6 +478,27 @@ describe('readTopology rules', () => {
|
||||
expect(server.beanAccounts![0]!.messages[0]!.sender).toEqual({ name: 'Alice', mailAddress: 'alice@senior.de' });
|
||||
});
|
||||
|
||||
it("reads an older file's kontoListe, which FILIUS 2.14 ignores, in the order of its entries", () => {
|
||||
const legacyAccount = (username: string) =>
|
||||
`<void method="put"><string>${username}@senior.de</string><object class="filius.software.email.EmailKonto">
|
||||
<void property="benutzername"><string>${username}</string></void>
|
||||
<void property="pop3port"><string>110</string></void>
|
||||
</object></void>`;
|
||||
const { nodes } = topologyOf(`<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Rechner">
|
||||
<void property="systemSoftware"><void property="installierteAnwendungen">
|
||||
<void method="put"><string>filius.software.email.EmailAnwendung</string><object class="filius.software.email.EmailAnwendung">
|
||||
<void property="kontoListe">${legacyAccount('rechner3')}${legacyAccount('rechner4')}</void>
|
||||
</object></void>
|
||||
</void></void></object>`)}</object>`);
|
||||
const client = nodes[0]!.system.applications![0]!;
|
||||
|
||||
expect(client.fileAccount).toBeNull();
|
||||
expect(client.legacyAccounts!.map((account) => [account.username, account.pop3Port])).toEqual([
|
||||
['rechner3', '110'],
|
||||
['rechner4', '110'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a modem Java's defaults and keeps an address the file sets to null", () => {
|
||||
const { nodes } = topologyOf(`<object class="java.util.LinkedList">
|
||||
${nodeItem('<object class="filius.hardware.knoten.Modem"/>')}
|
||||
|
||||
Reference in New Issue
Block a user