From 1efbb8a66076369a51192a8cdbf305842edcc474 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Wed, 30 Sep 2026 07:36:05 +0000 Subject: [PATCH] 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 --- CHANGES.md | 5 +- corpus/corpus.swift.json | 842 +++++++++++++++++++ packages/core/src/runtime/config.ts | 80 ++ packages/fls/src/importEmail.ts | 133 +++ packages/fls/src/importProject.ts | 25 +- packages/fls/src/javaEmail.ts | 28 +- packages/fls/src/systemSoftware.ts | 4 + packages/fls/test/importProject.test.ts | 246 +++++- packages/fls/test/topology.test.ts | 21 + tools/swift-oracle/README.md | 2 +- tools/swift-oracle/Sources/Oracle/main.swift | 45 + 11 files changed, 1404 insertions(+), 27 deletions(-) create mode 100644 packages/fls/src/importEmail.ts diff --git a/CHANGES.md b/CHANGES.md index b68a678..9c467b6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,7 +12,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/model/node.ts` | `Model/TopologyNode.swift` | übertragen; Standard-Ports und -Namen wie Java; ein gegebener Name bleibt, auch leer (Java lässt Router unbenannt) | | `src/model/link.ts`, `src/model/graph.ts` | `Model/TopologyLink.swift`, `Model/TopologyGraph.swift` | übertragen, bisher nur die Daten (Abfragen folgen mit dem Editor) | | `src/model/documentation.ts` | `Model/TopologyDocumentation.swift` | übertragen; FNV-Kennung mit BigInt, gegen die IDs aus dem Swift-Import geprüft | -| `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, DHCP, Portweiterleitung, Remote Link ohne LAN-Transport, Programme und ihre Konfiguration, Firewall, DNS-Einträge aus `State/TopologyDNSResolver.swift`); dazu Echo-Server- und Client-Konfiguration, die Swift nicht speichert | übertragen; Java-Beschriftungen der Oberfläche weggelassen | +| `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, DHCP, Portweiterleitung, Remote Link ohne LAN-Transport, Programme und ihre Konfiguration, Firewall, DNS-Einträge aus `State/TopologyDNSResolver.swift`, E-Mail-Konten und -Nachrichten aus `State/TopologyEmail.swift`); dazu Echo-Server- und Client-Konfiguration, die Swift nicht speichert | übertragen; Java-Beschriftungen der Oberfläche weggelassen | | `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, UDP, TCP, DHCP, DNS, RIP, Remote Link, WLAN | | `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen | | `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` | @@ -38,7 +38,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene | | `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen | | `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Dateisysteme, Programme, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt (`swiftTrim`), Rechner ohne Dateisystem leer wie in Java (Swift: Demo-Dateien), alle Programme bleiben (Swift verwirft sechs), Webserver-Dateien bleiben in `/webserver` (Swift: `/www`), keine versteckten Zustandsdateien im Dateisystem, DNS-Einträge so, wie Javas Server sie aus `/dns/hosts` beantwortet; gegen Swifts importiertes Modell auf 13 Dateien geprüft | -| `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, zwei begründete Abweichungen), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) | +| `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, begründete Abweichungen; im Dateisystem ausgenommen nur Swifts versteckte Zustandsdateien), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) | | `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware`, `Dateisystem`, `Datei` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP (ein kaputter statischer Eintrag fällt allein weg, Java verliert alle folgenden), statische NAT, Modem, Dateisystem und installierte Programme von Rechner und Notebook; `Character.isWhitespace` am JDK gemessen; gegen Java geprüft | | `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen | | `src/dnsHosts.ts` | Java `DNSServer.leseRecordListe`, `ResourceRecord`, `Dateisystem.holeDatei`; Swift `State/TopologyDNSResolver.swift` (`TopologyDNSHostsFileCodec`) als Vergleich | neu nach Java: Felder nur an einzelnen Leerzeichen, keine Kommentare, ungültige TTL → 3600, Doppelte bleiben; ins Modell kommt nur, was Java beantwortet (Typ A/MX/NS, Name nicht leer); Javas Fehler nicht übernommen: Label mit Ziffer vorn erlaubt, kein Backslash, letztes Label nicht rein numerisch (RFC 1123/3696), Typ in jeder Schreibung (RFC 4343), negative TTL ungültig (RFC 2181); an 76 Fällen mit Java und Swift gemessen | @@ -47,6 +47,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/topology.test.ts` | — | neu: alle 23 Korpus-Dateien Feld für Feld wie Java (Geräte, Schnittstellen, Kabel, Doku-Elemente), dazu die Regeln, die der Korpus nicht berührt | | `test/javaIp.test.ts`, `test/ip-check.*` | — | neu: `IP.ipCheck`-Fälle, Ergebnis aus Java | | `src/javaEmail.ts` | Java `EmailServer` (Konten-Bean, `kontenLaden`/`stringZuListeBenutzerkonten`), `EmailAnwendung` (Ordner, Adressbuch, `laden`), `EmailKonto`, `Email`, `AddressEntry`, `EmailUtils` | neu nach Java: E-Mail-Beans über ihre Setter, `/mailserver/konten.txt` und `/konten.txt` im Format von Javas Lesern, ohne ihre Fehler (leere Felder zählen, eine kurze Zeile fällt allein weg, Nachrichten mit leerem Text oder Betreff bleiben, `;` im Betreff bleibt, CRLF, beim Client die letzte vollständige Zeile); Empfänger älterer Dateien als Zeichenketten; gegen Java auf dem Korpus und an 43 Fällen geprüft (13 benannte Java-Fehler) | +| `src/importEmail.ts` | `Persistence/TopologyProjectStore.swift` (`registerJavaEmailConfigurations`, `assignedJavaEmailMessages`) | übertragen, Eingabe ist Javas Sicht: Server-Konten aus `/mailserver/konten.txt`, ohne Datei aus der Bean-Liste (Java verlöre sie); Client-Konto aus `/konten.txt`, ohne Datei aus der älteren `kontoListe` (Filius 2.14 liest sie nicht mehr); Konten und Nachrichten bleiben, wo Swift verwirft, was seine Prüfung nicht besteht; Vor- und Nachname getrennt; Empfangszeit nur aus der Datei (Swift nummeriert); gegen Swifts Modell geprüft | | `src/javaFileSystem.ts`, `src/javaString.ts` | Java `Dateisystem.holeDatei`, `String.trim`/`split`/`equalsIgnoreCase` | neu nach Java; die Groß-/Kleinschreibung gegenüber ASCII am JDK über die ganze BMP gemessen | | `test/javaEmail.test.ts`, `test/email-*.cases.txt`, `test/email-*.java.json`, `test/javaEmailNames.ts`, `test/javaFileSystem.test.ts` | — | neu: Kontodateien von Server und Client, Ergebnis aus Java; Dateifund | | `test/dnsHosts.test.ts`, `test/dns-hosts.*` | — | neu: `/dns/hosts`-Fälle, wie Java bis auf 7 benannte Java-Fehler, das Modell wie Swift bis auf benannte Fälle mit Grund | diff --git a/corpus/corpus.swift.json b/corpus/corpus.swift.json index 2bfb85b..f5bec83 100644 --- a/corpus/corpus.swift.json +++ b/corpus/corpus.swift.json @@ -5644,6 +5644,26 @@ 266 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "rechner3@senior.de", + "inbox" : [ + + ], + "name" : "Rechner 3", + "nextMessageID" : 1, + "password" : "rechner3", + "pop3Host" : "141.99.50.233", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "141.99.50.233", + "smtpPort" : 25, + "username" : "rechner3" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -5695,6 +5715,26 @@ 375 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "rechner4@senior.de", + "inbox" : [ + + ], + "name" : "Rechner 4", + "nextMessageID" : 1, + "password" : "rechner4", + "pop3Host" : "141.99.50.233", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "141.99.50.233", + "smtpPort" : 25, + "username" : "rechner4" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -5746,6 +5786,26 @@ 59 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "rechner1@filius.de", + "inbox" : [ + + ], + "name" : "Rechner 1", + "nextMessageID" : 1, + "password" : "rechner1", + "pop3Host" : "141.99.50.232", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "141.99.50.232", + "smtpPort" : 25, + "username" : "rechner1" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -5797,6 +5857,26 @@ 161 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "rechner2@filius.de", + "inbox" : [ + + ], + "name" : "Rechner 2", + "nextMessageID" : 1, + "password" : "rechner2", + "pop3Host" : "141.99.50.232", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "141.99.50.232", + "smtpPort" : 25, + "username" : "rechner2" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -6094,6 +6174,29 @@ 285 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "rechner1", + "username" : "rechner1" + }, + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "rechner2", + "username" : "rechner2" + } + ], + "domain" : "filius.de", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] @@ -6144,6 +6247,29 @@ 414 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "rechner3", + "username" : "rechner3" + }, + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "rechner4", + "username" : "rechner4" + } + ], + "domain" : "senior.de", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] @@ -6575,6 +6701,26 @@ 169 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "herbert@gmx.com", + "inbox" : [ + + ], + "name" : "Herbert Schmidt", + "nextMessageID" : 1, + "password" : "herbert1234", + "pop3Host" : "mail.gmx.com", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "mail.gmx.com", + "smtpPort" : 25, + "username" : "herbert" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -6791,6 +6937,26 @@ 274 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "frimp@webmail.com", + "inbox" : [ + + ], + "name" : "Frimp Mueller", + "nextMessageID" : 1, + "password" : "frimp1234", + "pop3Host" : "mail.webmail.com", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "mail.webmail.com", + "smtpPort" : 25, + "username" : "frimp" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -7279,6 +7445,29 @@ 417 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "herbert1234", + "username" : "herbert" + }, + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "george1234", + "username" : "george" + } + ], + "domain" : "gmx.com", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] @@ -7329,6 +7518,21 @@ 684 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "frimp1234", + "username" : "frimp" + } + ], + "domain" : "webmail.com", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] @@ -7476,6 +7680,26 @@ 840 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "george@gmx.com", + "inbox" : [ + + ], + "name" : "George Brown", + "nextMessageID" : 1, + "password" : "george1234", + "pop3Host" : "mail.gmx.com", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "mail.gmx.com", + "smtpPort" : 25, + "username" : "george" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -8406,6 +8630,103 @@ 165 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "herbert@gmx.de", + "inbox" : [ + + ], + "name" : "Herbert Schmidt", + "nextMessageID" : 4, + "password" : "herbert1234", + "pop3Host" : "mail.gmx.de", + "pop3Port" : 110, + "sent" : [ + { + "bcc" : [ + + ], + "body" : "Huhu! \n\nHi Frimp!\n\nDein \nHerbert", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Herbert Schmidt", + "herbert@gmx.de" + ], + "id" : 1, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "Hallo Frimp!", + "to" : [ + [ + null, + "frimp@webmail.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "Hallo Frimp!\n\nFrimp Müller schrieb:\n> Huhu! \n> \n> Hi Herbert!\n> \n> Dein \n> Frimp\n> ", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Herbert Schmidt", + "herbert@gmx.de" + ], + "id" : 2, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "RE: Hallo Herbert!", + "to" : [ + [ + null, + "frimp@webmail.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "jetzt?!\n\nFrimp Mueller schrieb:\n> Hi Herbert Again!\n> \n> Herbert Schmidt schrieb:\n> > Hallo Frimp!\n> > \n> > Frimp Müller schrieb:\n> > > Huhu! \n> > > \n> > > Hi Herbert!\n> > > \n> > > Dein \n> > > Frimp\n> > >\n> >\n> ", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Herbert Schmidt", + "herbert@gmx.de" + ], + "id" : 3, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "RE: RE: RE: Hallo Herbert!", + "to" : [ + [ + "Frimp Mueller", + "frimp@webmail.de" + ] + ] + } + ], + "smtpHost" : "mail.gmx.de", + "smtpPort" : 25, + "username" : "herbert" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -9054,6 +9375,29 @@ 417 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "herbert1234", + "username" : "herbert" + }, + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "george1234", + "username" : "george" + } + ], + "domain" : "gmx.de", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] @@ -9104,6 +9448,21 @@ 649 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "frimp1234", + "username" : "frimp" + } + ], + "domain" : "webmail.de", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] @@ -9251,6 +9610,26 @@ 826 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "george@gmx.de", + "inbox" : [ + + ], + "name" : "George Brown", + "nextMessageID" : 1, + "password" : "george1234", + "pop3Host" : "mail.gmx.de", + "pop3Port" : 110, + "sent" : [ + + ], + "smtpHost" : "mail.gmx.de", + "smtpPort" : 25, + "username" : "george" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -11349,6 +11728,258 @@ 264 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "alice@senior.de", + "inbox" : [ + { + "bcc" : [ + + ], + "body" : "test", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Rechner 1", + "rechner1@filius.de" + ], + "id" : 1, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : false, + "receivedAtMilliseconds" : null, + "subject" : "test", + "to" : [ + [ + null, + "rechner3@senior.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Bob 1", + "bob@filius.de" + ], + "id" : 2, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : false, + "receivedAtMilliseconds" : null, + "subject" : "test3", + "to" : [ + [ + null, + "alice@senior.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "test bl\n", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Bob 1", + "bob@filius.de" + ], + "id" : 3, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : false, + "receivedAtMilliseconds" : null, + "subject" : "test bl", + "to" : [ + [ + null, + "alice@senior.de" + ] + ] + } + ], + "name" : "Alice 3", + "nextMessageID" : 10, + "password" : "alice", + "pop3Host" : "141.99.50.233", + "pop3Port" : 110, + "sent" : [ + { + "bcc" : [ + + ], + "body" : "Hallo Bob,\n\nschön von dir zu hören.\n\nGruß\nAlice", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 4, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "Beste Grüße", + "to" : [ + [ + null, + "Bob@filius.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "test", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 5, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "test", + "to" : [ + [ + null, + "bob@filius.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "\n\nBob 1 schrieb:\n> ", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 6, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "RE: test3", + "to" : [ + [ + null, + "bob@filius.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "\n\nBob 1 schrieb:\n> ", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 7, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "RE: test3", + "to" : [ + [ + null, + "bob@filius.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "huhu bob!\n\nBob 1 schrieb:\n> test bl\n> ", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 8, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "RE: test bl", + "to" : [ + [ + "Bob 1", + "bob@filius.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "Hi BoB!", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 9, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "Hi Bob!", + "to" : [ + [ + null, + "bob@filius.de" + ] + ] + } + ], + "smtpHost" : "141.99.50.233", + "smtpPort" : 25, + "username" : "alice" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -11444,6 +12075,14 @@ ], "recursiveResolutionEnabled" : false }, + "emailServer" : { + "accounts" : [ + + ], + "domain" : "eve.de", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "dnsServer", "emailServer", @@ -11496,6 +12135,128 @@ 120 ], "system" : { + "emailClient" : { + "drafts" : [ + + ], + "email" : "bob@filius.de", + "inbox" : [ + { + "bcc" : [ + + ], + "body" : "Bob 1 schrieb:\n>", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 1, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : false, + "receivedAtMilliseconds" : null, + "subject" : "RE: test3", + "to" : [ + [ + null, + "bob@filius.de" + ] + ] + } + ], + "name" : "Bob 1", + "nextMessageID" : 5, + "password" : "bob", + "pop3Host" : "141.99.50.232", + "pop3Port" : 110, + "sent" : [ + { + "bcc" : [ + + ], + "body" : "test", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Rechner 1", + "rechner1@filius.de" + ], + "id" : 2, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "test", + "to" : [ + [ + null, + "rechner3@senior.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Bob 1", + "bob@filius.de" + ], + "id" : 3, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "test3", + "to" : [ + [ + null, + "alice@senior.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "test bl", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Bob 1", + "bob@filius.de" + ], + "id" : 4, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : true, + "receivedAtMilliseconds" : null, + "subject" : "test bl", + "to" : [ + [ + null, + "alice@senior.de" + ] + ] + } + ], + "smtpHost" : "141.99.50.232", + "smtpPort" : 25, + "username" : "bob" + }, "installedPrograms" : [ "emailClient", "webBrowser" @@ -11793,6 +12554,72 @@ 382 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + { + "bcc" : [ + + ], + "body" : "huhu bob!\n\nBob 1 schrieb:\n> test bl\n>\n", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 1, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : false, + "receivedAtMilliseconds" : 1, + "subject" : "RE: test bl", + "to" : [ + [ + "Bob 1", + "bob@filius.de" + ] + ] + }, + { + "bcc" : [ + + ], + "body" : "Hi BoB!\n", + "cc" : [ + + ], + "deliveryIdentity" : null, + "from" : [ + "Alice 3", + "alice@senior.de" + ], + "id" : 2, + "isMarkedForDeletion" : false, + "isNew" : true, + "isSent" : false, + "receivedAtMilliseconds" : 2, + "subject" : "Hi Bob!", + "to" : [ + [ + null, + "bob@filius.de" + ] + ] + } + ], + "name" : "Vorname Nachname", + "password" : "bob", + "username" : "bob" + } + ], + "domain" : "filius.de", + "nextMessageID" : 3, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] @@ -11843,6 +12670,21 @@ 405 ], "system" : { + "emailServer" : { + "accounts" : [ + { + "mailbox" : [ + + ], + "name" : "Vorname Nachname", + "password" : "alice", + "username" : "alice" + } + ], + "domain" : "senior.de", + "nextMessageID" : 1, + "pop3Port" : 110 + }, "installedPrograms" : [ "emailServer" ] diff --git a/packages/core/src/runtime/config.ts b/packages/core/src/runtime/config.ts index 26311c1..119f257 100644 --- a/packages/core/src/runtime/config.ts +++ b/packages/core/src/runtime/config.ts @@ -201,6 +201,86 @@ export interface DnsServerConfiguration { readonly records: readonly DnsResourceRecord[]; } +/** + * An e-mail address as Java's AddressEntry holds it: a display name, an + * address, or both (Swift's TopologyRuntimeEmailAddress wants the address). + */ +export interface EmailAddress { + readonly name: string | null; + readonly mailAddress: string | null; +} + +/** An e-mail (Swift's TopologyRuntimeEmailMessage without its run-time delivery identity). */ +export interface EmailMessage { + /** Numbered from 1 per program, in folder order, as Swift numbers them. */ + readonly id: number; + readonly from: EmailAddress; + readonly to: readonly EmailAddress[]; + readonly cc: readonly EmailAddress[]; + readonly bcc: readonly EmailAddress[]; + readonly subject: string; + readonly body: string; + /** Only when the file gives one; Java never sets it. */ + readonly receivedAtMilliseconds?: number; + readonly isNew: boolean; + readonly isMarkedForDeletion: boolean; + /** A message of the sent folder. */ + readonly isSent: boolean; +} + +/** The account an e-mail client uses. First and last name stay apart, as Java keeps them. */ +export interface EmailClientAccount { + readonly pop3Host: string; + readonly pop3Port: number; + readonly smtpHost: string; + readonly smtpPort: number; + readonly username: string; + readonly password: string; + readonly firstName: string; + readonly lastName: string; + readonly email: string; +} + +/** Java's Kontakt: an address book entry. */ +export interface EmailContact { + readonly name: string; + readonly firstName: string; + readonly street: string; + readonly houseNumber: number; + readonly postalCode: number; + readonly city: string; + readonly email: string; + readonly phone: string; +} + +export interface EmailClientConfiguration { + /** Null when the client has none (Java opens it without one). */ + readonly account: EmailClientAccount | null; + readonly inbox: readonly EmailMessage[]; + readonly sent: readonly EmailMessage[]; + readonly drafts: readonly EmailMessage[]; + readonly addressBook: readonly EmailContact[]; + readonly nextMessageID: number; +} + +export interface EmailServerAccount { + readonly username: string; + readonly password: string; + readonly firstName: string; + readonly lastName: string; + readonly mailbox: readonly EmailMessage[]; +} + +export interface EmailServerConfiguration { + readonly domain: string; + readonly pop3Port: number; + readonly accounts: readonly EmailServerAccount[]; + readonly nextMessageID: number; +} + +export const DEFAULT_POP3_PORT = 110; +export const DEFAULT_SMTP_PORT = 25; + /** * Java's echo server (ServerBaustein) and generic client (ClientBaustein) * keep their port and target in the file; Swift asks for them at run time. diff --git a/packages/fls/src/importEmail.ts b/packages/fls/src/importEmail.ts new file mode 100644 index 0000000..42cf83e --- /dev/null +++ b/packages/fls/src/importEmail.ts @@ -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 }]; + }); +} diff --git a/packages/fls/src/importProject.ts b/packages/fls/src/importProject.ts index 3edd3f1..2b5445a 100644 --- a/packages/fls/src/importProject.ts +++ b/packages/fls/src/importProject.ts @@ -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; gnutellaConfigurations: Map; dnsServerConfigurations: Map; + emailClientConfigurations: Map; + emailServerConfigurations: Map; echoServerConfigurations: Map; simpleClientConfigurations: Map; /** 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 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(); @@ -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)); diff --git a/packages/fls/src/javaEmail.ts b/packages/fls/src/javaEmail.ts index 304515e..17d9e7a 100644 --- a/packages/fls/src/javaEmail.ts +++ b/packages/fls/src/javaEmail.ts @@ -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 []; diff --git a/packages/fls/src/systemSoftware.ts b/packages/fls/src/systemSoftware.ts index 4213282..57906eb 100644 --- a/packages/fls/src/systemSoftware.ts +++ b/packages/fls/src/systemSoftware.ts @@ -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; } } diff --git a/packages/fls/test/importProject.test.ts b/packages/fls/test/importProject.test.ts index 133236c..6565f19 100644 --- a/packages/fls/test/importProject.test.ts +++ b/packages/fls/test/importProject.test.ts @@ -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 }; +type SwiftNode = { displayName: string; hostLabelPolicy: string; system: Record; 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 SwiftNode>> = { + 'java-examples/man-in-the-middle.fls': { 5: unnumberedMailbox(['1', '2']) }, 'java-examples/multicast_server.fls': { // useIPAsName is ``, 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 => ({ + 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 => ({ + sender: { name: 'Alice', mailAddress: 'alice@senior.de' }, + to: ['Bob '], + cc: [], + bcc: [], + subject, + text: 'Hi', + dateReceived: '', + isNew: true, + isDeleted: false, + isSent: false, + ...fields, + }); + const host = (application: Partial) => + 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) => + 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', { diff --git a/packages/fls/test/topology.test.ts b/packages/fls/test/topology.test.ts index 73ca4f1..af5e1d2 100644 --- a/packages/fls/test/topology.test.ts +++ b/packages/fls/test/topology.test.ts @@ -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) => + `${username}@senior.de + ${username} + 110 + `; + const { nodes } = topologyOf(`${nodeItem(` + + filius.software.email.EmailAnwendung + ${legacyAccount('rechner3')}${legacyAccount('rechner4')} + + `)}`); + 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(` ${nodeItem('')} diff --git a/tools/swift-oracle/README.md b/tools/swift-oracle/README.md index 79acaf5..06998a3 100644 --- a/tools/swift-oracle/README.md +++ b/tools/swift-oracle/README.md @@ -41,7 +41,7 @@ der Port nicht übernimmt. `--dns-hosts` liest dieselben `/dns/hosts`-Fälle wie das Java-Orakel (`packages/fls/test/dns-hosts.cases.txt`) mit Swifts `TopologyDNSHostsFileCodec`; unter `state` stehen die DNS-Einträge jedes -Servers (`dnsServer.records`). +Servers (`dnsServer.records`) und E-Mail-Client und -Server mit Konten und Nachrichten (`emailClient`, `emailServer`). ## Wie es gebaut wird diff --git a/tools/swift-oracle/Sources/Oracle/main.swift b/tools/swift-oracle/Sources/Oracle/main.swift index 0af184d..ebd5577 100644 --- a/tools/swift-oracle/Sources/Oracle/main.swift +++ b/tools/swift-oracle/Sources/Oracle/main.swift @@ -404,6 +404,30 @@ func describeSystem(_ state: TopologyEditorState, _ node: TopologyNode) -> JSON "records": dns.typedRecords.map { dnsRecordJSON($0) }, ] as JSON } + if let client = state.runtimeEmailClientConfigurationsByNodeID[node.id] { + json["emailClient"] = [ + "pop3Host": client.pop3Host, "pop3Port": client.pop3Port, + "smtpHost": client.smtpHost, "smtpPort": client.smtpPort, + "username": client.username, "password": client.password, + "name": client.name, "email": client.email, + "inbox": client.inbox.map(emailMessageJSON), + "sent": client.sent.map(emailMessageJSON), + "drafts": client.drafts.map(emailMessageJSON), + "nextMessageID": Int(client.nextMessageID), + ] as JSON + } + if let server = state.runtimeEmailServerConfigurationsByNodeID[node.id] { + json["emailServer"] = [ + "domain": server.domain, "pop3Port": server.pop3Port, + "accounts": server.accounts.map { account in + [ + "username": account.username, "password": account.password, "name": account.name, + "mailbox": account.mailbox.map(emailMessageJSON), + ] as JSON + }, + "nextMessageID": Int(server.nextMessageID), + ] as JSON + } if let link = state.remoteLinkConfigurationsByNodeID[node.id] { json["remoteLink"] = [ "pairIdentifier": link.pairIdentifier, @@ -615,6 +639,27 @@ func foldPaths(_ cases: [PathCase]) -> [JSON] { } } +func emailAddressJSON(_ address: TopologyRuntimeEmailAddress) -> [Any] { + [address.name ?? NSNull(), address.mailAddress] +} + +func emailMessageJSON(_ message: TopologyRuntimeEmailMessage) -> JSON { + [ + "id": Int(message.id), + "from": emailAddressJSON(message.from), + "to": message.to.map(emailAddressJSON), + "cc": message.cc.map(emailAddressJSON), + "bcc": message.bcc.map(emailAddressJSON), + "subject": message.subject, + "body": message.body, + "deliveryIdentity": message.deliveryIdentity ?? NSNull(), + "receivedAtMilliseconds": message.receivedAtMilliseconds.map { Int($0) } ?? NSNull(), + "isNew": message.isNew, + "isMarkedForDeletion": message.isMarkedForDeletion, + "isSent": message.isSent, + ] +} + // MARK: - /dns/hosts cases (packages/fls/test/dns-hosts.cases.txt) func dnsRecordJSON(_ record: TopologyDNSResourceRecord) -> [Any] {