From 419b5af1483055a2a2f000f993ebf87e6c9e9054 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 1 Jun 2026 10:07:08 +0000 Subject: [PATCH 01/13] chore(deps): update dependency vitest to v4 --- web/package-lock.json | 1447 ++++------------------------------------- web/package.json | 2 +- 2 files changed, 128 insertions(+), 1321 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 27e6fc1..91b7bbf 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -31,7 +31,7 @@ "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", "vite": "^8.0.0", - "vitest": "^3.2.4" + "vitest": "^4.0.0" } }, "node_modules/@emnapi/core": { @@ -68,448 +68,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1176,395 +734,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1972,59 +1141,87 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -2032,28 +1229,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2249,43 +1443,16 @@ } } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2359,6 +1526,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2415,16 +1589,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2529,55 +1693,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3150,13 +2265,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3545,13 +2653,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3794,16 +2895,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -4086,58 +3177,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4257,25 +3296,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/svelte": { "version": "5.55.7", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", @@ -4441,30 +3467,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4669,104 +3675,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -4788,65 +3696,79 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4857,118 +3779,19 @@ }, "jsdom": { "optional": true - } - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true }, "vite": { - "optional": true + "optional": false } } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, - "node_modules/vitest/node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5035,22 +3858,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index 90b545e..66b2c8c 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,7 @@ "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", "vite": "^8.0.0", - "vitest": "^3.2.4" + "vitest": "^4.0.0" }, "dependencies": { "bootstrap": "^5.3.8", From 7953dfc3ed862aad38269fe3f0dee53d661a85cc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 3 Jun 2026 23:06:10 +0900 Subject: [PATCH 02/13] analyzer: strip resolver address from DNS lookup error messages Wrap user-facing lookup errors through a new formatDNSError helper that clears net.DNSError.Server so the " on " suffix no longer leaks the upstream resolver (e.g. "on 127.0.0.11:53") to end users. Closes: https://framagit.org/happyDomain/happydeliver/-/work_items/2 --- pkg/analyzer/dns_bimi.go | 2 +- pkg/analyzer/dns_dkim.go | 2 +- pkg/analyzer/dns_dmarc.go | 2 +- pkg/analyzer/dns_mx.go | 2 +- pkg/analyzer/dns_resolver.go | 13 +++++++++++++ pkg/analyzer/dns_spf.go | 2 +- 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/pkg/analyzer/dns_bimi.go b/pkg/analyzer/dns_bimi.go index 223bfdc..b037978 100644 --- a/pkg/analyzer/dns_bimi.go +++ b/pkg/analyzer/dns_bimi.go @@ -45,7 +45,7 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord Selector: selector, Domain: domain, Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %s", formatDNSError(err))), } } diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go index 115e347..5708d1c 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -122,7 +122,7 @@ func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord { Domain: h.Domain, SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %s", formatDNSError(err))), } } diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index b89500b..20058b2 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -193,7 +193,7 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { if err != nil { return &model.DMARCRecord{ Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %s", formatDNSError(err))), } } if foundDomain == "" { diff --git a/pkg/analyzer/dns_mx.go b/pkg/analyzer/dns_mx.go index c48c9a4..51c9eca 100644 --- a/pkg/analyzer/dns_mx.go +++ b/pkg/analyzer/dns_mx.go @@ -39,7 +39,7 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord { return &[]model.MXRecord{ { Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %s", formatDNSError(err))), }, } } diff --git a/pkg/analyzer/dns_resolver.go b/pkg/analyzer/dns_resolver.go index f60484f..266078e 100644 --- a/pkg/analyzer/dns_resolver.go +++ b/pkg/analyzer/dns_resolver.go @@ -23,9 +23,22 @@ package analyzer import ( "context" + "errors" "net" ) +// formatDNSError renders a resolution error without exposing the upstream +// resolver address that net.DNSError.Error() normally appends as " on ". +func formatDNSError(err error) string { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + sanitized := *dnsErr + sanitized.Server = "" + return sanitized.Error() + } + return err.Error() +} + // DNSResolver defines the interface for DNS resolution operations. // This interface abstracts DNS lookups to allow for custom implementations, // such as mock resolvers for testing or caching resolvers for performance. diff --git a/pkg/analyzer/dns_spf.go b/pkg/analyzer/dns_spf.go index ccb1674..5628986 100644 --- a/pkg/analyzer/dns_spf.go +++ b/pkg/analyzer/dns_spf.go @@ -67,7 +67,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, { Domain: &domain, Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %s", formatDNSError(err))), }, } } From 96c3a6ea0da0bba0f67605c840848f050779246f Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 30 May 2026 07:51:46 +0000 Subject: [PATCH 03/13] chore(deps): update module github.com/jackc/pgx/v5 to v5.9.2 [security] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a975215..85e6e04 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index f4c8d28..f7a56d3 100644 --- a/go.sum +++ b/go.sum @@ -93,8 +93,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= From 5f0b5b62d91a22e87f54d1eda49674f71f5fb9a2 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 3 Jun 2026 15:06:48 +0000 Subject: [PATCH 04/13] chore(deps): update module golang.org/x/net to v0.55.0 [security] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 85e6e04..dbd8002 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.4.0 - golang.org/x/net v0.54.0 + golang.org/x/net v0.55.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -72,7 +72,7 @@ require ( golang.org/x/crypto v0.51.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index f7a56d3..e90c11e 100644 --- a/go.sum +++ b/go.sum @@ -227,8 +227,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -249,8 +249,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= From 0b76d51d2bd269b0b67067e560bd1ef7e8211d12 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 4 Jun 2026 02:06:43 +0000 Subject: [PATCH 05/13] chore(deps): update module golang.org/x/crypto to v0.52.0 [security] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dbd8002..c638f4a 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index e90c11e..f467434 100644 --- a/go.sum +++ b/go.sum @@ -215,8 +215,8 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= From 27dcb1b0c36347090692c8ece574af1773fa8c73 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 13:03:06 +0900 Subject: [PATCH 06/13] docker: Listen both in ipv4 and ipv6 --- docker/postfix/main.cf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 5a73fb3..764b62b 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -7,7 +7,7 @@ myhostname = __HOSTNAME__ mydomain = __DOMAIN__ myorigin = $mydomain inet_interfaces = all -inet_protocols = ipv4 +inet_protocols = all # Recipient settings mydestination = localhost.$mydomain, localhost From e168446b44e026455fffbd54e4fb8a17c937aedf Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 13:27:35 +0900 Subject: [PATCH 07/13] dns: add HELO/PTR consistency check Compare the HELO/EHLO hostname announced by the sending server (first Received hop) against the sender IP's PTR records, surfacing the same signal as x-ptr/policy.ptr in Authentication-Results. Adds helo_hostname and helo_ptr_match to DNSResults, applies a 15-point PTR sub-score penalty on mismatch, and displays the result in a new HELO/PTR Consistency card. --- api/schemas.yaml | 33 ++++++ pkg/analyzer/authentication.go | 7 ++ pkg/analyzer/authentication_x_ptr.go | 61 ++++++++++ pkg/analyzer/authentication_x_ptr_test.go | 81 ++++++++++++++ pkg/analyzer/dns.go | 10 ++ pkg/analyzer/dns_fcr.go | 21 ++++ pkg/analyzer/dns_fcr_test.go | 104 ++++++++++++++++++ .../lib/components/AuthenticationCard.svelte | 48 ++++++++ web/src/lib/components/DnsRecordsCard.svelte | 8 ++ .../lib/components/HeloPtrMatchDisplay.svelte | 87 +++++++++++++++ 10 files changed, 460 insertions(+) create mode 100644 pkg/analyzer/authentication_x_ptr.go create mode 100644 pkg/analyzer/authentication_x_ptr_test.go create mode 100644 pkg/analyzer/dns_fcr_test.go create mode 100644 web/src/lib/components/HeloPtrMatchDisplay.svelte diff --git a/api/schemas.yaml b/api/schemas.yaml index 53aa297..55246d7 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -537,6 +537,9 @@ components: x_aligned_from: $ref: '#/components/schemas/AuthResult' description: X-Aligned-From authentication result (checks address alignment) + x_ptr: + $ref: '#/components/schemas/XPtrResult' + description: X-Ptr result (HELO hostname vs reverse DNS consistency check) AuthResult: type: object @@ -606,6 +609,29 @@ components: description: Additional details about the IP reverse lookup example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" + XPtrResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, none, temperror, permerror] + description: HELO/PTR consistency check result + example: "fail" + helo: + type: string + description: HELO/EHLO hostname announced by the sending server (smtp.helo) + example: "relay.example.org" + ptr: + type: string + description: Reverse DNS (PTR) hostname of the sender IP (policy.ptr) + example: "mail.example.com" + details: + type: string + description: Additional details about the x-ptr check + example: "smtp.helo=relay.example.org policy.ptr=mail.example.com" + SpamAssassinResult: type: object required: @@ -796,6 +822,13 @@ components: type: string description: A or AAAA records resolved from the PTR hostnames (forward confirmation) example: ["192.0.2.1", "2001:db8::1"] + helo_hostname: + type: string + description: HELO/EHLO hostname announced by the sending server (from the first Received hop) + example: "mail.example.com" + helo_ptr_match: + type: boolean + description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive) errors: type: array items: diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index bd8880d..bb34583 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -140,6 +140,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.XAlignedFrom = a.parseXAlignedFromResult(part) } } + + // Parse x-ptr + if strings.HasPrefix(part, "x-ptr=") { + if results.XPtr == nil { + results.XPtr = a.parseXPtrResult(part) + } + } } } diff --git a/pkg/analyzer/authentication_x_ptr.go b/pkg/analyzer/authentication_x_ptr.go new file mode 100644 index 0000000..93ecd03 --- /dev/null +++ b/pkg/analyzer/authentication_x_ptr.go @@ -0,0 +1,61 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseXPtrResult parses the x-ptr result from Authentication-Results. +// Example: x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com +func (a *AuthenticationAnalyzer) parseXPtrResult(part string) *model.XPtrResult { + result := &model.XPtrResult{} + + // Extract result (pass, fail, none, temperror, permerror) + re := regexp.MustCompile(`x-ptr=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = model.XPtrResultResult(resultStr) + } + + // Extract announced HELO hostname (smtp.helo) + heloRe := regexp.MustCompile(`smtp\.helo=([^\s;()]+)`) + if matches := heloRe.FindStringSubmatch(part); len(matches) > 1 { + helo := matches[1] + result.Helo = &helo + } + + // Extract reverse DNS hostname (policy.ptr) + ptrRe := regexp.MustCompile(`policy\.ptr=([^\s;()]+)`) + if matches := ptrRe.FindStringSubmatch(part); len(matches) > 1 { + ptr := matches[1] + result.Ptr = &ptr + } + + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-ptr=")) + + return result +} diff --git a/pkg/analyzer/authentication_x_ptr_test.go b/pkg/analyzer/authentication_x_ptr_test.go new file mode 100644 index 0000000..7015951 --- /dev/null +++ b/pkg/analyzer/authentication_x_ptr_test.go @@ -0,0 +1,81 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +func TestParseXPtrResult(t *testing.T) { + a := NewAuthenticationAnalyzer("receiver.com") + + tests := []struct { + name string + part string + expectedResult model.XPtrResultResult + expectedHelo *string + expectedPtr *string + }{ + { + name: "x-ptr fail with helo and ptr", + part: "x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com", + expectedResult: model.XPtrResultResultFail, + expectedHelo: utils.PtrTo("relay.example.org"), + expectedPtr: utils.PtrTo("mail.example.com"), + }, + { + name: "x-ptr pass", + part: "x-ptr=pass smtp.helo=mail.example.com policy.ptr=mail.example.com", + expectedResult: model.XPtrResultResultPass, + expectedHelo: utils.PtrTo("mail.example.com"), + expectedPtr: utils.PtrTo("mail.example.com"), + }, + { + name: "x-ptr none without ptr", + part: "x-ptr=none smtp.helo=relay.example.org", + expectedResult: model.XPtrResultResultNone, + expectedHelo: utils.PtrTo("relay.example.org"), + expectedPtr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := a.parseXPtrResult(tt.part) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Result != tt.expectedResult { + t.Errorf("Result = %q, want %q", result.Result, tt.expectedResult) + } + if !equalStrPtr(result.Helo, tt.expectedHelo) { + t.Errorf("Helo = %v, want %v", result.Helo, tt.expectedHelo) + } + if !equalStrPtr(result.Ptr, tt.expectedPtr) { + t.Errorf("Ptr = %v, want %v", result.Ptr, tt.expectedPtr) + } + }) + } +} diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 6bc7c39..c4d215c 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -88,6 +88,16 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head if len(forwardRecords) > 0 { results.PtrForwardRecords = &forwardRecords } + + // Record the announced HELO name and whether it matches the PTR record + if firstHop.From != nil && *firstHop.From != "" { + helo := *firstHop.From + results.HeloHostname = &helo + if len(ptrRecords) > 0 { + match := checkHeloPtrMatch(helo, ptrRecords) + results.HeloPtrMatch = &match + } + } } } diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go index 07e5ab9..2652b4c 100644 --- a/pkg/analyzer/dns_fcr.go +++ b/pkg/analyzer/dns_fcr.go @@ -23,6 +23,7 @@ package analyzer import ( "context" + "strings" "git.happydns.org/happyDeliver/internal/model" ) @@ -62,6 +63,21 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { return ptrNames, forwardIPs } +// checkHeloPtrMatch reports whether the announced HELO hostname matches one of +// the sender's PTR records (case-insensitive, trailing dot ignored). +func checkHeloPtrMatch(helo string, ptrRecords []string) bool { + helo = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(helo)), ".") + if helo == "" { + return false + } + for _, ptr := range ptrRecords { + if strings.TrimSuffix(strings.ToLower(ptr), ".") == helo { + return true + } + } + return false +} + // Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) { if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { @@ -73,6 +89,11 @@ func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP stri score -= 15 } + // Penalty when the announced HELO name doesn't match the PTR hostname + if results.HeloPtrMatch != nil && !*results.HeloPtrMatch { + score -= 15 + } + // Additional 50 points for forward-confirmed reverse DNS (FCrDNS) // This means the PTR hostname resolves back to IPs that include the original sender IP if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { diff --git a/pkg/analyzer/dns_fcr_test.go b/pkg/analyzer/dns_fcr_test.go new file mode 100644 index 0000000..2b9429b --- /dev/null +++ b/pkg/analyzer/dns_fcr_test.go @@ -0,0 +1,104 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "testing" + + "git.happydns.org/happyDeliver/internal/model" +) + +func TestCheckHeloPtrMatch(t *testing.T) { + tests := []struct { + name string + helo string + ptrRecords []string + want bool + }{ + {"exact match", "mail.example.com", []string{"mail.example.com"}, true}, + {"case insensitive", "Mail.Example.COM", []string{"mail.example.com"}, true}, + {"trailing dot ignored", "mail.example.com.", []string{"mail.example.com"}, true}, + {"mismatch", "relay.example.org", []string{"mail.example.com"}, false}, + {"match among several", "smtp.example.com", []string{"mail.example.com", "smtp.example.com"}, true}, + {"empty helo", "", []string{"mail.example.com"}, false}, + {"no ptr records", "mail.example.com", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkHeloPtrMatch(tt.helo, tt.ptrRecords); got != tt.want { + t.Errorf("checkHeloPtrMatch(%q, %v) = %v, want %v", tt.helo, tt.ptrRecords, got, tt.want) + } + }) + } +} + +func TestCalculatePTRScoreHeloMismatch(t *testing.T) { + d := NewDNSAnalyzer(0) + senderIP := "80.67.179.207" + ptr := []string{"mail.example.com"} + forward := []string{senderIP} + + matchTrue := true + matchFalse := false + + tests := []struct { + name string + results *model.DNSResults + want int + }{ + { + name: "helo matches ptr - no penalty (PTR+FCrDNS)", + results: &model.DNSResults{ + PtrRecords: &ptr, + PtrForwardRecords: &forward, + HeloPtrMatch: &matchTrue, + }, + want: 100, + }, + { + name: "helo mismatch - 15 point penalty", + results: &model.DNSResults{ + PtrRecords: &ptr, + PtrForwardRecords: &forward, + HeloPtrMatch: &matchFalse, + }, + want: 85, + }, + { + name: "no helo info - no penalty", + results: &model.DNSResults{ + PtrRecords: &ptr, + PtrForwardRecords: &forward, + }, + want: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := d.calculatePTRScore(tt.results, senderIP); got != tt.want { + t.Errorf("calculatePTRScore() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 46a4d2d..4f5ff6d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -170,6 +170,54 @@ {/if} + + {#if authentication.x_ptr} +
+
+ +
+ HELO / PTR + + + {authentication.x_ptr.result} + + {#if authentication.x_ptr.helo} +
+ Announced HELO: + {authentication.x_ptr.helo} +
+ {/if} + {#if authentication.x_ptr.ptr} +
+ Reverse DNS (PTR): + {authentication.x_ptr.ptr} +
+ {/if} + {#if authentication.x_ptr.details} +
{authentication.x_ptr.details}
+ {/if} +
+
+
+ {/if} +
diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 6dabe0b..e1d31cb 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -6,6 +6,7 @@ import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte"; import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte"; + import HeloPtrMatchDisplay from "./HeloPtrMatchDisplay.svelte"; import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte"; @@ -92,6 +93,13 @@ {senderIp} /> + + +
diff --git a/web/src/lib/components/HeloPtrMatchDisplay.svelte b/web/src/lib/components/HeloPtrMatchDisplay.svelte new file mode 100644 index 0000000..1d8cee7 --- /dev/null +++ b/web/src/lib/components/HeloPtrMatchDisplay.svelte @@ -0,0 +1,87 @@ + + +{#if heloHostname} +
+
+
+ + HELO / PTR Consistency +
+ HELO +
+
+

+ The HELO/EHLO hostname is the name the sending server announces when it connects. + Many mail servers check that this name matches the sender IP's reverse DNS (PTR) + record. A mismatch is a common spam signal and can hurt deliverability. +

+
+ Announced HELO: {heloHostname} +
+ {#if ptrRecords && ptrRecords.length > 0} +
+ PTR Hostname(s): + {#each ptrRecords as ptr} +
+ {#if normalize(heloHostname) === normalize(ptr)} + Match + {:else} + Different + {/if} + {ptr} +
+ {/each} +
+ {/if} +
+ {#if !isMatch} +
+
+
+ + Warning: The announced HELO hostname + {heloHostname} + {#if ptrRecords && ptrRecords.length > 0} + does not match the sender's PTR record{ptrRecords.length > 1 ? "s" : ""} + ({#each ptrRecords as ptr, i}{ptr}{i < + ptrRecords.length - 1 + ? ", " + : ""}{/each}). + {:else} + could not be matched against a PTR record. + {/if} + Configuring the HELO name to match reverse DNS improves deliverability. +
+
+
+ {/if} +
+{/if} From a65b8084eeb27ce5bcb7694532a3c40579956be2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 14:02:06 +0900 Subject: [PATCH 08/13] dns: add ReturnOK check for sender domain reachability Verify that the From and Return-Path domains can actually receive replies and bounces, mirroring Fastmail's authentication_milter ReturnOK handler. Each domain is checked for MX records, falling back to A/AAAA (implicit MX) and then to the organizational domain, yielding a pass/warn/fail status. Adds return_ok to DNSResults, a 10-point DNS sub-score penalty per domain that is wholly unreachable, and a new "Return Address Reachability" card. --- api/schemas.yaml | 37 ++++ pkg/analyzer/dns.go | 20 +++ pkg/analyzer/dns_returnok.go | 113 ++++++++++++ pkg/analyzer/dns_returnok_test.go | 170 ++++++++++++++++++ web/src/lib/components/DnsRecordsCard.svelte | 7 +- web/src/lib/components/ReturnOkDisplay.svelte | 106 +++++++++++ 6 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 pkg/analyzer/dns_returnok.go create mode 100644 pkg/analyzer/dns_returnok_test.go create mode 100644 web/src/lib/components/ReturnOkDisplay.svelte diff --git a/api/schemas.yaml b/api/schemas.yaml index 55246d7..662cf4c 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -829,12 +829,49 @@ components: helo_ptr_match: type: boolean description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive) + return_ok: + $ref: '#/components/schemas/ReturnOK' errors: type: array items: type: string description: DNS lookup errors + ReturnOK: + type: object + description: Whether the sender domains can receive replies and bounces (MX, with A/AAAA fallback) + properties: + from: + $ref: '#/components/schemas/ReturnOKDomain' + return_path: + $ref: '#/components/schemas/ReturnOKDomain' + + ReturnOKDomain: + type: object + required: + - domain + - status + properties: + domain: + type: string + description: Domain that was evaluated + example: "example.com" + status: + type: string + enum: [pass, warn, fail] + x-go-type: string + description: pass = MX present, warn = only A/AAAA records (implicit MX), fail = no records + has_mx: + type: boolean + description: Whether the domain has at least one MX record + has_address: + type: boolean + description: Whether the domain has an A or AAAA record (implicit MX fallback) + org_domain: + type: string + description: Organizational domain used as fallback when the domain itself had no records + example: "example.com" + MXRecord: type: object required: diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index c4d215c..9927d1b 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -110,6 +110,15 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head results.RpMxRecords = d.checkMXRecords(*results.RpDomain) } + // Verify the sender domains can actually receive replies/bounces (MX, with + // A/AAAA fallback), mirroring the ReturnOK milter check. + results.ReturnOk = &model.ReturnOK{ + From: d.checkReturnOKDomain(fromDomain, orgDomainOrEmpty(headersResults.DomainAlignment.FromOrgDomain)), + } + if results.RpDomain != nil && *results.RpDomain != "" { + results.ReturnOk.ReturnPath = d.checkReturnOKDomain(*results.RpDomain, orgDomainOrEmpty(headersResults.DomainAlignment.ReturnPathOrgDomain)) + } + // Check SPF records (for Return-Path domain - this is the envelope sender) // SPF validates the MAIL FROM command, which corresponds to Return-Path results.SpfRecords = d.checkSPFRecords(spfDomain) @@ -148,6 +157,11 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults { // Check SPF records results.SpfRecords = d.checkSPFRecords(domain) + // Verify the domain can receive replies/bounces (MX, with A/AAAA fallback) + results.ReturnOk = &model.ReturnOK{ + From: d.checkReturnOKDomain(domain, ""), + } + // Check DMARC record results.DmarcRecord = d.checkDMARCRecord(domain) @@ -179,6 +193,9 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, // DMARC Record: 40 points score += 40 * d.calculateDMARCScore(results) / 100 + // Penalty when a sender domain cannot receive replies/bounces at all + score += calculateReturnOKPenalty(results) + // BIMI Record: only bonus if results.BimiRecord != nil && results.BimiRecord.Valid { if score >= 100 { @@ -224,6 +241,9 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP stri // DMARC Record: 20 points score += 20 * d.calculateDMARCScore(results) / 100 + // Penalty when a sender domain cannot receive replies/bounces at all + score += calculateReturnOKPenalty(results) + // BIMI Record // BIMI is optional but indicates advanced email branding if results.BimiRecord != nil && results.BimiRecord.Valid { diff --git a/pkg/analyzer/dns_returnok.go b/pkg/analyzer/dns_returnok.go new file mode 100644 index 0000000..29e12b3 --- /dev/null +++ b/pkg/analyzer/dns_returnok.go @@ -0,0 +1,113 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// ReturnOKDomain.Status values, matching the schema enum. Kept as a plain string +// in the generated model (x-go-type) to avoid colliding with other "pass"/"fail" +// enums in the global enum namespace. +const ( + returnOKStatusPass = "pass" + returnOKStatusWarn = "warn" + returnOKStatusFail = "fail" +) + +// domainCanReceive reports whether a domain can accept mail, looking up records +// in the same order as Fastmail's ReturnOK milter: MX first, then A/AAAA. +func (d *DNSAnalyzer) domainCanReceive(domain string) (hasMX, hasAddress bool) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + if mxRecords, err := d.resolver.LookupMX(ctx, domain); err == nil && len(mxRecords) > 0 { + return true, false + } + + if addrs, err := d.resolver.LookupHost(ctx, domain); err == nil && len(addrs) > 0 { + return false, true + } + + return false, false +} + +// checkReturnOKDomain verifies that a domain can receive replies/bounces. +// It checks the domain itself, then falls back to its organizational domain +// (when different) the same way the ReturnOK milter retries the org domain. +func (d *DNSAnalyzer) checkReturnOKDomain(domain, orgDomain string) *model.ReturnOKDomain { + if domain == "" { + return nil + } + + result := &model.ReturnOKDomain{Domain: domain} + + hasMX, hasAddress := d.domainCanReceive(domain) + + // Fall back to the organizational domain when the domain itself has nothing. + if !hasMX && !hasAddress && orgDomain != "" && orgDomain != domain { + if orgMX, orgAddr := d.domainCanReceive(orgDomain); orgMX || orgAddr { + hasMX, hasAddress = orgMX, orgAddr + result.OrgDomain = utils.PtrTo(orgDomain) + } + } + + result.HasMx = utils.PtrTo(hasMX) + result.HasAddress = utils.PtrTo(hasAddress) + + switch { + case hasMX: + result.Status = returnOKStatusPass + case hasAddress: + result.Status = returnOKStatusWarn + default: + result.Status = returnOKStatusFail + } + + return result +} + +// calculateReturnOKPenalty returns a non-positive value: each sender domain that +// can receive neither replies nor bounces (status=fail) costs points, since +// those messages would be silently lost. +func calculateReturnOKPenalty(results *model.DNSResults) (penalty int) { + if results.ReturnOk == nil { + return 0 + } + for _, dom := range []*model.ReturnOKDomain{results.ReturnOk.From, results.ReturnOk.ReturnPath} { + if dom != nil && dom.Status == returnOKStatusFail { + penalty -= 10 + } + } + return +} + +// orgDomainOrEmpty dereferences an optional organizational domain pointer. +func orgDomainOrEmpty(orgDomain *string) string { + if orgDomain == nil { + return "" + } + return *orgDomain +} diff --git a/pkg/analyzer/dns_returnok_test.go b/pkg/analyzer/dns_returnok_test.go new file mode 100644 index 0000000..55aaa5c --- /dev/null +++ b/pkg/analyzer/dns_returnok_test.go @@ -0,0 +1,170 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "net" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/model" +) + +// returnOKMockResolver lets tests control MX and host (A/AAAA) lookups per domain. +type returnOKMockResolver struct { + mx map[string][]*net.MX + hosts map[string][]string +} + +func (m *returnOKMockResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) { + if recs, ok := m.mx[name]; ok { + return recs, nil + } + return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} +} + +func (m *returnOKMockResolver) LookupHost(_ context.Context, host string) ([]string, error) { + if recs, ok := m.hosts[host]; ok { + return recs, nil + } + return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} +} + +func (m *returnOKMockResolver) LookupTXT(_ context.Context, _ string) ([]string, error) { + return nil, nil +} +func (m *returnOKMockResolver) LookupAddr(_ context.Context, _ string) ([]string, error) { + return nil, nil +} + +func TestCheckReturnOKDomain(t *testing.T) { + mx := []*net.MX{{Host: "mail.example.com.", Pref: 10}} + + tests := []struct { + name string + domain string + orgDomain string + resolver *returnOKMockResolver + wantStatus string + wantHasMX bool + wantHasAddr bool + wantOrgDomain string // "" means OrgDomain should be nil + }{ + { + name: "domain with MX passes", + domain: "example.com", + resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}}, + wantStatus: returnOKStatusPass, + wantHasMX: true, + wantHasAddr: false, + }, + { + name: "no MX but A/AAAA warns", + domain: "example.com", + resolver: &returnOKMockResolver{hosts: map[string][]string{"example.com": {"192.0.2.1"}}}, + wantStatus: returnOKStatusWarn, + wantHasMX: false, + wantHasAddr: true, + }, + { + name: "fallback to org domain MX", + domain: "sub.example.com", + orgDomain: "example.com", + resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}}, + wantStatus: returnOKStatusPass, + wantHasMX: true, + wantHasAddr: false, + wantOrgDomain: "example.com", + }, + { + name: "nothing anywhere fails", + domain: "example.com", + orgDomain: "example.com", + resolver: &returnOKMockResolver{}, + wantStatus: returnOKStatusFail, + wantHasMX: false, + wantHasAddr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := NewDNSAnalyzerWithResolver(5*time.Second, tt.resolver) + got := d.checkReturnOKDomain(tt.domain, tt.orgDomain) + if got == nil { + t.Fatalf("checkReturnOKDomain returned nil") + } + if got.Status != tt.wantStatus { + t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) + } + if got.HasMx == nil || *got.HasMx != tt.wantHasMX { + t.Errorf("HasMx = %v, want %v", got.HasMx, tt.wantHasMX) + } + if got.HasAddress == nil || *got.HasAddress != tt.wantHasAddr { + t.Errorf("HasAddress = %v, want %v", got.HasAddress, tt.wantHasAddr) + } + if tt.wantOrgDomain == "" { + if got.OrgDomain != nil { + t.Errorf("OrgDomain = %v, want nil", *got.OrgDomain) + } + } else { + if got.OrgDomain == nil || *got.OrgDomain != tt.wantOrgDomain { + t.Errorf("OrgDomain = %v, want %q", got.OrgDomain, tt.wantOrgDomain) + } + } + }) + } +} + +func TestCheckReturnOKDomainEmpty(t *testing.T) { + d := NewDNSAnalyzerWithResolver(5*time.Second, &returnOKMockResolver{}) + if got := d.checkReturnOKDomain("", ""); got != nil { + t.Errorf("checkReturnOKDomain(\"\") = %v, want nil", got) + } +} + +func TestCalculateReturnOKPenalty(t *testing.T) { + fail := &model.ReturnOKDomain{Domain: "a.example", Status: returnOKStatusFail} + pass := &model.ReturnOKDomain{Domain: "b.example", Status: returnOKStatusPass} + warn := &model.ReturnOKDomain{Domain: "c.example", Status: returnOKStatusWarn} + + tests := []struct { + name string + results *model.DNSResults + want int + }{ + {"nil return_ok", &model.DNSResults{}, 0}, + {"both pass", &model.DNSResults{ReturnOk: &model.ReturnOK{From: pass, ReturnPath: pass}}, 0}, + {"warn is not penalised", &model.DNSResults{ReturnOk: &model.ReturnOK{From: warn}}, 0}, + {"one fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: pass}}, -10}, + {"both fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: fail}}, -20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calculateReturnOKPenalty(tt.results); got != tt.want { + t.Errorf("calculateReturnOKPenalty() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index e1d31cb..eedd0db 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -10,6 +10,7 @@ import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte"; + import ReturnOkDisplay from "./ReturnOkDisplay.svelte"; import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte"; interface Props { @@ -100,6 +101,9 @@ heloPtrMatch={dnsResults.helo_ptr_match} /> + + +
@@ -150,8 +154,7 @@ {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} - Differs from Return-Path - domain + Differs from Return-Path domain {/if}
diff --git a/web/src/lib/components/ReturnOkDisplay.svelte b/web/src/lib/components/ReturnOkDisplay.svelte new file mode 100644 index 0000000..11d4c00 --- /dev/null +++ b/web/src/lib/components/ReturnOkDisplay.svelte @@ -0,0 +1,106 @@ + + +{#if rows.length > 0} +
+
+
+ + Return Address Reachability +
+ RETURN-OK +
+
+

+ Replies (to the From address) and bounces (to the Return-Path) can only be delivered + if the sender's domains accept mail. A domain should publish MX records; an A/AAAA + record works as an implicit fallback but is not recommended. A domain with neither + is unreachable and silently drops replies and bounces. +

+
+
+ {#each rows as { label, entry } (label)} +
+
+ {label} domain: + {entry.domain} + + {badgeLabel(entry.status)} + + {#if entry.org_domain} + + via organizational domain {entry.org_domain} + + {/if} +
+
+ {/each} +
+ {#if hasFail || hasWarn} +
+
+ {#if hasFail} +
+ + Error: At least one sender domain has no MX and no A/AAAA record. + Replies or bounce messages to that domain will be lost. Publish an MX record pointing + to a mail server that accepts mail. +
+ {:else if hasWarn} +
+ + Warning: A sender domain has no MX record and relies on its A/AAAA + record (implicit MX). Mail is still deliverable, but publishing an explicit MX + record is recommended. +
+ {/if} +
+
+ {/if} +
+{/if} From 8e7e56851b9c06018b2ec39f19b26ae184dc79ae Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 12:58:51 +0900 Subject: [PATCH 09/13] postfix: add tlsmgr service to enable STARTTLS Without tlsmgr, smtpd has no PRNG/entropy source and disables TLS, rejecting STARTTLS with "454 4.7.0 TLS not available due to local problem". --- docker/postfix/master.cf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index 9c2ac57..822d56e 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -3,6 +3,9 @@ # SMTP service smtp inet n - n - - smtpd +# TLS session cache and PRNG manager (required for STARTTLS) +tlsmgr unix - - n 1000? 1 tlsmgr + # Pickup service pickup unix n - n 60 1 pickup From d53c1b1e005f4aa6c617044d771dcfbf8c10b8e8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 15:15:32 +0900 Subject: [PATCH 10/13] tls: surface transport TLS status in email path and authentication Parse TLS details (version, cipher, bits, cert verification) from the Postfix Received header parenthetical and expose them per hop, rendered as a per-hop badge in the Email Path card. Add an x-tls Authentication-Results result: parse it when present, and otherwise synthesize it from the inbound hop's TLS info. A negative result (unencrypted inbound connection) applies a -10 authentication score penalty and is shown in the Authentication card. Enable the TLS handler in authentication_milter. Closes: https://git.nemunai.re/happyDomain/happyDeliver/issues/40 --- api/schemas.yaml | 28 +++ .../authentication_milter.json | 2 + docker/postfix/main.cf | 3 + pkg/analyzer/authentication.go | 10 ++ pkg/analyzer/authentication_x_tls.go | 154 ++++++++++++++++ pkg/analyzer/authentication_x_tls_test.go | 165 ++++++++++++++++++ pkg/analyzer/headers.go | 46 +++++ pkg/analyzer/headers_test.go | 75 ++++++++ pkg/analyzer/report.go | 4 + .../lib/components/AuthenticationCard.svelte | 34 ++++ web/src/lib/components/EmailPathCard.svelte | 72 ++++++++ 11 files changed, 593 insertions(+) create mode 100644 pkg/analyzer/authentication_x_tls.go create mode 100644 pkg/analyzer/authentication_x_tls_test.go diff --git a/api/schemas.yaml b/api/schemas.yaml index 662cf4c..042a3b3 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -434,6 +434,29 @@ components: type: string description: Reverse DNS (PTR record) for the IP address example: "mail.example.com" + tls: + $ref: '#/components/schemas/TLSInfo' + description: TLS details of the connection for this hop, if encrypted + + TLSInfo: + type: object + properties: + version: + type: string + description: TLS protocol version + example: "TLSv1.3" + cipher: + type: string + description: Cipher suite used + example: "TLS_AES_256_GCM_SHA384" + bits: + type: integer + description: Cipher strength in bits + example: 256 + verified: + type: boolean + description: Whether the peer certificate was verified/trusted + example: true DKIMDomainInfo: type: object @@ -540,6 +563,11 @@ components: x_ptr: $ref: '#/components/schemas/XPtrResult' description: X-Ptr result (HELO hostname vs reverse DNS consistency check) + x_tls: + $ref: '#/components/schemas/AuthResult' + description: >- + Transport TLS encryption of the inbound connection (x-tls). + Synthesized from the inbound Received hop when no x-tls header is present. AuthResult: type: object diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json index 5db3bbc..cd3bd03 100644 --- a/docker/authentication_milter/authentication_milter.json +++ b/docker/authentication_milter/authentication_milter.json @@ -52,6 +52,8 @@ "PTR" : {}, + "TLS" : {}, + "SenderID" : { "hide_none" : 1 }, diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 764b62b..9f09396 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -36,5 +36,8 @@ smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination +# TLS - record the negotiated cipher/protocol in the Received: header +smtpd_tls_received_header = yes + # Logging debug_peer_level = 2 diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index bb34583..666f9ee 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -147,6 +147,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.XPtr = a.parseXPtrResult(part) } } + + // Parse x-tls + if strings.HasPrefix(part, "x-tls=") { + if results.XTls == nil { + results.XTls = a.parseXTLSResult(part) + } + } } } @@ -183,6 +190,9 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut // Penalty-only: X-Aligned-From (up to -5 points on failure) score += 5 * a.calculateXAlignedFromScore(results) / 100 + // Penalty-only: X-TLS / transport encryption (-10 points when not encrypted) + score += 10 * a.calculateXTLSScore(results) / 100 + // Ensure score doesn't exceed 100 if score > 100 { score = 100 diff --git a/pkg/analyzer/authentication_x_tls.go b/pkg/analyzer/authentication_x_tls.go new file mode 100644 index 0000000..440f806 --- /dev/null +++ b/pkg/analyzer/authentication_x_tls.go @@ -0,0 +1,154 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "fmt" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// parseXTLSResult parses the x-tls result from Authentication-Results. +// Example: x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256 +func (a *AuthenticationAnalyzer) parseXTLSResult(part string) *model.AuthResult { + result := &model.AuthResult{} + + // Extract result (pass, fail, none, ...) + re := regexp.MustCompile(`x-tls=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + result.Result = model.AuthResultResult(strings.ToLower(matches[1])) + } + + result.Details = utils.PtrTo(formatTLSDetails( + submatch(part, `smtp\.version=([^\s;()]+)`), + submatch(part, `smtp\.cipher=([^\s;()]+)`), + submatch(part, `smtp\.bits=(\d+)`), + )) + + return result +} + +// calculateXTLSScore returns a penalty for a negative transport-TLS result. +// pass (or absent) does not alter the score; any other result is penalized. +func (a *AuthenticationAnalyzer) calculateXTLSScore(results *model.AuthenticationResults) (score int) { + if results.XTls != nil { + switch results.XTls.Result { + case model.AuthResultResultPass: + // pass: don't alter the score + default: + return -100 + } + } + + return 0 +} + +// ReconcileXTLS fills in the x-tls result from the inbound connection's parsed TLS +// information when no x-tls Authentication-Results header was present. The inbound +// connection is the most recent hop (index 0) of the received chain. +func (a *AuthenticationAnalyzer) ReconcileXTLS(results *model.AuthenticationResults, chain *[]model.ReceivedHop) { + if results == nil || results.XTls != nil { + return + } + if chain == nil || len(*chain) == 0 { + return + } + + inbound := (*chain)[0] + switch { + case inbound.Tls != nil: + // Full TLS parenthetical present (smtpd_tls_received_header = yes). + var version, cipher, bits string + if inbound.Tls.Version != nil { + version = *inbound.Tls.Version + } + if inbound.Tls.Cipher != nil { + cipher = *inbound.Tls.Cipher + } + if inbound.Tls.Bits != nil { + bits = fmt.Sprintf("%d", *inbound.Tls.Bits) + } + results.XTls = &model.AuthResult{ + Result: model.AuthResultResultPass, + Details: utils.PtrTo(formatTLSDetails(version, cipher, bits)), + } + + case protocolIndicatesTLS(inbound.With): + // No TLS parenthetical (smtpd_tls_received_header may be disabled), but the + // transport keyword (ESMTPS, ESMTPSA, ...) tells us the session was encrypted. + // We just don't have the cipher details. + results.XTls = &model.AuthResult{ + Result: model.AuthResultResultPass, + Details: utils.PtrTo(fmt.Sprintf("Encrypted connection (%s); cipher details unavailable", *inbound.With)), + } + + case inbound.With != nil: + // A plaintext transport keyword (SMTP, ESMTP, ESMTPA, ...) is positive + // evidence the inbound connection was not encrypted. + results.XTls = &model.AuthResult{ + Result: model.AuthResultResultNone, + Details: utils.PtrTo(fmt.Sprintf("Inbound connection was not encrypted (%s)", *inbound.With)), + } + + default: + // Neither TLS details nor a transport keyword: we cannot tell whether the + // connection was encrypted. Leave x-tls unset rather than wrongly penalize. + } +} + +// protocolIndicatesTLS reports whether an SMTP "with" transport keyword denotes a +// TLS-encrypted session. Per RFC 3848 the keyword gains a trailing "S" when STARTTLS +// (or implicit TLS) was negotiated: ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA, UTF8SMTPS... +// The plaintext variants end in "P" (SMTP, ESMTP, LMTP) or "A" (ESMTPA, LMTPA). +func protocolIndicatesTLS(with *string) bool { + if with == nil { + return false + } + p := strings.ToUpper(strings.TrimSpace(*with)) + return strings.HasSuffix(p, "S") || strings.HasSuffix(p, "SA") +} + +// submatch returns the first capture group of pattern in s, or "". +func submatch(s, pattern string) string { + if matches := regexp.MustCompile(pattern).FindStringSubmatch(s); len(matches) > 1 { + return matches[1] + } + return "" +} + +// formatTLSDetails builds a human-readable summary of the TLS parameters. +func formatTLSDetails(version, cipher, bits string) string { + var parts []string + if version != "" { + parts = append(parts, version) + } + if cipher != "" { + parts = append(parts, "cipher "+cipher) + } + if bits != "" { + parts = append(parts, bits+" bits") + } + return strings.Join(parts, ", ") +} diff --git a/pkg/analyzer/authentication_x_tls_test.go b/pkg/analyzer/authentication_x_tls_test.go new file mode 100644 index 0000000..52a655c --- /dev/null +++ b/pkg/analyzer/authentication_x_tls_test.go @@ -0,0 +1,165 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "strings" + "testing" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +func TestParseXTLSResult(t *testing.T) { + analyzer := NewAuthenticationAnalyzer("") + + result := analyzer.parseXTLSResult("x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256") + + if result.Result != model.AuthResultResultPass { + t.Errorf("Result = %v, want pass", result.Result) + } + if result.Details == nil { + t.Fatal("Details should not be nil") + } + for _, want := range []string{"TLSv1.3", "TLS_AES_256_GCM_SHA384", "256 bits"} { + if !strings.Contains(*result.Details, want) { + t.Errorf("Details %q should contain %q", *result.Details, want) + } + } +} + +func TestCalculateXTLSScore(t *testing.T) { + analyzer := NewAuthenticationAnalyzer("") + + tests := []struct { + name string + xtls *model.AuthResult + score int + }{ + {"nil", nil, 0}, + {"pass", &model.AuthResult{Result: model.AuthResultResultPass}, 0}, + {"none", &model.AuthResult{Result: model.AuthResultResultNone}, -100}, + {"fail", &model.AuthResult{Result: model.AuthResultResultFail}, -100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := &model.AuthenticationResults{XTls: tt.xtls} + if got := analyzer.calculateXTLSScore(results); got != tt.score { + t.Errorf("calculateXTLSScore = %d, want %d", got, tt.score) + } + }) + } +} + +func TestReconcileXTLS(t *testing.T) { + analyzer := NewAuthenticationAnalyzer("") + + t.Run("keeps existing x-tls header result", func(t *testing.T) { + existing := &model.AuthResult{Result: model.AuthResultResultFail} + results := &model.AuthenticationResults{XTls: existing} + chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{Version: utils.PtrTo("TLSv1.3")}}} + analyzer.ReconcileXTLS(results, chain) + if results.XTls != existing { + t.Error("existing XTls should be preserved") + } + }) + + t.Run("synthesizes pass from encrypted inbound hop", func(t *testing.T) { + results := &model.AuthenticationResults{} + chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{ + Version: utils.PtrTo("TLSv1.3"), + Cipher: utils.PtrTo("TLS_AES_256_GCM_SHA384"), + Bits: utils.PtrTo(256), + }}} + analyzer.ReconcileXTLS(results, chain) + if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass { + t.Fatalf("expected synthesized pass, got %+v", results.XTls) + } + if results.XTls.Details == nil || !strings.Contains(*results.XTls.Details, "TLSv1.3") { + t.Errorf("details should mention TLS version, got %v", results.XTls.Details) + } + }) + + t.Run("synthesizes pass from ESMTPS protocol without TLS parenthetical", func(t *testing.T) { + // smtpd_tls_received_header disabled: no TLS details, but ESMTPS proves encryption. + results := &model.AuthenticationResults{} + chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTPS")}} + analyzer.ReconcileXTLS(results, chain) + if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass { + t.Fatalf("expected synthesized pass, got %+v", results.XTls) + } + }) + + t.Run("synthesizes none from plaintext ESMTP protocol", func(t *testing.T) { + results := &model.AuthenticationResults{} + chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTP")}} + analyzer.ReconcileXTLS(results, chain) + if results.XTls == nil || results.XTls.Result != model.AuthResultResultNone { + t.Fatalf("expected synthesized none, got %+v", results.XTls) + } + }) + + t.Run("leaves nil when neither TLS info nor protocol is known", func(t *testing.T) { + results := &model.AuthenticationResults{} + chain := &[]model.ReceivedHop{{}} + analyzer.ReconcileXTLS(results, chain) + if results.XTls != nil { + t.Errorf("expected nil XTls when undetermined, got %+v", results.XTls) + } + }) + + t.Run("leaves nil with empty chain", func(t *testing.T) { + results := &model.AuthenticationResults{} + analyzer.ReconcileXTLS(results, &[]model.ReceivedHop{}) + if results.XTls != nil { + t.Errorf("expected nil XTls, got %+v", results.XTls) + } + }) +} + +func TestProtocolIndicatesTLS(t *testing.T) { + tests := []struct { + with string + want bool + }{ + {"ESMTPS", true}, + {"ESMTPSA", true}, + {"SMTPS", true}, + {"LMTPS", true}, + {"LMTPSA", true}, + {"SMTP", false}, + {"ESMTP", false}, + {"ESMTPA", false}, + {"LMTP", false}, + } + for _, tt := range tests { + t.Run(tt.with, func(t *testing.T) { + if got := protocolIndicatesTLS(utils.PtrTo(tt.with)); got != tt.want { + t.Errorf("protocolIndicatesTLS(%q) = %v, want %v", tt.with, got, tt.want) + } + }) + } + if protocolIndicatesTLS(nil) { + t.Error("protocolIndicatesTLS(nil) should be false") + } +} diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 6d7b547..448de57 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -26,6 +26,7 @@ import ( "net" "net/mail" "regexp" + "strconv" "strings" "time" @@ -693,5 +694,50 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.Receiv } } + // Extract TLS details from the Received header parentheticals + // (e.g. "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) ...)") + hop.Tls = parseReceivedTLS(normalized) + return hop } + +// parseReceivedTLS extracts TLS connection details from a normalized Received header value. +// Returns nil when the hop was not encrypted (no TLS version/cipher found). +func parseReceivedTLS(normalized string) *model.TLSInfo { + tls := &model.TLSInfo{} + found := false + + // TLS protocol version, e.g. "using TLSv1.3" + if matches := regexp.MustCompile(`(?i)using\s+(TLSv[0-9.]+|SSLv[0-9.]+)`).FindStringSubmatch(normalized); len(matches) > 1 { + tls.Version = &matches[1] + found = true + } + + // Cipher suite, e.g. "with cipher TLS_AES_256_GCM_SHA384" + if matches := regexp.MustCompile(`(?i)with cipher\s+([A-Za-z0-9_-]+)`).FindStringSubmatch(normalized); len(matches) > 1 { + tls.Cipher = &matches[1] + found = true + } + + // Cipher strength, e.g. "(256/256 bits)" + if matches := regexp.MustCompile(`\((\d+)/\d+ bits\)`).FindStringSubmatch(normalized); len(matches) > 1 { + if bits, err := strconv.Atoi(matches[1]); err == nil { + tls.Bits = &bits + } + } + + if !found { + return nil + } + + // Certificate verification status. Postfix emits "(verified OK)" when the peer + // certificate was trusted, "(not verified)" otherwise. "No client certificate + // requested" leaves the field unset (trust is simply not applicable). + if regexp.MustCompile(`(?i)verified OK`).MatchString(normalized) { + tls.Verified = utils.PtrTo(true) + } else if regexp.MustCompile(`(?i)not verified`).MatchString(normalized) { + tls.Verified = utils.PtrTo(false) + } + + return tls +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index d7469d7..7b453fa 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -677,6 +677,77 @@ func TestParseReceivedHeader(t *testing.T) { } } +func TestParseReceivedTLS(t *testing.T) { + tests := []struct { + name string + receivedValue string + expectNil bool + expectVersion *string + expectCipher *string + expectBits *int + expectVerified *bool + }{ + { + name: "TLS 1.3 no client certificate", + receivedValue: "from mail.example.com (unknown [IPv6:2001:db8::1]) " + + "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) " + + "key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) " + + "(No client certificate requested) " + + "by mx.example.org (Postfix) with ESMTPSA id 1EFD11611EA; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)", + expectVersion: strPtr("TLSv1.3"), + expectCipher: strPtr("TLS_AES_256_GCM_SHA384"), + expectBits: intPtr(256), + expectVerified: nil, + }, + { + name: "TLS with verified client certificate", + receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) " + + "(using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) " + + "(Client CN \"example\", Issuer \"CA\" (verified OK)) " + + "by mx.receiver.com (Postfix) with ESMTPS id ABC; Mon, 01 Jan 2024 12:00:00 +0000", + expectVersion: strPtr("TLSv1.2"), + expectCipher: strPtr("ECDHE-RSA-AES128-GCM-SHA256"), + expectBits: intPtr(128), + expectVerified: boolPtr(true), + }, + { + name: "Plaintext (no TLS)", + receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTP id ABC; Mon, 01 Jan 2024 12:00:00 +0000", + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized := strings.Join(strings.Fields(tt.receivedValue), " ") + tls := parseReceivedTLS(normalized) + + if tt.expectNil { + if tls != nil { + t.Fatalf("expected nil TLS info, got %+v", tls) + } + return + } + + if tls == nil { + t.Fatal("parseReceivedTLS returned nil") + } + if !equalStrPtr(tls.Version, tt.expectVersion) { + t.Errorf("Version = %v, want %v", ptrToStr(tls.Version), ptrToStr(tt.expectVersion)) + } + if !equalStrPtr(tls.Cipher, tt.expectCipher) { + t.Errorf("Cipher = %v, want %v", ptrToStr(tls.Cipher), ptrToStr(tt.expectCipher)) + } + if (tls.Bits == nil) != (tt.expectBits == nil) || (tls.Bits != nil && *tls.Bits != *tt.expectBits) { + t.Errorf("Bits = %v, want %v", tls.Bits, tt.expectBits) + } + if (tls.Verified == nil) != (tt.expectVerified == nil) || (tls.Verified != nil && *tls.Verified != *tt.expectVerified) { + t.Errorf("Verified = %v, want %v", tls.Verified, tt.expectVerified) + } + }) + } +} + func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) { analyzer := NewHeaderAnalyzer() @@ -908,6 +979,10 @@ func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { + return &b +} + func ptrToStr(p *string) string { if p == nil { return "" diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 26cd59d..e20e571 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -85,6 +85,10 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) + // Fall back to the received chain's inbound TLS when no x-tls header was present. + if results.Authentication != nil && results.Headers != nil { + r.authAnalyzer.ReconcileXTLS(results.Authentication, results.Headers.ReceivedChain) + } results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.DNSWL = r.dnswlChecker.CheckEmail(email) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 4f5ff6d..749263d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -218,6 +218,40 @@
{/if} + + {#if authentication.x_tls} +
+
+ +
+ Transport TLS + + + {authentication.x_tls.result} + + {#if authentication.x_tls.details} +
+ {authentication.x_tls.details} +
+ {/if} +
+
+
+ {/if} +
diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index a4fda45..72cfd94 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -7,6 +7,21 @@ } let { receivedChain }: Props = $props(); + + // Mirror of the backend protocolIndicatesTLS (RFC 3848): the transport keyword + // gains a trailing "S" when TLS was used (ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA...). + function protocolIndicatesTLS(withProto: string | undefined | null): boolean { + if (!withProto) return false; + const p = withProto.trim().toUpperCase(); + return p.endsWith("S") || p.endsWith("SA"); + } + + // RFC 3848: a trailing "A" means the sender authenticated (SMTP AUTH): + // ESMTPA, ESMTPSA, LMTPA, LMTPSA... + function protocolIndicatesAuth(withProto: string | undefined | null): boolean { + if (!withProto) return false; + return withProto.trim().toUpperCase().endsWith("A"); + } {#if receivedChain && receivedChain.length > 0} @@ -60,6 +75,63 @@ {/if}

{/if} +

+ {#if hop.tls} + + TLS + + {#if hop.tls.version} + + Version: + {hop.tls.version} + + {/if} + {#if hop.tls.cipher} + + Cipher: + {hop.tls.cipher} + + {/if} + {#if hop.tls.bits} + + Strength: + {hop.tls.bits} bits + + {/if} + {#if hop.tls.verified !== undefined} + + + {hop.tls.verified + ? "Certificate trusted" + : "Certificate not trusted"} + + {/if} + {:else if protocolIndicatesTLS(hop.with)} + + TLS + + {:else if hop.with} + + No TLS + + {:else} + + TLS unknown + + {/if} + {#if protocolIndicatesAuth(hop.with)} + + Authenticated + + {/if} +

{/each}
From 970cbc02a3577843700e7522bc8bb462ebc6ca51 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 17:14:41 +0900 Subject: [PATCH 11/13] bimi: suggest declination record when no valid BIMI record is found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show an informational tip with a ready-to-copy declination record (§4.3.1 of draft-brand-indicators-for-message-identification) so users who do not intend to publish a logo can explicitly opt out and prevent mail clients from falling back to a parent-domain record. --- .../lib/components/BimiRecordDisplay.svelte | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/web/src/lib/components/BimiRecordDisplay.svelte b/web/src/lib/components/BimiRecordDisplay.svelte index 889e24f..8d21b1f 100644 --- a/web/src/lib/components/BimiRecordDisplay.svelte +++ b/web/src/lib/components/BimiRecordDisplay.svelte @@ -72,6 +72,26 @@ {bimiRecord.error} {/if} + {#if !bimiRecord.valid} +
+
+ + Explicitly decline BIMI participation +
+

+ If you do not intend to publish a brand logo, you can add a declination + record to signal that this domain deliberately opts out of BIMI. This + prevents mail clients from falling back to a parent-domain record: +

+ {bimiRecord.selector}._bimi.{bimiRecord.domain}. IN TXT "v=BIMI1; l=; a=" +

+ Declination record format as defined in § 4.3.1 of + draft-brand-indicators-for-message-identification. +

+
+ {/if} {/if} From 57022129e38613a0788303a02ea615952c8d45c4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 17:30:24 +0900 Subject: [PATCH 12/13] content: fix false-positive suspicious URL detection for email addresses in link text The domain regex in hasDomainMisalignment matched local-parts like "john.doe" in "john.doe@example.com" as if they were domain names, causing legitimate mailto and http links to be incorrectly flagged. Normalize email addresses in link text to their domain part before applying the regex. --- pkg/analyzer/content.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 06f8ddf..3e29a7a 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -501,6 +501,11 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool { return false } + // Replace email addresses with just their domain part to avoid false positives + // e.g. "john.doe@example.com" → "example.com" so local-part dots don't look like domains + emailAddrRegex := regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@([a-z0-9.\-]+\.[a-z]{2,})`) + linkText = emailAddrRegex.ReplaceAllString(linkText, "$1") + // Common generic link texts that shouldn't trigger warnings genericTexts := []string{ "click here", "read more", "learn more", "download", "subscribe", From 1c2218a779b8132fd1b9269c57268d0e4b382b12 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 17:49:57 +0900 Subject: [PATCH 13/13] headers: detect fake reply/forward subject without thread headers Flag emails where the Subject starts with a Re:/Fwd: prefix (in ~17 languages) but neither References nor In-Reply-To is present, a common spam/phishing technique to falsely imply an ongoing conversation. --- pkg/analyzer/headers.go | 44 +++++++++++ pkg/analyzer/headers_test.go | 140 +++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 448de57..9e5853e 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -589,9 +589,53 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss }) } + // Check for fake reply/forward: Subject has Re:/Fwd: prefix but no thread headers + subject := email.GetHeaderValue("Subject") + if h.hasReplyPrefix(subject) && !email.HasHeader("References") && !email.HasHeader("In-Reply-To") { + issues = append(issues, model.HeaderIssue{ + Header: "Subject", + Severity: model.HeaderIssueSeverityHigh, + Message: "Subject indicates a reply or forward but no References or In-Reply-To header is present", + Advice: utils.PtrTo("Remove the Re:/Fwd: prefix from the subject, or add References/In-Reply-To headers if this is a genuine reply"), + }) + } + return issues } +// hasReplyPrefix reports whether a subject line starts with a reply or forward prefix. +func (h *HeaderAnalyzer) hasReplyPrefix(subject string) bool { + // Normalize: collapse leading whitespace and make comparison case-insensitive + s := strings.ToLower(strings.TrimSpace(subject)) + + prefixes := []string{ + "re:", // English / universal + "fwd:", // English forward + "fw:", // English forward (short) + "aw:", // German Antwort + "wg:", // German Weitergeleitet + "sv:", // Scandinavian Svar + "vs:", // Finnish Vastaus / Norwegian + "ref:", // Some clients + "rép:", // French Réponse + "tr:", // French Transfert + "odp:", // Polish Odpowiedź + "ynt:", // Turkish Yanıt + "res:", // Portuguese/Spanish Resposta/Respuesta + "enc:", // Spanish Enviado/Reenviado + "vl:", // Dutch Verwijzing + "antw:", // Dutch Antwoord + "rv:", // Norwegian/Swedish + } + + for _, p := range prefixes { + if strings.HasPrefix(s, p) { + return true + } + } + return false +} + // parseReceivedChain extracts the chain of Received headers from an email func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop { if email == nil || email.Header == nil { diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 7b453fa..8426c58 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -974,6 +974,146 @@ func TestCheckHeader_DateValidation(t *testing.T) { } } +func TestHasReplyPrefix(t *testing.T) { + tests := []struct { + subject string + expected bool + }{ + // Positive cases + {"Re: Hello", true}, + {"RE: Hello", true}, + {"re: Hello", true}, + {"Fwd: Hello", true}, + {"FWD: Hello", true}, + {"fw: Hello", true}, + {"FW: Hello", true}, + {"Aw: Hallo", true}, + {"WG: Weitergeleitet", true}, + {"Sv: Hej", true}, + {"Vs: Vastaus", true}, + {"Ref: something", true}, + {"Rép: Bonjour", true}, + {"TR: Transféré", true}, + {"Odp: Odpowiedź", true}, + {"Ynt: Yanıt", true}, + {"Res: Resposta", true}, + {"Enc: Reenviado", true}, + {"Vl: Verwijzing", true}, + {"Antw: Antwoord", true}, + {"Rv: Svar", true}, + // Negative cases + {"Hello", false}, + {"", false}, + {"react: something", false}, + {"reference: check this", false}, + {"Resources available", false}, + {"Friendly reminder", false}, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.subject, func(t *testing.T) { + result := analyzer.hasReplyPrefix(tt.subject) + if result != tt.expected { + t.Errorf("hasReplyPrefix(%q) = %v, want %v", tt.subject, result, tt.expected) + } + }) + } +} + +func TestFindHeaderIssues_FakeReply(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expectIssueType string // non-empty means we expect an issue containing this substring + }{ + { + name: "Re: subject without thread headers", + headers: map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Subject": "Re: Your invoice", + }, + expectIssueType: "References or In-Reply-To", + }, + { + name: "Fwd: subject without thread headers", + headers: map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Subject": "Fwd: Important update", + }, + expectIssueType: "References or In-Reply-To", + }, + { + name: "Re: subject with References header - no issue", + headers: map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Subject": "Re: Your invoice", + "References": "", + }, + expectIssueType: "", + }, + { + name: "Re: subject with In-Reply-To only - no issue", + headers: map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Subject": "Re: Your invoice", + "In-Reply-To": "", + }, + expectIssueType: "", + }, + { + name: "Normal subject without thread headers - no issue", + headers: map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Subject": "Your invoice", + }, + expectIssueType: "", + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(tt.headers), + } + + issues := analyzer.findHeaderIssues(email) + + found := false + for _, issue := range issues { + if strings.Contains(issue.Message, tt.expectIssueType) { + found = true + break + } + } + + if tt.expectIssueType != "" && !found { + t.Errorf("expected issue containing %q, but none found (issues: %v)", tt.expectIssueType, issues) + } + if tt.expectIssueType == "" { + for _, issue := range issues { + if strings.Contains(issue.Message, "References or In-Reply-To") { + t.Errorf("unexpected fake-reply issue found: %s", issue.Message) + } + } + } + }) + } +} + // Helper functions for testing func strPtr(s string) *string { return &s