SMTP on port 25 stores mails for the domain's accounts as Java reads them anew and tells a local sender of unknown recipients; POP3 on port 110 hands them out. The mailboxes are in the snapshot and go back into the project. EmailRun plays four stories over the network; MailCases feeds Java's workers without one (26 cases, de and en). 18 cases name Java bugs that the core does not take. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
19 KiB
TypeScript
390 lines
19 KiB
TypeScript
// The e-mail server's SMTP and POP3 dialogues (apps/emailServer.ts) against
|
|
// Java FILIUS' own: tools/java-oracle's MailCases fed Java's workers with the
|
|
// messages of email-server.cases.txt (email-server.<de|en>.java.json). After
|
|
// each message: what was sent, the window's lines, whether the connection
|
|
// was closed, and the accounts must be Java's, in both languages, except
|
|
// where Java errs.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
addressEntry,
|
|
deviceConfiguration,
|
|
NetworkRuntimeEngine,
|
|
topologySnapshot,
|
|
type TopologySnapshotInput,
|
|
addressText,
|
|
formatMail,
|
|
mailboxEntry,
|
|
MailServerState,
|
|
mailSize,
|
|
parseMail,
|
|
Pop3Session,
|
|
SmtpSession,
|
|
type EmailServerLogEntry,
|
|
type MailAccount,
|
|
type PageLanguage,
|
|
type Steps,
|
|
} from '../src/index.js';
|
|
import { link, pc, uuid } from './fixtures.js';
|
|
import { MAIL_ACCOUNTS } from './mailAccounts.js';
|
|
import { emailServerText } from './programLines.js';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
|
|
type JavaAccounts = [string, [string, boolean, boolean][]][];
|
|
|
|
interface JavaStep {
|
|
message: string | null;
|
|
sent: string[];
|
|
lines: string[];
|
|
closed: boolean;
|
|
thrown: string | null;
|
|
accounts: JavaAccounts;
|
|
}
|
|
|
|
interface JavaCase {
|
|
name: string;
|
|
protocol: 'smtp' | 'pop3';
|
|
steps: JavaStep[];
|
|
}
|
|
|
|
const CLIENT = '192.168.0.20';
|
|
|
|
/** Runs a dialogue's steps to their end: nothing waits here, the connection sends at once. */
|
|
function run(steps: Steps): void {
|
|
for (let next = steps.next(); next.done !== true; next = steps.next()) throw new Error('a step waited');
|
|
}
|
|
|
|
/** The session's own run of a case: each step as MailCases writes Java's. */
|
|
function play(javaCase: JavaCase, language: PageLanguage): JavaStep[] {
|
|
const lines: EmailServerLogEntry[] = [];
|
|
const accounts: MailAccount[] = MAIL_ACCOUNTS.map((account) => ({ ...account, mailbox: account.mailbox.map(mailboxEntry) }));
|
|
const state = new MailServerState(
|
|
'filius.de',
|
|
accounts,
|
|
language,
|
|
(entry) => lines.push(entry),
|
|
() => undefined,
|
|
() => '192.168.0.30',
|
|
() => 0,
|
|
);
|
|
const sent: string[] = [];
|
|
const connection = {
|
|
address: CLIENT,
|
|
*send(text: string): Steps<boolean> {
|
|
sent.push(text);
|
|
return true;
|
|
},
|
|
};
|
|
const session = javaCase.protocol === 'smtp' ? new SmtpSession(state, connection) : new Pop3Session(state, connection);
|
|
const step = (text: string | null): JavaStep => {
|
|
const result: JavaStep = {
|
|
message: text,
|
|
sent: [...sent],
|
|
lines: lines.map((entry) => emailServerText(entry, language)),
|
|
closed: session.closed,
|
|
thrown: null,
|
|
accounts: accounts.map((account) => [account.username, account.mailbox.map((entry) => [formatMail(entry.mail), entry.isNew, entry.isMarkedForDeletion])]),
|
|
};
|
|
sent.length = 0;
|
|
lines.length = 0;
|
|
return result;
|
|
};
|
|
run(session.begin());
|
|
const steps = [step(null)];
|
|
for (const each of javaCase.steps.slice(1)) {
|
|
run(session.receive(each.message!));
|
|
steps.push(step(each.message));
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
/** Java's date of a notice, in its locale and time: a placeholder. */
|
|
const withoutDate = (text: string) => text.replace(/\nDate: [^\n]*\n/g, '\nDate: <date>\n');
|
|
|
|
const stored = (username: string, language: PageLanguage) => emailServerText({ kind: 'stored', username }, language);
|
|
|
|
/**
|
|
* A step as Java means it: the answers given, the window's lines rebuilt
|
|
* from them (received, sent, then the mails stored), the accounts as given.
|
|
*/
|
|
function fixed(
|
|
step: JavaStep,
|
|
language: PageLanguage,
|
|
changes: { sent?: string[]; stored?: string[]; accounts?: JavaAccounts; closed?: boolean },
|
|
): JavaStep {
|
|
const sent = changes.sent ?? step.sent;
|
|
const storedLines = changes.stored?.map((username) => stored(username, language)) ?? step.lines.filter((line) => !line.startsWith(`${CLIENT}`));
|
|
return {
|
|
...step,
|
|
sent,
|
|
lines: [`${CLIENT}< ${step.message}`, ...sent.map((text) => `${CLIENT}> ${text}`), ...storedLines],
|
|
closed: changes.closed ?? step.closed,
|
|
thrown: null,
|
|
accounts: changes.accounts ?? step.accounts,
|
|
};
|
|
}
|
|
|
|
type Fix = (steps: JavaStep[], language: PageLanguage) => JavaStep[];
|
|
|
|
/** Each step from `from` on with the accounts given (a mail Java stores differently changes every later step). */
|
|
const accountsFrom = (steps: JavaStep[], from: number, accounts: JavaAccounts) => steps.map((step, index) => (index >= from ? { ...step, accounts } : step));
|
|
|
|
const order = (language: PageLanguage) => (language === 'de' ? '503 Falsche Reihenfolge der Kommandos.' : '503 Wrong order of commands.');
|
|
const queued = '250 Mail queued for delivery';
|
|
const noSuchMessage = '-ERR no such message';
|
|
const unknownCommand = '-ERR unknown command';
|
|
|
|
function addTo(accounts: JavaAccounts, username: string, ...mails: [string, boolean, boolean][]): JavaAccounts {
|
|
return accounts.map(([name, mailbox]) => [name, name === username ? [...mailbox, ...mails] : mailbox]);
|
|
}
|
|
|
|
/**
|
|
* Where Java errs, the case as Java means it (the core's departures, see
|
|
* emailServer.ts).
|
|
*/
|
|
const JAVA_BUGS: Record<string, Fix> = {
|
|
// After a mail Java answers the next command 503 and keeps sender,
|
|
// recipients and text: the second mail went to anna too, with the first's text.
|
|
smtpTwice: (steps, language) => {
|
|
const start = steps[0]!.accounts;
|
|
const first = steps[6]!.accounts.find(([name]) => name === 'anna')![1].at(-1)!;
|
|
const second: [string, boolean, boolean] = ['From: <anna@filius.de>\nTo: <bob@filius.de>\nSubject: Zwei\n\nan Bob', true, false];
|
|
const result = steps.map((step, index) => (index === 7 ? fixed(step, language, { sent: ['250 Sender OK'] }) : step));
|
|
result[11] = fixed(steps[11]!, language, { stored: ['bob'] });
|
|
return accountsFrom(result, 11, addTo(addTo(start, 'anna', first), 'bob', second));
|
|
},
|
|
// A command the state does not take: Java answers nothing.
|
|
smtpStart: (steps, language) => steps.map((step, index) => (index === 1 || index === 2 ? fixed(step, language, { sent: [order(language)] }) : step)),
|
|
// HELO again: Java answers nothing; the core begins anew.
|
|
smtpHelo: (steps, language) => steps.map((step, index) => (index === 3 ? fixed(step, language, { sent: ['250 Hello wieder'] }) : step)),
|
|
// "MAIL FROM" (two spaces) and HELO again: Java answers nothing. After
|
|
// HELO the envelope is new: DATA and the text are out of order, nothing is stored.
|
|
smtpEnvelope: (steps, language) => {
|
|
const result = steps.map((step, index) => {
|
|
if (index === 3) return fixed(step, language, { sent: [order(language)] });
|
|
if (index === 8) return fixed(step, language, { sent: ['250 Hello noch einmal'] });
|
|
if (index === 9 || index === 10) return fixed(step, language, { sent: [order(language)], stored: [] });
|
|
return step;
|
|
});
|
|
return accountsFrom(result, 10, steps[0]!.accounts);
|
|
},
|
|
// Java compares the domain exactly and sends the mail on; anna@FILIUS.DE is anna's.
|
|
smtpDomainCase: (steps, language) => {
|
|
const mail: [string, boolean, boolean] = ['Subject: Gross\n\nText', true, false];
|
|
const result = steps.map((step, index) => (index === 5 ? fixed(step, language, { stored: ['anna'] }) : step));
|
|
return accountsFrom(result, 5, addTo(steps[0]!.accounts, 'anna', mail));
|
|
},
|
|
// A field Java does not know ends its header, and Subject became text.
|
|
smtpUnknownHeader: (steps) => accountsFrom(steps, 5, addTo(steps[0]!.accounts, 'anna', ['From: <bob@filius.de>\nSubject: Betreff\n\nText', true, false])),
|
|
// A line without a field name (": leer") ends Java's header and is dropped; the core's text begins with it.
|
|
smtpColonFirst: (steps) => accountsFrom(steps, 5, addTo(steps[0]!.accounts, 'anna', ['Subject: vorn\n\n: leer\n\nText', true, false])),
|
|
// Without a header Java drops the first line.
|
|
smtpNoHeader: (steps) => accountsFrom(steps, 5, addTo(steps[0]!.accounts, 'anna', ['\nHallo\nWelt', true, false])),
|
|
// "quit" during DATA: Java ends the connection; it is the mail's text.
|
|
smtpQuitInData: (steps, language) => steps.map((step, index) => (index === 6 ? { ...fixed(step, language, { sent: [], closed: false }), lines: [`${CLIENT}< quit`] } : step)),
|
|
// An empty sender: Java's notice throws (NullPointerException); none is sent.
|
|
smtpEmptySender: (steps, language) => steps.map((step, index) => (index === 5 ? fixed(step, language, {}) : step)),
|
|
// A recipient without "@": Java's delivery throws; the core sends the sender a notice, to the
|
|
// envelope's sender, as the mail has no From.
|
|
smtpWithoutAt: (steps, language) => {
|
|
const notice: [string, boolean, boolean] = [
|
|
'From: <noreply@filius.de>\nTo: <bob@filius.de>\nSubject: Non Delivery Notification\n\n' +
|
|
'The mail could not be delivered either because user or mail domain is unknown.\nMessage could not be delivered to the following recipients:\n\tanna\n\n' +
|
|
'Date: <date>\nMail-Server: 192.168.0.30\n\n______________________________________________________________________\nSubject: Ohne\n\nText',
|
|
true,
|
|
false,
|
|
];
|
|
const result = steps.map((step, index) => (index === 5 ? fixed(step, language, { sent: [queued], stored: ['bob'] }) : step));
|
|
return accountsFrom(result, 5, addTo(steps[0]!.accounts, 'bob', notice));
|
|
},
|
|
// RETR of a message retrieved before: Java refuses.
|
|
pop3Again: (steps, language) => steps.map((step, index) => (index === 4 ? fixed(step, language, { sent: steps[3]!.sent }) : step)),
|
|
// After DELE 0, Java's LIST numbers the other message 0; RETR 0 still means the deleted one.
|
|
pop3Numbers: (steps, language) => steps.map((step, index) => (index === 4 ? fixed(step, language, { sent: ['+OK 1 89\n1 89\n'] }) : step)),
|
|
// LIST without messages: Java's list fails.
|
|
pop3EmptyList: (steps, language) => steps.map((step, index) => (index === 4 ? fixed(step, language, { sent: ['+OK 0 0\n'] }) : step)),
|
|
// Before log-in Java answers empty.
|
|
pop3Before: (steps, language) =>
|
|
steps.map((step, index) => (index >= 1 && index <= 5 ? fixed(step, language, { sent: [unknownCommand] }) : index === 7 ? fixed(step, language, { sent: ['+OK'] }) : step)),
|
|
// A message that is not there: Java sends an empty mail or an empty
|
|
// answer; LIST 0 lists all; RETR x throws and ends the connection.
|
|
pop3BadArguments: (steps, language) =>
|
|
steps.map((step, index) => {
|
|
if (index === 3 || index === 4 || index === 5 || index === 9) return fixed(step, language, { sent: [noSuchMessage] });
|
|
if (index === 6) return fixed(step, language, { sent: ['+OK 0 80'] });
|
|
return step;
|
|
}),
|
|
// USER without a name: Java throws and ends the connection.
|
|
pop3NoArgument: (steps, language) => steps.map((step, index) => (index === 1 ? fixed(step, language, { sent: ['-ERR user or password wrong'] }) : step)),
|
|
// Two spaces before the number: Java throws and ends the connection.
|
|
pop3TwoSpaces: (steps, language) => steps.map((step, index) => (index === 3 ? fixed(step, language, { sent: [noSuchMessage] }) : step)),
|
|
};
|
|
|
|
function javaCases(language: PageLanguage): JavaCase[] {
|
|
return JSON.parse(readFileSync(join(here, `email-server.${language}.java.json`), 'utf8')) as JavaCase[];
|
|
}
|
|
|
|
const placeholders = (step: JavaStep): JavaStep => ({
|
|
...step,
|
|
accounts: step.accounts.map(([name, mailbox]) => [name, mailbox.map(([text, isNew, deleted]) => [withoutDate(text), isNew, deleted])]),
|
|
});
|
|
|
|
describe("the e-mail server's dialogues as Java FILIUS answers them", () => {
|
|
for (const language of ['de', 'en'] as const) {
|
|
const cases = javaCases(language);
|
|
|
|
it(`corrects Java only where it errs (${language})`, () => {
|
|
for (const [name, fix] of Object.entries(JAVA_BUGS)) {
|
|
const javaCase = cases.find((each) => each.name === name)!;
|
|
expect(JSON.stringify(fix(javaCase.steps, language)), name).not.toBe(JSON.stringify(javaCase.steps));
|
|
}
|
|
});
|
|
|
|
for (const javaCase of cases) {
|
|
it(`${javaCase.name} (${language})`, () => {
|
|
const expected = (JAVA_BUGS[javaCase.name]?.(javaCase.steps, language) ?? javaCase.steps).map(placeholders);
|
|
const actual = play(javaCase, language).map(placeholders);
|
|
for (const [index, step] of expected.entries()) expect(actual[index], `step ${index}: ${JSON.stringify(step.message)}`).toEqual(step);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
describe('e-mail as Java writes it', () => {
|
|
it('reads addresses as AddressEntry does', () => {
|
|
expect(addressEntry(' Anna Muster <anna@filius.de> ')).toEqual({ name: 'Anna Muster', mailAddress: 'anna@filius.de' });
|
|
expect(addressEntry('<anna@filius.de>')).toEqual({ name: null, mailAddress: 'anna@filius.de' });
|
|
expect(addressEntry('anna@filius.de')).toEqual({ name: null, mailAddress: 'anna@filius.de' });
|
|
expect(addressEntry('Anna')).toEqual({ name: 'Anna', mailAddress: null });
|
|
expect(addressEntry('a>b<c')).toEqual({ name: 'a>b<c', mailAddress: null });
|
|
expect(addressText({ name: null, mailAddress: null })).toBeNull();
|
|
});
|
|
|
|
it('writes no Bcc, and counts a size as Java does', () => {
|
|
const mail = parseMail('From: A <a@b.de>\nTo: x@y.de, Y <y@z.de>\nBcc: geheim@b.de\nSubject: Hallo \n\nText\n');
|
|
expect(mail.bcc).toEqual([{ name: null, mailAddress: 'geheim@b.de' }]);
|
|
expect(formatMail(mail)).toBe('From: A <a@b.de>\nTo: <x@y.de>, Y <y@z.de>\nSubject: Hallo\n\nText');
|
|
expect(mailSize(mail)).toBe('Text\n'.length + 'A <a@b.de>'.length + 'Hallo'.length + '[<x@y.de>, Y <y@z.de>]'.length);
|
|
expect(mailSize(parseMail('\nx'))).toBe('x\n'.length + 'null'.length * 2 + '[]'.length);
|
|
});
|
|
});
|
|
|
|
const SERVER = uuid(0x5001);
|
|
const HOST = uuid(0x5002);
|
|
|
|
function mailNetwork(): TopologySnapshotInput {
|
|
return {
|
|
nodes: [pc(SERVER, uuid(0x5011)), pc(HOST, uuid(0x5012))],
|
|
links: [link(SERVER, uuid(0x5011), HOST, uuid(0x5012))],
|
|
deviceConfigurations: new Map([
|
|
[SERVER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
|
|
[HOST, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '')],
|
|
]),
|
|
emailServersByNodeId: new Map([[SERVER, { domain: 'filius.de', accounts: MAIL_ACCOUNTS, isActive: true }]]),
|
|
};
|
|
}
|
|
|
|
/** Connects to the server's port and sends each message, each after the answer to the one before; the answers, the greeting first. */
|
|
function talk(engine: NetworkRuntimeEngine, port: number, ...messages: string[]): string[] {
|
|
const socket = engine.openTcpClientSocket(HOST, '10.0.0.1', port)!;
|
|
if (engine.connectTcpWithResult(socket) !== 'connected') return [];
|
|
const answers: string[] = [];
|
|
const receive = () => {
|
|
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 2_000 });
|
|
const answer = engine.receiveTcp(socket);
|
|
if (answer !== undefined) answers.push(new TextDecoder().decode(answer));
|
|
};
|
|
receive();
|
|
for (const message of messages) {
|
|
engine.sendTcp(socket, new TextEncoder().encode(message));
|
|
receive();
|
|
}
|
|
return answers;
|
|
}
|
|
|
|
describe('the e-mail server beyond the cases', () => {
|
|
it('writes a failed answer as Java does (sw_smtpmitarbeiter_msg14)', () => {
|
|
const lines: EmailServerLogEntry[] = [];
|
|
const state = new MailServerState('filius.de', [], 'de', (entry) => lines.push(entry), () => undefined, () => '10.0.0.1', () => 0);
|
|
const session = new SmtpSession(state, {
|
|
address: '10.0.0.2',
|
|
*send(): Steps<boolean> {
|
|
return false;
|
|
},
|
|
});
|
|
run(session.begin());
|
|
expect(lines.map((entry) => emailServerText(entry))).toEqual(['----', 'Fehler beim Senden an 10.0.0.2']);
|
|
});
|
|
|
|
it("takes a model's message as Java loads it from its file: the date, the subject trimmed when written, no sender as an empty name", () => {
|
|
const entry = mailboxEntry({ ...MAIL_ACCOUNTS[0]!.mailbox[0]!, subject: ' Termin ', receivedAtMilliseconds: 1_000, from: { name: null, mailAddress: null } });
|
|
expect(formatMail(entry.mail)).toBe('From: \nTo: <anna@filius.de>\nSubject: Termin\nDate Received: 1000\n\nErste Zeile\nZweite Zeile');
|
|
expect(entry.mail.subject).toBe(' Termin ');
|
|
});
|
|
});
|
|
|
|
describe('the e-mail server in the engine', () => {
|
|
it('switches SMTP and POP3 on and off together', () => {
|
|
const engine = new NetworkRuntimeEngine(14);
|
|
engine.handle({ type: 'start', snapshot: topologySnapshot(mailNetwork()), seed: 14, initialTimeMilliseconds: 0 });
|
|
const server = engine.emailServer(SERVER)!;
|
|
expect(server.isActive).toBe(true);
|
|
server.setActive(false);
|
|
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
|
expect(server.isActive).toBe(false);
|
|
expect(talk(engine, 25)).toEqual([]);
|
|
expect(talk(engine, 110)).toEqual([]);
|
|
server.setActive(true);
|
|
expect(talk(engine, 25, 'QUIT')).toEqual(['220 Willkommen bei filius.de', '221 Server beendet Verbindung.']);
|
|
expect(talk(engine, 110, 'QUIT')).toEqual(['+OK POP3 server ready', '+OK']);
|
|
});
|
|
|
|
it('keeps its mailboxes in the snapshot: a mail taken, a mail deleted; a new start begins from there', () => {
|
|
const engine = new NetworkRuntimeEngine(14);
|
|
engine.handle({ type: 'start', snapshot: topologySnapshot({ ...mailNetwork(), language: 'en' }), seed: 14, initialTimeMilliseconds: 0 });
|
|
talk(engine, 25, 'HELO x', 'MAIL FROM:<anna@filius.de>', 'RCPT TO:<bob@filius.de>', 'DATA', 'Bcc: <bob@filius.de>\nSubject: Neu\n\nText\r\n.\r\n', 'QUIT');
|
|
talk(engine, 25, 'HELO x', 'MAIL FROM:<anna@filius.de>', 'RCPT TO:<anna@filius.de>', 'DATA', 'Ohne Kopf\n.\n', 'QUIT');
|
|
const mailbox = (username = 'bob') => engine.state.topologySnapshot.emailServersByNodeId.get(SERVER)!.accounts.find((account) => account.username === username)!.mailbox;
|
|
// Stored as Java reads the mail anew: no Bcc, no "\r".
|
|
expect(mailbox()).toEqual([expect.objectContaining({ id: 4, bcc: [], subject: 'Neu', body: 'Text\n', isNew: true, isMarkedForDeletion: false })]);
|
|
expect(mailbox('anna').at(-1)).toEqual(expect.objectContaining({ from: { name: null, mailAddress: null }, subject: '', body: 'Ohne Kopf\n' }));
|
|
expect(talk(engine, 110, 'USER bob', 'PASS passwort', 'RETR 0', 'DELE 0')).toEqual([
|
|
'+OK POP3 server ready',
|
|
'+OK enter password',
|
|
'+OK Mailbox locked and ready',
|
|
'+OK message follows \nSubject: Neu\n\nText',
|
|
'+OK message marked for delete',
|
|
]);
|
|
expect(mailbox()).toEqual([expect.objectContaining({ isNew: false, isMarkedForDeletion: true })]);
|
|
engine.handle({ type: 'stop' });
|
|
engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: 14, initialTimeMilliseconds: 0 });
|
|
// As Java loads its file: every message new, none deleted; no sender is an empty name ("Text\n", "", "Neu", "[]").
|
|
expect(talk(engine, 110, 'USER bob', 'PASS passwort', 'STAT', 'DELE 0', 'QUIT')).toEqual([
|
|
'+OK POP3 server ready',
|
|
'+OK enter password',
|
|
'+OK Mailbox locked and ready',
|
|
'+OK 1 10',
|
|
'+OK message marked for delete',
|
|
'+OK',
|
|
]);
|
|
expect(mailbox()).toEqual([]);
|
|
});
|
|
|
|
it("writes a notice's date in the project's language, at the wall clock's time", () => {
|
|
const at = new Date(2026, 9, 1, 18, 5).getTime();
|
|
const notice = (language: PageLanguage) => {
|
|
const accounts: MailAccount[] = MAIL_ACCOUNTS.map((account) => ({ ...account, mailbox: [] }));
|
|
const state = new MailServerState('filius.de', accounts, language, () => undefined, () => undefined, () => '10.0.0.1', () => at);
|
|
state.deliver(parseMail('Subject: x\n\ny\n'), 'anna@filius.de', ['niemand@filius.de']);
|
|
return formatMail(accounts[0]!.mailbox[0]!.mail);
|
|
};
|
|
expect(notice('de')).toContain('\nDate: 01.10.26, 18:05\nMail-Server: 10.0.0.1\n');
|
|
expect(notice('en')).toContain('\nDate: 01/10/2026, 18:05\n');
|
|
expect(notice('de')).toContain('To: <anna@filius.de>\n');
|
|
});
|
|
});
|