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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:44:36 +00:00
co-authored by Claude Opus 5.5
parent aa54e5400e
commit 11f13bca84
33 changed files with 2841 additions and 52 deletions
+5
View File
@@ -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:<id>` 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;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 |
+93 -14
View File
@@ -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<EmailClientLogEntry>;
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 };
}
}
+48 -4
View File
@@ -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;
}
/**
+5
View File
@@ -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);
}
+24 -1
View File
@@ -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(';');
}
+17 -7
View File
@@ -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;
}
}
+5
View File
@@ -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);
}
+196
View File
@@ -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<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
async function start(page: Page): Promise<void> {
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(/<rechner2@filius\.de>\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 <rechner1@filius.de>');
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 <rechner1@filius\.de> 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 });
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -12,7 +12,8 @@
// bar, as Java's windows do; on a phone it fills the surface. A program's
// icon opens its window over the desktop (ProgramFrame), the home button
// leaves it; so far the command line (F4.6b), the web browser and the web
// server (F4.6d) have one, the others follow. The browser's window takes its
// server (F4.6d), the e-mail program and server (F4.6e) have one, the others
// follow. The browser's window takes its
// title from its page, as Java's does.
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
@@ -28,6 +29,8 @@ import type { Simulation } from '../simulation/useSimulation.js';
import { ProgramFrame } from './ProgramFrame.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js';
import { BrowserPanel, browserView } from './BrowserPanel.js';
import { EmailClientPanel } from './EmailClientPanel.js';
import { EmailServerPanel } from './EmailServerPanel.js';
import { pageTitle } from './browserPage.js';
import { InstallationPanel } from './InstallationPanel.js';
import { TerminalPanel } from './TerminalPanel.js';
@@ -60,7 +63,7 @@ export interface DesktopWindowProps {
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer']);
const WITH_WINDOW: ReadonlySet<DesktopProgram> = 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' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
{shown === 'webBrowser' ? <BrowserPanel node={id} simulation={simulation} /> : null}
{shown === 'webServer' ? <WebServerPanel node={id} simulation={simulation} /> : null}
{shown === 'emailClient' ? <EmailClientPanel node={id} simulation={simulation} /> : null}
{shown === 'emailServer' ? <EmailServerPanel node={id} simulation={simulation} /> : null}
</ProgramFrame>
)}
{info ? (
@@ -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<EmailClientLogEntry>[] = [];
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<EmailClientLogEntry>[] | undefined) ?? NO_LINES;
const [folder, setFolder] = useState<Folder>('inbox');
const [chosen, setChosen] = useState<{ readonly folder: Folder; readonly index: number } | null>(null);
const [compose, setCompose] = useState<MailForm | null>(null);
const [account, setAccount] = useState<AccountForm | null>(null);
const [notice, setNotice] = useState<string | null>(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<EmailClientLogEntry, { kind: 'failed' }>).failure, t));
}, [lines, app?.live, t]);
if (view === null || view === undefined) return <div className="mail-client" />;
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 (
<div className="mail-client">
<div className="mail-toolbar" role="toolbar">
<button
type="button"
title={t('emailClient.fetch')}
aria-label={t('emailClient.fetch')}
onClick={() => {
act(node, 'emailClient', 'fetch');
setFolder('inbox');
}}
>
<img src={fetchIcon} alt="" />
</button>
<button type="button" title={t('emailClient.compose')} aria-label={t('emailClient.compose')} onClick={() => setCompose(EMPTY_MAIL)}>
<img src={composeIcon} alt="" />
</button>
<button type="button" title={t('emailClient.answer')} aria-label={t('emailClient.answer')} onClick={answer}>
<img src={answerIcon} alt="" />
</button>
<button type="button" title={t('emailClient.deleteHint')} onClick={remove}>
{t('emailClient.delete')}
</button>
<button type="button" title={t('emailClient.accountHint')} onClick={() => setAccount(accountForm(view))}>
{t('emailClient.account')}
</button>
</div>
<div className="mail-tabs" role="tablist">
{(['inbox', 'sent'] as const).map((each) => (
<button key={each} type="button" role="tab" aria-selected={folder === each} onClick={() => setFolder(each)}>
<img src={each === 'inbox' ? inboxIcon : sentIcon} alt="" />
{t(`emailClient.${each}`)}
</button>
))}
</div>
<div className="mail-list" role="tabpanel" aria-label={t(`emailClient.${folder}`)}>
<table>
<thead>
<tr>
<th>{t(folder === 'inbox' ? 'emailClient.sender' : 'emailClient.recipient')}</th>
<th>{t('emailClient.subject')}</th>
</tr>
</thead>
<tbody>
{mails.map((mail, index) => {
const [who, subject] = folder === 'inbox' ? inboxRow(mail) : sentRow(mail);
return (
<tr key={index} aria-selected={chosen?.folder === folder && chosen.index === index} onClick={() => setChosen({ folder, index })}>
<td>{who}</td>
<td>{subject}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mail-preview" aria-label={t('emailClient.preview')}>
{chosenMail === undefined ? (
<p className="mail-welcome">
<img src={welcomeIcon} alt="" />
{`${t('emailClient.welcome')}!`}
</p>
) : (
<pre>{chosenMail.text}</pre>
)}
</div>
{compose === null ? null : (
<form className="mail-dialog" aria-label={t('emailClient.composeTitle')} onSubmit={send}>
<h4>{t('emailClient.composeTitle')}</h4>
<div className="mail-dialog-buttons">
<button type="submit">{t('emailClient.send')}</button>
<button type="button" onClick={() => setCompose(null)}>
{t('emailClient.cancel')}
</button>
</div>
<select aria-label={t('emailClient.sender')} value={view.account?.username ?? ''} disabled>
{view.account === null ? <option value="" /> : <option value={view.account.username}>{view.account.username}</option>}
</select>
{(['to', 'cc', 'bcc', 'subject'] as const).map((field) => (
<label key={field}>
<span>{t(`emailClient.${field}Label`)}</span>
<input
value={compose[field]}
aria-invalid={field === 'subject' || validMailField(compose[field]) ? undefined : 'true'}
autoCapitalize="off"
spellCheck={field === 'subject'}
onChange={(event) => setCompose({ ...compose, [field]: event.target.value })}
/>
</label>
))}
<textarea aria-label={t('emailClient.text')} value={compose.text} onChange={(event) => setCompose({ ...compose, text: event.target.value })} />
</form>
)}
{account === null ? null : (
<form className="mail-dialog" aria-label={t('emailClient.accountTitle')} onSubmit={saveAccount}>
<h4>{t('emailClient.accountTitle')}</h4>
{ACCOUNT_FIELDS.map((field) => (
<label key={field}>
<span>{t(`emailClient.${field}`)}</span>
<input
type={field === 'password' ? 'password' : 'text'}
value={account[field]}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
autoComplete={field === 'password' ? 'new-password' : 'off'}
onChange={(event) => setAccount({ ...account, [field]: event.target.value })}
/>
</label>
))}
<div className="mail-dialog-buttons">
<button type="submit">{t('emailClient.save')}</button>
<button type="button" onClick={() => setAccount(null)}>
{t('emailClient.cancel')}
</button>
</div>
</form>
)}
{notice === null ? null : (
<div className="mail-question" role="alertdialog" aria-label={notice}>
<p>{notice}</p>
<button type="button" onClick={() => setNotice(null)}>
{t('mail.ok')}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,196 @@
// A computer's e-mail server, after Java's GUIApplicationEmailServerWindow:
// the start button and the mail domain above (the domain only while the
// server is off, red while Java's pattern does not take it), then three
// pages — a new account, the list of accounts with the number of mails each
// holds and the button that removes the one chosen (after Java's question),
// and the server's lines. The server is the room's; the panel shows its view
// and lines and checks a new account as Java's window does before it hands
// it over.
//
// Better than Java: the new account's name has a field (Java's window has
// its label and no field: every account is "Vorname Nachname" — still so
// without a name); a user name the server has already is refused (Java's
// window says it made it).
import { useEffect, useLayoutEffect, useRef, useState, type FormEvent } from 'react';
import { isDomainName, splitName, type EmailServerLogEntry, type LogLine } from '@mdg-filius/core';
import type { EmailServerView } from '@mdg-filius/protocol';
import { useTranslate, type Translate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { emailServerLine } from './mailLines.js';
const NO_LINES: readonly LogLine<EmailServerLogEntry>[] = [];
type Page = 'newAccount' | 'accounts' | 'log';
/** What Java's window says to a new account before it hands it over (emailserver_msg25, msg24), and that the server has it already. */
export function newAccountProblem(username: string, password: string, accounts: readonly { readonly username: string }[]): 'missing' | 'whiteSpace' | 'exists' | undefined {
if (username === '' || password === '') return 'missing';
if (/[ \t\n\v\f\r]/.test(username)) return 'whiteSpace';
return accounts.some((account) => account.username.toLowerCase() === username.toLowerCase()) ? 'exists' : undefined;
}
/**
* A new account's names from the name typed (the e-mail program's split);
* without one, Java's placeholders "Vorname Nachname" (emailserver_msg21,
* msg20): its server takes no new account while one has an empty name.
*/
export function accountNames(name: string, t: Translate): { readonly firstName: string; readonly lastName: string } {
const names = splitName(name);
return names.firstName === '' && names.lastName === '' ? { firstName: t('emailServer.firstNamePlaceholder'), lastName: t('emailServer.lastNamePlaceholder') } : names;
}
export function EmailServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/emailServer` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as EmailServerView | null | undefined;
const lines = (simulation.apps.get(topic)?.lines as readonly LogLine<EmailServerLogEntry>[] | undefined) ?? NO_LINES;
const [page, setPage] = useState<Page>('newAccount');
const [domain, setDomain] = useState(view?.domain ?? '');
useEffect(() => setDomain(view?.domain ?? ''), [view?.domain]);
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [notice, setNotice] = useState<string | null>(null);
const [chosen, setChosen] = useState<string | null>(null);
const [asking, setAsking] = useState(false);
const log = useRef<HTMLPreElement>(null);
const text = lines.flatMap((line) => {
const shown = emailServerLine(line, t);
return shown === undefined ? [] : [`${shown}\n`];
}).join('');
useLayoutEffect(() => {
const element = log.current;
if (element !== null) element.scrollTop = element.scrollHeight;
}, [text, page]);
if (view === null || view === undefined) return <div className="mail-server" />;
const accounts = view.accounts;
const chosenAccount = accounts.find((account) => account.username === chosen);
const commitDomain = () => {
if (domain !== view.domain && !view.active) act(node, 'emailServer', 'setDomain', { domain });
};
const create = (event: FormEvent) => {
event.preventDefault();
const problem = newAccountProblem(username, password, accounts);
if (problem !== undefined) {
setNotice(t(`emailServer.${problem}`, { name: username }));
return;
}
act(node, 'emailServer', 'addAccount', { username, password, ...accountNames(name, t) });
setNotice(t('emailServer.created', { name: username }));
setUsername('');
setName('');
setPassword('');
};
return (
<div className="mail-server">
<div className="mail-server-bar">
<button type="button" onClick={() => act(node, 'emailServer', 'setActive', { active: !view.active })}>
{t(view.active ? 'emailServer.stop' : 'emailServer.start')}
</button>
<label>
{t('emailServer.domain')}
<input
value={domain}
disabled={view.active}
aria-invalid={isDomainName(domain) && domain !== '' ? undefined : 'true'}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
onChange={(event) => setDomain(event.target.value)}
onBlur={commitDomain}
onKeyDown={(event) => event.key === 'Enter' && commitDomain()}
/>
</label>
</div>
<div className="mail-tabs" role="tablist">
{(['newAccount', 'accounts', 'log'] as const).map((each) => (
<button key={each} type="button" role="tab" aria-selected={page === each} onClick={() => setPage(each)}>
{t(`emailServer.${each}`)}
</button>
))}
</div>
{page === 'newAccount' ? (
<form className="mail-form" role="tabpanel" aria-label={t('emailServer.newAccount')} onSubmit={create}>
<label>
<span>{t('emailServer.username')}</span>
<input
value={username}
aria-invalid={/^[a-zA-Z0-9._-]*$/.test(username) ? undefined : 'true'}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label>
<span>{t('emailServer.name')}</span>
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<span>{t('emailServer.password')}</span>
<input type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} />
</label>
<button type="submit">{t('emailServer.create')}</button>
{notice === null ? null : (
<p className="mail-notice" role="status">
{notice}
</p>
)}
</form>
) : null}
{page === 'accounts' ? (
<div className="mail-accounts" role="tabpanel" aria-label={t('emailServer.accounts')}>
<table>
<thead>
<tr>
<th>{t('emailServer.address')}</th>
<th>{t('emailServer.mails')}</th>
</tr>
</thead>
<tbody>
{accounts.map((account) => (
<tr key={account.username} aria-selected={account.username === chosen} onClick={() => setChosen(account.username)}>
<td>{`${account.username}@${view.domain}`}</td>
<td>{account.mails}</td>
</tr>
))}
</tbody>
</table>
{asking && chosenAccount !== undefined ? (
<div className="mail-question" role="alertdialog" aria-label={t('emailServer.confirmTitle')}>
<p>{t('emailServer.confirmDelete', { name: chosenAccount.username })}</p>
<button
type="button"
onClick={() => {
act(node, 'emailServer', 'removeAccount', { username: chosenAccount.username });
setAsking(false);
setChosen(null);
}}
>
{t('emailServer.yes')}
</button>
<button type="button" onClick={() => setAsking(false)}>
{t('emailServer.cancel')}
</button>
</div>
) : (
<button type="button" disabled={chosenAccount === undefined} onClick={() => setAsking(true)}>
{t('emailServer.deleteAccount')}
</button>
)}
</div>
) : null}
{page === 'log' ? (
<pre className="mail-log" ref={log} role="tabpanel" aria-label={t('emailServer.log')} tabIndex={0}>
{text}
</pre>
) : null}
</div>
);
}
+273
View File
@@ -536,3 +536,276 @@
overflow-wrap: anywhere;
user-select: text;
}
/* The e-mail program and server (GUIApplicationEmailAnwendungWindow,
* GUIApplicationEmailServerWindow): buttons above, Java's tabs as a row,
* a list, below the mail chosen or the page's form; writing and the account
* as a window over the program (Java's showModularWindow), Java's dialogs as
* a notice with its buttons. */
.mail-client,
.mail-server {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 5px;
padding: 5px;
background: #eeeeee;
color: #000000;
font: 13px Dialog, sans-serif;
}
.mail-toolbar,
.mail-server-bar,
.mail-dialog-buttons {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px 10px;
flex: none;
}
.mail-toolbar button,
.mail-server-bar button,
.mail-dialog button,
.mail-question button,
.mail-form button,
.mail-accounts > button {
min-width: 44px;
min-height: 40px;
padding: 0 12px;
border: 1px solid #a0a0a0;
border-radius: 6px;
background: #f8f8f8;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.mail-toolbar img {
display: block;
width: 32px;
height: 32px;
}
.mail-server-bar label {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
min-width: 200px;
}
.mail-server-bar input,
.mail-form input,
.mail-dialog input,
.mail-dialog select {
flex: 1;
min-width: 0;
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
font: 16px Dialog, sans-serif;
}
.mail-client [aria-invalid='true'],
.mail-server [aria-invalid='true'] {
border-color: #ff0000;
color: #ff0000;
}
.mail-tabs {
display: flex;
gap: 2px;
flex: none;
border-bottom: 1px solid #a0a0a0;
}
.mail-tabs button {
display: flex;
align-items: center;
gap: 6px;
min-height: 40px;
padding: 0 12px;
border: 1px solid #a0a0a0;
border-bottom: none;
border-radius: 6px 6px 0 0;
background: #e0e0e0;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.mail-tabs button[aria-selected='true'] {
background: #ffffff;
}
.mail-list,
.mail-accounts {
flex: 1;
min-height: 80px;
overflow: auto;
background: #ffffff;
border: 1px solid #a0a0a0;
}
.mail-accounts {
display: flex;
flex-direction: column;
gap: 5px;
padding-bottom: 5px;
}
.mail-accounts > button {
align-self: flex-start;
margin-left: 5px;
}
.mail-list table,
.mail-accounts table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.mail-list th,
.mail-accounts th {
padding: 4px 6px;
border-bottom: 1px solid #a0a0a0;
background: #e4e4e4;
font-weight: normal;
text-align: left;
}
.mail-list td,
.mail-accounts td {
height: 30px;
padding: 0 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
.mail-list tr[aria-selected='true'] td,
.mail-accounts tr[aria-selected='true'] td {
background: rgb(184 207 229);
}
.mail-preview {
flex: 1;
min-height: 80px;
overflow: auto;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
}
.mail-preview pre,
.mail-log {
margin: 0;
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
.mail-welcome {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
font-family: Arial, sans-serif;
}
.mail-log {
flex: 1;
min-height: 80px;
overflow: auto;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
}
.mail-form {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
max-width: 420px;
}
.mail-form label,
.mail-dialog label {
display: flex;
align-items: center;
gap: 6px;
}
.mail-form label span,
.mail-dialog label span {
flex: none;
width: 140px;
}
.mail-form > button {
align-self: flex-start;
}
.mail-notice {
margin: 0;
}
.mail-dialog {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 6px;
overflow: auto;
padding: 0 8px 8px;
background: #eeeeee;
}
.mail-dialog h4 {
margin: 0 -8px;
padding: 6px;
background: rgb(100 100 100);
color: rgb(250 250 250);
font: 13px Dialog, sans-serif;
text-align: center;
}
.mail-dialog textarea {
flex: 1;
min-height: 120px;
padding: 4px;
border: 1px solid #a0a0a0;
font: 16px ui-monospace, Menlo, Consolas, monospace;
}
.mail-question {
position: absolute;
left: 50%;
top: 30%;
z-index: 3;
transform: translateX(-50%);
width: min(90%, 380px);
padding: 12px;
border: 1px solid #808080;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 4px 16px rgb(0 0 0 / 30%);
}
.mail-question p {
margin: 0 0 10px;
}
.mail-question button + button {
margin-left: 8px;
}
+50
View File
@@ -0,0 +1,50 @@
// The e-mail programs' lines as Java's windows show them, in the person's
// language: the server's window appends each line its server tells it
// (EmailServer, SMTPMitarbeiter, POP3Mitarbeiter, SMTPClient — only those that
// are text: a failure the SMTP client throws does not show there); the
// program's window shows a failure's message in a dialog (Exception.getMessage).
import type { EmailServerLogEntry, MailFailure } from '@mdg-filius/core';
import type { Translate } from '../i18n/i18n.js';
/** EmailServer.LINE_SEPARATOR. */
const SEPARATOR = '----';
/** A line of the server's window; undefined for what Java's window does not show. */
export function emailServerLine(entry: EmailServerLogEntry, t: Translate): string | undefined {
switch (entry.kind) {
case 'connectionOpened':
case 'connectionStarting':
return SEPARATOR;
case 'received':
return `${entry.address}< ${entry.text}`;
case 'sent':
return `${entry.address}> ${entry.text}`;
case 'sendFailed':
return t('mail.sendFailed', { address: entry.address });
case 'stored':
return t('mail.stored', { name: entry.username });
case 'switched':
return t(entry.active ? 'emailServer.started' : 'emailServer.stopped');
case 'smtpConnected':
return t('mail.connected', { address: `${entry.address}:${entry.port}` });
case 'smtpDisconnected':
return t('mail.disconnected', { address: `${entry.address}:${entry.port}` });
case 'mailSent':
return t('mail.mailSent', { recipients: entry.recipients });
case 'failed':
return undefined;
}
}
/** The message of a failure, as the program's window shows it. */
export function mailFailureText(failure: MailFailure, t: Translate): string {
switch (failure.kind) {
case 'answer':
return failure.text;
case 'connect':
return t(`mail.failure.${failure.failure}`);
case 'receiveFailed':
return t('mail.failure.receiveFailed');
}
}
+73
View File
@@ -59,6 +59,69 @@
"dhcp.static": "Statische Adresszuweisung",
"dhcp.subnetMask": "Netzmaske",
"dhcp.upperBound": "Adress-Obergrenze",
"emailClient.account": "Konto einrichten",
"emailClient.accountHint": "E-Mail-Konto verwalten",
"emailClient.accountInvalid": "Die Angaben sind entweder fehlerhaft oder unvollständig!",
"emailClient.accountTitle": "E-Mail-Konto verwalten",
"emailClient.answer": "E-Mail beantworten",
"emailClient.bccLabel": "BCC:",
"emailClient.cancel": "Abbrechen",
"emailClient.ccLabel": "CC:",
"emailClient.compose": "Neue E-Mail verfassen",
"emailClient.composeTitle": "E-Mail verfassen",
"emailClient.delete": "E-Mail löschen",
"emailClient.deleteHint": "ausgewählte E-Mail löschen",
"emailClient.email": "E-Mail-Adresse:",
"emailClient.fetch": "E-Mails abrufen",
"emailClient.inbox": "Posteingang",
"emailClient.invalidAddress": "Mindestens eine eingegebene E-Mail-Adresse ist nicht korrekt!",
"emailClient.name": "Name:",
"emailClient.noAccount": "Es wurde noch kein E-Mail-Konto eingerichtet.",
"emailClient.noRecipient": "Sie müssen mindestens eine Empfänger-Adresse eingeben!",
"emailClient.password": "Passwort:",
"emailClient.pop3Host": "POP3-Server:",
"emailClient.pop3Port": "POP3-Port:",
"emailClient.preview": "Ausgewählte E-Mail",
"emailClient.re": "RE:",
"emailClient.recipient": "Empfänger",
"emailClient.save": "Speichern",
"emailClient.send": "Senden",
"emailClient.sender": "Absender",
"emailClient.sent": "Gesendete",
"emailClient.smtpHost": "SMTP-Server:",
"emailClient.smtpPort": "SMTP-Port:",
"emailClient.subject": "Betreff",
"emailClient.subjectLabel": "Betreff:",
"emailClient.text": "Text der E-Mail",
"emailClient.toLabel": "An:",
"emailClient.username": "Benutzername:",
"emailClient.welcome": "Willkommen in der E-Mail-Anwendung",
"emailClient.wrote": "schrieb:",
"emailServer.accounts": "Konten-Liste",
"emailServer.address": "E-Mail Adresse",
"emailServer.cancel": "Abbrechen",
"emailServer.confirmDelete": "Wollen Sie das Konto '{name}' wirklich entfernen?",
"emailServer.confirmTitle": "Sicherheitsabfrage!",
"emailServer.create": "Konto erstellen",
"emailServer.created": "Das Benutzerkonto {name} wurde angelegt.",
"emailServer.deleteAccount": "Markiertes Konto entfernen",
"emailServer.domain": "Maildomain:",
"emailServer.exists": "Das Benutzerkonto {name} gibt es schon.",
"emailServer.firstNamePlaceholder": "Vorname",
"emailServer.lastNamePlaceholder": "Nachname",
"emailServer.log": "Log Fenster",
"emailServer.mails": "Anzahl Mails",
"emailServer.missing": "Geben Sie einen Benutzernamen und ein Passwort ein!",
"emailServer.name": "Vorname und Nachname:",
"emailServer.newAccount": "Neues Konto",
"emailServer.password": "Passwort:",
"emailServer.start": "Starten",
"emailServer.started": "POP3- und SMTP-Server wurden gestartet.",
"emailServer.stop": "Anhalten",
"emailServer.stopped": "POP3- und SMTP-Server wurden angehalten.",
"emailServer.username": "Benutzername:",
"emailServer.whiteSpace": "Der Benutzername darf keine Leerzeichen enthalten!",
"emailServer.yes": "Ja",
"firewall.accept": "akzeptieren",
"firewall.action": "Aktion",
"firewall.active": "Firewall aktivieren",
@@ -91,6 +154,16 @@
"installation.available": "Verfügbar:",
"installation.installed": "Installiert:",
"installation.remove": "Entfernen",
"mail.connected": "Verbindung zu {address} hergestellt",
"mail.disconnected": "Verbindung zu {address} getrennt",
"mail.failure.failed": "Aktives Öffnen des Sockets fehlgeschlagen",
"mail.failure.receiveFailed": "Verbindung nicht aufgebaut!",
"mail.failure.timedOut": "Zeitüberschreitung beim Verbindungsaufbau",
"mail.failure.unresolved": "Ziel konnte nicht bestimmt werden!",
"mail.mailSent": "Mail versendet an {recipients}",
"mail.ok": "OK",
"mail.sendFailed": "Fehler beim Senden an {address}",
"mail.stored": "E-Mail für {name} im Postfach gespeichert.",
"menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen",
+73
View File
@@ -59,6 +59,69 @@
"dhcp.static": "Static Address Assignment",
"dhcp.subnetMask": "Netmask",
"dhcp.upperBound": "Upper bound of address",
"emailClient.account": "Account",
"emailClient.accountHint": "Manage email account",
"emailClient.accountInvalid": "Wrong or incomplete data!",
"emailClient.accountTitle": "Manage email account",
"emailClient.answer": "Answer",
"emailClient.bccLabel": "BCC:",
"emailClient.cancel": "Cancel",
"emailClient.ccLabel": "CC:",
"emailClient.compose": "New email",
"emailClient.composeTitle": "New email",
"emailClient.delete": "Delete email",
"emailClient.deleteHint": "delete selected email",
"emailClient.email": "Email address:",
"emailClient.fetch": "Retrieve emails",
"emailClient.inbox": "Inbox",
"emailClient.invalidAddress": "At least one email address is not correct!",
"emailClient.name": "Name:",
"emailClient.noAccount": "There is no email account yet.",
"emailClient.noRecipient": "You have to enter at least one receiver address!",
"emailClient.password": "Password:",
"emailClient.pop3Host": "POP3 server:",
"emailClient.pop3Port": "POP3 port:",
"emailClient.preview": "Selected email",
"emailClient.re": "RE:",
"emailClient.recipient": "Receiver",
"emailClient.save": "Save",
"emailClient.send": "Send",
"emailClient.sender": "Sender",
"emailClient.sent": "Sent",
"emailClient.smtpHost": "SMTP server:",
"emailClient.smtpPort": "SMTP port:",
"emailClient.subject": "Subject",
"emailClient.subjectLabel": "Subject:",
"emailClient.text": "Text of the email",
"emailClient.toLabel": "To:",
"emailClient.username": "User:",
"emailClient.welcome": "Welcome to the Email Application",
"emailClient.wrote": "wrote:",
"emailServer.accounts": "Account list",
"emailServer.address": "Email address",
"emailServer.cancel": "Cancel",
"emailServer.confirmDelete": "Do you really want to delete the account '{name}'?",
"emailServer.confirmTitle": "Security request!",
"emailServer.create": "New account",
"emailServer.created": "The account {name} was built.",
"emailServer.deleteAccount": "Delete selected account",
"emailServer.domain": "Mail domain:",
"emailServer.exists": "The account {name} exists already.",
"emailServer.firstNamePlaceholder": "First name",
"emailServer.lastNamePlaceholder": "Last name",
"emailServer.log": "Log panel",
"emailServer.mails": "Number of mails",
"emailServer.missing": "Enter user and password!",
"emailServer.name": "First and last name:",
"emailServer.newAccount": "New account",
"emailServer.password": "Password:",
"emailServer.start": "Start",
"emailServer.started": "POP3 and SMTP servers are activated.",
"emailServer.stop": "Stop",
"emailServer.stopped": "POP3 and SMTP servers are stopped.",
"emailServer.username": "User:",
"emailServer.whiteSpace": "User names may not contain whitespace!",
"emailServer.yes": "Yes",
"firewall.accept": "accept",
"firewall.action": "Action",
"firewall.active": "Activate firewall",
@@ -91,6 +154,16 @@
"installation.available": "Available:",
"installation.installed": "Installed:",
"installation.remove": "Remove",
"mail.connected": "Connection to {address} established",
"mail.disconnected": "Connection to {address} disconnected",
"mail.failure.failed": "Active opening of socket failed.",
"mail.failure.receiveFailed": "Connection could not be established!",
"mail.failure.timedOut": "Timeout while connecting",
"mail.failure.unresolved": "Target does not exist!",
"mail.mailSent": "Mail sent to {recipients}",
"mail.ok": "OK",
"mail.sendFailed": "Error while sending {address}",
"mail.stored": "E-mail for {name} saved in mailbox.",
"menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable",
+79 -1
View File
@@ -135,7 +135,67 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'server.serverSocketFailed': 'sw_serveranwendung_msg6',
'server.sendFailed.notConnected': 'sw_tcpsocket_msg6',
'server.sendFailed.connectionLost': 'sw_tcpsocket_msg7',
'server.sendFailed.timedOut': 'sw_tcpsocket_msg8',
'server.sendFailed.timedOut': 'sw_tcpsocket_msg8', 'emailClient.sender': 'emailanwendung_msg1',
'emailClient.subject': 'emailanwendung_msg2',
'emailClient.recipient': 'emailanwendung_msg3',
'emailClient.inbox': 'emailanwendung_msg5',
'emailClient.sent': 'emailanwendung_msg6',
'emailClient.fetch': 'emailanwendung_msg7',
'emailClient.compose': 'emailanwendung_msg8',
'emailClient.answer': 'emailanwendung_msg9',
'emailClient.deleteHint': 'emailanwendung_msg10',
'emailClient.accountHint': 'emailanwendung_msg11',
'emailClient.composeTitle': 'emailanwendung_msg12',
'emailClient.send': 'emailanwendung_msg13',
'emailClient.toLabel': 'emailanwendung_msg14',
'emailClient.ccLabel': 'emailanwendung_msg15',
'emailClient.bccLabel': 'emailanwendung_msg16',
'emailClient.subjectLabel': 'emailanwendung_msg17',
'emailClient.re': 'emailanwendung_msg18',
'emailClient.wrote': 'emailanwendung_msg19',
'emailClient.invalidAddress': 'emailanwendung_msg20',
'emailClient.noRecipient': 'emailanwendung_msg21',
'emailClient.accountTitle': 'emailanwendung_msg25',
'emailClient.email': 'emailanwendung_msg26',
'emailClient.pop3Host': 'emailanwendung_msg27',
'emailClient.pop3Port': 'emailanwendung_msg28',
'emailClient.smtpHost': 'emailanwendung_msg29',
'emailClient.smtpPort': 'emailanwendung_msg30',
'emailClient.username': 'emailanwendung_msg31',
'emailClient.password': 'emailanwendung_msg32',
'emailClient.save': 'emailanwendung_msg33',
'emailClient.cancel': 'emailanwendung_msg37',
'emailClient.delete': 'emailanwendung_msg43',
'emailClient.account': 'emailanwendung_msg44',
'emailClient.name': 'emailanwendung_msg45',
'emailClient.accountInvalid': 'emailanwendung_msg46',
'emailClient.noAccount': 'emailanwendung_msg47',
'emailServer.start': 'emailserver_msg1',
'emailServer.started': 'emailserver_msg2',
'emailServer.stopped': 'emailserver_msg3',
'emailServer.domain': 'emailserver_msg4',
'emailServer.deleteAccount': 'emailserver_msg8',
'emailServer.yes': 'emailserver_msg9',
'emailServer.cancel': 'emailserver_msg10',
'emailServer.confirmTitle': 'emailserver_msg13',
'emailServer.address': 'emailserver_msg14',
'emailServer.mails': 'emailserver_msg15',
'emailServer.username': 'emailserver_msg16',
'emailServer.name': 'emailserver_msg17',
'emailServer.password': 'emailserver_msg18',
'emailServer.create': 'emailserver_msg19',
'emailServer.lastNamePlaceholder': 'emailserver_msg20',
'emailServer.firstNamePlaceholder': 'emailserver_msg21',
'emailServer.whiteSpace': 'emailserver_msg24',
'emailServer.missing': 'emailserver_msg25',
'emailServer.newAccount': 'emailserver_msg26',
'emailServer.accounts': 'emailserver_msg27',
'emailServer.log': 'emailserver_msg28',
'emailServer.stop': 'emailserver_msg29',
'mail.failure.unresolved': 'sw_socket_msg1',
'mail.failure.timedOut': 'sw_tcpsocket_msg2',
'mail.failure.failed': 'sw_tcpsocket_msg3',
'mail.failure.receiveFailed': 'sw_tcpsocket_msg6',
};
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
@@ -155,6 +215,8 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
// Java's "\n" as line breaks; the last sentence of the German starts with a small letter there.
'firewall.intro': ['jfirewalldialog_msg37', (java) => java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')],
'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')],
// Java's welcome without its bold tags (the panel writes it as text, Java's "!" after it).
'emailClient.welcome': ['emailanwendung_msg4', (java) => java.replace(/<\/?b>/g, '')],
// Without Java's key (Strg+D, Strg+R): in a browser they bookmark and reload the page.
'mode.design': ['guimainmemu_msg3', (java) => java.replace(/ \((Strg|Ctrl)\+D\)$/, '')],
'mode.simulation': ['guimainmemu_msg4', (java) => java.replace(/ \((Strg|Ctrl)\+R\)$/, '')],
@@ -174,6 +236,18 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
const FROM_JAVA_JOINED: Readonly<Record<string, readonly [string, string, string]>> = {
'server.connectionAccepted': ['sw_serveranwendung_msg4', '{address}', 'sw_serveranwendung_msg5'],
'server.connectionClosed': ['sw_servermitarbeiter_msg1', '{address}', 'sw_servermitarbeiter_msg2'],
'emailServer.created': ['emailserver_msg22', '{name}', 'emailserver_msg23'],
'mail.stored': ['sw_smtpmitarbeiter_msg12', '{name}', 'sw_smtpmitarbeiter_msg13'],
'mail.connected': ['sw_smtpclient_msg1', '{address}', 'sw_smtpclient_msg2'],
'mail.disconnected': ['sw_smtpclient_msg1', '{address}', 'sw_smtpclient_msg3'],
};
/** Java's words before a value, joined as its programs join them (SMTPMitarbeiter, SMTPClient, the server window's question). */
const FROM_JAVA_BEFORE: Readonly<Record<string, readonly [string, string, string?]>> = {
'mail.sendFailed': ['sw_smtpmitarbeiter_msg14', ' {address}'],
'mail.mailSent': ['sw_smtpclient_msg4', ' {recipients}'],
// "'" + name + "'", the question's two halves written against it.
'emailServer.confirmDelete': ['emailserver_msg11', '{name}', 'emailserver_msg12'],
};
/** Java's page for a server it cannot reach (sw_webbrowser_msg1): its title and its heading. */
@@ -216,6 +290,10 @@ describe('the catalogs', () => {
expect(CATALOGS.de[key], key).toBe(`${de.get(before)} ${value} ${de.get(after)}`);
expect(CATALOGS.en[key], key).toBe(`${en.get(before)} ${value} ${en.get(after)}`);
}
for (const [key, [before, value, after]] of Object.entries(FROM_JAVA_BEFORE)) {
expect(CATALOGS.de[key], key).toBe(`${de.get(before)}${value}${after === undefined ? '' : de.get(after)}`);
expect(CATALOGS.en[key], key).toBe(`${en.get(before)}${value}${after === undefined ? '' : en.get(after)}`);
}
for (const [key, [javaKey, part]] of Object.entries(FROM_JAVA_PAGE)) {
expect(CATALOGS.de[key], key).toBe(part.exec(de.get(javaKey)!)![1]);
expect(CATALOGS.en[key], key).toBe(part.exec(en.get(javaKey)!)![1]);
+68
View File
@@ -0,0 +1,68 @@
// The e-mail windows' lines and checks against Java's
// (packages/sim/test/mailWindow.java.json, MailWindowRun): each kind of line
// the server's window got there in Java's words, what it leaves out, a
// failure's message, and the checks of a new account.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { EmailServerLogEntry } from '@mdg-filius/core';
import { accountNames, newAccountProblem } from '../src/desktop/EmailServerPanel.js';
import { emailServerLine, mailFailureText } from '../src/desktop/mailLines.js';
import { translate } from '../src/i18n/i18n.js';
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../sim/test/mailWindow.java.json'), 'utf8')) as { steps: { step: string; log?: string }[] };
const log = (step: string) => java.steps.find((each) => each.step === step)!.log!;
const de = (key: string, values?: Readonly<Record<string, string | number>>) => translate('de', key, values);
const en = (key: string, values?: Readonly<Record<string, string | number>>) => translate('en', key, values);
describe("the e-mail server window's lines", () => {
it("are Java's, line by line", () => {
const after = log('server:after mails');
const entries: EmailServerLogEntry[] = [
{ kind: 'connectionOpened' },
{ kind: 'received', address: '192.168.0.20', text: 'MAIL FROM: <anna@schule.de>' },
{ kind: 'sent', address: '192.168.0.20', text: '250 Sender OK' },
{ kind: 'stored', username: 'anna' },
];
for (const entry of entries) expect(after, entry.kind).toContain(`${emailServerLine(entry, de)}\n`);
expect(log('server:start')).toBe(`${emailServerLine({ kind: 'switched', active: true }, de)}\n`);
expect(log('server:stop').endsWith(`\n${emailServerLine({ kind: 'switched', active: false }, de)}\n`)).toBe(true);
});
it("write the SMTP client's lines, and leave out its failures as Java's window does", () => {
expect(emailServerLine({ kind: 'connectionStarting' }, de)).toBe('----');
expect(emailServerLine({ kind: 'smtpConnected', address: '1.2.3.4', port: 25 }, de)).toBe('Verbindung zu 1.2.3.4:25 hergestellt');
expect(emailServerLine({ kind: 'smtpDisconnected', address: '1.2.3.4', port: 25 }, en)).toBe('Connection to 1.2.3.4:25 disconnected');
expect(emailServerLine({ kind: 'mailSent', recipients: 'a@b.de' }, de)).toBe('Mail versendet an a@b.de');
expect(emailServerLine({ kind: 'sendFailed', address: '1.2.3.4' }, de)).toBe('Fehler beim Senden an 1.2.3.4');
expect(emailServerLine({ kind: 'failed', failure: { kind: 'receiveFailed' } }, de)).toBeUndefined();
});
it("show a failure's message as Java's dialog does", () => {
expect(mailFailureText({ kind: 'answer', text: '-ERR wrong password' }, de)).toBe('-ERR wrong password');
expect(mailFailureText({ kind: 'connect', failure: 'unresolved' }, de)).toBe('Ziel konnte nicht bestimmt werden!');
expect(mailFailureText({ kind: 'connect', failure: 'timedOut' }, en)).toBe('Timeout while connecting');
expect(mailFailureText({ kind: 'connect', failure: 'failed' }, de)).toBe('Aktives Öffnen des Sockets fehlgeschlagen');
expect(mailFailureText({ kind: 'receiveFailed' }, de)).toBe('Verbindung nicht aufgebaut!');
});
});
describe('a new account', () => {
it("is checked as Java's window checks it, and refused where the server has the name", () => {
const accounts = [{ username: 'anna' }];
expect(newAccountProblem('', 'x', accounts)).toBe('missing');
expect(newAccountProblem('bob', '', accounts)).toBe('missing');
for (const name of ['carl os', 'a\tb', 'a\nb']) expect(newAccountProblem(name, 'x', accounts), name).toBe('whiteSpace');
expect(newAccountProblem('ANNA', 'x', accounts)).toBe('exists');
expect(newAccountProblem('bob', 'x', accounts)).toBeUndefined();
});
it("takes the name typed, and Java's placeholders without one", () => {
expect(accountNames('Neue Nutzerin', de)).toEqual({ firstName: 'Neue', lastName: 'Nutzerin' });
expect(accountNames('Anna', de)).toEqual({ firstName: 'Anna', lastName: '' });
expect(accountNames(' ', de)).toEqual({ firstName: 'Vorname', lastName: 'Nachname' });
expect(accountNames('', en)).toEqual({ firstName: 'First name', lastName: 'Last name' });
});
});
+3 -18
View File
@@ -75,7 +75,7 @@
import {
javaFileTypeName,
accountFileField,
clientAccountFileText,
serverAccountsFileText,
javaBase64,
defaultSwitchConfiguration,
@@ -413,24 +413,9 @@ function javaContact(contact: EmailContact): JavaContact {
return { ...contact };
}
/** A field of an account file: each character it cannot carry becomes a space, with a warning. */
const field = accountFileField;
/** EmailKonto.toString: nine fields; a field may hold no ';' and no line break. */
/** EmailKonto.toString (the core's clientAccountFileText). */
function clientAccountFile(client: EmailClientConfiguration, warn: Warn): string {
const account = client.account!;
const text = (value: string, what: string) => field(value, /[;\r\n]/, `${EMAIL_CLIENT_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(';');
return clientAccountFileText(client.account!, warn);
}
/**
+1
View File
@@ -2,4 +2,5 @@
export * from './host.js';
export * from './sim.js';
export * from './terminal.js';
export * from './mail.js';
export * from './web.js';
+45
View File
@@ -0,0 +1,45 @@
// What the e-mail program's and the e-mail server's windows show, as the
// room keeps them and every client shows them (plan §7.3), after
// GUIApplicationEmailAnwendungWindow and GUIApplicationEmailServerWindow
// (measured with tools/java-oracle's MailWindowRun).
import { addressText, type EmailClientAccount, type Mail } from '@mdg-filius/core';
/** The e-mail program: its account (null before one is set) and its two folders, oldest first. */
export interface EmailClientView {
readonly account: EmailClientAccount | null;
readonly inbox: readonly Mail[];
readonly sent: readonly Mail[];
}
/** The e-mail server: its button, its domain, and each account with the number of mails it holds. */
export interface EmailServerView {
readonly active: boolean;
readonly domain: string;
readonly accounts: readonly { readonly username: string; readonly mails: number }[];
}
/** posteingangAktualisieren: a received mail's row — its sender's name, else its address, nothing without a sender — and its subject. */
export function inboxRow(mail: Mail): readonly [string, string] {
const sender = mail.sender === null ? '' : (mail.sender.name ?? mail.sender.mailAddress ?? '');
return [sender, mail.subject ?? ''];
}
/** gesendeteAktualisieren: a sent mail's row — its recipients as Email.holeEmpfaengerListe writes them — and its subject. */
export function sentRow(mail: Mail): readonly [string, string] {
return [mail.to.map((each) => addressText(each) ?? 'null').join(', '), mail.subject ?? ''];
}
/**
* emailVerfassen with a mail to answer: to its sender, the subject after
* Java's "RE:", the text quoted below "<sender> wrote:" (replyLayout: each
* line after "> ").
*/
export function replyForm(mail: Mail, words: { readonly re: string; readonly wrote: string }): { readonly to: string; readonly subject: string; readonly text: string } {
const sender = mail.sender === null ? 'null' : (addressText(mail.sender) ?? 'null');
return {
to: sender,
subject: `${words.re} ${mail.subject ?? 'null'}`,
text: `\n\n${sender} ${words.wrote}\n> ${mail.text.replaceAll('\n', '\n> ')}`,
};
}
+84 -2
View File
@@ -19,8 +19,9 @@
// Java's do (TerminalWindow, BrowserWindow).
//
// A program's window may show more than its lines (plan §7.3): the browser's
// page, the web server's button and table go to the clients as a view,
// whole, when they change.
// page, the web server's button and table, the e-mail program's account and
// folders, the e-mail server's domain and accounts go to the clients as a
// view, whole, when they change.
//
// While the simulation runs, the document's programs are watched: a program
// installed or removed on the desktop (Java's software installation) is
@@ -29,6 +30,7 @@
import * as Y from 'yjs';
import {
accountOfForm,
DEFAULT_SPEED_PERCENT,
NetworkRuntimeEngine,
captureRows,
@@ -53,6 +55,8 @@ import {
type NodeSettings,
type SimMode,
type Topic,
type EmailClientView,
type EmailServerView,
type TraceFrame,
type WebServerView,
} from '@mdg-filius/protocol';
@@ -324,6 +328,68 @@ export class SimulationRoom {
return this.actInBrowser(input);
case 'webServer':
return this.actInWebServer(input);
case 'emailClient':
return this.actInEmailClient(input);
case 'emailServer':
return this.actInEmailServer(input);
default:
return 'unknownAction';
}
}
/** The account dialog (setAccount), the compose window (send), fetching and deleting a mail of a folder. */
private actInEmailClient(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const client = this.engine.emailClient(input.node);
if (client === undefined) return 'unknownApp';
const { args } = input;
const text = (...names: string[]) => names.every((name) => typeof args[name] === 'string');
switch (input.action) {
case 'setAccount': {
if (!text('name', 'email', 'pop3Host', 'pop3Port', 'smtpHost', 'smtpPort', 'username', 'password')) return 'badArgs';
const account = accountOfForm(args as unknown as Parameters<typeof accountOfForm>[0]);
if (account === undefined) return 'badArgs';
client.setAccount(account);
return undefined;
}
case 'send':
if (!text('to', 'cc', 'bcc', 'subject', 'text')) return 'badArgs';
return client.send(args as unknown as Parameters<typeof client.send>[0]) === undefined ? undefined : 'badArgs';
case 'fetch':
// Without an account Java's button does nothing.
client.fetch();
return undefined;
case 'removeMail':
if ((args.folder !== 'inbox' && args.folder !== 'sent') || !Number.isInteger(args.index)) return 'badArgs';
client.removeMail(args.folder, args.index as number);
return undefined;
default:
return 'unknownAction';
}
}
/** The start button, the domain (only while the server is off, as Java's field), a new account and the removal of one. */
private actInEmailServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const server = this.engine.emailServer(input.node);
if (server === undefined) return 'unknownApp';
const { args } = input;
switch (input.action) {
case 'setActive':
if (typeof args.active !== 'boolean') return 'badArgs';
server.setActive(args.active, true);
return undefined;
case 'setDomain':
if (typeof args.domain !== 'string') return 'badArgs';
return !server.isActive && server.setDomain(args.domain) ? undefined : 'badArgs';
case 'addAccount': {
const { username, password, firstName, lastName } = args;
if (typeof username !== 'string' || typeof password !== 'string' || typeof firstName !== 'string' || typeof lastName !== 'string') return 'badArgs';
// The window's checks: a user name and a password, the name without white space (musterKeineLeerzeichen).
if (username === '' || password === '' || /[ \t\n\v\f\r]/.test(username)) return 'badArgs';
return server.addAccount({ username, password, firstName, lastName }) ? undefined : 'badArgs';
}
case 'removeAccount':
if (typeof args.username !== 'string') return 'badArgs';
return server.removeAccount(args.username) ? undefined : 'badArgs';
default:
return 'unknownAction';
}
@@ -401,6 +467,22 @@ export class SimulationRoom {
const view: WebServerView = { active: server.isActive, useVirtualHosts: server.useVirtualHosts, virtualHosts: server.virtualHostTable.map(([host, folder]) => [host, folder] as const) };
return view;
}
case 'emailClient': {
const client = this.engine.emailClient(node);
if (client === undefined) return null;
const view: EmailClientView = { account: client.account, inbox: client.inbox.map((entry) => entry.mail), sent: client.sent.map((entry) => entry.mail) };
return view;
}
case 'emailServer': {
const server = this.engine.emailServer(node);
if (server === undefined) return null;
const view: EmailServerView = {
active: server.isActive,
domain: server.domain,
accounts: server.state.accounts.map((account) => ({ username: account.username, mails: account.mailbox.length })),
};
return view;
}
default:
return undefined;
}
+509
View File
@@ -0,0 +1,509 @@
{
"steps": [
{
"step": "server:open",
"button": "Starten",
"domain": "filius.de",
"domainEnabled": true,
"accounts": [],
"username": "",
"log": "",
"accountsFile": "",
"dialogs": []
},
{
"step": "server:domain ungültig domain!",
"button": "Starten",
"domain": "filius.de",
"domainEnabled": true,
"accounts": [],
"username": "",
"log": "",
"accountsFile": "",
"dialogs": []
},
{
"step": "server:domain schule.de",
"button": "Starten",
"domain": "schule.de",
"domainEnabled": true,
"accounts": [],
"username": "",
"log": "",
"accountsFile": "",
"dialogs": []
},
{
"step": "server:start",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n",
"accountsFile": "",
"dialogs": []
},
{
"step": "server:add anna/geheim",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": [
"Das Benutzerkonto anna wurde angelegt."
]
},
{
"step": "server:add /x",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": [
"Geben Sie einen Benutzernamen und ein Passwort ein!"
]
},
{
"step": "server:add carl os/x",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
]
],
"username": "carl os",
"log": "POP3- und SMTP-Server wurden gestartet.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": [
"Der Benutzername darf keine Leerzeichen enthalten!"
]
},
{
"step": "server:add ANNA/anders",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": [
"Das Benutzerkonto ANNA wurde angelegt."
]
},
{
"step": "server:add bob/",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
]
],
"username": "bob",
"log": "POP3- und SMTP-Server wurden gestartet.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": [
"Geben Sie einen Benutzernamen und ein Passwort ein!"
]
},
{
"step": "server:add bob/pw",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
],
[
"bob@schule.de",
"0"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\nbob;schule.de;pw;Nachname;Vorname;\n",
"dialogs": [
"Das Benutzerkonto bob wurde angelegt."
]
},
{
"step": "client:open",
"inbox": [],
"sent": [],
"tab": 0,
"preview": "html",
"composing": false,
"accountFile": null,
"dialogs": []
},
{
"step": "client:account an na 110",
"inbox": [],
"sent": [],
"tab": 0,
"preview": "html",
"composing": false,
"accountFile": null,
"dialogs": [
"Die Angaben sind entweder fehlerhaft oder unvollständig!"
]
},
{
"step": "client:account anna 110x",
"inbox": [],
"sent": [],
"tab": 0,
"preview": "html",
"composing": false,
"accountFile": null,
"dialogs": [
"Die Angaben sind entweder fehlerhaft oder unvollständig!"
]
},
{
"step": "client:account anna 110",
"inbox": [],
"sent": [],
"tab": 0,
"preview": "html",
"composing": false,
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "client:account shown",
"fields": [
"Anna Maria Muster",
"anna@schule.de",
"192.168.0.30",
"110",
"192.168.0.30",
"25",
"anna",
"geheim"
]
},
{
"step": "client:send Falsch",
"inbox": [],
"sent": [],
"tab": 0,
"preview": "html",
"composing": true,
"composeFields": [
"bob@schule.de, kein",
"",
"",
"Falsch"
],
"composeText": "x",
"composeSender": "anna",
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": [
"Mindestens eine eingegebene E-Mail-Adresse ist nicht korrekt!"
]
},
{
"step": "client:send Leer",
"inbox": [],
"sent": [],
"tab": 0,
"preview": "html",
"composing": true,
"composeFields": [
"",
"",
"",
"Leer"
],
"composeText": "x",
"composeSender": "anna",
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": [
"Sie müssen mindestens eine Empfänger-Adresse eingeben!"
]
},
{
"step": "client:send Hallo",
"inbox": [],
"sent": [
[
"<bob@schule.de>",
"Hallo"
]
],
"tab": 1,
"preview": "html",
"composing": false,
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "client:send Selbst",
"inbox": [],
"sent": [
[
"<bob@schule.de>",
"Hallo"
],
[
"<anna@schule.de>",
"Selbst"
]
],
"tab": 1,
"preview": "html",
"composing": false,
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "server:after mails",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"1"
],
[
"bob@schule.de",
"1"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<bob@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <bob@schule.de>\nSubject: Hallo\n\nZeile 1\nZeile 2\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für bob im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<anna@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für anna im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;#Anna Maria Muster <anna@schule.de>$<anna@schule.de>$$$$Selbst$Erste Zeile|Zweite Zeile|\nbob;schule.de;pw;Nachname;Vorname;#Anna Maria Muster <anna@schule.de>$<bob@schule.de>$$$$Hallo$Zeile 1|Zeile 2|\n",
"dialogs": []
},
{
"step": "client:fetch",
"inbox": [
[
"Anna Maria Muster",
"Selbst"
]
],
"sent": [
[
"<bob@schule.de>",
"Hallo"
],
[
"<anna@schule.de>",
"Selbst"
]
],
"tab": 0,
"preview": "html",
"composing": false,
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "client:choose inbox 0",
"inbox": [
[
"Anna Maria Muster",
"Selbst"
]
],
"sent": [
[
"<bob@schule.de>",
"Hallo"
],
[
"<anna@schule.de>",
"Selbst"
]
],
"tab": 0,
"preview": "Erste Zeile\nZweite Zeile\n",
"composing": false,
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "client:answer",
"inbox": [
[
"Anna Maria Muster",
"Selbst"
]
],
"sent": [
[
"<bob@schule.de>",
"Hallo"
],
[
"<anna@schule.de>",
"Selbst"
]
],
"tab": 0,
"preview": "Erste Zeile\nZweite Zeile\n",
"composing": true,
"composeFields": [
"Anna Maria Muster <anna@schule.de>",
"",
"",
"RE: Selbst"
],
"composeText": "\n\nAnna Maria Muster <anna@schule.de> schrieb:\n> Erste Zeile\n> Zweite Zeile\n> ",
"composeSender": "anna",
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "client:choose sent 0",
"inbox": [
[
"Anna Maria Muster",
"Selbst"
]
],
"sent": [
[
"<bob@schule.de>",
"Hallo"
],
[
"<anna@schule.de>",
"Selbst"
]
],
"tab": 0,
"preview": "Zeile 1\nZeile 2",
"composing": false,
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "client:delete inbox 0",
"inbox": [
[
"Anna Maria Muster",
"Selbst"
]
],
"sent": [
[
"<anna@schule.de>",
"Selbst"
]
],
"tab": 0,
"preview": " ",
"composing": false,
"accountFile": "192.168.0.30;192.168.0.30;110;25;anna;geheim;Muster;Anna Maria;anna@schule.de",
"dialogs": []
},
{
"step": "server:after fetch",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
],
[
"bob@schule.de",
"1"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<bob@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <bob@schule.de>\nSubject: Hallo\n\nZeile 1\nZeile 2\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für bob im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<anna@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für anna im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> +OK POP3 server ready\n192.168.0.20< USER anna\n192.168.0.20> +OK enter password\n192.168.0.20< PASS geheim\n192.168.0.20> +OK Mailbox locked and ready\n192.168.0.20< STAT\n192.168.0.20> +OK 1 83\n192.168.0.20< LIST\n192.168.0.20> +OK 1 83\n0 83\n\n192.168.0.20< RETR 0\n192.168.0.20> +OK message follows \nFrom: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\n192.168.0.20< DELE 0\n192.168.0.20> +OK message marked for delete\n192.168.0.20< QUIT\n192.168.0.20> +OK\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\nbob;schule.de;pw;Nachname;Vorname;#Anna Maria Muster <anna@schule.de>$<bob@schule.de>$$$$Hallo$Zeile 1|Zeile 2|\n",
"dialogs": []
},
{
"step": "server:delete row 1",
"button": "Anhalten",
"domain": "schule.de",
"domainEnabled": false,
"accounts": [
[
"anna@schule.de",
"0"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<bob@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <bob@schule.de>\nSubject: Hallo\n\nZeile 1\nZeile 2\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für bob im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<anna@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für anna im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> +OK POP3 server ready\n192.168.0.20< USER anna\n192.168.0.20> +OK enter password\n192.168.0.20< PASS geheim\n192.168.0.20> +OK Mailbox locked and ready\n192.168.0.20< STAT\n192.168.0.20> +OK 1 83\n192.168.0.20< LIST\n192.168.0.20> +OK 1 83\n0 83\n\n192.168.0.20< RETR 0\n192.168.0.20> +OK message follows \nFrom: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\n192.168.0.20< DELE 0\n192.168.0.20> +OK message marked for delete\n192.168.0.20< QUIT\n192.168.0.20> +OK\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": [
"Wollen Sie das Konto 'bob' wirklich entfernen?"
]
},
{
"step": "server:stop",
"button": "Starten",
"domain": "schule.de",
"domainEnabled": true,
"accounts": [
[
"anna@schule.de",
"0"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<bob@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <bob@schule.de>\nSubject: Hallo\n\nZeile 1\nZeile 2\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für bob im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<anna@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für anna im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> +OK POP3 server ready\n192.168.0.20< USER anna\n192.168.0.20> +OK enter password\n192.168.0.20< PASS geheim\n192.168.0.20> +OK Mailbox locked and ready\n192.168.0.20< STAT\n192.168.0.20> +OK 1 83\n192.168.0.20< LIST\n192.168.0.20> +OK 1 83\n0 83\n\n192.168.0.20< RETR 0\n192.168.0.20> +OK message follows \nFrom: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\n192.168.0.20< DELE 0\n192.168.0.20> +OK message marked for delete\n192.168.0.20< QUIT\n192.168.0.20> +OK\nPOP3- und SMTP-Server wurden angehalten.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": []
},
{
"step": "server:domain neu.de",
"button": "Starten",
"domain": "neu.de",
"domainEnabled": true,
"accounts": [
[
"anna@neu.de",
"0"
]
],
"username": "",
"log": "POP3- und SMTP-Server wurden gestartet.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<bob@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <bob@schule.de>\nSubject: Hallo\n\nZeile 1\nZeile 2\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für bob im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> 220 Willkommen bei schule.de\n192.168.0.20< HELO 192.168.0.20\n192.168.0.20> 250 Hello 192.168.0.20\n192.168.0.20< MAIL FROM: <anna@schule.de>\n192.168.0.20> 250 Sender OK\n192.168.0.20< RCPT TO:<anna@schule.de>\n192.168.0.20> 250 Recipient OK\n192.168.0.20< DATA\n192.168.0.20> 354 End data with <CR><LF>.<CR><LF>\n192.168.0.20< From: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\r\n.\r\n\n192.168.0.20> 250 Mail queued for delivery\nE-Mail für anna im Postfach gespeichert.\n192.168.0.20< QUIT\n192.168.0.20> 221 Server beendet Verbindung.\n----\n192.168.0.20> +OK POP3 server ready\n192.168.0.20< USER anna\n192.168.0.20> +OK enter password\n192.168.0.20< PASS geheim\n192.168.0.20> +OK Mailbox locked and ready\n192.168.0.20< STAT\n192.168.0.20> +OK 1 83\n192.168.0.20< LIST\n192.168.0.20> +OK 1 83\n0 83\n\n192.168.0.20< RETR 0\n192.168.0.20> +OK message follows \nFrom: Anna Maria Muster <anna@schule.de>\nTo: <anna@schule.de>\nSubject: Selbst\n\nErste Zeile\nZweite Zeile\n192.168.0.20< DELE 0\n192.168.0.20> +OK message marked for delete\n192.168.0.20< QUIT\n192.168.0.20> +OK\nPOP3- und SMTP-Server wurden angehalten.\n",
"accountsFile": "anna;schule.de;geheim;Nachname;Vorname;\n",
"dialogs": []
},
{
"accounts": "1",
"step": "server:accounts"
}
]
}
+253
View File
@@ -0,0 +1,253 @@
// The e-mail program's and the e-mail server's windows against Java's
// (tools/java-oracle MailWindowRun) on mail-lan's network: the same server
// switched, its domain set, accounts made and removed; the same account set
// in the program, mails written, sent, fetched, answered and deleted — the
// same rows, previews, files and lines after each. What Java's window checks
// before it acts (a form's addresses, a user name), the editor's panel checks
// with the core's functions; here they are compared with Java's dialogs.
// Named where the windows are better than Java's:
// - a user name the server has already is refused (Java's window says it
// made it),
// - deleting deletes the mail chosen in the folder shown (Java's deleted a
// mail of the other folder, where one was chosen before),
// - a new domain is written into the accounts file at once (Java writes it
// with the next change of the accounts).
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { accountOfForm, mailFormProblem, NetworkRuntimeEngine, splitName, VirtualFileSystem, withSnapshot, type AccountForm, type MailForm } from '@mdg-filius/core';
import { inboxRow, replyForm, sentRow } from '@mdg-filius/protocol';
import { snapshotOf, type Scenario } from '../../core/test/scenario.js';
interface JavaStep {
readonly step: string;
readonly [field: string]: unknown;
}
const here = dirname(fileURLToPath(import.meta.url));
const java = JSON.parse(readFileSync(join(here, 'mailWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
const scenario = JSON.parse(readFileSync(join(here, '../../core/test/scenarios/mail-lan.scenario.json'), 'utf8')) as Scenario;
const CLIENT = '00000000-0000-0000-0000-000000000201';
const SERVER = '00000000-0000-0000-0000-000000000203';
const step = (name: string) => java.steps.find((each) => each.step === name)!;
/** Java's window's placeholders for a new account's names (emailserver_msg21, msg20). */
const PLACEHOLDER = { firstName: 'Vorname', lastName: 'Nachname' };
function setUp() {
const engine = new NetworkRuntimeEngine(scenario.seed);
const serverFiles = new VirtualFileSystem();
const clientFiles = new VirtualFileSystem();
const snapshot = withSnapshot(snapshotOf(scenario), {
emailServersByNodeId: new Map([[SERVER, { domain: 'filius.de', accounts: [], isActive: false }]]),
emailClientsByNodeId: new Map([[CLIENT, { account: null, inbox: [], sent: [], drafts: [], addressBook: [], nextMessageID: 1 }]]),
fileSystemsByNodeId: new Map([
[SERVER, serverFiles],
[CLIENT, clientFiles],
]),
});
engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 });
const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds });
return { engine, server: engine.emailServer(SERVER)!, client: engine.emailClient(CLIENT)!, serverFiles, clientFiles, run };
}
/** The server's window as Java's shows it: button, domain field, rows of address and number of mails. */
function serverWindow(context: ReturnType<typeof setUp>) {
const { server } = context;
return {
button: server.isActive ? 'Anhalten' : 'Starten',
domain: server.domain,
domainEnabled: !server.isActive,
accounts: server.state.accounts.map((account) => [`${account.username}@${server.domain}`, String(account.mailbox.length)]),
accountsFile: context.serverFiles.textFile('/mailserver/konten.txt'),
};
}
const javaServerWindow = (each: JavaStep) => ({ button: each.button, domain: each.domain, domainEnabled: each.domainEnabled, accounts: each.accounts, accountsFile: each.accountsFile });
/** What Java's server window checks before it makes an account, as the editor's panel checks it (emailserver_msg25, msg24). */
function serverFormProblem(username: string, password: string): 'missing' | 'whiteSpace' | undefined {
if (username === '' || password === '') return 'missing';
return /[ \t\n\v\f\r]/.test(username) ? 'whiteSpace' : undefined;
}
const ACCOUNT_FORM: AccountForm = {
name: 'Anna Maria Muster',
email: 'anna@schule.de',
pop3Host: '192.168.0.30',
pop3Port: '110',
smtpHost: '192.168.0.30',
smtpPort: '25',
username: 'anna',
password: 'geheim',
};
const MAIL_FORMS: Record<string, MailForm> = {
invalid: { to: 'bob@schule.de, kein', cc: '', bcc: '', subject: 'Falsch', text: 'x' },
empty: { to: '', cc: '', bcc: '', subject: 'Leer', text: 'x' },
hello: { to: 'bob@schule.de', cc: '', bcc: '', subject: 'Hallo', text: 'Zeile 1\nZeile 2' },
self: { to: 'anna@schule.de', cc: '', bcc: '', subject: 'Selbst', text: 'Erste Zeile\nZweite Zeile' },
};
describe("the e-mail windows, against Java's", () => {
it("switch the server, set its domain and make its accounts as Java's window does", () => {
const context = setUp();
const { server } = context;
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:open')));
expect(server.setDomain('ungültig domain!')).toBe(false);
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:domain ungültig domain!')));
expect(server.setDomain('schule.de')).toBe(true);
expect(serverWindow(context)).toEqual({ ...javaServerWindow(step('server:domain schule.de')), accountsFile: '' });
server.setActive(true, true);
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:start')));
expect(server.log.all.map((line) => line.kind)).toEqual(['switched']);
const dialogs: Record<string, string> = {
missing: 'Geben Sie einen Benutzernamen und ein Passwort ein!',
whiteSpace: 'Der Benutzername darf keine Leerzeichen enthalten!',
};
for (const [username, password] of [['anna', 'geheim'], ['', 'x'], ['carl os', 'x'], ['ANNA', 'anders'], ['bob', ''], ['bob', 'pw']] as const) {
const recorded = step(`server:add ${username}/${password}`);
const problem = serverFormProblem(username, password);
if (problem !== undefined) {
expect(recorded.dialogs, username).toEqual([dialogs[problem]]);
} else {
const made = server.addAccount({ username, password, ...PLACEHOLDER });
// Java's window says it made "ANNA"; the server has "anna" already and takes nothing.
expect(recorded.dialogs).toEqual([`Das Benutzerkonto ${username} wurde angelegt.`]);
expect(made, username).toBe(username !== 'ANNA');
}
expect(serverWindow(context), username).toEqual(javaServerWindow(recorded));
}
});
it("set the program's account, write and send its mails, fetch, answer and delete them as Java's window does", () => {
const context = setUp();
const { server, client, clientFiles, run } = context;
server.setDomain('schule.de');
server.setActive(true, true);
server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER });
server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER });
expect(client.account).toBeNull();
for (const [username, port] of [['an na', '110'], ['anna', '110x'], ['anna', '110']] as const) {
const recorded = step(`client:account ${username} ${port}`);
const account = accountOfForm({ ...ACCOUNT_FORM, username, pop3Port: port });
if (account === undefined) expect(recorded.dialogs).toEqual(['Die Angaben sind entweder fehlerhaft oder unvollständig!']);
else client.setAccount(account);
expect(clientFiles.contains('/konten.txt') ? clientFiles.textFile('/konten.txt') : null, username + port).toBe(recorded.accountFile);
}
// Opened again, the dialog shows the account: first and last name with a space between.
const shown = client.account!;
expect([`${shown.firstName} ${shown.lastName}`, shown.email, shown.pop3Host, String(shown.pop3Port), shown.smtpHost, String(shown.smtpPort), shown.username, shown.password]).toEqual(
step('client:account shown').fields,
);
const problems: Record<string, string> = {
invalidAddress: 'Mindestens eine eingegebene E-Mail-Adresse ist nicht korrekt!',
noRecipient: 'Sie müssen mindestens eine Empfänger-Adresse eingeben!',
};
for (const form of Object.values(MAIL_FORMS)) {
const name = form.subject;
const recorded = step(`client:send ${name}`);
const problem = mailFormProblem(form, true);
if (problem !== undefined) {
expect(recorded.dialogs, name).toEqual([problems[problem]]);
// The compose window stays open with what was typed.
expect([recorded.composing, recorded.composeFields, recorded.composeText, recorded.composeSender]).toEqual([true, [form.to, form.cc, form.bcc, form.subject], form.text, 'anna']);
} else {
expect(client.send(form)).toBeUndefined();
run(20_000);
// Sent: the compose window closes, the window shows the sent mails.
expect([recorded.composing, recorded.tab]).toEqual([false, 1]);
}
expect(client.sent.map((entry) => sentRow(entry.mail)), name).toEqual(recorded.sent);
}
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:after mails')));
client.fetch();
run(20_000);
const fetched = step('client:fetch');
expect([client.inbox.map((entry) => inboxRow(entry.mail)), fetched.tab]).toEqual([fetched.inbox, 0]);
expect(client.inbox[0]!.mail.text).toBe(step('client:choose inbox 0').preview);
const answer = replyForm(client.inbox[0]!.mail, { re: 'RE:', wrote: 'schrieb:' });
const answered = step('client:answer');
expect([[answer.to, '', '', answer.subject], answer.text, 'anna']).toEqual([answered.composeFields, answered.composeText, answered.composeSender]);
expect(client.sent[0]!.mail.text).toBe(step('client:choose sent 0').preview);
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:after fetch')));
// The mail chosen in the inbox is deleted (Java deleted the sent mail chosen before).
client.removeMail('inbox', 0);
const deleted = step('client:delete inbox 0');
expect(client.inbox).toEqual([]);
expect(deleted.inbox).toEqual(fetched.inbox);
expect(client.sent.map((entry) => sentRow(entry.mail))).toEqual((fetched.sent as unknown[]).slice());
expect(deleted.sent).toEqual((fetched.sent as unknown[]).slice(1));
// A row it has not: nothing.
client.removeMail('sent', 5);
client.removeMail('sent', -1);
expect(client.sent).toHaveLength(2);
});
it("remove an account, stop the server and set its domain while it is off, as Java's window does", () => {
const context = setUp();
const { server } = context;
server.setDomain('schule.de');
server.setActive(true, true);
server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER });
server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER });
expect(step('server:delete row 1').dialogs).toEqual(["Wollen Sie das Konto 'bob' wirklich entfernen?"]);
expect(server.removeAccount('bob')).toBe(true);
expect(server.removeAccount('bob')).toBe(false);
expect(server.removeAccount('ANNA')).toBe(false);
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:delete row 1')));
server.setActive(false, true);
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:stop')));
expect(server.log.all.map((line) => line.kind)).toEqual(['switched', 'switched']);
expect(server.log.all.map((line) => line.kind === 'switched' && line.active)).toEqual([true, false]);
expect(server.setDomain('neu.de')).toBe(true);
// The new domain goes into the file at once (Java's keeps the old one until the accounts change).
const recorded = step('server:domain neu.de');
expect(serverWindow(context)).toEqual({ ...javaServerWindow(recorded), accountsFile: 'anna;neu.de;geheim;Nachname;Vorname;\n' });
expect(recorded.accountsFile).toBe('anna;schule.de;geheim;Nachname;Vorname;\n');
expect(server.setDomain('')).toBe(false);
expect(server.domain).toBe('neu.de');
});
it("split a name as Java's account dialog does", () => {
expect(splitName('Anna Maria Muster')).toEqual({ firstName: 'Anna Maria', lastName: 'Muster' });
expect(splitName('Anna')).toEqual({ firstName: 'Anna', lastName: '' });
expect(splitName(' Anna ')).toEqual({ firstName: '', lastName: 'Anna' });
expect(splitName('Anna ')).toEqual({ firstName: 'Anna', lastName: '' });
expect(splitName(' ')).toEqual({ firstName: '', lastName: '' });
expect(accountOfForm({ ...ACCOUNT_FORM, username: '', smtpPort: '65535', pop3Port: '1' })).toMatchObject({ username: '', smtpPort: 65535, pop3Port: 1 });
for (const port of ['0', '65536', '', '080', ' 25']) expect(accountOfForm({ ...ACCOUNT_FORM, smtpPort: port }), port).toBeUndefined();
expect(accountOfForm({ ...ACCOUNT_FORM, username: 'a.b_c-d9' })).toBeDefined();
expect(accountOfForm({ ...ACCOUNT_FORM, username: 'anna@x' })).toBeUndefined();
});
it('deletes a sent mail, sends with the account a mail was written with, and keeps a new domain in the snapshot', () => {
const context = setUp();
const { engine, server, client, run } = context;
server.setDomain('schule.de');
expect(engine.state.topologySnapshot.emailServersByNodeId.get(SERVER)!.domain).toBe('schule.de');
server.setActive(true, true);
server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER });
server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER });
client.setAccount(accountOfForm(ACCOUNT_FORM)!);
expect(client.send({ ...MAIL_FORMS.hello!, to: 'bob@schule.de, anna@schule.de' })).toBeUndefined();
// Changed before the mail goes to its second recipient: it still goes with the account it was written with.
client.setAccount(accountOfForm({ ...ACCOUNT_FORM, smtpHost: 'niemand.de' })!);
run(40_000);
expect(client.sent.map((entry) => entry.mail.subject)).toEqual(['Hallo']);
expect(server.state.accounts.map((account) => account.mailbox.length)).toEqual([1, 1]);
client.setAccount(accountOfForm(ACCOUNT_FORM)!);
expect(client.send(MAIL_FORMS.self!)).toBeUndefined();
run(20_000);
client.removeMail('sent', 0);
expect(client.sent.map((entry) => entry.mail.subject)).toEqual(['Selbst']);
expect(engine.state.topologySnapshot.emailClientsByNodeId.get(CLIENT)!.sent.map((message) => message.subject)).toEqual(['Selbst']);
});
});
+92 -2
View File
@@ -196,7 +196,7 @@ describe('a simulation room', () => {
expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' });
expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'emailClient', action: 'send' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'gnutella', action: 'search' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
send({ kind: 'nonsense', reqId: 'q' });
@@ -458,7 +458,7 @@ describe("a simulation room's browser and web server windows", () => {
[server, 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 2 }, 'badArgs'],
[server, 'webServer', 'restart', {}, 'unknownAction'],
[notebook, 'webServer', 'setActive', { active: true }, 'unknownApp'],
[server, 'emailServer', 'setActive', { active: true }, 'unknownAction'],
[server, 'echoServer', 'setActive', { active: true }, 'unknownAction'],
];
for (const [target, app, action, args, code] of cases) {
send({ kind: 'input', node: target, app, action, args, reqId: `${app}.${action}.${JSON.stringify(args)}` });
@@ -467,3 +467,93 @@ describe("a simulation room's browser and web server windows", () => {
expect(ofKind(got, 'ack')).toEqual([]);
});
});
describe("a simulation room's e-mail windows", () => {
const views = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'view').filter((each) => each.node === node && each.app === app).map((each) => each.view);
it('sends the e-mail program and server as views, and takes their actions', () => {
const { node, send, run, got } = setUp(exampleDoc('java-examples/email_komplett.fls'));
const one = node('Rechner 1').id;
const two = node('Rechner 2').id;
const server = node('Mail-Server (filius.de)').id;
send({ kind: 'sub', topics: [`app:${one}/emailClient`, `app:${two}/emailClient`, `app:${server}/emailServer`] });
expect(views(got, server, 'emailServer')).toEqual([null]);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(views(got, server, 'emailServer').at(-1)).toEqual({ active: true, domain: 'filius.de', accounts: [{ username: 'rechner1', mails: expect.any(Number) }, { username: 'rechner2', mails: expect.any(Number) }] });
const mails = (views(got, server, 'emailServer').at(-1) as { accounts: { mails: number }[] }).accounts[1]!.mails;
expect(views(got, one, 'emailClient').at(-1)).toMatchObject({ account: { username: 'rechner1', email: 'rechner1@filius.de' } });
const act = (target: Id, app: string, action: string, args: object, reqId: string) => send({ kind: 'input', node: target, app, action, args, reqId });
act(one, 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Hallo', text: 'Zeile' }, 'send');
run(20_000);
expect(ofKind(got, 'ack').map((each) => each.reqId)).toEqual(['send']);
expect((views(got, one, 'emailClient').at(-1) as { sent: { subject: string }[] }).sent.map((mail) => mail.subject)).toContain('Hallo');
expect((views(got, server, 'emailServer').at(-1) as { accounts: { mails: number }[] }).accounts[1]!.mails).toBe(mails + 1);
act(two, 'emailClient', 'fetch', {}, 'fetch');
run(20_000);
const inbox = () => (views(got, two, 'emailClient').at(-1) as { inbox: { subject: string }[] }).inbox;
expect(inbox().map((mail) => mail.subject)).toContain('Hallo');
const before = inbox().length;
act(two, 'emailClient', 'removeMail', { folder: 'inbox', index: 0 }, 'remove');
expect(inbox()).toHaveLength(before - 1);
// The program's lines go on as lines.
expect(ofKind(got, 'app').some((each) => each.app === 'emailClient' && each.node === one && each.lines.length > 0)).toBe(true);
act(two, 'emailClient', 'setAccount', { name: 'Zwei Zwo', email: 'zwei@filius.de', pop3Host: '141.99.50.232', pop3Port: '110', smtpHost: '141.99.50.232', smtpPort: '25', username: 'zwei', password: 'x' }, 'account');
expect(views(got, two, 'emailClient').at(-1)).toMatchObject({ account: { firstName: 'Zwei', lastName: 'Zwo', username: 'zwei', pop3Port: 110 } });
// The server: switched off with the window's line, its domain set, an account made and removed.
act(server, 'emailServer', 'setActive', { active: false }, 'off');
act(server, 'emailServer', 'setDomain', { domain: 'schule.de' }, 'domain');
act(server, 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'Vorname', lastName: 'Nachname' }, 'add');
act(server, 'emailServer', 'removeAccount', { username: 'rechner1' }, 'removeAccount');
expect(views(got, server, 'emailServer').at(-1)).toEqual({ active: false, domain: 'schule.de', accounts: [{ username: 'rechner2', mails: expect.any(Number) }, { username: 'neu', mails: 0 }] });
const lines = ofKind(got, 'app').filter((each) => each.app === 'emailServer').flatMap((each) => each.lines as { kind: string }[]);
expect(lines.at(-1)).toMatchObject({ kind: 'switched', active: false });
});
it('names a wrong action in the e-mail windows', () => {
const { node, send, run, got } = setUp(exampleDoc('java-examples/email_komplett.fls'));
const one = node('Rechner 1').id;
const server = node('Mail-Server (filius.de)').id;
send({ kind: 'start' });
run(TICK_MILLISECONDS);
const account = { name: 'A', email: 'a@filius.de', pop3Host: 'h', pop3Port: '110', smtpHost: 'h', smtpPort: '25', username: 'a', password: 'p' };
const cases: [string, string, string, Record<string, unknown>, string][] = [
[one, 'emailClient', 'send', { to: 'kein', cc: '', bcc: '', subject: '', text: '' }, 'badArgs'],
[one, 'emailClient', 'send', { to: '', cc: '', bcc: '', subject: '', text: '' }, 'badArgs'],
[one, 'emailClient', 'send', { to: 'a@b.de', cc: '', bcc: '', subject: '' }, 'badArgs'],
[one, 'emailClient', 'setAccount', { ...account, pop3Port: '0' }, 'badArgs'],
[one, 'emailClient', 'setAccount', { ...account, password: 5 }, 'badArgs'],
[one, 'emailClient', 'removeMail', { folder: 'drafts', index: 0 }, 'badArgs'],
[one, 'emailClient', 'removeMail', { folder: 'inbox', index: 0.5 }, 'badArgs'],
[one, 'emailClient', 'reply', {}, 'unknownAction'],
[server, 'emailClient', 'fetch', {}, 'unknownApp'],
[server, 'emailServer', 'setActive', { active: 1 }, 'badArgs'],
[server, 'emailServer', 'setDomain', { domain: 'neu.de' }, 'badArgs'],
[server, 'emailServer', 'setDomain', { domain: 3 }, 'badArgs'],
[server, 'emailServer', 'addAccount', { username: 'rechner1', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
[server, 'emailServer', 'addAccount', { username: '', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
[server, 'emailServer', 'addAccount', { username: 'a', password: '', firstName: '', lastName: '' }, 'badArgs'],
[server, 'emailServer', 'addAccount', { username: 'a b', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
[server, 'emailServer', 'addAccount', { username: 'a\tb', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
[server, 'emailServer', 'addAccount', { username: 'a', password: 'p', firstName: 1, lastName: '' }, 'badArgs'],
[server, 'emailServer', 'removeAccount', { username: 'niemand' }, 'badArgs'],
[server, 'emailServer', 'removeAccount', {}, 'badArgs'],
[server, 'emailServer', 'restart', {}, 'unknownAction'],
[one, 'emailServer', 'setActive', { active: true }, 'unknownApp'],
];
for (const [target, app, action, args, code] of cases) {
const reqId = `${app}.${action}.${JSON.stringify(args)}`;
send({ kind: 'input', node: target, app, action, args, reqId });
expect(ofKind(got, 'error').at(-1), reqId).toMatchObject({ code, reqId });
}
expect(ofKind(got, 'ack')).toEqual([]);
// Off, the server takes a domain, and only one Java's pattern takes.
send({ kind: 'input', node: server, app: 'emailServer', action: 'setActive', args: { active: false }, reqId: 'off' });
send({ kind: 'input', node: server, app: 'emailServer', action: 'setDomain', args: { domain: 'kein domain!' }, reqId: 'bad' });
expect(ofKind(got, 'error').at(-1)).toMatchObject({ code: 'badArgs', reqId: 'bad' });
send({ kind: 'input', node: server, app: 'emailServer', action: 'setDomain', args: { domain: 'neu.de' }, reqId: 'good' });
expect(ofKind(got, 'ack').map((each) => each.reqId)).toEqual(['off', 'good']);
});
});
+36
View File
@@ -1332,3 +1332,39 @@ Gemessen (03.10.2026):
`StringTokenizer` die Leerzeilen, und die Zelle einer unvollständigen
Zeile rückt in die davor. Liegt die Datei da, ist das Kästchen nach dem
nächsten Start wieder an, auch abgeschaltet.
### E-Mail-Programm und E-Mail-Server (F4.6e)
`MailWindowRun` öffnet Javas `GUIApplicationEmailServerWindow` und
`GUIApplicationEmailAnwendungWindow` auf je einem `GUIDesktopPanel` im Netz
von mail-lan und benutzt sie wie eine Person: am Server Startknopf,
Domainfeld, das Formular „Neues Konto“ (der Knopf hört auf `mousePressed`,
nicht auf eine Aktion), Konten-Liste und Entfernen; im Programm den
Konto-Dialog, das Fenster zum Verfassen (seine Felder und Knöpfe auf dem
Desktop), Abrufen, eine gewählte Mail, Antworten, Löschen. Javas
Meldungsdialoge beantwortet ein Wächter-Thread wie eine Person (OK, „Ja“)
und schreibt ihren Text mit (`packages/sim/test/mailWindow.java.json`).
Gemessen (03.10.2026):
* Der Server: „Starten“/„Anhalten“; das Fenster selbst, nicht der Server,
schreibt „POP3- und SMTP-Server wurden gestartet.“/„… angehalten.“ ins
Log. Die Domain nimmt nur, was `musterDomain` nimmt, und nur angehalten
(das Feld ist sonst gesperrt); die Konten-Liste zeigt danach die neue
Domain, `konten.txt` die alte bis zur nächsten Kontenänderung.
* Neues Konto: ohne Name oder Passwort `emailserver_msg25`, mit Leerzeichen
`msg24`; das Formular hat das Etikett „Vorname und Nachname:“, aber kein
Feld — jedes Konto heißt „Vorname Nachname“. Ein vorhandener Name (auch
in anderer Schreibweise) wird nicht angelegt, das Fenster meldet trotzdem
„Das Benutzerkonto … wurde angelegt.“ Entfernen fragt erst.
* Das Programm: Der Konto-Dialog prüft die Ports (`musterPort`) und den
Benutzernamen (`musterEmailBenutzername`, leer geht), sonst
`emailanwendung_msg46`; der Name wird geteilt (letztes Wort Nachname) und
`/konten.txt` geschrieben. Verfassen prüft die Adressen (`msg20`) und
verlangt einen Empfänger (`msg21`); gesendet schließt das Fenster und
zeigt „Gesendete“, Abrufen den Posteingang. Zeilen: Absendername (sonst
Adresse) bzw. die Empfängerliste wie `holeEmpfaengerListe`; Antworten
schreibt an den Absender, „RE: …“ und den Text zitiert unter „… schrieb:“.
* Löschen löscht in Javas Fenster die Zeile der zuletzt **angeklickten**
Liste: War zuvor eine gesendete Mail gewählt und ist im Posteingang schon
eine markiert, geht die gesendete.
+12 -1
View File
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
fi
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
mkdir -p "$here/.build"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java"
# FILIUS writes its settings below $HOME; keep them out of the real one.
mkdir -p "$here/.build/home"
# Removed first: a crashed run must not leave the previous result standing.
@@ -378,6 +378,17 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -
"$root/packages/core/test/scenarios/web-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at \|AWT-EventQueue' || true
test -s "$output"
# FILIUS' e-mail server and e-mail program windows used as a person does —
# the server switched, its domain set, accounts made and removed; the
# program's account set, mails written, sent, fetched, answered and deleted,
# Java's dialogs answered — on mail-lan's network (MailWindowRun): the room's
# e-mail windows (packages/sim/test/mailWindow.test.ts).
output="$root/packages/sim/test/mailWindow.java.json"
rm -f "$output"
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" MailWindowRun \
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at \|AWT-EventQueue' || true
test -s "$output"
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts)
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
+356
View File
@@ -0,0 +1,356 @@
// Uses FILIUS' own e-mail server and e-mail program windows
// (GUIApplicationEmailServerWindow, GUIApplicationEmailAnwendungWindow on
// their GUIDesktopPanels) on mail-lan's network as a person does: the
// server's start button, its domain field, its form for a new account (the
// button's mouse listener, as Java's window listens), the account list and
// its delete button; the program's account dialog, its compose window (the
// fields and buttons of the window it shows on the desktop), fetching, a mail
// chosen, answered and deleted. Java's message dialogs are answered as a
// person would (OK, "yes" to a question) and their text is written down.
// After each step it writes what the windows show: the server's button,
// domain field, account rows, log and its accounts file; the program's
// folders' rows, preview, the compose window's fields and its accounts file.
// The reference for the editor's e-mail windows
// (packages/sim/test/mailWindow.test.ts).
//
// Usage: java -cp filius.jar:lib/*:classes MailWindowRun <scenario.json> <output json>
import filius.gui.anwendungssicht.GUIApplicationEmailAnwendungWindow;
import filius.gui.anwendungssicht.GUIApplicationEmailServerWindow;
import filius.gui.anwendungssicht.GUIDesktopPanel;
import filius.software.email.EmailServer;
import filius.software.system.Betriebssystem;
import filius.software.system.Datei;
import java.awt.Component;
import java.awt.Container;
import java.awt.Window;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.io.FileOutputStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.swing.AbstractButton;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JTabbedPane;
import javax.swing.JTable;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.table.TableModel;
public class MailWindowRun {
static final String CLIENT = "00000000-0000-0000-0000-000000000201";
static final String SERVER = "00000000-0000-0000-0000-000000000203";
static final List<Object> steps = new ArrayList<>();
/** The texts of Java's message dialogs since the last step. */
static final List<String> dialogs = new ArrayList<>();
static Object field(Class<?> owner, Object object, String name) throws Exception {
Field field = owner.getDeclaredField(name);
field.setAccessible(true);
return field.get(object);
}
static List<List<String>> rows(JTable table) {
List<List<String>> rows = new ArrayList<>();
TableModel model = table.getModel();
for (int row = 0; row < model.getRowCount(); row++) {
List<String> cells = new ArrayList<>();
for (int column = 0; column < model.getColumnCount(); column++) cells.add(String.valueOf(model.getValueAt(row, column)));
rows.add(cells);
}
return rows;
}
static List<String> takeDialogs() {
synchronized (dialogs) {
List<String> taken = new ArrayList<>(dialogs);
dialogs.clear();
return taken;
}
}
/** Answers each message dialog as a person would: OK, or "yes" to a question; writes down its text. */
static void watchDialogs() {
Thread watcher = new Thread(() -> {
while (true) {
for (Window window : Window.getWindows()) {
if (!(window instanceof JDialog) || !window.isVisible()) continue;
JOptionPane pane = find((Container) window, JOptionPane.class);
if (pane == null) continue;
synchronized (dialogs) {
dialogs.add(String.valueOf(pane.getMessage()));
}
pane.setValue(pane.getOptionType() == JOptionPane.DEFAULT_OPTION ? JOptionPane.OK_OPTION : JOptionPane.YES_OPTION);
window.setVisible(false);
}
try {
Thread.sleep(50);
} catch (InterruptedException error) {
return;
}
}
});
watcher.setDaemon(true);
watcher.start();
}
@SuppressWarnings("unchecked")
static <T> T find(Container container, Class<T> kind) {
for (Component component : container.getComponents()) {
if (kind.isInstance(component)) return (T) component;
if (component instanceof Container) {
T found = find((Container) component, kind);
if (found != null) return found;
}
}
return null;
}
static <T> List<T> all(Container container, Class<T> kind, List<T> found) {
for (Component component : container.getComponents()) {
if (kind.isInstance(component)) found.add(kind.cast(component));
if (component instanceof Container) all((Container) component, kind, found);
}
return found;
}
static AbstractButton button(Container container, String text) {
for (AbstractButton each : all(container, AbstractButton.class, new ArrayList<>())) if (text.equals(each.getText())) return each;
throw new IllegalStateException("no button " + text);
}
static void onEdt(Runnable action) throws Exception {
SwingUtilities.invokeLater(action);
Thread.sleep(400);
}
/** A press with the mouse, as Java's server window listens (mousePressed, not an action). */
static void press(AbstractButton button) throws Exception {
onEdt(() -> {
MouseEvent event = new MouseEvent(button, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), 0, 5, 5, 1, false);
for (MouseListener listener : button.getMouseListeners()) listener.mousePressed(event);
});
}
static String file(Betriebssystem system, String path) {
Datei datei = system.getDateisystem().holeDatei(path);
return datei == null ? null : datei.getDateiInhalt();
}
static void serverStep(GUIApplicationEmailServerWindow window, Betriebssystem system, String step) throws Exception {
Class<?> type = GUIApplicationEmailServerWindow.class;
Map<String, Object> result = new LinkedHashMap<>();
result.put("step", step);
result.put("button", ((JButton) field(type, window, "startStopButton")).getText());
JTextField domain = (JTextField) field(type, window, "domainField");
result.put("domain", domain.getText());
result.put("domainEnabled", domain.isEnabled());
result.put("accounts", rows((JTable) field(type, window, "kontenListenTabelle")));
result.put("username", ((JTextField) field(type, window, "benutzernameField")).getText());
result.put("log", ((JTextArea) field(type, window, "logArea")).getText());
result.put("accountsFile", file(system, "/mailserver/konten.txt"));
result.put("dialogs", takeDialogs());
steps.add(result);
}
static void clientStep(GUIApplicationEmailAnwendungWindow window, GUIDesktopPanel desktop, Betriebssystem system, String step) throws Exception {
Class<?> type = GUIApplicationEmailAnwendungWindow.class;
Map<String, Object> result = new LinkedHashMap<>();
result.put("step", step);
result.put("inbox", rows((JTable) field(type, window, "posteingangTable")));
result.put("sent", rows((JTable) field(type, window, "gesendeteTable")));
result.put("tab", ((JTabbedPane) field(type, window, "tabbedPane")).getSelectedIndex());
JEditorPane preview = (JEditorPane) field(type, window, "emailVorschau");
result.put("preview", preview.getContentType().equals("text/plain") ? preview.getText() : "html");
JPanel compose = (JPanel) field(type, window, "verfassenPanel");
boolean composing = compose != null && compose.getParent() != null;
result.put("composing", composing);
if (composing) {
List<String> fields = new ArrayList<>();
for (JTextField each : all(compose, JTextField.class, new ArrayList<>())) fields.add(each.getText());
result.put("composeFields", fields);
result.put("composeText", find(compose, JTextArea.class).getText());
JComboBox<?> sender = find(compose, JComboBox.class);
result.put("composeSender", sender.getSelectedItem() == null ? null : sender.getSelectedItem().toString());
}
result.put("accountFile", file(system, "/konten.txt"));
result.put("dialogs", takeDialogs());
steps.add(result);
}
static void waitFor(java.util.function.BooleanSupplier condition, long milliseconds) throws Exception {
long until = System.currentTimeMillis() + milliseconds;
while (System.currentTimeMillis() < until && !condition.getAsBoolean()) Thread.sleep(100);
Thread.sleep(1500);
}
public static void main(String[] args) throws Exception {
try {
run(args);
} catch (Throwable error) {
error.printStackTrace();
System.exit(1);
}
System.exit(0);
}
static void run(String[] args) throws Exception {
ScenarioNetwork network = ScenarioNetwork.read(args[0]);
Betriebssystem serverSystem = (Betriebssystem) network.nodes.get(SERVER).getSystemSoftware();
Betriebssystem clientSystem = (Betriebssystem) network.nodes.get(CLIENT).getSystemSoftware();
serverSystem.installApp("filius.software.email.EmailServer", false);
clientSystem.installApp("filius.software.email.EmailAnwendung", false);
EmailServer server = (EmailServer) serverSystem.holeSoftware("filius.software.email.EmailServer");
network.start();
Thread.sleep(500);
watchDialogs();
JFrame frame = new JFrame();
GUIDesktopPanel serverDesktop = new GUIDesktopPanel(serverSystem);
GUIDesktopPanel clientDesktop = new GUIDesktopPanel(clientSystem);
frame.getContentPane().setLayout(new java.awt.FlowLayout());
frame.getContentPane().add(serverDesktop);
frame.getContentPane().add(clientDesktop);
GUIApplicationEmailServerWindow serverWindow = (GUIApplicationEmailServerWindow) serverDesktop.starteAnwendung("filius.software.email.EmailServer");
GUIApplicationEmailAnwendungWindow window = (GUIApplicationEmailAnwendungWindow) clientDesktop.starteAnwendung("filius.software.email.EmailAnwendung");
SwingUtilities.invokeAndWait(() -> {
frame.pack();
frame.setVisible(true);
});
Thread.sleep(500);
Class<?> serverType = GUIApplicationEmailServerWindow.class;
JTextField domain = (JTextField) field(serverType, serverWindow, "domainField");
JTextField username = (JTextField) field(serverType, serverWindow, "benutzernameField");
JPasswordField password = (JPasswordField) field(serverType, serverWindow, "passwortField");
JButton startStop = (JButton) field(serverType, serverWindow, "startStopButton");
AbstractButton addAccount = (AbstractButton) field(serverType, serverWindow, "addButton");
serverStep(serverWindow, serverSystem, "server:open");
for (String typed : new String[] { "ungültig domain!", "schule.de" }) {
onEdt(() -> {
domain.setText(typed);
domain.postActionEvent();
});
serverStep(serverWindow, serverSystem, "server:domain " + typed);
}
onEdt(startStop::doClick);
serverStep(serverWindow, serverSystem, "server:start");
for (String[] account : new String[][] { { "anna", "geheim" }, { "", "x" }, { "carl os", "x" }, { "ANNA", "anders" }, { "bob", "" }, { "bob", "pw" } }) {
onEdt(() -> {
username.setText(account[0]);
password.setText(account[1]);
});
press(addAccount);
serverStep(serverWindow, serverSystem, "server:add " + account[0] + "/" + account[1]);
}
// The program: its account, first with a user name Java does not take, then as it should be.
Class<?> clientType = GUIApplicationEmailAnwendungWindow.class;
clientStep(window, clientDesktop, clientSystem, "client:open");
String[][] accounts = { { "Anna Maria Muster", "anna@schule.de", "192.168.0.30", "110", "192.168.0.30", "25", "an na", "geheim" },
{ "Anna Maria Muster", "anna@schule.de", "192.168.0.30", "110x", "192.168.0.30", "25", "anna", "geheim" },
{ "Anna Maria Muster", "anna@schule.de", "192.168.0.30", "110", "192.168.0.30", "25", "anna", "geheim" } };
for (String[] account : accounts) {
onEdt(((AbstractButton) field(clientType, window, "buttonKonten"))::doClick);
String[] names = { "tfName", "tfEmailAdresse", "tfPOP3Server", "tfPOP3Port", "tfSMTPServer", "tfSMTPPort", "tfBenutzername", "tfPasswort" };
onEdt(() -> {
try {
for (int index = 0; index < names.length; index++) ((JTextField) field(clientType, window, names[index])).setText(account[index]);
} catch (Exception error) {
throw new RuntimeException(error);
}
});
onEdt(button(clientDesktop, "Speichern")::doClick);
clientStep(window, clientDesktop, clientSystem, "client:account " + account[6] + " " + account[3]);
// A dialog Java leaves open after a failure: closed as a person would.
for (AbstractButton cancel : all(clientDesktop, AbstractButton.class, new ArrayList<>())) {
if (cancel.getText() != null && cancel.getText().equals("Abbrechen") && cancel.isShowing()) onEdt(cancel::doClick);
}
}
// Opened again, the dialog shows the account.
onEdt(((AbstractButton) field(clientType, window, "buttonKonten"))::doClick);
Map<String, Object> shown = new LinkedHashMap<>();
shown.put("step", "client:account shown");
List<String> fields = new ArrayList<>();
for (String name : new String[] { "tfName", "tfEmailAdresse", "tfPOP3Server", "tfPOP3Port", "tfSMTPServer", "tfSMTPPort", "tfBenutzername", "tfPasswort" }) {
fields.add(((JTextField) field(clientType, window, name)).getText());
}
shown.put("fields", fields);
steps.add(shown);
onEdt(button(clientDesktop, "Abbrechen")::doClick);
// Mails: written in the compose window, with addresses Java does not take, none, then to bob and to anna herself.
String[][] mails = { { "bob@schule.de, kein", "", "", "Falsch", "x" }, { "", "", "", "Leer", "x" }, { "bob@schule.de", "", "", "Hallo", "Zeile 1\nZeile 2" },
{ "anna@schule.de", "", "", "Selbst", "Erste Zeile\nZweite Zeile" } };
for (String[] mail : mails) {
JPanel open = (JPanel) field(clientType, window, "verfassenPanel");
if (open == null || open.getParent() == null) onEdt(((AbstractButton) field(clientType, window, "buttonMailVerfassen"))::doClick);
JPanel compose = (JPanel) field(clientType, window, "verfassenPanel");
onEdt(() -> {
List<JTextField> boxes = all(compose, JTextField.class, new ArrayList<>());
for (int index = 0; index < 4; index++) boxes.get(index).setText(mail[index]);
find(compose, JTextArea.class).setText(mail[4]);
});
int before = ((JTable) field(clientType, window, "gesendeteTable")).getRowCount();
onEdt(button(compose, "Senden")::doClick);
JTable sent = (JTable) field(clientType, window, "gesendeteTable");
if (!mail[0].isEmpty() && !mail[0].contains("kein")) waitFor(() -> sent.getRowCount() > before, 20000);
clientStep(window, clientDesktop, clientSystem, "client:send " + mail[3]);
}
serverStep(serverWindow, serverSystem, "server:after mails");
onEdt(((AbstractButton) field(clientType, window, "buttonMailsAbholen"))::doClick);
JTable inbox = (JTable) field(clientType, window, "posteingangTable");
waitFor(() -> inbox.getRowCount() > 0, 20000);
clientStep(window, clientDesktop, clientSystem, "client:fetch");
onEdt(() -> inbox.setRowSelectionInterval(0, 0));
clientStep(window, clientDesktop, clientSystem, "client:choose inbox 0");
onEdt(((AbstractButton) field(clientType, window, "buttonMailAntworten"))::doClick);
clientStep(window, clientDesktop, clientSystem, "client:answer");
onEdt(button((JPanel) field(clientType, window, "verfassenPanel"), "Abbrechen")::doClick);
JTable sent = (JTable) field(clientType, window, "gesendeteTable");
onEdt(() -> sent.setRowSelectionInterval(0, 0));
clientStep(window, clientDesktop, clientSystem, "client:choose sent 0");
onEdt(() -> inbox.setRowSelectionInterval(0, 0));
onEdt(((AbstractButton) field(clientType, window, "buttonEmailLoeschen"))::doClick);
clientStep(window, clientDesktop, clientSystem, "client:delete inbox 0");
serverStep(serverWindow, serverSystem, "server:after fetch");
// The server: an account chosen and deleted (Java asks), then stopped.
JTable list = (JTable) field(serverType, serverWindow, "kontenListenTabelle");
onEdt(() -> list.setRowSelectionInterval(1, 1));
press((AbstractButton) field(serverType, serverWindow, "deleteButton"));
serverStep(serverWindow, serverSystem, "server:delete row 1");
onEdt(startStop::doClick);
serverStep(serverWindow, serverSystem, "server:stop");
onEdt(() -> {
domain.setText("neu.de");
domain.postActionEvent();
});
serverStep(serverWindow, serverSystem, "server:domain neu.de");
steps.add(Map.of("step", "server:accounts", "accounts", String.valueOf(server.getListeBenutzerkonten().size())));
Map<String, Object> output = new LinkedHashMap<>();
output.put("steps", steps);
try (FileOutputStream out = new FileOutputStream(args[1])) {
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
}
network.stop();
System.out.println("mail window steps: " + steps.size());
}
}