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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(';');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
export * from './host.js';
|
||||
export * from './sim.js';
|
||||
export * from './terminal.js';
|
||||
export * from './mail.js';
|
||||
export * from './web.js';
|
||||
|
||||
@@ -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> ')}`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user