The e-mail client and server of every host come into the model (core: EmailClientConfiguration, EmailServerConfiguration, EmailMessage), from Java's view: - the server's accounts are those of /mailserver/konten.txt, as Java reads them at every simulation start; without the file those of the bean list, which Java would drop; - the client's account is that of /konten.txt; without it the first of an older file's kontoListe, which FILIUS 2.14 no longer reads; - messages are numbered per program in folder order, as Swift does; the sent folder's are sent; a date is taken only when the file gives one. Departures from Swift: accounts and messages stay where Swift drops what fails its checks; first and last name stay apart; a port that is no number is the default, with a warning. On the corpus the model matches Swift's on all 13 files; the one Swift error is named (it numbers a mailbox's messages as their received time and writes that into the konten.txt it rewrites). The Swift dump now carries the e-mail state, and the file system comparison leaves out only Swift's hidden state files (and the directories it makes for them) instead of all of /mail, /mailserver and /peer2peer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
// Java's e-mail programs as FILIUS 2.14 loads them. Two places hold their
|
|
// state:
|
|
// - the file's JavaBeans: the server's accounts (listeBenutzerkonten, with
|
|
// their mailboxes), the client's three folders and address book;
|
|
// - text files in the host's file system: /mailserver/konten.txt, which the
|
|
// server reads (kontenLaden) whenever a simulation starts and so wins over
|
|
// the bean list, and /konten.txt, the client's account, read (laden) when
|
|
// its window opens. The client's account is in no bean: its accessors
|
|
// (holeKontoListe, setzeKontoListe) are not bean properties.
|
|
// Checked against Java on the corpus (corpus.java.json, applications) and on
|
|
// the account files of test/email-*.cases.txt, where Java's errors in reading
|
|
// them are not copied (see readEmailServerFile, readEmailClientFile).
|
|
|
|
import { isJavaObject, listItems, mapEntries, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
|
|
import { javaSplit, javaTrim } from './javaString.js';
|
|
|
|
/** AddressEntry: a display name, an address, or both. */
|
|
export interface JavaAddressEntry {
|
|
readonly name: string | null;
|
|
readonly mailAddress: string | null;
|
|
}
|
|
|
|
/**
|
|
* A recipient as the list holds it: older files keep recipients as plain
|
|
* strings, which the decoder puts into the typed list unchecked.
|
|
*/
|
|
export type JavaRecipient = JavaAddressEntry | string | null;
|
|
|
|
export interface JavaEmail {
|
|
readonly sender: JavaAddressEntry;
|
|
readonly to: readonly JavaRecipient[];
|
|
readonly cc: readonly JavaRecipient[];
|
|
readonly bcc: readonly JavaRecipient[];
|
|
readonly subject: string | null;
|
|
readonly text: string | null;
|
|
readonly dateReceived: string | null;
|
|
readonly isNew: boolean;
|
|
readonly isDeleted: boolean;
|
|
readonly isSent: boolean;
|
|
}
|
|
|
|
/** EmailKonto: on the server an account with its mailbox, in the client the account it uses. */
|
|
export interface JavaEmailAccount {
|
|
readonly username: string | null;
|
|
readonly password: string | null;
|
|
readonly lastName: string;
|
|
readonly firstName: string;
|
|
readonly emailAddress: string | null;
|
|
readonly pop3Server: string | null;
|
|
readonly pop3Port: string | null;
|
|
readonly smtpServer: string | null;
|
|
readonly smtpPort: string | null;
|
|
readonly messages: readonly JavaEmail[];
|
|
}
|
|
|
|
/** Kontakt: an address book entry. */
|
|
export interface JavaContact {
|
|
readonly name: string | null;
|
|
readonly firstName: string | null;
|
|
readonly street: string | null;
|
|
readonly houseNumber: number;
|
|
readonly postalCode: number;
|
|
readonly city: string | null;
|
|
readonly email: string | null;
|
|
readonly phone: string | null;
|
|
}
|
|
|
|
export const EMAIL_SERVER_FILE = '/mailserver/konten.txt';
|
|
export const EMAIL_CLIENT_FILE = '/konten.txt';
|
|
|
|
type Warn = (message: string) => void;
|
|
|
|
// JavaBeans
|
|
|
|
export function javaEmailAccounts(list: JavaValue | undefined, warn: Warn): JavaEmailAccount[] {
|
|
return emailAccountsOf(listItems(list), warn);
|
|
}
|
|
|
|
/**
|
|
* The client accounts of older files (EmailAnwendung's kontoListe, a map by
|
|
* address). FILIUS 2.14 has no such property and loses them; they are read
|
|
* for files without /konten.txt.
|
|
*/
|
|
export function javaLegacyEmailAccounts(map: JavaValue | undefined, warn: Warn): JavaEmailAccount[] {
|
|
return emailAccountsOf(
|
|
mapEntries(map).map(([, account]) => account),
|
|
warn,
|
|
);
|
|
}
|
|
|
|
function emailAccountsOf(items: JavaValue[], warn: Warn): JavaEmailAccount[] {
|
|
return objectsOf(items, 'EmailKonto', warn).map((account) => {
|
|
const at = (property: string) => text(account.properties.get(property), `EmailKonto ${property}`, warn);
|
|
return {
|
|
username: at('benutzername'),
|
|
password: at('passwort'),
|
|
// setNachname and setVorname keep "" for null.
|
|
lastName: at('nachname') ?? '',
|
|
firstName: at('vorname') ?? '',
|
|
emailAddress: at('emailAdresse'),
|
|
pop3Server: at('pop3server'),
|
|
pop3Port: at('pop3port'),
|
|
smtpServer: at('smtpserver'),
|
|
smtpPort: at('smtpport'),
|
|
messages: javaEmails(account.properties.get('nachrichten'), warn),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function javaEmails(list: JavaValue | undefined, warn: Warn): JavaEmail[] {
|
|
return objectsOf(listItems(list), 'Email', warn).map((email) => {
|
|
const at = (property: string) => email.properties.get(property);
|
|
const flag = (property: string, fallback: boolean) => {
|
|
const value = at(property);
|
|
if (value === undefined) return fallback;
|
|
if (typeof value === 'boolean') return value;
|
|
warn(`Email ${property} is not a boolean, used ${fallback}`);
|
|
return fallback;
|
|
};
|
|
return {
|
|
sender: senderOf(at('absender'), warn),
|
|
to: recipientsOf(at('empfaenger'), warn),
|
|
cc: recipientsOf(at('cc'), warn),
|
|
bcc: recipientsOf(at('bcc'), warn),
|
|
subject: text(at('betreff'), 'Email betreff', warn),
|
|
text: at('text') === undefined ? '' : text(at('text'), 'Email text', warn),
|
|
dateReceived: at('dateReceived') === undefined ? '' : text(at('dateReceived'), 'Email dateReceived', warn),
|
|
isNew: flag('neu', true),
|
|
isDeleted: flag('delete', false),
|
|
isSent: flag('versendet', false),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function javaContacts(list: JavaValue | undefined, warn: Warn): JavaContact[] {
|
|
return objectsOf(listItems(list), 'Kontakt', warn).map((contact) => {
|
|
const at = (property: string) => text(contact.properties.get(property), `Kontakt ${property}`, warn);
|
|
const number = (property: string) => {
|
|
const value = contact.properties.get(property);
|
|
if (value === undefined) return 0;
|
|
if (typeof value === 'number' && Number.isInteger(value)) return value;
|
|
warn(`Kontakt ${property} is not an int, used 0`);
|
|
return 0;
|
|
};
|
|
return {
|
|
name: at('name'),
|
|
firstName: at('vorname'),
|
|
street: at('strasse'),
|
|
houseNumber: number('hausnr'),
|
|
postalCode: number('plz'),
|
|
city: at('wohnort'),
|
|
email: at('email'),
|
|
phone: at('telefon'),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Email.getAbsender(): a copy, so never null — a missing sender has neither name nor address. */
|
|
function senderOf(value: JavaValue | undefined, warn: Warn): JavaAddressEntry {
|
|
if (typeof value === 'string') return addressEntry(value);
|
|
if (isAddressEntry(value)) return beanAddressEntry(value, warn);
|
|
if (value !== undefined && value !== null) warn('Email absender is neither a string nor an AddressEntry, ignored');
|
|
return { name: null, mailAddress: null };
|
|
}
|
|
|
|
function recipientsOf(list: JavaValue | undefined, warn: Warn): JavaRecipient[] {
|
|
return listItems(list).flatMap((item): JavaRecipient[] => {
|
|
if (item === null || typeof item === 'string') return [item];
|
|
if (isAddressEntry(item)) return [beanAddressEntry(item, warn)];
|
|
warn('an e-mail recipient is neither a string nor an AddressEntry, left out');
|
|
return [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An AddressEntry — also the copy a getter returns, which the file fills and
|
|
* then sets (`<void id="AddressEntry0" property="absender">`): the decoder
|
|
* names such an object by its id.
|
|
*/
|
|
function isAddressEntry(value: JavaValue | undefined): value is JavaObject {
|
|
return isJavaObject(value) && simpleClassName(value.className) === 'AddressEntry';
|
|
}
|
|
|
|
function beanAddressEntry(entry: JavaObject, warn: Warn): JavaAddressEntry {
|
|
return {
|
|
name: text(entry.properties.get('name'), 'AddressEntry name', warn),
|
|
mailAddress: text(entry.properties.get('mailAddress'), 'AddressEntry mailAddress', warn),
|
|
};
|
|
}
|
|
|
|
function objectsOf(items: JavaValue[], simpleClass: string, warn: Warn): JavaObject[] {
|
|
return items.flatMap((item) => {
|
|
if (isJavaObject(item) && simpleClassName(item.className) === simpleClass) return [item];
|
|
warn(`a list of ${simpleClass} holds something else, left out`);
|
|
return [];
|
|
});
|
|
}
|
|
|
|
function text(value: JavaValue | undefined, name: string, warn: Warn): string | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value === 'string') return value;
|
|
warn(`${name} is not a string, ignored`);
|
|
return null;
|
|
}
|
|
|
|
// Text files
|
|
|
|
/** `new AddressEntry(text)`: "Name <address>", "<address>", a bare address with "@", else a name. */
|
|
export function addressEntry(raw: string): JavaAddressEntry {
|
|
const entry = javaTrim(raw);
|
|
const start = entry.indexOf('<');
|
|
const end = entry.indexOf('>');
|
|
if (start >= 0 && end > start) {
|
|
return { name: start > 0 ? javaTrim(entry.slice(0, start)) : null, mailAddress: javaTrim(entry.slice(start + 1, end)) };
|
|
}
|
|
if (!entry.includes('<') && !entry.includes('>') && entry.includes('@')) return { name: null, mailAddress: entry };
|
|
return { name: entry, mailAddress: null };
|
|
}
|
|
|
|
/** `EmailUtils.stringToAddressEntryList`: comma-separated, blank parts skipped. */
|
|
function addressEntries(raw: string): JavaAddressEntry[] {
|
|
return javaSplit(raw, ',')
|
|
.filter((part) => javaTrim(part) !== '')
|
|
.map(addressEntry);
|
|
}
|
|
|
|
/**
|
|
* `EmailServer.stringZuListeBenutzerkonten`: one account a line —
|
|
* user;domain;password;last name;first name;messages. Messages are
|
|
* "#"-separated, seven "$"-separated fields each: sender, to, cc, bcc, date,
|
|
* subject, text. Java's errors are not copied (each named in
|
|
* javaEmail.test.ts): its StringTokenizer skips empty fields, so an empty
|
|
* password shifts the rest; one line with fewer than five fields makes it
|
|
* throw for the whole file; its split drops trailing empty fields, so a
|
|
* message with an empty text or subject is lost; a CR of a CRLF line stays.
|
|
* Here fields count also when empty, a short line alone is left out, and a
|
|
* message needs its seven fields, empty or not.
|
|
*/
|
|
export function readEmailServerFile(content: string, warn: Warn): JavaEmailAccount[] {
|
|
const accounts: JavaEmailAccount[] = [];
|
|
lines(content).forEach((line, index) => {
|
|
if (line === '') return;
|
|
const fields = splitAtMost(line, ';', 6);
|
|
if (fields.length < 5) {
|
|
warn(`${EMAIL_SERVER_FILE}: line ${index + 1} has fewer than five fields, left out`);
|
|
return;
|
|
}
|
|
const [username, , password, lastName, firstName, messages] = fields as [string, string, string, string, string, string?];
|
|
accounts.push({
|
|
username,
|
|
password,
|
|
lastName,
|
|
firstName,
|
|
emailAddress: null,
|
|
pop3Server: null,
|
|
pop3Port: null,
|
|
smtpServer: null,
|
|
smtpPort: null,
|
|
messages: messages === undefined ? [] : fileMessages(messages, username, warn),
|
|
});
|
|
});
|
|
return accounts;
|
|
}
|
|
|
|
function fileMessages(messages: string, username: string, warn: Warn): JavaEmail[] {
|
|
return messages.split('#').flatMap((message) => {
|
|
if (message === '') return [];
|
|
const fields = message.split('$');
|
|
if (fields.length !== 7) {
|
|
warn(`${EMAIL_SERVER_FILE}: a message of '${username}' has ${fields.length} fields, not seven, left out`);
|
|
return [];
|
|
}
|
|
const [sender, to, cc, bcc, dateReceived, subject, body] = fields as [string, string, string, string, string, string, string];
|
|
return [
|
|
{
|
|
sender: addressEntry(sender),
|
|
to: addressEntries(to),
|
|
cc: addressEntries(cc),
|
|
bcc: addressEntries(bcc),
|
|
subject,
|
|
text: unescapedBody(body),
|
|
dateReceived,
|
|
isNew: true,
|
|
isDeleted: false,
|
|
isSent: false,
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
/** The lines of a text file, LF or CRLF. */
|
|
function lines(content: string): string[] {
|
|
return content.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
|
|
}
|
|
|
|
/** Split at a separator into at most `limit` parts, empty ones included; the last keeps the rest. */
|
|
function splitAtMost(text: string, separator: string, limit: number): string[] {
|
|
const parts = text.split(separator);
|
|
return parts.length <= limit ? parts : [...parts.slice(0, limit - 1), parts.slice(limit - 1).join(separator)];
|
|
}
|
|
|
|
/** brtonl, then undoSpecialChar: "|" is a line break, "&&00124&&" a "|". */
|
|
function unescapedBody(body: string): string {
|
|
return body
|
|
.replaceAll('|', '\n')
|
|
.replaceAll('&&00036&&', '$')
|
|
.replaceAll('&&00059&&', ';')
|
|
.replaceAll('&&00124&&', '|')
|
|
.replaceAll('&&00035&&', '#');
|
|
}
|
|
|
|
/**
|
|
* `EmailAnwendung.laden`: an account of nine ";"-separated fields — POP3
|
|
* server, SMTP server, POP3 port, SMTP port, user, password, last name,
|
|
* first name, address; further fields are ignored. Java takes the last line
|
|
* whatever it holds; its errors are not copied (each named in
|
|
* javaEmail.test.ts): a last line with fewer fields — also a complete one
|
|
* whose address is empty, since split drops trailing empty fields — leaves
|
|
* the account empty, and a CR of a CRLF line stays in the address. Here the
|
|
* last line with all nine fields is the account. Null when there is none.
|
|
*/
|
|
export function readEmailClientFile(content: string, warn: Warn): JavaEmailAccount | null {
|
|
let account: JavaEmailAccount | null = null;
|
|
lines(content).forEach((line, index) => {
|
|
const fields = line.split(';');
|
|
if (fields.length < 9) {
|
|
if (javaTrim(line) !== '') warn(`${EMAIL_CLIENT_FILE}: line ${index + 1} is no account of nine fields, ignored`);
|
|
return;
|
|
}
|
|
const [pop3Server, smtpServer, pop3Port, smtpPort, username, password, lastName, firstName, emailAddress] = fields as string[];
|
|
account = {
|
|
username: username!,
|
|
password: password!,
|
|
lastName: lastName!,
|
|
firstName: firstName!,
|
|
emailAddress: emailAddress!,
|
|
pop3Server: pop3Server!,
|
|
pop3Port: pop3Port!,
|
|
smtpServer: smtpServer!,
|
|
smtpPort: smtpPort!,
|
|
messages: [],
|
|
};
|
|
});
|
|
return account;
|
|
}
|