// 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 (``): 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
", "
", 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; }