Release Mail Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:47:30 +02:00
parent 9bc0870404
commit 2ab972a9f3
35 changed files with 2061 additions and 150 deletions
+1 -1
View File
@@ -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 },
});
});
+18
View File
@@ -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);
});
+46
View File
@@ -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",
});
});
});
+110
View File
@@ -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",
});
});
});
+47
View File
@@ -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);
});
});
+27
View File
@@ -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",
+16
View File
@@ -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,