From 11f13bca84137338441be5595ced37cf505d8363 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sat, 3 Oct 2026 16:44:36 +0000 Subject: [PATCH] feat(editor): F4.6e the e-mail program and e-mail server on a computer's desktop The oracle MailWindowRun uses Java's e-mail windows as a person does and answers their dialogs. The core sets and saves the program's account, deletes a mail, checks a form as Java's window does, and gives the server its domain, new and removed accounts and the window's own line; the room sends both programs' views and takes their actions; the editor has both windows. Better than Java: a name field for a new account, a user name the server has is refused, deleting hits the folder shown, a new domain goes into the accounts file at once. Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 5 + packages/core/src/runtime/apps/emailClient.ts | 107 +++- packages/core/src/runtime/apps/emailServer.ts | 52 +- packages/core/src/runtime/apps/web.ts | 5 + .../core/src/runtime/emailAccountsFile.ts | 25 +- packages/core/src/runtime/engine.ts | 24 +- packages/core/test/programLines.ts | 5 + packages/editor/browser/mail.spec.ts | 196 +++++++ .../assets/desktop/email_email_antworten.png | Bin 0 -> 1199 bytes .../assets/desktop/email_email_verfassen.png | Bin 0 -> 1130 bytes .../assets/desktop/email_emails_abholen.png | Bin 0 -> 1937 bytes .../editor/src/assets/desktop/email_icon.png | Bin 0 -> 3643 bytes .../assets/desktop/email_ordner_gesendet.png | Bin 0 -> 1186 bytes .../desktop/email_ordner_posteingang.png | Bin 0 -> 1156 bytes packages/editor/src/desktop/DesktopWindow.tsx | 9 +- .../editor/src/desktop/EmailClientPanel.tsx | 238 ++++++++ .../editor/src/desktop/EmailServerPanel.tsx | 196 +++++++ packages/editor/src/desktop/desktop.css | 273 ++++++++++ packages/editor/src/desktop/mailLines.ts | 50 ++ packages/editor/src/i18n/de.json | 73 +++ packages/editor/src/i18n/en.json | 73 +++ packages/editor/test/i18n.test.ts | 80 ++- packages/editor/test/mailLines.test.ts | 68 +++ packages/fls/src/exportProject.ts | 21 +- packages/protocol/src/index.ts | 1 + packages/protocol/src/mail.ts | 45 ++ packages/sim/src/room.ts | 86 ++- packages/sim/test/mailWindow.java.json | 509 ++++++++++++++++++ packages/sim/test/mailWindow.test.ts | 253 +++++++++ packages/sim/test/room.test.ts | 94 +++- tools/java-oracle/README.md | 36 ++ tools/java-oracle/run.sh | 13 +- tools/java-oracle/src/MailWindowRun.java | 356 ++++++++++++ 33 files changed, 2841 insertions(+), 52 deletions(-) create mode 100644 packages/editor/browser/mail.spec.ts create mode 100644 packages/editor/src/assets/desktop/email_email_antworten.png create mode 100644 packages/editor/src/assets/desktop/email_email_verfassen.png create mode 100644 packages/editor/src/assets/desktop/email_emails_abholen.png create mode 100644 packages/editor/src/assets/desktop/email_icon.png create mode 100644 packages/editor/src/assets/desktop/email_ordner_gesendet.png create mode 100644 packages/editor/src/assets/desktop/email_ordner_posteingang.png create mode 100644 packages/editor/src/desktop/EmailClientPanel.tsx create mode 100644 packages/editor/src/desktop/EmailServerPanel.tsx create mode 100644 packages/editor/src/desktop/mailLines.ts create mode 100644 packages/editor/test/mailLines.test.ts create mode 100644 packages/protocol/src/mail.ts create mode 100644 packages/sim/test/mailWindow.java.json create mode 100644 packages/sim/test/mailWindow.test.ts create mode 100644 tools/java-oracle/src/MailWindowRun.java diff --git a/CHANGES.md b/CHANGES.md index d989e84..7cce994 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -17,6 +17,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/runtime/engine.ts` (ganze Millisekunden) | Java `System.currentTimeMillis` (long) | behoben (F4.6b): `start` und `advance` runden auf ganze Millisekunden ab, wie Javas Uhr zählt. Gefunden im Browser: der Raum gab `performance.now()` mit Bruchteilen weiter, und ein Ping blieb nach der ersten Zeitüberschreitung hängen; alle Tests liefen zuvor mit glatten Takten | | `src/runtime/engine.ts` (Installation), `src/runtime/apps/installation.ts`, `test/installation.test.ts` | Java `InternetKnotenBetriebssystem.installAndStartAppIfAvailable/entferneSoftware`, `WebServer.erzeugeStandardVerzeichnis`, `PeerToPeerAnwendung`/`EmailServer`/`DNSServer.setSystemSoftware/starten`, `tmpl/webserver_index_*`, `gfx/allgemein/splashscreen-mini.png` | neu (F4.6c): was ein Programm bei der Installation in die Dateien legt (Javas Startseite je Sprache, Bild, `/peer2peer`, `/mailserver/konten.txt`, `/dns/hosts`); `installProgram`/`removeProgram` im laufenden Betrieb — Einstellungen und fehlende Dateien aus dem Schnappschuss des Dokuments, Programm sofort gestartet; entfernt wird ein Server abgeschaltet, die Dateien bleiben. Die Programmerzeugung beim Start läuft über dieselbe `startProgram` in der bisherigen Reihenfolge | | `src/runtime/apps/web.ts` (F4.6d), `test/web.test.ts` | Java `WebBrowser.retrieveImages`, `GUIApplicationWebBrowserWindow.hyperLinkListener`, `WebServer.changeVHostTable/saveVHosts/setUseVHost` (gemessen mit `BrowserWindowRun`) | erweitert (F4.6d): die Zeile einer Seite nennt Host und Pfad, die eines Bildes trägt seine Daten (für das Fenster); `pageReference` und `linkTarget` — relative Bilder und Links zählen vom Ordner der Seite (Java vom Wurzelordner des Servers; für eine Seite im Wurzelordner sind die Anfragen Javas), eine Marke (`#…`) und andere Schemata führen nirgends hin; der Webserver schaltet seine virtuellen Hosts um und schreibt ihre Tabelle, nur mit vollständigen Zeilen (Java schreibt jede Zeile, und beim Lesen rückt eine Zelle einer unvollständigen in die Zeile davor) | +| `src/runtime/apps/emailClient.ts`, `src/runtime/apps/emailServer.ts`, `src/runtime/emailAccountsFile.ts`, `src/runtime/engine.ts` (F4.6e) | Java `GUIApplicationEmailAnwendungWindow.kontoSpeichern/emailLoeschen`, `EmailAnwendung.setzeKonto/speichern/removeReceivedMail/removeSentMail`, `GUIApplicationEmailServerWindow`, `EmailServer.setMailDomain/benutzerHinzufuegen/kontoLoeschen` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): das E-Mail-Programm setzt sein Konto (`accountOfForm` mit Javas Mustern für Ports und Benutzernamen, `splitName` wie Javas Dialog: letztes Wort Nachname) und schreibt es nach `/konten.txt` (`clientAccountFileText`, jetzt im Kern statt im Export), löscht eine Mail eines Ordners, prüft ein Formular wie Javas Fenster (`mailFormProblem`) und sendet mit dem Konto, mit dem die Mail geschrieben wurde; der Server setzt seine Domain (nicht leer, Javas Muster), legt ein Konto an (einen vorhandenen Namen in jeder Schreibweise nicht — Javas Fenster meldet es als angelegt) und entfernt eines, sein Startknopf schreibt die eigene Zeile des Fensters; die neue Domain steht sofort in Schnappschuss und Kontendatei (Java erst bei der nächsten Kontenänderung) | | `src/runtime/threads.ts` | Javas Threads der Programme (`Anwendung extends Thread`, `ServerMitarbeiter`) | neu (F3.1): ein Programm-Thread als Generator, der liefert, worauf er wartet (Bedingung, Zeit oder beides); die Engine setzt nach jedem Ereignis fort, wessen Warten vorbei ist, in der Reihenfolge, in der die Threads begannen; nichts blockiert die Engine. Abweichend von Java: ein Thread wacht sofort, wenn seine Bedingung gilt (Java sieht oft nur alle 100 ms nach) | | `src/runtime/apps/program.ts` | Java `filius.software.Anwendung` | neu (F3.1): die Zeilen im Fenster eines Programms (strukturiert, damit der Editor sie übersetzt) und der Befehls-Thread (`ausfuehren`) | | `src/runtime/apps/server.ts` | Java `ServerAnwendung`, `ServerMitarbeiter` | neu (F3.2a, aus `echo.ts` herausgelöst): was jeder Server teilt — ein Thread nimmt Verbindungen an, solange er eingeschaltet ist, je Verbindung einer beantwortet jede Nachricht; ausgeschaltet schließt er Socket und Verbindungen; Port belegt → er schaltet sich aus; seit F3.3a kann ein Server das Gespräch einer Verbindung selbst führen (`converse`: Begrüßung, keine oder mehrere Antworten, Schließen) | @@ -149,6 +150,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sim.ts` (`node`) | Java `NetworkInfoPanel.updateInfo`, `GUIDesktopWindow.updateTitle` | erweitert (F4.6a): Thema `node:` und Nachricht `node` — die erste Karte eines Geräts, wie die Simulation sie fährt (DHCP ändert sie), für Titel und Netzwerk-Info des Desktops | | `src/terminal.ts` | Java `GUIApplicationTerminalWindow` (gemessen mit `TerminalWindowRun`) | neu (F4.6b): die Zeilen eines Befehlszeilen-Fensters (Text, Eingabe nach ihrem Prompt, cls, Beschäftigt, Prompt, exit) und wie ein Client sie liest — Text ab dem letzten cls, Prompt, ob er sichtbar ist (verborgen, solange ein Befehl läuft), Verlauf; Javas Pfeiltasten (`commandHistoryPointer`) | | `src/web.ts`, `src/sim.ts` (`view`, `start.language`) | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): der Bildschirmstand eines Programmfensters (`view`, ganz, wenn er sich ändert) — der des Browsers (Adresszeile, Adresse des letzten Abrufs, Seite: Start, nicht erreichbar, Fehler mit Status, HTML mit den gekommenen Bildern, Text, Bild) und der des Webservers (Knopf, Kästchen, Tabelle); `browserTitle` wie Javas Fenster (Programmname, dann die Adresse, dann der Titel der Seite), `htmlTitle`; der Start nennt die Sprache, in der FILIUS für die startende Person läuft | +| `src/mail.ts` | Java `GUIApplicationEmailAnwendungWindow` (`posteingangAktualisieren`, `gesendeteAktualisieren`, `replyLayout`), `GUIApplicationEmailServerWindow` (gemessen mit `MailWindowRun`) | neu (F4.6e): die Bildschirmstände von E-Mail-Programm (Konto, Posteingang, Gesendete) und E-Mail-Server (Knopf, Domain, Konten mit Zahl der Mails); die Zeilen der Ordner und die Antwort (an den Absender, „RE:“, zitierter Text) wie Javas Fenster | ## packages/sim @@ -162,6 +164,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/terminalWindow.ts`, `src/room.ts` (Befehlszeile), `test/terminalWindow.test.ts`, `test/terminalWindow.java.json` | Java `GUIApplicationTerminalWindow`, `Terminal.setSystemSoftware` | neu (F4.6b): das Fenster der Befehlszeile im Raum, eines je Rechner für alle — Startbildschirm, jede Eingabe nach ihrem Prompt, leere Zeile, cls, exit, danach die Zeilen des Programms in ihrer Reihenfolge; es überdauert Stopp und Start mit Text, Verlauf und Arbeitsverzeichnis wie Javas. Gegen Javas Fenster Taste für Taste geprüft; benannt: was ein Lauf in den Dateien anlegt, steht nach dem Neustart noch nicht im Dokument (F4.6f), ein dort angelegter Ordner fehlt dann | | `src/room.ts` (Installation) | Java `GUIInstallationsDialog` (Programme starten sofort) | erweitert (F4.6c): im laufenden Betrieb beobachtet der Raum die installierten Programme des Dokuments und installiert oder entfernt sie in der Engine; eine neue Befehlszeile bekommt ihr Fenster | | `src/browserWindow.ts`, `src/room.ts` (Browser, Webserver), `test/browserWindow.test.ts`, `test/browserWindow.java.json` | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): das Browserfenster im Raum, eines je Rechner für alle, über Stopps behalten — Adresse öffnen (keine Adresse: die Zeile zurück auf „http://“), Links und Formulare der gezeigten Seite folgen, was das Programm bekam, in den Bildschirmstand; der Raum schickt Bildschirmstände (Browser statt Zeilen, Webserver zu seinen Zeilen), nimmt die Aktionen `open`/`follow` und `setActive`/`useVirtualHosts`/`setVirtualHost` an und startet in der Sprache der Person. Gegen Javas Fenster geprüft; benannt: Javas Fehlerseite findet ihre Vorlage nie, Bilder und Links einer Seite im Ordner, eine Marke lädt in Java die Startseite, die Datei der virtuellen Hosts | +| `src/room.ts` (E-Mail), `test/mailWindow.test.ts`, `test/mailWindow.java.json` | Java `GUIApplicationEmailAnwendungWindow`, `GUIApplicationEmailServerWindow` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): der Raum schickt die Bildschirmstände von E-Mail-Programm und -Server zu ihren Zeilen und nimmt `setAccount`/`send`/`fetch`/`removeMail` und `setActive`/`setDomain` (nur bei angehaltenem Server)/`addAccount`/`removeAccount` an. Gegen Javas Fenster geprüft; benannt: ein vorhandener Benutzername, Löschen im falschen Ordner, die Domain in der Kontendatei | | `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker | ## packages/editor @@ -190,9 +193,11 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/desktop/InstallationPanel.tsx`, `src/assets/desktop/pfeil_*.png` | Java `GUIInstallationsDialog` (`installationsdialog_msg1`–`4`), Javas Pfeile | neu (F4.6c): die Software-Installation auf dem Desktop — „Installiert:“ und „Verfügbar:“ in der Reihenfolge von Javas Liste, Pfeile oder Doppelklick verschieben, ein Tippen wählt (ein zweites lässt los), „Änderungen annehmen“ wendet an und führt zurück; Javas Liste der installierten Programme kommt in der Reihenfolge einer HashMap, hier in der der Liste | | `src/desktop/BrowserPanel.tsx`, `src/desktop/browserPage.ts`, `src/assets/desktop/browser_waterwolf_logo.png` | Java `GUIApplicationWebBrowserWindow` (`webbrowser_msg2`, `sw_webbrowser_msg1`, `tmpl/http_fehler_*`), Javas Logo (`gfx/desktop`) | neu (F4.6d): der Webbrowser — Adresszeile mit „Start“, Javas Startseite mit Logo, die Seite in einem Rahmen mit Sandbox und Richtlinie (kein Skript, nichts lädt außer den gekommenen Bildern als Daten, kein Formular geht ab), Links und Formulare gehen an den Raum (ein Formular wie Javas Fenster: seine Felder in Reihenfolge samt Knopf), eine Marke springt in der Seite; der Titel des Fensters aus der Seite mit aufgelösten Entities (Java zeigt „&amp;“), die Fehlerseite aus Javas Vorlage (Java findet sie nie und zeigt „Fehler“), Text als Text und ein Bild als Bild (Java zeigt beides als HTML) | | `src/desktop/WebServerPanel.tsx`, `src/desktop/serverLines.ts` | Java `GUIApplicationWebServerWindow` (`webserver_msg1`–`6`), `ServerAnwendung`/`ServerMitarbeiter`/`TCPSocket` (`sw_serveranwendung_msg1`–`6`, `sw_servermitarbeiter_msg1`/`2`, `sw_tcpsocket_msg6`–`8`) | neu (F4.6d): der Webserver — „Starten“/„Anhalten“, „Verwende virtuelle Hosts“ mit der Tabelle (eine Zelle wird beim Verlassen oder mit Enter geschrieben, Escape nimmt sie zurück), darunter die Zeilen des Servers in Javas Worten | +| `src/desktop/EmailClientPanel.tsx`, `src/desktop/EmailServerPanel.tsx`, `src/desktop/mailLines.ts`, `src/assets/desktop/email_*.png` | Java `GUIApplicationEmailAnwendungWindow` (`emailanwendung_msg1`–`47`), `GUIApplicationEmailServerWindow` (`emailserver_msg1`–`29`), `SMTPMitarbeiter`/`SMTPClient` (`sw_smtpmitarbeiter_msg12`–`14`, `sw_smtpclient_msg1`–`4`), Javas Symbole (`gfx/desktop`, `img/email_icon.png`) | neu (F4.6e): das E-Mail-Programm — Abrufen, Verfassen, Antworten, Löschen, Konto einrichten, Posteingang und Gesendete, darunter die gewählte Mail (sonst Javas Willkommen), Verfassen und Konto als Fenster über dem Programm, Javas Dialoge als Hinweis mit OK, ein Fehler des Programms wie Javas Dialog; die gewählte Mail gehört zu ihrem Ordner (Java löschte die zuvor im anderen Ordner gewählte). Der E-Mail-Server — Starten/Anhalten, Maildomain (nur angehalten, rot, wo Javas Muster sie nicht nimmt), Neues Konto mit Namensfeld (Java hat das Etikett ohne Feld; ohne Namen Javas Platzhalter), Konten-Liste mit Javas Frage vor dem Entfernen, Log Fenster in Javas Worten | | `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht | | `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) | | `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster | +| `browser/mail.spec.ts`, `test/mailLines.test.ts` | — | neu (F4.6e): E-Mail in Chromium auf Javas Beispiel `email_komplett` — Prüfungen und Dialoge, Senden, Abrufen, Antworten, Löschen im gewählten Ordner, Konto-Dialog, ein Fehler als Dialog; Konten anlegen und entfernen, Domain nur angehalten; die Zeilen des Server-Fensters gegen Javas Fenster | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | diff --git a/packages/core/src/runtime/apps/emailClient.ts b/packages/core/src/runtime/apps/emailClient.ts index c90c673..587ca7e 100644 --- a/packages/core/src/runtime/apps/emailClient.ts +++ b/packages/core/src/runtime/apps/emailClient.ts @@ -6,6 +6,10 @@ // program also writes how each connection went, as Java tells its // observers. // +// The window (GUIApplicationEmailAnwendungWindow, measured with +// tools/java-oracle's MailWindowRun) sets the account — saved to /konten.txt +// as Java saves it — and deletes a mail of a folder. +// // Departures from Java (emailClientJava.test.ts names each): // - a connection attempt sends three SYNs, as every TCP connection in the // core (Java's client sends one, tcpJava.test.ts); @@ -226,7 +230,61 @@ export interface MailForm { export type MailFormProblem = 'noAccount' | 'invalidAddress' | 'noRecipient'; /** The window's mailPruefen: each address of the field, if any, as Java's pattern wants it. */ -const validField = (field: string) => javaTrim(field) === '' || javaSplit(field, ',').every((each) => isMailAddress(javaTrim(each))); +export const validMailField = (field: string) => javaTrim(field) === '' || javaSplit(field, ',').every((each) => isMailAddress(javaTrim(each))); + +/** Why the window does not send a form (its checks before EmailAnwendung.versendeEmail); undefined when it does. */ +export function mailFormProblem(form: MailForm, hasAccount: boolean): MailFormProblem | undefined { + if (!hasAccount) return 'noAccount'; + if (![form.to, form.cc, form.bcc].every(validMailField)) return 'invalidAddress'; + if (addressList(form.to).length + addressList(form.cc).length + addressList(form.bcc).length === 0) return 'noRecipient'; + return undefined; +} + +/** What the window's account dialog asks (GUIApplicationEmailAnwendungWindow.kontoVerwalten), as typed. */ +export interface AccountForm { + readonly name: string; + readonly email: string; + readonly pop3Host: string; + readonly pop3Port: string; + readonly smtpHost: string; + readonly smtpPort: string; + readonly username: string; + readonly password: string; +} + +/** EingabenUeberpruefung.musterPort and musterEmailBenutzername (an empty name passes, as in Java). */ +const PORT_PATTERN = /^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/; +const MAIL_USERNAME_PATTERN = /^[a-zA-Z0-9._-]*$/; + +/** kontoSpeichern's name: its last word the last name, the words before it the first name; one word a first name. */ +export function splitName(name: string): { readonly firstName: string; readonly lastName: string } { + if (javaTrim(name) === '') return { firstName: '', lastName: '' }; + const words = javaSplit(name, ' '); + if (words.length === 1) return { firstName: javaTrim(name), lastName: '' }; + return { firstName: javaTrim(words.slice(0, -1).map((word) => `${word} `).join('')), lastName: words[words.length - 1]! }; +} + +/** + * GUIApplicationEmailAnwendungWindow.kontoSpeichern: the account from the + * dialog — the name's last word the last name, the words before it the + * first name —, undefined when a port or the user name is not as Java's + * patterns want it (emailanwendung_msg46). + */ +export function accountOfForm(form: AccountForm): EmailClientAccount | undefined { + if (!PORT_PATTERN.test(form.pop3Port) || !PORT_PATTERN.test(form.smtpPort) || !MAIL_USERNAME_PATTERN.test(form.username)) return undefined; + const { firstName, lastName } = splitName(form.name); + return { + pop3Host: form.pop3Host, + pop3Port: Number(form.pop3Port), + smtpHost: form.smtpHost, + smtpPort: Number(form.smtpPort), + username: form.username, + password: form.password, + firstName, + lastName, + email: form.email, + }; +} /** A mail of the program's folders, as the model holds it. */ function entryOf(message: EmailMessage): MailboxEntry { @@ -246,8 +304,9 @@ function entryOf(message: EmailMessage): MailboxEntry { }; } -/** The program's folders back in the model: numbered from 1, inbox, sent, drafts. */ +/** The program's account and folders back in the model: numbered from 1, inbox, sent, drafts. */ export interface EmailClientFolders { + readonly account: EmailClientAccount | null; readonly inbox: readonly EmailMessage[]; readonly sent: readonly EmailMessage[]; readonly drafts: readonly EmailMessage[]; @@ -257,7 +316,7 @@ export interface EmailClientFolders { /** EmailAnwendung: its account, inbox and sent mails. */ export class EmailClient { readonly log: ProgramLog; - readonly account: EmailClientAccount | null; + private accountValue: EmailClientAccount | null; readonly inbox: MailboxEntry[]; readonly sent: MailboxEntry[]; private readonly drafts: readonly EmailMessage[]; @@ -270,9 +329,11 @@ export class EmailClient { private readonly nodeId: Id, configuration: { readonly account: EmailClientAccount | null; readonly inbox: readonly EmailMessage[]; readonly sent: readonly EmailMessage[]; readonly drafts: readonly EmailMessage[] }, private readonly changed: (folders: EmailClientFolders) => void, + /** EmailAnwendung.speichern: the account into the program's file. */ + private readonly saveAccount: (account: EmailClientAccount) => void = () => {}, ) { this.log = new ProgramLog(engine); - this.account = configuration.account; + this.accountValue = configuration.account; this.inbox = configuration.inbox.map(entryOf); this.sent = configuration.sent.map(entryOf); this.drafts = configuration.drafts; @@ -286,9 +347,9 @@ export class EmailClient { * connection for each recipient (EmailAnwendung.versendeEmail). */ send(form: MailForm): MailFormProblem | undefined { - const account = this.account; - if (account === null) return 'noAccount'; - if (![form.to, form.cc, form.bcc].every(validField)) return 'invalidAddress'; + const problem = mailFormProblem(form, this.accountValue !== null); + if (problem !== undefined) return problem; + const account = this.accountValue!; const mail: Mail = { // AddressEntry trims the name: without a last name, Java's window leaves out the space. sender: addressEntry(`${account.firstName} ${account.lastName} <${account.email}>`), @@ -299,19 +360,18 @@ export class EmailClient { text: form.text, dateReceived: '', }; - if (mail.to.length + mail.cc.length + mail.bcc.length === 0) return 'noRecipient'; // Java joins the addresses into a comma list and reads them back (EmailAnwendung.versendeEmail, SMTPClient.versendeEmail). const recipients = [...mail.to, ...mail.cc, ...mail.bcc].map((each) => each.mailAddress).filter((each): each is string => each !== null); const entry: MailboxEntry = { mail, isNew: true, isMarkedForDeletion: false }; for (const recipient of recipients) { - this.smtp.hand(() => this.sendTo(entry, recipient)); + this.smtp.hand(() => this.sendTo(account, entry, recipient)); } this.engine.resumeThreads(); return undefined; } - private *sendTo(entry: MailboxEntry, recipient: string): Steps { - const account = this.account!; + /** With the account the mail was sent from, as Java's window hands it over. */ + private *sendTo(account: EmailClientAccount, entry: MailboxEntry, recipient: string): Steps { const sent = yield* sendMailSteps(this.engine, this.nodeId, (each) => this.log.write(each), account.smtpHost, account.smtpPort, entry.mail, account.email, recipient); if (sent && !this.sent.includes(entry)) { this.sent.push(entry); @@ -321,7 +381,7 @@ export class EmailClient { /** The window's fetch button: the account's mails from its POP3 server (EmailAnwendung.emailsAbholenEmails). */ fetch(): boolean { - const account = this.account; + const account = this.accountValue; if (account === null) return false; this.pop3.hand(() => fetchMailSteps(this.engine, this.nodeId, (each) => this.log.write(each), account.pop3Host, account.pop3Port, account.username, account.password, (mail) => { @@ -333,12 +393,31 @@ export class EmailClient { return true; } - /** The folders as the model keeps them. */ + get account(): EmailClientAccount | null { + return this.accountValue; + } + + /** The account dialog's "save" (EmailAnwendung.setzeKonto, speichern): the program's one account, into its file. */ + setAccount(account: EmailClientAccount): void { + this.accountValue = account; + this.saveAccount(account); + this.changed(this.folders()); + } + + /** EmailAnwendung.removeReceivedMail, removeSentMail: a mail of a folder, by its row; nothing for a row it has not. */ + removeMail(folder: 'inbox' | 'sent', index: number): void { + const mails = folder === 'inbox' ? this.inbox : this.sent; + if (!Number.isInteger(index) || index < 0 || index >= mails.length) return; + mails.splice(index, 1); + this.changed(this.folders()); + } + + /** The account and folders as the model keeps them. */ folders(): EmailClientFolders { let id = 1; const inbox = this.inbox.map((entry) => emailMessage(entry, id++)); const sent = this.sent.map((entry) => ({ ...emailMessage(entry, id++), isSent: true })); const drafts = this.drafts.map((message) => ({ ...message, id: id++ })); - return { inbox, sent, drafts, nextMessageID: id }; + return { account: this.accountValue, inbox, sent, drafts, nextMessageID: id }; } } diff --git a/packages/core/src/runtime/apps/emailServer.ts b/packages/core/src/runtime/apps/emailServer.ts index 159f44f..28f980f 100644 --- a/packages/core/src/runtime/apps/emailServer.ts +++ b/packages/core/src/runtime/apps/emailServer.ts @@ -3,6 +3,16 @@ // SMTP on port 25 takes mails and stores those for its accounts, POP3 on // port 110 hands an account its mails. Switched on and off together. The // window shows each connection's messages both ways and each mail stored. +// From the window (GUIApplicationEmailServerWindow, measured with +// tools/java-oracle's MailWindowRun) the server is switched — the window +// writing a line of its own —, its domain set while it is off, an account +// made or removed. +// +// Departures from Java's window: an account it makes has the name typed (Java +// asks for it and has no field: every account is "Vorname Nachname"; still +// so without a name, as Java's server takes no new account while one has an +// empty name); a user name the server has already is refused (Java's window +// says it made it); an empty domain is none (Java's pattern takes it). // // Departures from Java (emailServer.test.ts names each): // SMTP @@ -38,6 +48,7 @@ import { pause, type Steps } from '../threads.js'; import { sendMailSteps, SMTP_SERVER_PORT, type SmtpClientLogEntry } from './emailClient.js'; import { CommandThread, ProgramLog } from './program.js'; import type { PageLanguage } from './routerPages.js'; +import { isDomainName } from './web.js'; import type { EmailServerSnapshot } from '../snapshot.js'; import { TcpServerProgram } from './server.js'; @@ -56,6 +67,8 @@ export type EmailServerLogEntry = | { readonly kind: 'sendFailed'; readonly address: string } /** sw_smtpmitarbeiter_msg12, msg13: a mail stored in the account's mailbox. */ | { readonly kind: 'stored'; readonly username: string } + /** emailserver_msg2, msg3: the window's own line when its button switched the server. */ + | { readonly kind: 'switched'; readonly active: boolean } /** What the SMTP client tells the server when it sends a mail on. */ | SmtpClientLogEntry; @@ -126,7 +139,7 @@ export interface MailOutbox { /** What the server's sessions share: its domain, accounts and window. */ export class MailServerState { constructor( - readonly domain: string, + public domain: string, readonly accounts: MailAccount[], readonly language: PageLanguage, readonly log: (entry: EmailServerLogEntry) => void, @@ -499,7 +512,7 @@ export class EmailServer { private readonly engine: NetworkRuntimeEngine, private readonly nodeId: Id, configuration: EmailServerSnapshot, - changed: (accounts: readonly EmailServerAccount[]) => void, + changed: (accounts: readonly EmailServerAccount[], domain: string) => void, ipAddress: () => string, now: () => number, ) { @@ -516,7 +529,7 @@ export class EmailServer { accounts, engine.state.topologySnapshot.language, (entry) => this.log.write(entry), - () => changed(this.accounts()), + () => changed(this.accounts(), this.state.domain), ipAddress, now, { @@ -537,9 +550,40 @@ export class EmailServer { return this.smtp.isActive; } - setActive(active: boolean): void { + /** Both servers on or off; from the window's button, with its line (emailserver_msg2, msg3). */ + setActive(active: boolean, fromWindow = false): void { this.pop3.setActive(active); this.smtp.setActive(active); + if (fromWindow) this.log.write({ kind: 'switched', active }); + } + + get domain(): string { + return this.state.domain; + } + + /** EmailServer.setMailDomain: a domain name as Java's pattern takes it, not empty; false for none. The window takes it only while the server is off. */ + setDomain(domain: string): boolean { + if (domain === '' || !isDomainName(domain)) return false; + this.state.domain = domain; + this.state.changed(); + return true; + } + + /** EmailServer.benutzerHinzufuegen: a new account, empty; false when the server has the user name already, in any case. */ + addAccount(account: { readonly username: string; readonly password: string; readonly firstName: string; readonly lastName: string }): boolean { + if (this.state.account(account.username) !== undefined) return false; + this.state.accounts.push({ ...account, mailbox: [] }); + this.state.changed(); + return true; + } + + /** EmailServer.kontoLoeschen: the account of the user name, as written; false when there is none. */ + removeAccount(username: string): boolean { + const index = this.state.accounts.findIndex((account) => account.username === username); + if (index < 0) return false; + this.state.accounts.splice(index, 1); + this.state.changed(); + return true; } /** diff --git a/packages/core/src/runtime/apps/web.ts b/packages/core/src/runtime/apps/web.ts index 7eb0f1c..8b07803 100644 --- a/packages/core/src/runtime/apps/web.ts +++ b/packages/core/src/runtime/apps/web.ts @@ -268,6 +268,11 @@ const DOMAIN_PATTERN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]){0,1}(\.[a-zA-Z export const IP_ADDRESS_PATTERN = /^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/; +/** EingabenUeberpruefung.musterDomain: a domain name (Java's pattern also takes the empty text). */ +export function isDomainName(text: string): boolean { + return DOMAIN_PATTERN.test(text); +} + function isHostName(text: string): boolean { return DOMAIN_PATTERN.test(text) || IP_ADDRESS_PATTERN.test(text); } diff --git a/packages/core/src/runtime/emailAccountsFile.ts b/packages/core/src/runtime/emailAccountsFile.ts index 192374a..55c8dd8 100644 --- a/packages/core/src/runtime/emailAccountsFile.ts +++ b/packages/core/src/runtime/emailAccountsFile.ts @@ -7,10 +7,13 @@ // and a character a field cannot carry in the format (a separator, a line // break) becomes a space, with a warning. -import type { EmailAddress, EmailServerAccount } from './config.js'; +import type { EmailAddress, EmailClientAccount, EmailServerAccount } from './config.js'; export const EMAIL_SERVER_ACCOUNTS_FILE = '/mailserver/konten.txt'; +/** The e-mail program's account file (EmailAnwendung.speichern), in the root. */ +export const EMAIL_CLIENT_ACCOUNT_FILE = '/konten.txt'; + /** A field's characters the format cannot carry, each a space, with a warning. */ export function accountFileField(value: string, forbidden: RegExp, what: string, warn: (message: string) => void): string { if (!forbidden.test(value)) return value; @@ -64,3 +67,23 @@ export function serverAccountsFileText(domain: string, accounts: readonly EmailS }) .join(''); } + +/** + * EmailKonto.toString, as the e-mail program saves its account + * (EmailAnwendung.speichern) and the project export writes it: nine fields; + * a field may hold no ';' and no line break. + */ +export function clientAccountFileText(account: EmailClientAccount, warn: (message: string) => void = () => {}): string { + const text = (value: string, what: string) => accountFileField(value, /[;\r\n]/, `${EMAIL_CLIENT_ACCOUNT_FILE}: ${what}`, warn); + return [ + text(account.pop3Host, 'POP3 server'), + text(account.smtpHost, 'SMTP server'), + String(account.pop3Port), + String(account.smtpPort), + text(account.username, 'user name'), + text(account.password, 'password'), + text(account.lastName, 'last name'), + text(account.firstName, 'first name'), + text(account.email, 'address'), + ].join(';'); +} diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index 380fedf..2d8bb8f 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -124,7 +124,7 @@ import { Terminal } from './apps/terminal.js'; import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js'; import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js'; import { parentPath, VirtualFileSystem } from '../model/virtualFileSystem.js'; -import { EMAIL_SERVER_ACCOUNTS_FILE, serverAccountsFileText } from './emailAccountsFile.js'; +import { clientAccountFileText, EMAIL_CLIENT_ACCOUNT_FILE, EMAIL_SERVER_ACCOUNTS_FILE, serverAccountsFileText } from './emailAccountsFile.js'; import { GNUTELLA_FOLDER, GnutellaPeer } from './apps/gnutella.js'; import { PRODUCTION_TRACE_RETENTION, @@ -1203,18 +1203,19 @@ export class NetworkRuntimeEngine { } /** An e-mail server's accounts as a run changed them, into the snapshot (and so into the project saved from it). */ - private setEmailServerAccounts(nodeId: Id, accounts: readonly EmailServerAccount[]): void { + private setEmailServerAccounts(nodeId: Id, accounts: readonly EmailServerAccount[], domain: string): void { const servers = new Map(this.stateValue.topologySnapshot.emailServersByNodeId); const server = servers.get(nodeId); if (server === undefined) return; - servers.set(nodeId, { ...server, accounts }); + servers.set(nodeId, { ...server, accounts, domain }); this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { emailServersByNodeId: servers }); - this.writeEmailServerAccountsFile(nodeId, server.domain, accounts); + this.writeEmailServerAccountsFile(nodeId, domain, accounts); } /** * The server's account file (EmailServer.kontenSpeichern), written from its - * accounts at its start and whenever they change. Java makes it empty at + * accounts at its start and whenever they or its domain change (Java writes + * a new domain only with the next change of its accounts). Java makes it empty at * its start when it is missing (and reads it), and writes it when an * account is made or removed, after a POP3 command, after an SMTP mail was * taken and when it stops — but before the notice to a local sender, which @@ -1305,7 +1306,7 @@ export class NetworkRuntimeEngine { this, nodeId, emailServer, - (accounts) => this.setEmailServerAccounts(nodeId, accounts), + (accounts, domain) => this.setEmailServerAccounts(nodeId, accounts, domain), () => this.networkInterfaces(nodeId)[0]?.ipAddress ?? 'null', this.wallClock, ), @@ -1324,7 +1325,16 @@ export class NetworkRuntimeEngine { return; } case 'emailClient': - this.emailClientsByNodeId.set(nodeId, new EmailClient(this, nodeId, snapshot.emailClientsByNodeId.get(nodeId)!, (folders) => this.setEmailClientFolders(nodeId, folders))); + this.emailClientsByNodeId.set( + nodeId, + new EmailClient( + this, + nodeId, + snapshot.emailClientsByNodeId.get(nodeId)!, + (folders) => this.setEmailClientFolders(nodeId, folders), + (account) => this.nodeFiles(nodeId).writeTextFile(EMAIL_CLIENT_ACCOUNT_FILE, clientAccountFileText(account)), + ), + ); return; } } diff --git a/packages/core/test/programLines.ts b/packages/core/test/programLines.ts index 1aaa60d..4f67ace 100644 --- a/packages/core/test/programLines.ts +++ b/packages/core/test/programLines.ts @@ -102,6 +102,11 @@ export function emailServerText(entry: EmailServerLogEntry, language: PageLangua return language === 'de' ? `Fehler beim Senden an ${entry.address}` : `Error while sending ${entry.address}`; case 'stored': return language === 'de' ? `E-Mail für ${entry.username} im Postfach gespeichert.` : `E-mail for ${entry.username} saved in mailbox.`; + case 'switched': + // `emailserver_msg2`, `emailserver_msg3`: the window's own line. + return language === 'de' + ? `POP3- und SMTP-Server wurden ${entry.active ? 'gestartet' : 'angehalten'}.` + : `POP3 and SMTP servers are ${entry.active ? 'activated' : 'stopped'}.`; default: return emailClientText(entry); } diff --git a/packages/editor/browser/mail.spec.ts b/packages/editor/browser/mail.spec.ts new file mode 100644 index 0000000..5af1a33 --- /dev/null +++ b/packages/editor/browser/mail.spec.ts @@ -0,0 +1,196 @@ +// The e-mail program and the e-mail server on a computer's desktop in +// Chromium (F4.6e), on FILIUS' example email_komplett: a mail written with +// Java's checks and sent, the server's accounts and lines, the mail fetched, +// chosen, answered and deleted, the account dialog; the server's new account, +// its question before it removes one, its domain only while it is off. + +import { expect, test, type Page } from '@playwright/test'; +import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js'; + +let server: { readonly base: string; readonly close: () => Promise }; + +test.beforeAll(async () => { + server = await serveEditor(); +}); + +test.afterAll(async () => { + await server.close(); +}); + +async function start(page: Page): Promise { + await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); +} + +/** Opens a computer's desktop with a tap and a program's window on it. */ +async function openProgram(page: Page, name: string, program: string) { + const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id; + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); + const desktop = page.locator(`section[data-desktop="${id}"]`); + await desktop.locator(`[data-program="${program}"]`).click(); + return { desktop, window: desktop.locator(`[data-program-window="${program}"]`) }; +} + +test("writes, sends, fetches, answers and deletes mails as Java's e-mail program does", async ({ page }) => { + test.setTimeout(120_000); + await openEditor(page, server.base, 'email_komplett.fls'); + await start(page); + const one = await openProgram(page, 'Rechner 1', 'emailClient'); + const window = one.window; + await expect(window.locator('.mail-welcome')).toHaveText('Willkommen in der E-Mail-Anwendung!'); + await expect(window.getByRole('tab', { name: 'Posteingang' })).toHaveAttribute('aria-selected', 'true'); + + // The account dialog shows the account, as Java's does. + await window.getByRole('button', { name: 'Konto einrichten' }).click(); + const dialog = window.getByRole('form', { name: 'E-Mail-Konto verwalten' }); + await expect(dialog.getByRole('textbox', { name: 'Name:', exact: true })).toHaveValue('Rechner 1'); + await expect(dialog.getByRole('textbox', { name: 'E-Mail-Adresse:' })).toHaveValue('rechner1@filius.de'); + await dialog.getByRole('textbox', { name: 'SMTP-Port:' }).fill('25x'); + await dialog.getByRole('button', { name: 'Speichern' }).click(); + await expect(window.getByRole('alertdialog')).toHaveText(/Die Angaben sind entweder fehlerhaft oder unvollständig!/); + await window.getByRole('alertdialog').getByRole('button', { name: 'OK' }).click(); + await dialog.getByRole('button', { name: 'Abbrechen' }).click(); + await expect(dialog).toHaveCount(0); + + // Writing: Java's checks, then sent; the window shows the sent mails. + await window.getByRole('button', { name: 'Neue E-Mail verfassen' }).click(); + const compose = window.getByRole('form', { name: 'E-Mail verfassen' }); + await compose.getByRole('textbox', { name: 'An:' }).fill('rechner2@filius.de, kein'); + await expect(compose.getByRole('textbox', { name: 'An:' })).toHaveAttribute('aria-invalid', 'true'); + await compose.getByRole('button', { name: 'Senden' }).click(); + await expect(window.getByRole('alertdialog')).toHaveText(/Mindestens eine eingegebene E-Mail-Adresse ist nicht korrekt!/); + await window.getByRole('alertdialog').getByRole('button', { name: 'OK' }).click(); + await compose.getByRole('textbox', { name: 'An:' }).fill(''); + await compose.getByRole('button', { name: 'Senden' }).click(); + await expect(window.getByRole('alertdialog')).toHaveText(/Sie müssen mindestens eine Empfänger-Adresse eingeben!/); + await window.getByRole('alertdialog').getByRole('button', { name: 'OK' }).click(); + await compose.getByRole('textbox', { name: 'An:' }).fill('rechner2@filius.de'); + await compose.getByRole('textbox', { name: 'Betreff:' }).fill('Hallo Zwei'); + await compose.getByRole('textbox', { name: 'Text der E-Mail' }).fill('Erste Zeile\nZweite Zeile'); + await compose.getByRole('button', { name: 'Senden' }).click(); + await expect(compose).toHaveCount(0); + await expect(window.getByRole('tab', { name: 'Gesendete' })).toHaveAttribute('aria-selected', 'true'); + await expect(window.getByRole('tabpanel').locator('tbody tr').last()).toHaveText(/\s*Hallo Zwei/, { timeout: 30_000 }); + await one.desktop.getByRole('button', { name: 'Schließen' }).click(); + + // The server holds it; its lines show the dialogue. + const mailServer = await openProgram(page, 'Mail-Server (filius.de)', 'emailServer'); + await mailServer.window.getByRole('tab', { name: 'Konten-Liste' }).click(); + await expect(mailServer.window.getByRole('tabpanel').locator('tbody tr')).toHaveText([/rechner1@filius\.de/, /rechner2@filius\.de\s*\d+/]); + await mailServer.window.getByRole('tab', { name: 'Log Fenster' }).click(); + await expect(mailServer.window.getByRole('tabpanel')).toContainText('E-Mail für rechner2 im Postfach gespeichert.'); + await mailServer.desktop.getByRole('button', { name: 'Schließen' }).click(); + + // Fetched, chosen, answered, deleted. + const two = await openProgram(page, 'Rechner 2', 'emailClient'); + // Fetching shows the inbox, from any folder. + await two.window.getByRole('tab', { name: 'Gesendete' }).click(); + await two.window.getByRole('button', { name: 'E-Mails abrufen' }).click(); + await expect(two.window.getByRole('tab', { name: 'Posteingang' })).toHaveAttribute('aria-selected', 'true'); + const rows = two.window.getByRole('tabpanel').locator('tbody tr'); + await expect(rows.filter({ hasText: 'Hallo Zwei' })).toHaveCount(1, { timeout: 30_000 }); + await expect(rows.filter({ hasText: 'Hallo Zwei' })).toHaveText(/Rechner 1\s*Hallo Zwei/); + await rows.filter({ hasText: 'Hallo Zwei' }).click(); + await expect(two.window.locator('.mail-preview pre')).toHaveText(/Erste Zeile\nZweite Zeile/); + await two.window.getByRole('button', { name: 'E-Mail beantworten' }).click(); + const reply = two.window.getByRole('form', { name: 'E-Mail verfassen' }); + await expect(reply.getByRole('textbox', { name: 'An:' })).toHaveValue('Rechner 1 '); + await expect(reply.getByRole('textbox', { name: 'Betreff:' })).toHaveValue('RE: Hallo Zwei'); + await expect(reply.getByRole('textbox', { name: 'Text der E-Mail' })).toHaveValue(/^\n\nRechner 1 schrieb:\n> Erste Zeile\n> Zweite Zeile/); + // Sent: the window shows the sent mails, the answer among them. + await reply.getByRole('button', { name: 'Senden' }).click(); + await expect(two.window.getByRole('tab', { name: 'Gesendete' })).toHaveAttribute('aria-selected', 'true'); + await expect(rows.filter({ hasText: 'RE: Hallo Zwei' })).toHaveCount(1, { timeout: 30_000 }); + // The mail chosen belongs to its folder: chosen here, nothing is chosen in the inbox, and deleting deletes nothing there (Java's deleted one). + await rows.filter({ hasText: 'RE: Hallo Zwei' }).click(); + await expect(two.window.locator('.mail-preview pre')).toHaveText(/schrieb:/); + await two.window.getByRole('tab', { name: 'Posteingang' }).click(); + const inboxBefore = await rows.count(); + await expect(two.window.locator('.mail-welcome')).toBeVisible(); + await two.window.getByRole('button', { name: 'E-Mail löschen' }).click(); + await expect(rows).toHaveCount(inboxBefore); + const before = await rows.count(); + await rows.filter({ hasText: 'Hallo Zwei' }).click(); + await two.window.getByRole('button', { name: 'E-Mail löschen' }).click(); + await expect(rows).toHaveCount(before - 1); + await expect(rows.filter({ hasText: 'Hallo Zwei' })).toHaveCount(0); + await expect(two.window.locator('.mail-welcome')).toBeVisible(); +}); + +test("makes and removes accounts and sets the domain as Java's e-mail server window does", async ({ page }) => { + test.setTimeout(120_000); + await openEditor(page, server.base, 'email_komplett.fls'); + await start(page); + const { window } = await openProgram(page, 'Mail-Server (filius.de)', 'emailServer'); + const domain = window.getByRole('textbox', { name: 'Maildomain:' }); + await expect(domain).toHaveValue('filius.de'); + await expect(domain).toBeDisabled(); + + // A new account: Java's checks, one the server has already, then made. + const form = window.getByRole('tabpanel', { name: 'Neues Konto' }); + const create = form.getByRole('button', { name: 'Konto erstellen' }); + await create.click(); + await expect(form.getByRole('status')).toHaveText('Geben Sie einen Benutzernamen und ein Passwort ein!'); + await form.getByRole('textbox', { name: 'Benutzername:' }).fill('carl os'); + await form.getByLabel('Passwort:').fill('x'); + await create.click(); + await expect(form.getByRole('status')).toHaveText('Der Benutzername darf keine Leerzeichen enthalten!'); + await form.getByRole('textbox', { name: 'Benutzername:' }).fill('RECHNER1'); + await create.click(); + await expect(form.getByRole('status')).toHaveText('Das Benutzerkonto RECHNER1 gibt es schon.'); + await form.getByRole('textbox', { name: 'Benutzername:' }).fill('neu'); + await form.getByRole('textbox', { name: 'Vorname und Nachname:' }).fill('Neue Nutzerin'); + await create.click(); + await expect(form.getByRole('status')).toHaveText('Das Benutzerkonto neu wurde angelegt.'); + await expect(form.getByRole('textbox', { name: 'Benutzername:' })).toHaveValue(''); + + // The list: chosen, asked, removed. + await window.getByRole('tab', { name: 'Konten-Liste' }).click(); + const list = window.getByRole('tabpanel', { name: 'Konten-Liste' }); + await expect(list.locator('tbody tr')).toHaveText([/rechner1@filius\.de/, /rechner2@filius\.de/, /neu@filius\.de\s*0/]); + await expect(list.getByRole('button', { name: 'Markiertes Konto entfernen' })).toBeDisabled(); + await list.locator('tbody tr').filter({ hasText: 'neu@' }).click(); + await list.getByRole('button', { name: 'Markiertes Konto entfernen' }).click(); + const question = list.getByRole('alertdialog', { name: 'Sicherheitsabfrage!' }); + await expect(question).toContainText("Wollen Sie das Konto 'neu' wirklich entfernen?"); + await question.getByRole('button', { name: 'Abbrechen' }).click(); + await expect(list.locator('tbody tr')).toHaveCount(3); + await list.getByRole('button', { name: 'Markiertes Konto entfernen' }).click(); + await list.getByRole('alertdialog').getByRole('button', { name: 'Ja' }).click(); + await expect(list.locator('tbody tr')).toHaveCount(2); + + // Stopped: the domain can be set, red where Java's pattern does not take it. + await window.getByRole('button', { name: 'Anhalten' }).click(); + await expect(window.getByRole('button', { name: 'Starten' })).toBeVisible(); + await expect(domain).toBeEnabled(); + await domain.fill('kein domain!'); + await expect(domain).toHaveAttribute('aria-invalid', 'true'); + await domain.fill('schule.de'); + await domain.press('Enter'); + await expect(list.locator('tbody tr')).toHaveText([/rechner1@schule\.de/, /rechner2@schule\.de/]); + await window.getByRole('tab', { name: 'Log Fenster' }).click(); + await expect(window.getByRole('tabpanel')).toContainText('POP3- und SMTP-Server wurden angehalten.'); +}); + +test("shows the program's failure as Java's dialog does, and saves a new account", async ({ page }) => { + test.setTimeout(120_000); + await openEditor(page, server.base, 'email_komplett.fls'); + await start(page); + const { window } = await openProgram(page, 'Rechner 1', 'emailClient'); + await window.getByRole('button', { name: 'Konto einrichten' }).click(); + const dialog = window.getByRole('form', { name: 'E-Mail-Konto verwalten' }); + await dialog.getByRole('textbox', { name: 'SMTP-Server:' }).fill('niemand.filius.de'); + await dialog.getByRole('textbox', { name: 'Name:', exact: true }).fill('Anna Maria Muster'); + await dialog.getByRole('button', { name: 'Speichern' }).click(); + await expect(dialog).toHaveCount(0); + await window.getByRole('button', { name: 'Konto einrichten' }).click(); + await expect(dialog.getByRole('textbox', { name: 'Name:', exact: true })).toHaveValue('Anna Maria Muster'); + await expect(dialog.getByRole('textbox', { name: 'SMTP-Server:' })).toHaveValue('niemand.filius.de'); + await dialog.getByRole('button', { name: 'Abbrechen' }).click(); + await window.getByRole('button', { name: 'Neue E-Mail verfassen' }).click(); + const compose = window.getByRole('form', { name: 'E-Mail verfassen' }); + await compose.getByRole('textbox', { name: 'An:' }).fill('rechner2@filius.de'); + await compose.getByRole('button', { name: 'Senden' }).click(); + await expect(window.getByRole('alertdialog')).toHaveText(/Ziel konnte nicht bestimmt werden!/, { timeout: 60_000 }); +}); diff --git a/packages/editor/src/assets/desktop/email_email_antworten.png b/packages/editor/src/assets/desktop/email_email_antworten.png new file mode 100644 index 0000000000000000000000000000000000000000..59855d5241b90efbd2b163904cacd4b30c41287a GIT binary patch literal 1199 zcmV;g1W@~lP)WdJZVFEKJNFgN3$x9b1^ z1O!P$K~z|U?UzkRBWD=Lf0M~a6O%Y5B#LX4O{t(Kg8{8x3cYyjrH4YR|XmKxHkG2=)4h+&e6Gt+~cU>f7bkL_Oi zhj*BH-)G+E_dd__&WB){CNEQ#m+Jk00Ki&086O}2&zz2oj8p?OHa42=?d{lXHcZp3 z*?@D$&%3W!ERs&A0fZ`mqoX4LdU|>=O%uZ~&TN+*GYo@7B7wzHnf3COuIr@JX$}q! zt~J~xWwTi{O~WvZvjG6?c02R)^DhKIQ4~5mJFEID03=CbZ*Pz7?d@xIp2=izI-LXp zf%5@GQN-u-5sSspb^ThLmr@&vM9v(qdJZ%-H35)JCa(oxeSICj-;dAds}BI6wY8Pi z)m2ngy^`}np+G8?qOY&7rp=iEE|&{Y6j@$gzO-}0Fi0ko^!4>2isHop09smF*xlV_ zXJ_Y906RN7SglsV;qbY3)>;F0yB)9BODq;+XlMvQ5NerL>FDSPMNx1#986A5A_xM4 zAdt)DSX^8r8jX$t%d#y0ToV8wpUl?ev^_=x95rV=JUnD$V}oEYh{xk09*@)2)rBNU03075v$?rRFc?G- z1T;-!dwUy2QMh^YCYekIOtAMup$Nqyo zU}SdmFT~@sjNTr@>2#uL8Xk{_P$-1g>ph899a*aaC{?e`W}~~i8%dHd z41;huOh-ots;V+IHN{%`3H}xBBi{_<*0JOBR23GRHF z!*qct>I@GL*Ph&2d;gyau(h>CTU#3g0|Qltu~>{JPakpjUXF&wlR|p$o_11Hr;4lG zG{_cpvc>ZO$|(><5x?Kh;NT#3yS>6_Zf=hI_dn$AcQ&E`i99ryLRK9Nw*pHVa# zebeXXhmStN8|bK&?~CYi)!FcTv~=zyg?*4$oAYwxaLZ!4$x9%qy}i3(tSMRO;KkUF_d{#+|or^O~ko z(2MlQvi#Qt=PBz#0J1C_z{6-XYCAmSWdJZUFEKJNFg8&?x$^)3 z1HVZ`K~z|U?U!FjWK|r;Klfhe|2QlD+fC73*FP=6ZkD?c1P!~i$VLx6*poqoBK@O6 zD7GF#3L+{H7wRGQVF|1adfb;KwOu6&^g)U+CTlGmogH;_{@j_lXXf71(+rM|I_69e z^nE$!cR2TaKj(MPJ?9=pN=Z(mqodO6^Lxv4`14=U)Ah_{qz+GIu5~q>PlV9xz7P zBn+M9zkcETqfhX`jW5mzPPJQ`(Rirh08&~c<3Ysz7pYgS@Yp$v_0XF2?ZU#{t*z*l ziUL@cNi;lzy7d&*+Bo*m3R?JMYHNI7PfpJ~fAPzkzg27vWQ0y4^eMIW4^fR6sSt$4 zX>Lq3Bc&Mler6^OJNAKcDY3bL5q^m}`v|PZh%Rdcm1prh^c5S46O51FOy~NRH3Oz8 zh;RIWH~T71^;a@M*of5=_YKl|?kaYNZ`U(r12ED_lHoDx-5;T*bo3PnWje99U!%S4 zGKx|>*+&MDQldxiQI&d&M#nA86^lsJ#fI%^>d(HcIclK?ZW&mRaB8di)b#Kz#HGtDqbKnGa0i;qXgb-Mkg{rD~Q$+vF ziMR(ie&#Bk>Xwp>ivln1Ogmc31)Qh)X=v&~Rm-!B?+ftrPxH)8e#3KvuhZbE#o=<7{CSlHprf;kj?Q;* zRr|_rToQoCn;Eg wLeSsek6{>CmUXC^9VE-Luq+F^-CofCH)Y|0$b5y`v;Y7A07*qoM6N<$g3)mj`v3p{ literal 0 HcmV?d00001 diff --git a/packages/editor/src/assets/desktop/email_emails_abholen.png b/packages/editor/src/assets/desktop/email_emails_abholen.png new file mode 100644 index 0000000000000000000000000000000000000000..a580529dcdb17917931134b7db3dc1741a77c4ce GIT binary patch literal 1937 zcmV;C2X6R@P)WdJZUFEKJNFg8&?x$^)3 z2IfgbK~z|Uy_Z{ToJSRazq$N-^9J8fD{mYs*nQWqJog& zv5$Rhc|-^lUXUV$S`{H4qL31)&{8!(BwVKjIk(2KY{#*axa;_`UhjH${r~e15000$ z*LD(wIMQg&%$%9??Kv~^&kAcTPb=ropAV$dX_-tW5keq@KuQUKl(N>YAFJ0_R#q4q z8lqCER0akH0MJkEeNB=NA3h9rc6K&6lv378eY;-2a^(u1=P@uaFulIuGXVqwfrJpE z>CT-ybaiz-6Zze{cPW?47-Ik$0WMy=sM_1xYt%q3eCur~<*q$@_B2mVPg5)wX=-X> zJ%!dG#YJ)Obx*Eyw<&UDS7bV0V5+Lw6(Q) zTmGU=0O{`TZZ2H7KrWY~y}g}kwb~+tARG=Ogdh|Ob!e^A3kwSri$zkY6qA#a#9}dw zF*uH6jWNP?UGn)nv$M0rVljK~-n~*d944Jk?;HWGHU0hl3=R$wkH;~_?5R{LIF5tX z8pmK!8Ld!M=U_@O|Ijx^-*m`t|E{ zcX#gy&8UUuI zrnq+P8XX-SoH%iUv9YmKwOVC)d71n7?-K|FB0`9uXqLjLMu7R*`Ow(dn21KBxUS3e z^fXE-3WY*&-6k9kv%0z(0!j^g;L4RNbar;q)6;{Ll1wI(Oe7K*V_05Z2FQm(q5M## z7&4D&+YDfgnJAab=X-m5X=!O;X=#Z-AW%JX=FCqTaW|#FTHD&(+)Q_OH}QBJ-}lj4 z$E~$gt5vktD5dZ`59_11iU5#Er_-&MFJI>L>C*uCzQ3}a^9=y$bh(_CF;JA z5v3Hy7?e^drLfj=H~z6ShZ+~FR;#hDuC7+sbr~ESL@9;Vy7aUFpQ}Ho)>_8K#x7{B z>FDU-=+UESt+Cefw`wT%n`IilM*aQ$*OXEzYb~Rrqw6>2*4OH0fWE%I2lWHz&Yh#F zsVR}kWa#SZqFgT5B2)EOBU_zI%q2$UDzBKwi0YCBB?mqy(d*%8krM%l^KYBjD89=Tpp6ip(Rj~e(H)Tie zyL@)+*LWH{4YBGG`bXZ!F^MhhOL@0V2rh?C4kx#bz!!ctYo!u6K}l}Bgl~KV7OZ7$ zYg>f1YxU#)rQSa~r60C3{x zvt_AdNI3%I`o(|zeyYhWdteOs{#w~epZ+?uUEF@$_YuK>A3S_O2IE135tR={RR7c) z&kaM}DG*Y8Qz|YBDF`(=O((y%e=fO8r@{&%(h}Bdbwpai3L?sK_NN~+lD!0&T1db4 z)hyrs!E>y5u;N)tYqeallGSLGvXIGDAwC404G{qN@~`H9p|pHiYe`Es^g&BHcs!;d zrlHAMt796PG{hVzG#lKTntbp&c_fsL>efKMfutULvWe*DOTB+$C6g@cGvzDO^ z_kfh*Ri(r!sYH(xRFx-f#%wmspa1wp=4V!jC}_`yNyHrr?vrNL`K)jJsztclp`nq9{~Rb XZkF{`v`+%c00000NkvXXu0mjfqVSyN literal 0 HcmV?d00001 diff --git a/packages/editor/src/assets/desktop/email_icon.png b/packages/editor/src/assets/desktop/email_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cd5fd25c8437380f401d034aead222e004cce8a6 GIT binary patch literal 3643 zcmWkx2|Sct7k^CBBsA6+jch3-QIm#*h9a+Q8T;7ht@(_dtXYT2mTbwsWt(9nnKH69 zWkeZ8MhRm}(q;{b?{<;G<;lMf>2Ze*XoBx(DK+3toO6 zc)3f2KzyiQpm&f(v#dFoBk^~RVIbZ;nBeCtXG8G8LmH@)nx{@`oSi9sya+_dyKHp9 zCamb4lamFKMc<(?Y9g!WpQVv`(wYSM4k5>c$p%QjWaPyqz28bHS>)}wl{>6Id)7CV z$gIjr-IDCodgZ1nqZ{`TN>)TAL_okRJapR7?VdX$lEGlC@3Fad@3#OVGRAs8gHrRq zcXm|U28!bq5@MrSk*V4rkCLZqVP!hSy$y{I_)US_{ zovnv&e6Gyc8Cub;pr}q2iJGb?SILoL5wI&m1}aL+`PQm%dF4FvTSGlnyci}nC|b=G zE-xyK=~~hCAL!QpXE_Fo#ka>J;@vbp*Ij!X3DFiQ92$#V`+l`w7DUMQDapok>pxx3k8yZe{H9I)g1=jG5(VN=a(@RPYOD0Wad`cx%D8u;@SbUY0 zDnJj>eg69m>Mu3%%kgr@EtT*0%mgS&q*}38aw?5Th!op8WWNj&ToE?TJK&GlSi z-Bg=Y-T`gXDiO0Gqodh8RboXe%y9SY2D&(vaWMF?3MbBikN)t-gm zEKUEYaQwyZ<>zm@LQM2(j~6WV0@r`S6szO-C8|6T(cRaoPO5D~j$~JkZ%gB0=tqjQ z$j?aC6c~Hu-XbIQHL=Tn(=f4HF{NT-~OllrcwPN>b^)=R0-*#uY z)PnO76`goRNjPuWq7Ez$vcV=&DBuR*ys)wy3>IrtX|*s7P+}m*rAkAtYqNcW%DLHc z?PyNOpUl~!ytcOJtV?zsGYoY}&j)IY_XLc6NRA4zi1?kI=#TA@|6t&=E8(SLkUw%? z$$Xr3ily@G0cY(~ZxuqPqjE)S2#!wF_#>V%F|y~*or^Awl}Ia#(kwRj3<_$d`9o1X zh41Y2`mUu1?z&&QwvU8}xyp-VG+c3)~?OR0c6^k^HGTmHOXUv3F+b*3r7#GnW@iK}Kq zHV@y|FRyW2k5BodXBp}txIB#>xMv!_{H+=Eay|kB_Y{D@BgCcBh9$Asn=O10j^oW} zoV#)V?x!3ogS5zyBXLdmGWPRFxA!LI?;6 zO)t;&n;gx)Wu(B8*7GU4R9rmL)YKIB=8aX%-bxapTbuRu>({375yIK1kr28yYimm< zA|gTqg_@nW;#!^oST8vrdE=;H=PjM%i@kG!px!GefK?6468Vt>?2Zn-?XAVeV-qbc z)9WRV25z{IX+`h;34Qu!eXQe4f4USFyJja(>I`+Yw@0@mc~4zLWSQ4!qEMuu`2lRM z6{a_+P6)WoLa_9{efw(O{wz(r6zJpYtAG^p3=Uqk?`Y)dlKt04LlRKS#$2AO`A2^$ zx?kn0ve<*x)&=(OKx8w|+(>6z+aXXosH2yeHOc0SYPqJPA3i*D@@vO(&g@e~L1<@T zMqAs`&nafXG4wa?Mf3c%wGihg{ygpN?Shk31qc#J=7o?>60UsSF4WtOA-+C7@?!i` zTWgCUlXZkWeQ!7%a#fYmu3UM(TrpP-b)=N->Ek0PDk{pNx96gDR6|v&R{I@K_Ab_M zZf@R{j+TNqsU0XREWB4z;*0*Fq|09U-1ej6_vD(E)cN!0M<*tFCMI0Qo;`(QY;A4* z7v3tZuC1MEp!SK@?`=IEwLE*-DSr5wgNOpD3s?kGj-cV%Myzox`1N6fQ~5^amR&WCpdGWLyS~2u zr9^<^T{{wVthPQuGpV&zdv$%?!_BSi+xqSIQ22{?pIJH9Mfx#^^|yZ}#0-wAm?zws zo5LT;zWnh?z>HCaB{Udt#L(kGWz$4 z*%!u&Qx-5*fQG()27rQ@xp~QxjshN$bYj={@9K0qeGnL&;}R*@Rh}0%KOl2NWdFyu z&_=v*9S#GShpoSx=FtSlz(0NZrk=J$gj4QB6D-`rQVrp{11s zf}@(6nj3+zf6tyhCRNrZM?_9Y)%G1YxJfKH`gQ>|GBTo}qmu%bBqr`9`ucjfyYngecb*YtFStcEau zVwACI&*$N>>VoQIXLT}B>%bUYID@s+bI5PQj%a9T_*3^_YHBJVcfd`=wwbx!|C$Nn z5b8>Qz(7W;b;-Otkg}lK-NOU+((55*C6}G#?&jsi%VaVi_#6WMiAJLh^h#L+145FX z{y5IT+)PDdc-#y+y7r1fCcq_$LSfN3Ac)YMTjPNQ@85#}`eyZc&Hf#Drpk}B8MM8l zBMeH*$nd;=z4n{E431MykRB*LXBYDFrS`7`RT5gjsZLzm`T!usvwjoL{W||7flbUT zEEMz2=f8g?&+u0T9pL@Kja`krfePIX{Rx%$M(PS?$uV|b-OHN4G15D@{7WO11mZ+6#p|{BkF+Oa0eVS;GlnpC6`n{+=WmL{G7sH1&Ny6dqgpJ()AAQdoj^ke)!d07 zdwKPGqE78$$Xs>*!E>;hYzoz3On6QuZmFle0(LVO7w>%Av}&YuY1T2vjQqPWe z+IptBdwU;9#DE)l?$MN22$ljiRX^1;aWdER$LSvRd)3-NMZ+vHrp9)L)~vAzz&{4Q zYnW_Z2c!qyqjclOjhz;FEg4+E%`VB`8P;_ucZZQ&O!?p(L;X#N3~&w{4tMK?M@P&< zpe!pO@I6s$>ExAI-TvA{r^mj3UWANa9-~h-t9gF~{~`E-Y6?Nx276IitPkJ({<%Jn Y*MA<`vvoI*fS)+%@WdJZUFEKJNFg8&?x$^)3 z1NTWpK~zYI-IPmgR8<(qf9F2tb#FV5)@iY=)^^H6N(%*~LW3a01sc?iF%bJD`~OdlkW%tELwT&|{|f@`e#QR&e&*K=4k(FL>m$X151!ln?e<3{5PvthzrP=V zL?joan=@j;{94A(9`{9gZ0(9oUk^Sk!F?s_Zrt^w;8sePkXog) zZAx@yZoGbE?>6tq{x9Eupb0wD13RWlPl{D7KY`FO^gOm*y#-voOC(*89%i`6%8pUB zx%SNVqx(O%j~@K$`8f%$7TaEZHvJoCZ}zhD^m|;+H&d`?Nfg|biogYCYV(pgRkFD%V!M6ckY;dcU`(?@47yt zSC??!-gtB(=d973;^uWi2+Fj++!Q~DSsSORwUw519n!ZLIOK9+9G-4)5wp9O|D4Qo z;m=%#6XRW=+ABcSG+-}4X5kgG7hhyaPXh_l1J40P(%U)3zGHF5ubA|8TM&5nyegzi z!3i^#YvYQQArz~pUciWy>3QZfcox#N_&NI_ zPE`;NhjBffSm_$1p?3Es(TD6ODL46Hykuw2tZ+8_=#+kbUnfZ z#Xu@?gfKy=1;qqmP^`Q~ZuA@4lS2SB#V^seatlH!lT7%CAfP_z1Zg1y3lU7Cfi|oQ zb*ZlCWrDf?l~^=JdePhDEAOIdQRcUTqU#Wp|MiY0gxCtou$UYBPSix}z%W77f~WdJZUFEKJNFg8&?x$^)3 z1KCMLK~zYI-IL328&wp>e|Ki?IPo~nG))sHO+u1R;#hgul}N3M7PYX01*IS))J<1N zh#h|cApQl4l)nHFqT*3l(N`K?YM2*AEeXiGtWvO9yEDwZVmw3 zwgbzu#Ngl{o12?{C=_~mc6K)Nv;>b_Q8JnQzFMscUDxUA>SApv-I zO&^QJg3iuPQ7)HBrBVnX&@>Gp1cgF@Mx(KI}6wJ@hfAhoyt?6CYeQk1b65FwtDHM`4%c<@Ja1?6mXZgFdV70GCX<$J+nLLkFX!*?xnpuBlR2Bq+2&DiLihF zeg*~x2!eo%7cb(vF8%%ek6l$uneY4Dym^ycE{9U;MZhqC3jkMDoRQ5ep64+#3oAJ-Wi93+)WVObVRDHazODHIBfjg1kD#V}2ia=FZ%J9ntp>qMhbhK7b%TwFvq zqn!MtYuOCz$Hgn3mDKuLg}@IGC{PNgyTLmXYZ$>6j^hxI$B|Ovc^<2)tK7YNmuj`j z_VzZ>Xq0q1jg*obH*T=9vcjidIpm8HGi)py0Nq3F<;)kqMH(6GfGbz75a^PL*`v4{e^IUnEd3hb zH5H!Hs9P3>rVBJpu-C7C)c=iSaRqk&zL4V|{dw z#PKjNqkEA}m`cND1Gbv?P}6m>tmy(7(zhS?-CGd~hnYD18j*ed)EZ5O$Kx2%-1Uti zG;vzfSVrtxw-ffBb|OYQ9TMsRY&UkV*M2 = new Set(['commandPrompt', 'webBrowser', 'webServer']); +const WITH_WINDOW: ReadonlySet = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer']); /** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */ function useDesktopView(doc: Y.Doc, id: string) { @@ -160,6 +163,8 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o {shown === 'commandPrompt' ? : null} {shown === 'webBrowser' ? : null} {shown === 'webServer' ? : null} + {shown === 'emailClient' ? : null} + {shown === 'emailServer' ? : null} )} {info ? ( diff --git a/packages/editor/src/desktop/EmailClientPanel.tsx b/packages/editor/src/desktop/EmailClientPanel.tsx new file mode 100644 index 0000000..2fcbe66 --- /dev/null +++ b/packages/editor/src/desktop/EmailClientPanel.tsx @@ -0,0 +1,238 @@ +// A computer's e-mail program, after Java's GUIApplicationEmailAnwendungWindow: +// the buttons to fetch, write, answer and delete a mail and to set up the +// account above, the inbox and the sent mails, the mail chosen below (Java's +// welcome before one is). Writing and the account open a window over the +// program, as Java's desktop shows them; Java's message dialogs show as a +// notice with its OK. The program is the room's: what the panel shows is its +// view, and it checks a mail and an account as Java's window does before it +// hands them over; a failure the program had comes as Java's dialog does. +// +// Better than Java: deleting deletes the mail chosen in the folder shown +// (Java's deleted the one chosen before in the other folder, where there was +// one). + +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { accountOfForm, mailFormProblem, validMailField, type AccountForm, type EmailClientLogEntry, type LogLine, type MailForm } from '@mdg-filius/core'; +import { inboxRow, replyForm, sentRow, type EmailClientView } from '@mdg-filius/protocol'; +import answerIcon from '../assets/desktop/email_email_antworten.png'; +import composeIcon from '../assets/desktop/email_email_verfassen.png'; +import fetchIcon from '../assets/desktop/email_emails_abholen.png'; +import welcomeIcon from '../assets/desktop/email_icon.png'; +import sentIcon from '../assets/desktop/email_ordner_gesendet.png'; +import inboxIcon from '../assets/desktop/email_ordner_posteingang.png'; +import { useTranslate } from '../i18n/i18n.js'; +import type { Simulation } from '../simulation/useSimulation.js'; +import { mailFailureText } from './mailLines.js'; + +type Folder = 'inbox' | 'sent'; +const NO_LINES: readonly LogLine[] = []; +const EMPTY_MAIL: MailForm = { to: '', cc: '', bcc: '', subject: '', text: '' }; + +/** The account dialog's fields for the program's account; Java's ports for a new one. */ +function accountForm(view: EmailClientView): AccountForm { + const account = view.account; + if (account === null) return { name: '', email: '', pop3Host: '', pop3Port: '110', smtpHost: '', smtpPort: '25', username: '', password: '' }; + return { + name: `${account.firstName} ${account.lastName}`, + email: account.email, + pop3Host: account.pop3Host, + pop3Port: String(account.pop3Port), + smtpHost: account.smtpHost, + smtpPort: String(account.smtpPort), + username: account.username, + password: account.password, + }; +} + +const ACCOUNT_FIELDS = ['name', 'email', 'pop3Host', 'pop3Port', 'smtpHost', 'smtpPort', 'username', 'password'] as const; + +export function EmailClientPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) { + const t = useTranslate(); + const topic = `app:${node}/emailClient` as const; + const { watch, act } = simulation; + useEffect(() => watch([topic]), [watch, topic]); + const view = simulation.views.get(topic) as EmailClientView | null | undefined; + const app = simulation.apps.get(topic); + const lines = (app?.lines as readonly LogLine[] | undefined) ?? NO_LINES; + const [folder, setFolder] = useState('inbox'); + const [chosen, setChosen] = useState<{ readonly folder: Folder; readonly index: number } | null>(null); + const [compose, setCompose] = useState(null); + const [account, setAccount] = useState(null); + const [notice, setNotice] = useState(null); + + // A failure the program had since the window opened: Java's dialog with its message. + const seen = useRef(lines.length); + useEffect(() => { + const from = Math.max(seen.current, app?.live ?? 0); + seen.current = lines.length; + const failed = lines.slice(from).filter((line) => line.kind === 'failed'); + if (failed.length > 0) setNotice(mailFailureText((failed.at(-1) as Extract).failure, t)); + }, [lines, app?.live, t]); + + if (view === null || view === undefined) return
; + const mails = folder === 'inbox' ? view.inbox : view.sent; + const chosenMail = chosen !== null && chosen.folder === folder ? mails[chosen.index] : undefined; + + const send = (event: FormEvent) => { + event.preventDefault(); + if (compose === null) return; + const problem = mailFormProblem(compose, view.account !== null); + if (problem !== undefined) { + setNotice(t(`emailClient.${problem}`)); + return; + } + act(node, 'emailClient', 'send', { ...compose }); + setCompose(null); + setFolder('sent'); + setChosen(null); + }; + const saveAccount = (event: FormEvent) => { + event.preventDefault(); + if (account === null) return; + if (accountOfForm(account) === undefined) { + setNotice(t('emailClient.accountInvalid')); + return; + } + act(node, 'emailClient', 'setAccount', { ...account }); + setAccount(null); + }; + const answer = () => { + if (chosenMail === undefined) return setCompose(EMPTY_MAIL); + const reply = replyForm(chosenMail, { re: t('emailClient.re'), wrote: t('emailClient.wrote') }); + setCompose({ ...EMPTY_MAIL, ...reply }); + }; + const remove = () => { + if (chosenMail === undefined || chosen === null) return; + act(node, 'emailClient', 'removeMail', { folder: chosen.folder, index: chosen.index }); + setChosen(null); + }; + + return ( +
+
+ + + + + +
+
+ {(['inbox', 'sent'] as const).map((each) => ( + + ))} +
+
+ + + + + + + + + {mails.map((mail, index) => { + const [who, subject] = folder === 'inbox' ? inboxRow(mail) : sentRow(mail); + return ( + setChosen({ folder, index })}> + + + + ); + })} + +
{t(folder === 'inbox' ? 'emailClient.sender' : 'emailClient.recipient')}{t('emailClient.subject')}
{who}{subject}
+
+
+ {chosenMail === undefined ? ( +

+ + {`${t('emailClient.welcome')}!`} +

+ ) : ( +
{chosenMail.text}
+ )} +
+ {compose === null ? null : ( +
+

{t('emailClient.composeTitle')}

+
+ + +
+ + {(['to', 'cc', 'bcc', 'subject'] as const).map((field) => ( + + ))} +