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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user