// The e-mail account files as the model takes them, against Swift's reader // (TopologyRuntimeEmailStorage.decodeJavaServer/decodeJavaClient, // email-*.swift.json, tools/swift-oracle --email-files) on the cases Java // read too (javaEmail.test.ts). Swift checks names, hosts and addresses and // fails the whole file on one it rejects, where Java reads it; each such // case is named with its reason and must really depart. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { EmailAddress, EmailMessage } from '@mdg-filius/core'; import { emailClientConfiguration, emailServerConfiguration } from '../src/importEmail.js'; import { readEmailClientFile, readEmailServerFile } from '../src/javaEmail.js'; import type { FlsApplication } from '../src/index.js'; const here = dirname(fileURLToPath(import.meta.url)); /** The cases file's escapes: a backslash, `u` and four hex digits for one UTF-16 unit. */ function unescape(line: string): string { return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); } const fullName = (person: { firstName: string; lastName: string }) => [person.firstName, person.lastName].filter((part) => part !== '').join(' '); function swiftMessage(message: EmailMessage) { const address = (entry: EmailAddress) => [entry.name, entry.mailAddress]; return { id: message.id, from: address(message.from), to: message.to.map(address), cc: message.cc.map(address), bcc: message.bcc.map(address), subject: message.subject, body: message.body, deliveryIdentity: null, receivedAtMilliseconds: message.receivedAtMilliseconds ?? null, isNew: message.isNew, isMarkedForDeletion: message.isMarkedForDeletion, isSent: message.isSent, }; } function serverAccounts(content: string) { const application = { fileAccounts: readEmailServerFile(content, () => {}), mailDomain: 'filius.de' } as FlsApplication; return emailServerConfiguration(application, () => {}).accounts.map((account) => ({ username: account.username, password: account.password, name: fullName(account), mailbox: account.mailbox.map(swiftMessage), })); } function clientAccount(content: string) { const application = { fileAccount: readEmailClientFile(content, () => {}) } as FlsApplication; const account = emailClientConfiguration(application, () => {}).account; if (account === null) return null; const { firstName, lastName, ...fields } = account; return { ...fields, name: fullName({ firstName, lastName }) }; } type Departures = { reason: string; lines: string[] }[]; /** * Swift's result where it means the same as ours: Swift numbers a mailbox's * messages as their received time (a Swift error named in * importProject.test.ts), and "no account" is an error to Swift. */ function comparable(expected: unknown): unknown { if (expected === '!invalidAccount("missing client account")') return null; if (!Array.isArray(expected)) return expected; return expected.map((account: { mailbox: { id: number; receivedAtMilliseconds: number | null }[] }) => ({ ...account, mailbox: account.mailbox.map((message) => message.receivedAtMilliseconds === message.id ? { ...message, receivedAtMilliseconds: null } : message, ), })); } function compareWithSwift(kind: 'server' | 'client', departures: Departures, read: (content: string) => unknown) { const cases = JSON.parse(readFileSync(join(here, `email-${kind}.swift.json`), 'utf8')) as [string, unknown][]; const lines = readFileSync(join(here, `email-${kind}.cases.txt`), 'ascii').split('\n').slice(0, -1); const byLine = new Map(departures.flatMap(({ reason, lines: named }) => named.map((line) => [line, reason] as const))); it('covers every line of the cases file and names only its lines', () => { expect(cases.map(([line]) => line)).toEqual(lines); expect([...byLine.keys()].filter((line) => !lines.includes(line))).toEqual([]); }); for (const [line, expected] of cases) { const reason = byLine.get(line); it(reason === undefined ? JSON.stringify(line) : `${JSON.stringify(line)} (departs: ${reason})`, () => { if (reason === undefined) expect(read(unescape(line))).toEqual(comparable(expected)); else expect(read(unescape(line))).not.toEqual(comparable(expected)); }); } } const SERVER_DEPARTURES: Departures = [ { reason: 'a line without the messages field is an account, as in Java; Swift wants the sixth field and fails the file', lines: [String.raw`bob;filius.de;bob;Nachname;Vorname`, String.raw`bob;filius.de;bob;Nachname;Vorname\u000D\u000A`], }, { reason: "the file's domain column is not used (Java takes the server's); Swift fails the file on an empty one", lines: [String.raw`bob;;bob;Nachname;Vorname;\u000A`], }, { reason: 'a short line alone is left out; Swift fails the whole file', lines: [String.raw`bob;filius.de;bob;Nachname\u000A`, String.raw`bob;filius.de;bob;Nachname;Vorname;\u000Abroken\u000A`], }, { reason: 'a user name stays as Java reads it; Swift rejects one with spaces and fails the file', lines: [String.raw` bob ;filius.de; bob ;Nachname;Vorname;\u000A`], }, { reason: 'a message without seven fields alone is left out; Swift fails the whole file', lines: [String.raw`bob;filius.de;bob;Nachname;Vorname;#Alice $bob@filius.de$$$$Hi $5$Text\u000A`], }, { reason: "addresses as Java reads them (blank entries skipped, a name alone, Java's trim); Swift fails the file", lines: [ String.raw`bob;filius.de;bob;Nachname;Vorname;#a@x$b@y, ,c@z,$$$$Hi$Text\u000A`, String.raw`bob;filius.de;bob;Nachname;Vorname;#Name { compareWithSwift('server', SERVER_DEPARTURES, serverAccounts); }); describe('the client account file against Swift', () => { compareWithSwift('client', CLIENT_DEPARTURES, clientAccount); });