feat(editor): F4.6e the e-mail program and e-mail server on a computer's desktop

The oracle MailWindowRun uses Java's e-mail windows as a person does and
answers their dialogs. The core sets and saves the program's account,
deletes a mail, checks a form as Java's window does, and gives the server
its domain, new and removed accounts and the window's own line; the room
sends both programs' views and takes their actions; the editor has both
windows. Better than Java: a name field for a new account, a user name the
server has is refused, deleting hits the folder shown, a new domain goes
into the accounts file at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:44:36 +00:00
co-authored by Claude Opus 5.5
parent aa54e5400e
commit 11f13bca84
33 changed files with 2841 additions and 52 deletions
+93 -14
View File
@@ -6,6 +6,10 @@
// program also writes how each connection went, as Java tells its
// observers.
//
// The window (GUIApplicationEmailAnwendungWindow, measured with
// tools/java-oracle's MailWindowRun) sets the account — saved to /konten.txt
// as Java saves it — and deletes a mail of a folder.
//
// Departures from Java (emailClientJava.test.ts names each):
// - a connection attempt sends three SYNs, as every TCP connection in the
// core (Java's client sends one, tcpJava.test.ts);
@@ -226,7 +230,61 @@ export interface MailForm {
export type MailFormProblem = 'noAccount' | 'invalidAddress' | 'noRecipient';
/** The window's mailPruefen: each address of the field, if any, as Java's pattern wants it. */
const validField = (field: string) => javaTrim(field) === '' || javaSplit(field, ',').every((each) => isMailAddress(javaTrim(each)));
export const validMailField = (field: string) => javaTrim(field) === '' || javaSplit(field, ',').every((each) => isMailAddress(javaTrim(each)));
/** Why the window does not send a form (its checks before EmailAnwendung.versendeEmail); undefined when it does. */
export function mailFormProblem(form: MailForm, hasAccount: boolean): MailFormProblem | undefined {
if (!hasAccount) return 'noAccount';
if (![form.to, form.cc, form.bcc].every(validMailField)) return 'invalidAddress';
if (addressList(form.to).length + addressList(form.cc).length + addressList(form.bcc).length === 0) return 'noRecipient';
return undefined;
}
/** What the window's account dialog asks (GUIApplicationEmailAnwendungWindow.kontoVerwalten), as typed. */
export interface AccountForm {
readonly name: string;
readonly email: string;
readonly pop3Host: string;
readonly pop3Port: string;
readonly smtpHost: string;
readonly smtpPort: string;
readonly username: string;
readonly password: string;
}
/** EingabenUeberpruefung.musterPort and musterEmailBenutzername (an empty name passes, as in Java). */
const PORT_PATTERN = /^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/;
const MAIL_USERNAME_PATTERN = /^[a-zA-Z0-9._-]*$/;
/** kontoSpeichern's name: its last word the last name, the words before it the first name; one word a first name. */
export function splitName(name: string): { readonly firstName: string; readonly lastName: string } {
if (javaTrim(name) === '') return { firstName: '', lastName: '' };
const words = javaSplit(name, ' ');
if (words.length === 1) return { firstName: javaTrim(name), lastName: '' };
return { firstName: javaTrim(words.slice(0, -1).map((word) => `${word} `).join('')), lastName: words[words.length - 1]! };
}
/**
* GUIApplicationEmailAnwendungWindow.kontoSpeichern: the account from the
* dialog — the name's last word the last name, the words before it the
* first name —, undefined when a port or the user name is not as Java's
* patterns want it (emailanwendung_msg46).
*/
export function accountOfForm(form: AccountForm): EmailClientAccount | undefined {
if (!PORT_PATTERN.test(form.pop3Port) || !PORT_PATTERN.test(form.smtpPort) || !MAIL_USERNAME_PATTERN.test(form.username)) return undefined;
const { firstName, lastName } = splitName(form.name);
return {
pop3Host: form.pop3Host,
pop3Port: Number(form.pop3Port),
smtpHost: form.smtpHost,
smtpPort: Number(form.smtpPort),
username: form.username,
password: form.password,
firstName,
lastName,
email: form.email,
};
}
/** A mail of the program's folders, as the model holds it. */
function entryOf(message: EmailMessage): MailboxEntry {
@@ -246,8 +304,9 @@ function entryOf(message: EmailMessage): MailboxEntry {
};
}
/** The program's folders back in the model: numbered from 1, inbox, sent, drafts. */
/** The program's account and folders back in the model: numbered from 1, inbox, sent, drafts. */
export interface EmailClientFolders {
readonly account: EmailClientAccount | null;
readonly inbox: readonly EmailMessage[];
readonly sent: readonly EmailMessage[];
readonly drafts: readonly EmailMessage[];
@@ -257,7 +316,7 @@ export interface EmailClientFolders {
/** EmailAnwendung: its account, inbox and sent mails. */
export class EmailClient {
readonly log: ProgramLog<EmailClientLogEntry>;
readonly account: EmailClientAccount | null;
private accountValue: EmailClientAccount | null;
readonly inbox: MailboxEntry[];
readonly sent: MailboxEntry[];
private readonly drafts: readonly EmailMessage[];
@@ -270,9 +329,11 @@ export class EmailClient {
private readonly nodeId: Id,
configuration: { readonly account: EmailClientAccount | null; readonly inbox: readonly EmailMessage[]; readonly sent: readonly EmailMessage[]; readonly drafts: readonly EmailMessage[] },
private readonly changed: (folders: EmailClientFolders) => void,
/** EmailAnwendung.speichern: the account into the program's file. */
private readonly saveAccount: (account: EmailClientAccount) => void = () => {},
) {
this.log = new ProgramLog(engine);
this.account = configuration.account;
this.accountValue = configuration.account;
this.inbox = configuration.inbox.map(entryOf);
this.sent = configuration.sent.map(entryOf);
this.drafts = configuration.drafts;
@@ -286,9 +347,9 @@ export class EmailClient {
* connection for each recipient (EmailAnwendung.versendeEmail).
*/
send(form: MailForm): MailFormProblem | undefined {
const account = this.account;
if (account === null) return 'noAccount';
if (![form.to, form.cc, form.bcc].every(validField)) return 'invalidAddress';
const problem = mailFormProblem(form, this.accountValue !== null);
if (problem !== undefined) return problem;
const account = this.accountValue!;
const mail: Mail = {
// AddressEntry trims the name: without a last name, Java's window leaves out the space.
sender: addressEntry(`${account.firstName} ${account.lastName} <${account.email}>`),
@@ -299,19 +360,18 @@ export class EmailClient {
text: form.text,
dateReceived: '',
};
if (mail.to.length + mail.cc.length + mail.bcc.length === 0) return 'noRecipient';
// Java joins the addresses into a comma list and reads them back (EmailAnwendung.versendeEmail, SMTPClient.versendeEmail).
const recipients = [...mail.to, ...mail.cc, ...mail.bcc].map((each) => each.mailAddress).filter((each): each is string => each !== null);
const entry: MailboxEntry = { mail, isNew: true, isMarkedForDeletion: false };
for (const recipient of recipients) {
this.smtp.hand(() => this.sendTo(entry, recipient));
this.smtp.hand(() => this.sendTo(account, entry, recipient));
}
this.engine.resumeThreads();
return undefined;
}
private *sendTo(entry: MailboxEntry, recipient: string): Steps {
const account = this.account!;
/** With the account the mail was sent from, as Java's window hands it over. */
private *sendTo(account: EmailClientAccount, entry: MailboxEntry, recipient: string): Steps {
const sent = yield* sendMailSteps(this.engine, this.nodeId, (each) => this.log.write(each), account.smtpHost, account.smtpPort, entry.mail, account.email, recipient);
if (sent && !this.sent.includes(entry)) {
this.sent.push(entry);
@@ -321,7 +381,7 @@ export class EmailClient {
/** The window's fetch button: the account's mails from its POP3 server (EmailAnwendung.emailsAbholenEmails). */
fetch(): boolean {
const account = this.account;
const account = this.accountValue;
if (account === null) return false;
this.pop3.hand(() =>
fetchMailSteps(this.engine, this.nodeId, (each) => this.log.write(each), account.pop3Host, account.pop3Port, account.username, account.password, (mail) => {
@@ -333,12 +393,31 @@ export class EmailClient {
return true;
}
/** The folders as the model keeps them. */
get account(): EmailClientAccount | null {
return this.accountValue;
}
/** The account dialog's "save" (EmailAnwendung.setzeKonto, speichern): the program's one account, into its file. */
setAccount(account: EmailClientAccount): void {
this.accountValue = account;
this.saveAccount(account);
this.changed(this.folders());
}
/** EmailAnwendung.removeReceivedMail, removeSentMail: a mail of a folder, by its row; nothing for a row it has not. */
removeMail(folder: 'inbox' | 'sent', index: number): void {
const mails = folder === 'inbox' ? this.inbox : this.sent;
if (!Number.isInteger(index) || index < 0 || index >= mails.length) return;
mails.splice(index, 1);
this.changed(this.folders());
}
/** The account and folders as the model keeps them. */
folders(): EmailClientFolders {
let id = 1;
const inbox = this.inbox.map((entry) => emailMessage(entry, id++));
const sent = this.sent.map((entry) => ({ ...emailMessage(entry, id++), isSent: true }));
const drafts = this.drafts.map((message) => ({ ...message, id: id++ }));
return { inbox, sent, drafts, nextMessageID: id };
return { account: this.accountValue, inbox, sent, drafts, nextMessageID: id };
}
}
+48 -4
View File
@@ -3,6 +3,16 @@
// SMTP on port 25 takes mails and stores those for its accounts, POP3 on
// port 110 hands an account its mails. Switched on and off together. The
// window shows each connection's messages both ways and each mail stored.
// From the window (GUIApplicationEmailServerWindow, measured with
// tools/java-oracle's MailWindowRun) the server is switched — the window
// writing a line of its own —, its domain set while it is off, an account
// made or removed.
//
// Departures from Java's window: an account it makes has the name typed (Java
// asks for it and has no field: every account is "Vorname Nachname"; still
// so without a name, as Java's server takes no new account while one has an
// empty name); a user name the server has already is refused (Java's window
// says it made it); an empty domain is none (Java's pattern takes it).
//
// Departures from Java (emailServer.test.ts names each):
// SMTP
@@ -38,6 +48,7 @@ import { pause, type Steps } from '../threads.js';
import { sendMailSteps, SMTP_SERVER_PORT, type SmtpClientLogEntry } from './emailClient.js';
import { CommandThread, ProgramLog } from './program.js';
import type { PageLanguage } from './routerPages.js';
import { isDomainName } from './web.js';
import type { EmailServerSnapshot } from '../snapshot.js';
import { TcpServerProgram } from './server.js';
@@ -56,6 +67,8 @@ export type EmailServerLogEntry =
| { readonly kind: 'sendFailed'; readonly address: string }
/** sw_smtpmitarbeiter_msg12, msg13: a mail stored in the account's mailbox. */
| { readonly kind: 'stored'; readonly username: string }
/** emailserver_msg2, msg3: the window's own line when its button switched the server. */
| { readonly kind: 'switched'; readonly active: boolean }
/** What the SMTP client tells the server when it sends a mail on. */
| SmtpClientLogEntry;
@@ -126,7 +139,7 @@ export interface MailOutbox {
/** What the server's sessions share: its domain, accounts and window. */
export class MailServerState {
constructor(
readonly domain: string,
public domain: string,
readonly accounts: MailAccount[],
readonly language: PageLanguage,
readonly log: (entry: EmailServerLogEntry) => void,
@@ -499,7 +512,7 @@ export class EmailServer {
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
configuration: EmailServerSnapshot,
changed: (accounts: readonly EmailServerAccount[]) => void,
changed: (accounts: readonly EmailServerAccount[], domain: string) => void,
ipAddress: () => string,
now: () => number,
) {
@@ -516,7 +529,7 @@ export class EmailServer {
accounts,
engine.state.topologySnapshot.language,
(entry) => this.log.write(entry),
() => changed(this.accounts()),
() => changed(this.accounts(), this.state.domain),
ipAddress,
now,
{
@@ -537,9 +550,40 @@ export class EmailServer {
return this.smtp.isActive;
}
setActive(active: boolean): void {
/** Both servers on or off; from the window's button, with its line (emailserver_msg2, msg3). */
setActive(active: boolean, fromWindow = false): void {
this.pop3.setActive(active);
this.smtp.setActive(active);
if (fromWindow) this.log.write({ kind: 'switched', active });
}
get domain(): string {
return this.state.domain;
}
/** EmailServer.setMailDomain: a domain name as Java's pattern takes it, not empty; false for none. The window takes it only while the server is off. */
setDomain(domain: string): boolean {
if (domain === '' || !isDomainName(domain)) return false;
this.state.domain = domain;
this.state.changed();
return true;
}
/** EmailServer.benutzerHinzufuegen: a new account, empty; false when the server has the user name already, in any case. */
addAccount(account: { readonly username: string; readonly password: string; readonly firstName: string; readonly lastName: string }): boolean {
if (this.state.account(account.username) !== undefined) return false;
this.state.accounts.push({ ...account, mailbox: [] });
this.state.changed();
return true;
}
/** EmailServer.kontoLoeschen: the account of the user name, as written; false when there is none. */
removeAccount(username: string): boolean {
const index = this.state.accounts.findIndex((account) => account.username === username);
if (index < 0) return false;
this.state.accounts.splice(index, 1);
this.state.changed();
return true;
}
/**
+5
View File
@@ -268,6 +268,11 @@ const DOMAIN_PATTERN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]){0,1}(\.[a-zA-Z
export const IP_ADDRESS_PATTERN =
/^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
/** EingabenUeberpruefung.musterDomain: a domain name (Java's pattern also takes the empty text). */
export function isDomainName(text: string): boolean {
return DOMAIN_PATTERN.test(text);
}
function isHostName(text: string): boolean {
return DOMAIN_PATTERN.test(text) || IP_ADDRESS_PATTERN.test(text);
}
+24 -1
View File
@@ -7,10 +7,13 @@
// and a character a field cannot carry in the format (a separator, a line
// break) becomes a space, with a warning.
import type { EmailAddress, EmailServerAccount } from './config.js';
import type { EmailAddress, EmailClientAccount, EmailServerAccount } from './config.js';
export const EMAIL_SERVER_ACCOUNTS_FILE = '/mailserver/konten.txt';
/** The e-mail program's account file (EmailAnwendung.speichern), in the root. */
export const EMAIL_CLIENT_ACCOUNT_FILE = '/konten.txt';
/** A field's characters the format cannot carry, each a space, with a warning. */
export function accountFileField(value: string, forbidden: RegExp, what: string, warn: (message: string) => void): string {
if (!forbidden.test(value)) return value;
@@ -64,3 +67,23 @@ export function serverAccountsFileText(domain: string, accounts: readonly EmailS
})
.join('');
}
/**
* EmailKonto.toString, as the e-mail program saves its account
* (EmailAnwendung.speichern) and the project export writes it: nine fields;
* a field may hold no ';' and no line break.
*/
export function clientAccountFileText(account: EmailClientAccount, warn: (message: string) => void = () => {}): string {
const text = (value: string, what: string) => accountFileField(value, /[;\r\n]/, `${EMAIL_CLIENT_ACCOUNT_FILE}: ${what}`, warn);
return [
text(account.pop3Host, 'POP3 server'),
text(account.smtpHost, 'SMTP server'),
String(account.pop3Port),
String(account.smtpPort),
text(account.username, 'user name'),
text(account.password, 'password'),
text(account.lastName, 'last name'),
text(account.firstName, 'first name'),
text(account.email, 'address'),
].join(';');
}
+17 -7
View File
@@ -124,7 +124,7 @@ import { Terminal } from './apps/terminal.js';
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
import { parentPath, VirtualFileSystem } from '../model/virtualFileSystem.js';
import { EMAIL_SERVER_ACCOUNTS_FILE, serverAccountsFileText } from './emailAccountsFile.js';
import { clientAccountFileText, EMAIL_CLIENT_ACCOUNT_FILE, EMAIL_SERVER_ACCOUNTS_FILE, serverAccountsFileText } from './emailAccountsFile.js';
import { GNUTELLA_FOLDER, GnutellaPeer } from './apps/gnutella.js';
import {
PRODUCTION_TRACE_RETENTION,
@@ -1203,18 +1203,19 @@ export class NetworkRuntimeEngine {
}
/** An e-mail server's accounts as a run changed them, into the snapshot (and so into the project saved from it). */
private setEmailServerAccounts(nodeId: Id, accounts: readonly EmailServerAccount[]): void {
private setEmailServerAccounts(nodeId: Id, accounts: readonly EmailServerAccount[], domain: string): void {
const servers = new Map(this.stateValue.topologySnapshot.emailServersByNodeId);
const server = servers.get(nodeId);
if (server === undefined) return;
servers.set(nodeId, { ...server, accounts });
servers.set(nodeId, { ...server, accounts, domain });
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { emailServersByNodeId: servers });
this.writeEmailServerAccountsFile(nodeId, server.domain, accounts);
this.writeEmailServerAccountsFile(nodeId, domain, accounts);
}
/**
* The server's account file (EmailServer.kontenSpeichern), written from its
* accounts at its start and whenever they change. Java makes it empty at
* accounts at its start and whenever they or its domain change (Java writes
* a new domain only with the next change of its accounts). Java makes it empty at
* its start when it is missing (and reads it), and writes it when an
* account is made or removed, after a POP3 command, after an SMTP mail was
* taken and when it stops — but before the notice to a local sender, which
@@ -1305,7 +1306,7 @@ export class NetworkRuntimeEngine {
this,
nodeId,
emailServer,
(accounts) => this.setEmailServerAccounts(nodeId, accounts),
(accounts, domain) => this.setEmailServerAccounts(nodeId, accounts, domain),
() => this.networkInterfaces(nodeId)[0]?.ipAddress ?? 'null',
this.wallClock,
),
@@ -1324,7 +1325,16 @@ export class NetworkRuntimeEngine {
return;
}
case 'emailClient':
this.emailClientsByNodeId.set(nodeId, new EmailClient(this, nodeId, snapshot.emailClientsByNodeId.get(nodeId)!, (folders) => this.setEmailClientFolders(nodeId, folders)));
this.emailClientsByNodeId.set(
nodeId,
new EmailClient(
this,
nodeId,
snapshot.emailClientsByNodeId.get(nodeId)!,
(folders) => this.setEmailClientFolders(nodeId, folders),
(account) => this.nodeFiles(nodeId).writeTextFile(EMAIL_CLIENT_ACCOUNT_FILE, clientAccountFileText(account)),
),
);
return;
}
}