feat(core): the e-mail program after Java, measured against Java
The program sends to its account's SMTP server, one connection for each recipient, and fetches its mails by POP3; the server now sends mails on to other domains by MX and returns notices to their senders. The program's folders are in the snapshot and go back into the project. EmailClientRun plays six stories with two servers; the mail oracles run in the language's locale, so Java's notice dates match the core's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ export * from './runtime/apps/web.js';
|
||||
export * from './runtime/apps/routerPages.js';
|
||||
export * from './runtime/apps/firewallSettings.js';
|
||||
export * from './runtime/apps/emailServer.js';
|
||||
export * from './runtime/apps/emailClient.js';
|
||||
export * from './runtime/email.js';
|
||||
export * from './runtime/http.js';
|
||||
export * from './util/equal.js';
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// FILIUS' e-mail program (filius.software.email: EmailAnwendung, SMTPClient,
|
||||
// POP3Client) and the SMTP client its server sends mails on with. The
|
||||
// program sends a mail to its account's SMTP server, one connection for each
|
||||
// recipient, and fetches the account's mails from its POP3 server (each
|
||||
// retrieved, then deleted). The window shows a failure's message; the
|
||||
// program also writes how each connection went, as Java tells its
|
||||
// observers.
|
||||
//
|
||||
// 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);
|
||||
// - the account's SMTP port counts (Java always connects to 25).
|
||||
|
||||
import type { Id } from '../../ids.js';
|
||||
import type { EmailClientAccount, EmailMessage } from '../config.js';
|
||||
import { addressEntry, addressList, emailMessage, formatMail, isMailAddress, javaSplit, parseMail, type Mail, type MailboxEntry } from '../email.js';
|
||||
import type { NetworkRuntimeEngine } from '../engine.js';
|
||||
import { javaTrim } from '../http.js';
|
||||
import { pause, type Steps } from '../threads.js';
|
||||
import type { ConnectFailure } from './echo.js';
|
||||
import { CommandThread, ProgramLog } from './program.js';
|
||||
|
||||
export const SMTP_SERVER_PORT = 25;
|
||||
|
||||
/** Why a mail could not be sent or fetched: the server's answer, or the connection. */
|
||||
export type MailFailure =
|
||||
/** An answer the client did not expect (Java throws Exception(answer)). */
|
||||
| { readonly kind: 'answer'; readonly text: string }
|
||||
/** The connection could not be made (TCPSocket's exceptions). */
|
||||
| { readonly kind: 'connect'; readonly failure: ConnectFailure }
|
||||
/** The connection ended while the client waited for an answer (sw_tcpsocket_msg9). */
|
||||
| { readonly kind: 'receiveFailed' };
|
||||
|
||||
/** What the SMTP client tells the program it works for (EmailAnwendung or EmailServer). */
|
||||
export type SmtpClientLogEntry =
|
||||
/** EmailServer.LINE_SEPARATOR: a connection begins. */
|
||||
| { readonly kind: 'connectionStarting' }
|
||||
/** sw_smtpclient_msg1, msg2. */
|
||||
| { readonly kind: 'smtpConnected'; readonly address: string; readonly port: number }
|
||||
/** sw_smtpclient_msg4 and the recipient. */
|
||||
| { readonly kind: 'mailSent'; readonly recipients: string }
|
||||
/** sw_smtpclient_msg1, msg3. */
|
||||
| { readonly kind: 'smtpDisconnected'; readonly address: string; readonly port: number }
|
||||
| { readonly kind: 'failed'; readonly failure: MailFailure };
|
||||
|
||||
export type EmailClientLogEntry = SmtpClientLogEntry;
|
||||
|
||||
const encode = (text: string) => new TextEncoder().encode(text);
|
||||
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
|
||||
|
||||
/** EingabenUeberpruefung.musterMindEinZeichen: at least one character, no line break. */
|
||||
const hasCharacters = (text: string) => /^[^\n\r\u0085\u2028\u2029]+$/.test(text);
|
||||
|
||||
/** A thrown failure of a dialogue (Java's exceptions). */
|
||||
class DialogueFailure extends Error {
|
||||
constructor(readonly failure: MailFailure) {
|
||||
super(failure.kind);
|
||||
}
|
||||
}
|
||||
|
||||
/** A client's connection to a server, as Java's TCPSocket of a ClientAnwendung. */
|
||||
class ClientConnection {
|
||||
private constructor(
|
||||
private readonly engine: NetworkRuntimeEngine,
|
||||
readonly socketId: Id,
|
||||
readonly address: string,
|
||||
readonly port: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* initialisiereSocket: the address (or name) resolved in the program's
|
||||
* thread, then connected by the new socket's thread, which begins after
|
||||
* what is already awake at this time (the last connection's close sends
|
||||
* its FIN first); the failure otherwise.
|
||||
*/
|
||||
static *open(engine: NetworkRuntimeEngine, nodeId: Id, host: string, port: number): Steps<ClientConnection | ConnectFailure> {
|
||||
const address = yield* engine.resolveHostSteps(nodeId, host);
|
||||
if (address === undefined) return 'unresolved';
|
||||
yield pause(engine.state.currentTimeMilliseconds);
|
||||
const socketId = engine.openTcpClientSocket(nodeId, address, port);
|
||||
const result = socketId === undefined ? 'invalidSocket' : yield* engine.connectTcpSteps(socketId);
|
||||
if (result !== 'connected') return result === 'timedOut' ? 'timedOut' : 'failed';
|
||||
return new ClientConnection(engine, socketId!, address, port);
|
||||
}
|
||||
|
||||
*send(text: string): Steps {
|
||||
yield* this.engine.sendTcpSteps(this.socketId, encode(text));
|
||||
}
|
||||
|
||||
/** TCPSocket.empfangen: the next message; a failure when the connection ends. */
|
||||
*receive(): Steps<string> {
|
||||
const received = yield* this.engine.receiveTcpSteps(this.socketId);
|
||||
if (received.type !== 'message') throw new DialogueFailure({ kind: 'receiveFailed' });
|
||||
return decode(received.payload);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.engine.requestTcpClose(this.socketId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTPClient.initialisiereSocket, versenden, schliesseSocket: one mail to
|
||||
* one server for the recipients given (a comma list), told to `log`; true
|
||||
* when the server took it.
|
||||
*/
|
||||
export function* sendMailSteps(
|
||||
engine: NetworkRuntimeEngine,
|
||||
nodeId: Id,
|
||||
log: (entry: SmtpClientLogEntry) => void,
|
||||
server: string,
|
||||
port: number,
|
||||
mail: Mail,
|
||||
sender: string,
|
||||
recipients: string,
|
||||
): Steps<boolean> {
|
||||
log({ kind: 'connectionStarting' });
|
||||
const connection = yield* ClientConnection.open(engine, nodeId, server, port);
|
||||
if (typeof connection === 'string') {
|
||||
log({ kind: 'failed', failure: { kind: 'connect', failure: connection } });
|
||||
return false;
|
||||
}
|
||||
log({ kind: 'smtpConnected', address: connection.address, port: connection.port });
|
||||
let sent = false;
|
||||
try {
|
||||
sent = yield* smtpDialogue(engine, nodeId, connection, mail, sender, recipients);
|
||||
log({ kind: 'mailSent', recipients });
|
||||
} catch (error) {
|
||||
if (!(error instanceof DialogueFailure)) throw error;
|
||||
log({ kind: 'failed', failure: error.failure });
|
||||
}
|
||||
connection.close();
|
||||
log({ kind: 'smtpDisconnected', address: connection.address, port: connection.port });
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** SMTPClient.versenden: HELO, MAIL FROM, RCPT TO, DATA, the mail, QUIT; false where Java gives up without a word. */
|
||||
function* smtpDialogue(engine: NetworkRuntimeEngine, nodeId: Id, connection: ClientConnection, mail: Mail, sender: string, recipients: string): Steps<boolean> {
|
||||
const expect = function* (command: string, accepted: (answer: string) => boolean): Steps {
|
||||
yield* connection.send(command);
|
||||
const answer = yield* connection.receive();
|
||||
if (!accepted(answer)) throw new DialogueFailure({ kind: 'answer', text: answer });
|
||||
};
|
||||
const code = (answer: string) => answer.slice(0, 3);
|
||||
if (!(yield* connection.receive()).startsWith('220')) return false;
|
||||
yield* expect(`HELO ${engine.networkInterfaces(nodeId)[0]?.ipAddress ?? 'null'}`, (answer) => answer.startsWith('250'));
|
||||
const senderAddress = addressEntry(sender).mailAddress;
|
||||
if (senderAddress === null || (senderAddress !== '' && !isMailAddress(senderAddress))) return false;
|
||||
yield* expect(`MAIL FROM: <${senderAddress}>`, (answer) => code(answer) === '250');
|
||||
for (const recipient of javaSplit(recipients, ',')) {
|
||||
if (!isMailAddress(recipient)) return false;
|
||||
yield* expect(`RCPT TO:<${recipient}>`, (answer) => code(answer) === '250' || code(answer) === '251');
|
||||
}
|
||||
yield* expect('DATA', (answer) => code(answer) === '354');
|
||||
yield* expect(`${formatMail(mail)}\r\n.\r\n`, (answer) => answer.startsWith('250'));
|
||||
yield* connection.send('QUIT');
|
||||
yield* connection.receive();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP3Client.emailsHolen: connected to the server, logged in, each mail
|
||||
* retrieved (and handed to `received`) and deleted, then QUIT; a failure
|
||||
* told to `log`.
|
||||
*/
|
||||
export function* fetchMailSteps(
|
||||
engine: NetworkRuntimeEngine,
|
||||
nodeId: Id,
|
||||
log: (entry: EmailClientLogEntry) => void,
|
||||
server: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
received: (mail: Mail) => void,
|
||||
): Steps {
|
||||
const connection = yield* ClientConnection.open(engine, nodeId, server, port);
|
||||
if (typeof connection === 'string') {
|
||||
log({ kind: 'failed', failure: { kind: 'connect', failure: connection } });
|
||||
return;
|
||||
}
|
||||
const expect = function* (command: string): Steps<string> {
|
||||
yield* connection.send(command);
|
||||
const answer = yield* connection.receive();
|
||||
if (!answer.startsWith('+OK')) throw new DialogueFailure({ kind: 'answer', text: answer });
|
||||
return answer;
|
||||
};
|
||||
try {
|
||||
// Java's chain of erfolg: a step that cannot be taken ends it without a word; QUIT comes in any case.
|
||||
if ((yield* connection.receive()).startsWith('+') && hasCharacters(username)) {
|
||||
yield* expect(`USER ${username}`);
|
||||
if (hasCharacters(password)) {
|
||||
yield* expect(`PASS ${password}`);
|
||||
const count = Number.parseInt(javaSplit(yield* expect('STAT'), ' ')[1] ?? '', 10);
|
||||
const numbers =
|
||||
count > 0
|
||||
? javaSplit(yield* expect('LIST'), '\n')
|
||||
.slice(1)
|
||||
.map((line) => Number.parseInt(javaSplit(line, ' ')[0] ?? '', 10))
|
||||
: [];
|
||||
for (const number of numbers) {
|
||||
const answer = yield* expect(`RETR ${number}`);
|
||||
const lineEnd = answer.indexOf('\n');
|
||||
if (lineEnd > 0) received(parseMail(answer.slice(lineEnd + 1)));
|
||||
yield* expect(`DELE ${number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* expect('QUIT');
|
||||
} catch (error) {
|
||||
if (!(error instanceof DialogueFailure)) throw error;
|
||||
log({ kind: 'failed', failure: error.failure });
|
||||
}
|
||||
connection.close();
|
||||
}
|
||||
|
||||
/** What the program's window asks for a mail (GUIApplicationEmailAnwendungWindow). */
|
||||
export interface MailForm {
|
||||
readonly to: string;
|
||||
readonly cc: string;
|
||||
readonly bcc: string;
|
||||
readonly subject: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/** Why the window does not send (emailanwendung_msg47, msg20, msg21). */
|
||||
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)));
|
||||
|
||||
/** A mail of the program's folders, as the model holds it. */
|
||||
function entryOf(message: EmailMessage): MailboxEntry {
|
||||
const sender = message.from.name === null && message.from.mailAddress === null ? null : message.from;
|
||||
return {
|
||||
mail: {
|
||||
sender,
|
||||
to: message.to,
|
||||
cc: message.cc,
|
||||
bcc: message.bcc,
|
||||
subject: message.subject,
|
||||
text: message.body,
|
||||
dateReceived: message.receivedAtMilliseconds === undefined ? '' : String(message.receivedAtMilliseconds),
|
||||
},
|
||||
isNew: message.isNew,
|
||||
isMarkedForDeletion: message.isMarkedForDeletion,
|
||||
};
|
||||
}
|
||||
|
||||
/** The program's folders back in the model: numbered from 1, inbox, sent, drafts. */
|
||||
export interface EmailClientFolders {
|
||||
readonly inbox: readonly EmailMessage[];
|
||||
readonly sent: readonly EmailMessage[];
|
||||
readonly drafts: readonly EmailMessage[];
|
||||
readonly nextMessageID: number;
|
||||
}
|
||||
|
||||
/** EmailAnwendung: its account, inbox and sent mails. */
|
||||
export class EmailClient {
|
||||
readonly log: ProgramLog<EmailClientLogEntry>;
|
||||
readonly account: EmailClientAccount | null;
|
||||
readonly inbox: MailboxEntry[];
|
||||
readonly sent: MailboxEntry[];
|
||||
private readonly drafts: readonly EmailMessage[];
|
||||
/** The SMTP client's and the POP3 client's threads. */
|
||||
private readonly smtp: CommandThread;
|
||||
private readonly pop3: CommandThread;
|
||||
|
||||
constructor(
|
||||
private readonly engine: NetworkRuntimeEngine,
|
||||
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,
|
||||
) {
|
||||
this.log = new ProgramLog(engine);
|
||||
this.account = configuration.account;
|
||||
this.inbox = configuration.inbox.map(entryOf);
|
||||
this.sent = configuration.sent.map(entryOf);
|
||||
this.drafts = configuration.drafts;
|
||||
this.smtp = new CommandThread(engine);
|
||||
this.pop3 = new CommandThread(engine);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's send button: the mail made from the form (the account's
|
||||
* name and address as sender), sent to the account's SMTP server, one
|
||||
* 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 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}>`),
|
||||
to: addressList(form.to),
|
||||
cc: addressList(form.cc),
|
||||
bcc: addressList(form.bcc),
|
||||
subject: form.subject,
|
||||
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.engine.resumeThreads();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private *sendTo(entry: MailboxEntry, recipient: string): Steps {
|
||||
const account = this.account!;
|
||||
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);
|
||||
this.changed(this.folders());
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's fetch button: the account's mails from its POP3 server (EmailAnwendung.emailsAbholenEmails). */
|
||||
fetch(): boolean {
|
||||
const account = this.account;
|
||||
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) => {
|
||||
this.inbox.push({ mail, isNew: true, isMarkedForDeletion: false });
|
||||
this.changed(this.folders());
|
||||
}),
|
||||
);
|
||||
this.engine.resumeThreads();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The 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 };
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,13 @@
|
||||
// connection ends).
|
||||
|
||||
import type { Id } from '../../ids.js';
|
||||
import type { EmailMessage, EmailServerAccount } from '../config.js';
|
||||
import { addressEntry, addressList, addressText, formatMail, javaSplit, mailSize, parseMail, type Mail } from '../email.js';
|
||||
import type { EmailServerAccount } from '../config.js';
|
||||
import { addressEntry, addressText, emailMessage, formatMail, javaSplit, mailboxEntry, mailSize, parseMail, type Mail, type MailboxEntry } from '../email.js';
|
||||
import type { NetworkRuntimeEngine } from '../engine.js';
|
||||
import { javaTrim } from '../http.js';
|
||||
import type { Steps } from '../threads.js';
|
||||
import { ProgramLog } from './program.js';
|
||||
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 type { EmailServerSnapshot } from '../snapshot.js';
|
||||
import { TcpServerProgram } from './server.js';
|
||||
@@ -54,14 +55,9 @@ export type EmailServerLogEntry =
|
||||
/** sw_smtpmitarbeiter_msg14: the answer could not be sent. */
|
||||
| { readonly kind: 'sendFailed'; readonly address: string }
|
||||
/** sw_smtpmitarbeiter_msg12, msg13: a mail stored in the account's mailbox. */
|
||||
| { readonly kind: 'stored'; readonly username: string };
|
||||
|
||||
/** A message in a mailbox, as POP3 sees it. */
|
||||
export interface MailboxEntry {
|
||||
mail: Mail;
|
||||
isNew: boolean;
|
||||
isMarkedForDeletion: boolean;
|
||||
}
|
||||
| { readonly kind: 'stored'; readonly username: string }
|
||||
/** What the SMTP client tells the server when it sends a mail on. */
|
||||
| SmtpClientLogEntry;
|
||||
|
||||
export interface MailAccount {
|
||||
readonly username: string;
|
||||
@@ -114,6 +110,19 @@ export interface MailConnection {
|
||||
send(text: string): Steps<boolean>;
|
||||
}
|
||||
|
||||
/** How the server sends a mail to other domains (EmailServer.weiterleiten with its SMTPClient). */
|
||||
export interface MailOutbox {
|
||||
/** EmailServer.emailWeiterleiten: later, by the server's own thread. */
|
||||
forward(mail: Mail, sender: string, recipients: readonly string[]): void;
|
||||
/**
|
||||
* SMTPClient.versendeEmail(null, …) in the calling thread: each
|
||||
* recipient's mail exchange looked up (Resolver.holeIPAdresseMailServer),
|
||||
* the mails then sent in a thread of their own, one after the other; the
|
||||
* recipients without a mail exchange.
|
||||
*/
|
||||
lookUpAndSend(mail: Mail, sender: string, recipients: readonly string[]): Steps<string[]>;
|
||||
}
|
||||
|
||||
/** What the server's sessions share: its domain, accounts and window. */
|
||||
export class MailServerState {
|
||||
constructor(
|
||||
@@ -126,6 +135,7 @@ export class MailServerState {
|
||||
/** The server's first address (primaryIPAdresse) and the wall clock, for a notice. */
|
||||
private readonly ipAddress: () => string,
|
||||
private readonly now: () => number,
|
||||
private readonly outbox: MailOutbox,
|
||||
) {}
|
||||
|
||||
/** EmailServer.sucheKonto: by name in any case. */
|
||||
@@ -140,9 +150,14 @@ export class MailServerState {
|
||||
return at >= 0 && address.slice(at + 1).toLowerCase() === this.domain.toLowerCase();
|
||||
}
|
||||
|
||||
/** SMTPMitarbeiter.verarbeiteEmail: each of the domain's recipients gets the mail as Java reads it anew. */
|
||||
deliver(mail: Mail, sender: string, recipients: readonly string[]): void {
|
||||
/**
|
||||
* SMTPMitarbeiter.verarbeiteEmail: each of the domain's recipients gets
|
||||
* the mail as Java reads it anew; the others' go on to their domains (by
|
||||
* the server's thread); the sender hears of the unknown ones.
|
||||
*/
|
||||
*deliver(mail: Mail, sender: string, recipients: readonly string[]): Steps {
|
||||
const unknown: string[] = [];
|
||||
const elsewhere: string[] = [];
|
||||
for (const recipient of recipients) {
|
||||
const account = this.isOwnDomain(recipient) ? this.account(javaSplit(recipient, '@')[0] ?? '') : undefined;
|
||||
if (account !== undefined) {
|
||||
@@ -150,20 +165,31 @@ export class MailServerState {
|
||||
this.log({ kind: 'stored', username: account.username });
|
||||
} else if (this.isOwnDomain(recipient) || !recipient.includes('@')) {
|
||||
unknown.push(recipient);
|
||||
} else {
|
||||
elsewhere.push(recipient);
|
||||
}
|
||||
// Other domains: sent on with the e-mail program (F3.3b).
|
||||
}
|
||||
this.changed();
|
||||
if (unknown.length > 0) this.notifyUnknown(mail, sender, unknown);
|
||||
// Java sends on the mail as it reads it anew; written for sending, that is the same text.
|
||||
if (elsewhere.length > 0) this.outbox.forward(mail, sender, elsewhere);
|
||||
if (unknown.length > 0) yield* this.notifyUnknown(mail, sender, unknown);
|
||||
}
|
||||
|
||||
/** EmailServer.sendUnknownReceiverResponse: a notice to a sender of the domain; none for an empty sender. */
|
||||
private notifyUnknown(mail: Mail, sender: string, unknown: readonly string[]): void {
|
||||
const address = addressEntry(sender).mailAddress;
|
||||
// An empty sender (<>) has no address of the domain.
|
||||
if (address === null || !this.isOwnDomain(address)) return;
|
||||
const account = this.account(javaSplit(address, '@')[0] ?? '');
|
||||
if (account === undefined) return;
|
||||
/** EmailServer.weiterleiten: the mail sent on; the sender hears of recipients without a mail exchange. */
|
||||
*sendOn(mail: Mail, sender: string, recipients: readonly string[]): Steps {
|
||||
const unknown = yield* this.outbox.lookUpAndSend(mail, sender, recipients);
|
||||
if (unknown.length > 0) yield* this.notifyUnknown(mail, sender, unknown);
|
||||
}
|
||||
|
||||
/**
|
||||
* EmailServer.sendUnknownReceiverResponse: a notice to the sender, stored
|
||||
* when the sender is the domain's, else sent on with an empty sender; none
|
||||
* for an empty sender.
|
||||
*/
|
||||
private *notifyUnknown(mail: Mail, sender: string, unknown: readonly string[]): Steps {
|
||||
const senderEntry = addressEntry(sender);
|
||||
const address = senderEntry.mailAddress;
|
||||
if (address === null || address === '') return;
|
||||
const text =
|
||||
'The mail could not be delivered either because user or mail domain is unknown.\n' +
|
||||
'Message could not be delivered to the following recipients:\n' +
|
||||
@@ -181,6 +207,12 @@ export class MailServerState {
|
||||
text,
|
||||
dateReceived: '',
|
||||
};
|
||||
if (!this.isOwnDomain(address)) {
|
||||
yield* this.sendOn(notice, '<>', [addressText(senderEntry)!]);
|
||||
return;
|
||||
}
|
||||
const account = this.account(javaSplit(address, '@')[0] ?? '');
|
||||
if (account === undefined) return;
|
||||
account.mailbox.push({ mail: notice, isNew: true, isMarkedForDeletion: false });
|
||||
this.log({ kind: 'stored', username: account.username });
|
||||
this.changed();
|
||||
@@ -236,7 +268,7 @@ export class SmtpSession {
|
||||
const recipients = this.recipients;
|
||||
this.state = 'envelope';
|
||||
this.resetEnvelope();
|
||||
this.server.deliver(mail, sender, recipients);
|
||||
yield* this.server.deliver(mail, sender, recipients);
|
||||
} else {
|
||||
this.source += message;
|
||||
}
|
||||
@@ -418,43 +450,6 @@ export class Pop3Session {
|
||||
}
|
||||
}
|
||||
|
||||
/** A message of the model as Java has it after loading the server's file: new, not deleted. */
|
||||
export function mailboxEntry(message: EmailMessage): MailboxEntry {
|
||||
const reread = (entries: EmailMessage['to']) => addressList(entries.map((entry) => addressText(entry) ?? '').join(','));
|
||||
return {
|
||||
mail: {
|
||||
sender: addressEntry(addressText(message.from) ?? ''),
|
||||
to: reread(message.to),
|
||||
cc: reread(message.cc),
|
||||
bcc: reread(message.bcc),
|
||||
subject: message.subject,
|
||||
text: message.body,
|
||||
dateReceived: message.receivedAtMilliseconds === undefined ? '' : String(message.receivedAtMilliseconds),
|
||||
},
|
||||
isNew: true,
|
||||
isMarkedForDeletion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** A mailbox's message back in the model, numbered by the caller. */
|
||||
export function emailMessage(entry: MailboxEntry, id: number): EmailMessage {
|
||||
const { mail } = entry;
|
||||
const received = /^[0-9]+$/.test(mail.dateReceived) ? Number(mail.dateReceived) : undefined;
|
||||
return {
|
||||
id,
|
||||
from: mail.sender ?? { name: null, mailAddress: null },
|
||||
to: mail.to,
|
||||
cc: mail.cc,
|
||||
bcc: mail.bcc,
|
||||
subject: mail.subject ?? '',
|
||||
body: mail.text,
|
||||
...(received === undefined ? {} : { receivedAtMilliseconds: received }),
|
||||
isNew: entry.isNew,
|
||||
isMarkedForDeletion: entry.isMarkedForDeletion,
|
||||
isSent: false,
|
||||
};
|
||||
}
|
||||
|
||||
const encode = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
/** SMTPServer, POP3Server: a server whose connections each hold a session. */
|
||||
@@ -497,10 +492,12 @@ export class EmailServer {
|
||||
readonly state: MailServerState;
|
||||
private readonly smtp: MailProtocolServer;
|
||||
private readonly pop3: MailProtocolServer;
|
||||
/** The server's own thread (Anwendung.ausfuehren): it sends mails on. */
|
||||
private readonly commands: CommandThread;
|
||||
|
||||
constructor(
|
||||
engine: NetworkRuntimeEngine,
|
||||
nodeId: Id,
|
||||
private readonly engine: NetworkRuntimeEngine,
|
||||
private readonly nodeId: Id,
|
||||
configuration: EmailServerSnapshot,
|
||||
changed: (accounts: readonly EmailServerAccount[]) => void,
|
||||
ipAddress: () => string,
|
||||
@@ -522,7 +519,15 @@ export class EmailServer {
|
||||
() => changed(this.accounts()),
|
||||
ipAddress,
|
||||
now,
|
||||
{
|
||||
forward: (mail, sender, recipients) => {
|
||||
this.commands.hand(() => this.state.sendOn(mail, sender, recipients));
|
||||
engine.resumeThreads();
|
||||
},
|
||||
lookUpAndSend: (mail, sender, recipients) => this.lookUpAndSend(mail, sender, recipients),
|
||||
},
|
||||
);
|
||||
this.commands = new CommandThread(engine);
|
||||
// POP3 first, as EmailServer.starten (its socket draws the first initial sequence number).
|
||||
this.pop3 = new MailProtocolServer(engine, nodeId, POP3_PORT, configuration.isActive, (connection) => new Pop3Session(this.state, connection));
|
||||
this.smtp = new MailProtocolServer(engine, nodeId, SMTP_PORT, configuration.isActive, (connection) => new SmtpSession(this.state, connection));
|
||||
@@ -537,6 +542,39 @@ export class EmailServer {
|
||||
this.smtp.setActive(active);
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTPClient.versendeEmail(null, …): each recipient's mail exchange looked
|
||||
* up here, then a new SMTP client sends the mail to each, one after the
|
||||
* other, in its own thread; the recipients without a mail exchange.
|
||||
*/
|
||||
private *lookUpAndSend(mail: Mail, sender: string, recipients: readonly string[]): Steps<string[]> {
|
||||
const targets: [string, string][] = [];
|
||||
const unknown: string[] = [];
|
||||
for (const each of javaSplit(recipients.join(','), ',')) {
|
||||
if (javaTrim(each) === '') continue;
|
||||
const recipient = addressEntry(javaTrim(each)).mailAddress;
|
||||
if (recipient === null) continue;
|
||||
const domain = javaSplit(recipient, '@')[1];
|
||||
const resolution = domain === undefined ? undefined : yield* this.engine.resolveDnsMailServerSteps(this.nodeId, domain);
|
||||
const address = resolution?.type === 'resolved' ? resolution.address : undefined;
|
||||
if (address === undefined) unknown.push(recipient);
|
||||
else targets.push([address, recipient]);
|
||||
}
|
||||
if (targets.length > 0) {
|
||||
const engine = this.engine;
|
||||
const nodeId = this.nodeId;
|
||||
const log = (entry: SmtpClientLogEntry) => this.log.write(entry);
|
||||
engine.startThread(
|
||||
(function* (): Steps {
|
||||
// A new thread: it begins after the one that made it goes on (the worker reads its next message first).
|
||||
yield pause(engine.state.currentTimeMilliseconds);
|
||||
for (const [address, recipient] of targets) yield* sendMailSteps(engine, nodeId, log, address, SMTP_SERVER_PORT, mail, sender, recipient);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
return unknown;
|
||||
}
|
||||
|
||||
/** The accounts as the model keeps them. */
|
||||
accounts(): EmailServerAccount[] {
|
||||
let id = 1;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// and reads the fields after it as text); a line that is no field begins the
|
||||
// text (Java drops it).
|
||||
|
||||
import type { EmailAddress } from './config.js';
|
||||
import type { EmailAddress, EmailMessage } from './config.js';
|
||||
import { javaTrim } from './http.js';
|
||||
|
||||
/** An e-mail as Java's Email holds it while the programs run. */
|
||||
@@ -63,6 +63,14 @@ export function addressList(text: string): EmailAddress[] {
|
||||
.map(addressEntry);
|
||||
}
|
||||
|
||||
const RAW_MAIL_ADDRESS = '[a-zA-Z0-9]([\\-_\\.]{0,1}[a-zA-Z0-9])*@[a-zA-Z0-9]([\\-_\\.]{0,1}[a-zA-Z0-9])*\\.{0,1}';
|
||||
const MAIL_ADDRESS_PATTERN = new RegExp(`^(?:[^<]*<${RAW_MAIL_ADDRESS}>|${RAW_MAIL_ADDRESS})$`);
|
||||
|
||||
/** EingabenUeberpruefung.musterEmailAdresse: "Name <address>" or an address. */
|
||||
export function isMailAddress(text: string): boolean {
|
||||
return MAIL_ADDRESS_PATTERN.test(text);
|
||||
}
|
||||
|
||||
/** Email(String): the header fields Java knows, then the text, each line ended by "\n". */
|
||||
export function parseMail(source: string): Mail {
|
||||
let sender: EmailAddress | null = null;
|
||||
@@ -115,3 +123,47 @@ export function mailSize(mail: Mail): number {
|
||||
const sender = mail.sender === null ? 'null' : shown(mail.sender);
|
||||
return (mail.text + sender + (mail.subject ?? 'null') + `[${mail.to.map(shown).join(', ')}]` + mail.dateReceived).length;
|
||||
}
|
||||
|
||||
/** A message in a mailbox, as POP3 sees it. */
|
||||
export interface MailboxEntry {
|
||||
mail: Mail;
|
||||
isNew: boolean;
|
||||
isMarkedForDeletion: boolean;
|
||||
}
|
||||
|
||||
/** A message of the model as Java has it after loading the server's file: new, not deleted. */
|
||||
export function mailboxEntry(message: EmailMessage): MailboxEntry {
|
||||
const reread = (entries: EmailMessage['to']) => addressList(entries.map((entry) => addressText(entry) ?? '').join(','));
|
||||
return {
|
||||
mail: {
|
||||
sender: addressEntry(addressText(message.from) ?? ''),
|
||||
to: reread(message.to),
|
||||
cc: reread(message.cc),
|
||||
bcc: reread(message.bcc),
|
||||
subject: message.subject,
|
||||
text: message.body,
|
||||
dateReceived: message.receivedAtMilliseconds === undefined ? '' : String(message.receivedAtMilliseconds),
|
||||
},
|
||||
isNew: true,
|
||||
isMarkedForDeletion: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** A mailbox's message back in the model, numbered by the caller. */
|
||||
export function emailMessage(entry: MailboxEntry, id: number): EmailMessage {
|
||||
const { mail } = entry;
|
||||
const received = /^[0-9]+$/.test(mail.dateReceived) ? Number(mail.dateReceived) : undefined;
|
||||
return {
|
||||
id,
|
||||
from: mail.sender ?? { name: null, mailAddress: null },
|
||||
to: mail.to,
|
||||
cc: mail.cc,
|
||||
bcc: mail.bcc,
|
||||
subject: mail.subject ?? '',
|
||||
body: mail.text,
|
||||
...(received === undefined ? {} : { receivedAtMilliseconds: received }),
|
||||
isNew: entry.isNew,
|
||||
isMarkedForDeletion: entry.isMarkedForDeletion,
|
||||
isSent: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ import { RuntimeSpeed } from './speed.js';
|
||||
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
||||
import { EchoServer, SimpleClient } from './apps/echo.js';
|
||||
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
|
||||
import { EmailClient, type EmailClientFolders } from './apps/emailClient.js';
|
||||
import { EmailServer } from './apps/emailServer.js';
|
||||
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
|
||||
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
|
||||
@@ -913,6 +914,7 @@ export class NetworkRuntimeEngine {
|
||||
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
||||
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
|
||||
private readonly emailServersByNodeId = new Map<Id, EmailServer>();
|
||||
private readonly emailClientsByNodeId = new Map<Id, EmailClient>();
|
||||
/** The time of day a program writes into a mail (Java's new Date()). */
|
||||
private readonly wallClock: () => number;
|
||||
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
|
||||
@@ -1043,6 +1045,7 @@ export class NetworkRuntimeEngine {
|
||||
this.webServersByNodeId.clear();
|
||||
this.webBrowsersByNodeId.clear();
|
||||
this.emailServersByNodeId.clear();
|
||||
this.emailClientsByNodeId.clear();
|
||||
this.arpLookups = [];
|
||||
this.protocolThreads.clear();
|
||||
this.dnsQueriesBySocketId.clear();
|
||||
@@ -1133,6 +1136,20 @@ export class NetworkRuntimeEngine {
|
||||
return this.emailServersByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
/** The node's e-mail program while the simulation runs, if it has one installed. */
|
||||
emailClient(nodeId: Id): EmailClient | undefined {
|
||||
return this.emailClientsByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
/** An e-mail program's folders as a run changed them, into the snapshot (and so into the project saved from it). */
|
||||
private setEmailClientFolders(nodeId: Id, folders: EmailClientFolders): void {
|
||||
const clients = new Map(this.stateValue.topologySnapshot.emailClientsByNodeId);
|
||||
const client = clients.get(nodeId);
|
||||
if (client === undefined) return;
|
||||
clients.set(nodeId, { ...client, ...folders });
|
||||
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { emailClientsByNodeId: clients });
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const servers = new Map(this.stateValue.topologySnapshot.emailServersByNodeId);
|
||||
@@ -1153,6 +1170,7 @@ export class NetworkRuntimeEngine {
|
||||
this.webServersByNodeId.clear();
|
||||
this.webBrowsersByNodeId.clear();
|
||||
this.emailServersByNodeId.clear();
|
||||
this.emailClientsByNodeId.clear();
|
||||
for (const node of snapshot.nodes) {
|
||||
const echoServer = snapshot.echoServersByNodeId.get(node.id);
|
||||
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
|
||||
@@ -1178,6 +1196,11 @@ export class NetworkRuntimeEngine {
|
||||
),
|
||||
);
|
||||
}
|
||||
const emailClient = snapshot.emailClientsByNodeId.get(node.id);
|
||||
if (emailClient !== undefined) {
|
||||
const nodeId = node.id;
|
||||
this.emailClientsByNodeId.set(nodeId, new EmailClient(this, nodeId, emailClient, (folders) => this.setEmailClientFolders(nodeId, folders)));
|
||||
}
|
||||
if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind);
|
||||
}
|
||||
}
|
||||
@@ -1961,6 +1984,22 @@ export class NetworkRuntimeEngine {
|
||||
return { type: 'resolved', address: exchange === undefined ? undefined : findDnsRecords(exchange.data, 'A', resolution.records)[0]?.data };
|
||||
}
|
||||
|
||||
/** Resolver.holeIPAdresseMailServer in a program's thread (the e-mail server sending a mail on). */
|
||||
*resolveDnsMailServerSteps(nodeId: Id, domain: string): Steps<DnsAddressResolution> {
|
||||
const domainName = domain.endsWith('.') ? domain : `${domain}.`;
|
||||
let lookup: DnsLookup | undefined;
|
||||
const server = this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
|
||||
this.beginDnsResolution(nodeId, domainName, 'MX', server, (result) => {
|
||||
lookup = result;
|
||||
});
|
||||
yield* waitUntil(() => lookup !== undefined || this.stateValue.phase !== 'running');
|
||||
const result = lookup as DnsLookup | undefined;
|
||||
if (result === undefined || result.type === 'timedOut') return { type: 'timedOut' };
|
||||
const records = [...result.answers, ...result.authority];
|
||||
const exchange = findDnsRecords(domainName, 'MX', records)[0];
|
||||
return { type: 'resolved', address: exchange === undefined ? undefined : findDnsRecords(exchange.data, 'A', records)[0]?.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* DNSQueryAgent.query: one question from a fresh socket; `done` with the
|
||||
* first datagram back, or undefined after five round trips.
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SwitchConfiguration,
|
||||
WebServerConfiguration,
|
||||
EmailServerAccount,
|
||||
EmailClientConfiguration,
|
||||
} from './config.js';
|
||||
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import type { PageLanguage } from './apps/routerPages.js';
|
||||
@@ -102,6 +103,8 @@ export interface TopologySnapshot {
|
||||
readonly webBrowserNodeIds: ReadonlySet<Id>;
|
||||
/** The installed e-mail servers, by node; a run's deliveries change their mailboxes here. */
|
||||
readonly emailServersByNodeId: ReadonlyMap<Id, EmailServerSnapshot>;
|
||||
/** The installed e-mail programs, by node; a run's sent and fetched mails change their folders here. */
|
||||
readonly emailClientsByNodeId: ReadonlyMap<Id, EmailClientConfiguration>;
|
||||
/** Each node's files, which its programs read (a web server its pages). */
|
||||
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
/** The language of the pages the devices write themselves (a router's), as Java's in the locale it runs in. */
|
||||
@@ -127,6 +130,7 @@ export interface TopologySnapshotInput {
|
||||
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
|
||||
webBrowserNodeIds?: ReadonlySet<Id>;
|
||||
emailServersByNodeId?: ReadonlyMap<Id, EmailServerSnapshot>;
|
||||
emailClientsByNodeId?: ReadonlyMap<Id, EmailClientConfiguration>;
|
||||
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
language?: PageLanguage;
|
||||
}
|
||||
@@ -151,6 +155,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
|
||||
webServersByNodeId: input.webServersByNodeId ?? new Map(),
|
||||
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
||||
emailServersByNodeId: input.emailServersByNodeId ?? new Map(),
|
||||
emailClientsByNodeId: input.emailClientsByNodeId ?? new Map(),
|
||||
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
||||
language: input.language ?? 'de',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user