@@ -128,7 +128,7 @@ test("integrates help, dark theme, PWA identity and hardened headers", async ({
|
||||
const manifest = await request.get("/deep/nested/mail/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.mail-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
privacy: { processing: "local", telemetry: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/mail/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deepRedactMessage } from "../../src/core/export";
|
||||
import { parseMessage } from "../../src/core/mime";
|
||||
|
||||
describe("deep redaction", () => {
|
||||
it("redacts nested headers, text bodies, and attachment payloads", () => {
|
||||
const message = parseMessage(`From: root@example.test
|
||||
Received: private-root
|
||||
Content-Type: multipart/mixed; boundary=x
|
||||
|
||||
--x
|
||||
Content-Type: message/rfc822
|
||||
|
||||
From: nested@example.test
|
||||
Received: private-nested
|
||||
Content-Type: text/plain
|
||||
|
||||
Nested secret
|
||||
--x
|
||||
Content-Type: application/octet-stream; name="secret.bin"
|
||||
Content-Disposition: attachment; filename="secret.bin"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
AQIDBA==
|
||||
--x--
|
||||
`);
|
||||
const result = deepRedactMessage(message, {
|
||||
redactTextBodies: true,
|
||||
removeAttachmentPayloads: true,
|
||||
});
|
||||
expect(result.output).not.toMatch(
|
||||
/private-root|private-nested|Nested secret|AQIDBA/iu,
|
||||
);
|
||||
expect(result.output).toContain(
|
||||
"W0F0dGFjaG1lbnQgcGF5bG9hZCByZW1vdmVkIGxvY2FsbHkuXQ",
|
||||
);
|
||||
expect(result.removedHeaders).toHaveLength(2);
|
||||
expect(result.redactedTextParts).toEqual(["1.1"]);
|
||||
expect(result.removedAttachments).toMatchObject([
|
||||
{ part: "2", filename: "secret.bin", bytes: 4 },
|
||||
]);
|
||||
expect(JSON.parse(result.report)).toMatchObject({
|
||||
operation: "mail-deep-redaction",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { bytesToBase64 } from "@add-ideas/toolbox-helpers";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inspectDkimSignatures,
|
||||
prepareDkimVerification,
|
||||
verifyDkimSignature,
|
||||
} from "../../src/core/dkim";
|
||||
import { parseMessage } from "../../src/core/mime";
|
||||
|
||||
async function signedMessage() {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength: 1024,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
const body = "Hello DKIM!\r\n";
|
||||
const bodyHash = bytesToBase64(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)),
|
||||
),
|
||||
);
|
||||
const unsigned = [
|
||||
"From: Ada <ada@example.test>",
|
||||
"Subject: Local verification",
|
||||
`DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.test; s=mail; h=from:subject; bh=${bodyHash}; b=`,
|
||||
"",
|
||||
body,
|
||||
].join("\r\n");
|
||||
const prepared = await prepareDkimVerification(parseMessage(unsigned));
|
||||
const signature = bytesToBase64(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
keyPair.privateKey,
|
||||
prepared.headerBytes,
|
||||
),
|
||||
),
|
||||
);
|
||||
const publicKey = bytesToBase64(
|
||||
new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey)),
|
||||
);
|
||||
return {
|
||||
source: unsigned.replace(/; b=(?=\r\n)/u, `; b=${signature}`),
|
||||
keyRecord: `v=DKIM1; k=rsa; h=sha256; s=email; p=${publicKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DKIM laboratory", () => {
|
||||
it("inspects signatures without network access", () => {
|
||||
const message = parseMessage(
|
||||
"From: a@example.test\r\nDKIM-Signature: v=1; a=rsa-sha256; d=example.test; s=mail; c=relaxed/relaxed; h=from; bh=YQ==; b=Yg==\r\n\r\na",
|
||||
);
|
||||
expect(inspectDkimSignatures(message)[0]).toMatchObject({
|
||||
queryName: "mail._domainkey.example.test",
|
||||
algorithm: "rsa-sha256",
|
||||
headerCanonicalization: "relaxed",
|
||||
bodyCanonicalization: "relaxed",
|
||||
supported: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies body and header signatures against a pasted key record", async () => {
|
||||
const fixture = await signedMessage();
|
||||
const result = await verifyDkimSignature(
|
||||
parseMessage(fixture.source),
|
||||
0,
|
||||
fixture.keyRecord,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
bodyHash: "pass",
|
||||
signature: "pass",
|
||||
status: "pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a changed body before checking the signature", async () => {
|
||||
const fixture = await signedMessage();
|
||||
const changed = fixture.source.replace("Hello DKIM!", "Hello altered!");
|
||||
const result = await verifyDkimSignature(
|
||||
parseMessage(changed),
|
||||
0,
|
||||
fixture.keyRecord,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
bodyHash: "fail",
|
||||
signature: "not-checked",
|
||||
status: "fail",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for malformed key records and unsupported signatures", async () => {
|
||||
const fixture = await signedMessage();
|
||||
expect(
|
||||
await verifyDkimSignature(parseMessage(fixture.source), 0, "v=DKIM1; p="),
|
||||
).toMatchObject({ signature: "error", status: "permerror" });
|
||||
const obsolete = parseMessage(
|
||||
fixture.source.replace("a=rsa-sha256", "a=rsa-sha1"),
|
||||
);
|
||||
expect(
|
||||
await verifyDkimSignature(obsolete, 0, fixture.keyRecord),
|
||||
).toMatchObject({
|
||||
status: "permerror",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMbox, searchMailbox } from "../../src/core/mbox";
|
||||
|
||||
describe("mbox import and threading", () => {
|
||||
it("splits mboxrd messages, unescapes body From lines, and threads replies", () => {
|
||||
const source = `From sender@example.test Tue Sep 1 10:00:00 2026
|
||||
From: Sender <sender@example.test>
|
||||
Date: Tue, 01 Sep 2026 10:00:00 +0000
|
||||
Message-ID: <root@example.test>
|
||||
Subject: Root
|
||||
|
||||
First body
|
||||
>From escaped body line
|
||||
From reply@example.test Tue Sep 1 10:01:00 2026
|
||||
From: Reply <reply@example.test>
|
||||
Date: Tue, 01 Sep 2026 10:01:00 +0000
|
||||
Message-ID: <reply@example.test>
|
||||
In-Reply-To: <root@example.test>
|
||||
References: <root@example.test>
|
||||
Subject: Re: Root
|
||||
|
||||
Reply body
|
||||
`;
|
||||
const mailbox = parseMbox(new TextEncoder().encode(source));
|
||||
expect(mailbox.entries).toHaveLength(2);
|
||||
expect(mailbox.entries[0]?.message.bodySource).toContain(
|
||||
"From escaped body line",
|
||||
);
|
||||
expect(mailbox.entries[1]).toMatchObject({
|
||||
parentIndex: 0,
|
||||
depth: 1,
|
||||
subject: "Re: Root",
|
||||
});
|
||||
expect(
|
||||
searchMailbox(mailbox, "escaped body").map((entry) => entry.index),
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
searchMailbox(mailbox, "reply root", { includeBodies: false }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requires an envelope separator", () => {
|
||||
expect(() =>
|
||||
parseMbox(new TextEncoder().encode("From: a@example.test\n\nbody")),
|
||||
).toThrow(/envelope separator/iu);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,33 @@ describe("MIME parser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves arbitrary source octets and exact multipart byte ranges", () => {
|
||||
const prefix = new TextEncoder().encode(
|
||||
"From: a@example.test\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n--x\r\nContent-Type: application/octet-stream\r\n\r\n",
|
||||
);
|
||||
const suffix = new TextEncoder().encode("\r\n--x--\r\n");
|
||||
const bytes = new Uint8Array(prefix.length + 4 + suffix.length);
|
||||
bytes.set(prefix);
|
||||
bytes.set([0x80, 0x81, 0xfe, 0xff], prefix.length);
|
||||
bytes.set(suffix, prefix.length + 4);
|
||||
const parsed = parseMessage(bytes);
|
||||
expect([...parsed.root.children[0]!.bytes]).toEqual([
|
||||
0x80, 0x81, 0xfe, 0xff,
|
||||
]);
|
||||
expect([...parsed.rawBytes]).toEqual([...bytes]);
|
||||
const range = parsed.root.children[0]!.sourceRange!;
|
||||
expect([...parsed.rawBytes.slice(range.bodyStart, range.end)]).toEqual([
|
||||
0x80, 0x81, 0xfe, 0xff,
|
||||
]);
|
||||
});
|
||||
|
||||
it("assembles RFC 2231 parameter continuations", () => {
|
||||
const parsed = parseMessage(
|
||||
"From: a@example.test\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename*0*=UTF-8''long%20; filename*1*=name%E2%9C%93.bin\r\n\r\ndata",
|
||||
);
|
||||
expect(attachments(parsed.root)[0]?.filename).toBe("long name✓.bin");
|
||||
});
|
||||
|
||||
it("parses nested message/rfc822 entities", () => {
|
||||
const parsed = parseMessage(
|
||||
"From: a@example.test\nContent-Type: message/rfc822\n\nFrom: b@example.test\nContent-Type: text/plain\n\nnested",
|
||||
|
||||
@@ -17,6 +17,22 @@ describe("inert rendering and output", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("embeds only explicitly supplied safe CID image bytes", () => {
|
||||
const output = sanitizeMailHtml(
|
||||
'<img src="cid:logo@example.test"><img src="https://bad.test/pixel">',
|
||||
[
|
||||
{
|
||||
contentId: "logo@example.test",
|
||||
mediaType: "image/png",
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(output).toContain("data:image/png;base64,AQID");
|
||||
expect(output).not.toContain("bad.test");
|
||||
expect(output).not.toContain("cid:");
|
||||
});
|
||||
|
||||
it("produces a bounded line comparison", () => {
|
||||
expect(compareBodies("a\nb", "a\nc")).toMatchObject({
|
||||
added: 1,
|
||||
|
||||
Reference in New Issue
Block a user