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
+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' });
});
});