diff --git a/.claude/launch.json b/.claude/launch.json index 9d4749f..34e6d82 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,8 +3,8 @@ "configurations": [ { "name": "playground", - "runtimeExecutable": "npx", - "runtimeArgs": ["-y", "@wp-playground/cli@latest", "server", "--port=9400", "--mount=./outbox-mail-log:/wordpress/wp-content/plugins/outbox-mail-log", "--blueprint=./tools-playground-blueprint.json", "--login"], + "runtimeExecutable": "node", + "runtimeArgs": ["scripts/playground-server.mjs", "--port", "9400"], "port": 9400 } ] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5bc3e7d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# LF everywhere: reproducible release ZIPs (Windows and CI builds are byte-identical) and stable PHPCS results. +* text=auto eol=lf + +*.png binary +*.jpg binary +*.gif binary +*.mo binary +*.zip binary + +# Not part of `git archive` exports (the release ZIP is built by scripts/build-zip.mjs anyway). +/.github export-ignore +/tests export-ignore +/scripts export-ignore +/i18n export-ignore +/.wordpress-org export-ignore diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..6ea4e74 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +# Fast local checks before every push (static analysis, unit tests, readiness, ZIP). Enable once with: +# git config core.hooksPath .githooks +exec node scripts/verify.mjs --fast diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f5a1012 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,45 @@ +# Keeps the pinned actions (SHA + version comment), the Composer dev tools and the npm dev tools current. +# One grouped PR per ecosystem and week; every PR runs the full CI. cooldown: new versions are only proposed +# after 7 days – compromised releases are usually caught within the first days. Security updates ignore it. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + groups: + actions: + patterns: ['*'] + + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + versioning-strategy: lockfile-only + ignore: + # Major jumps (PHPUnit 10+, …) need a deliberate migration. + - dependency-name: '*' + update-types: ['version-update:semver-major'] + groups: + dev-tools: + patterns: ['*'] + + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + ignore: + - dependency-name: '*' + update-types: ['version-update:semver-major'] + groups: + npm-dev-tools: + patterns: ['*'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf3a813 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,356 @@ +name: CI + +# Checks and builds the plugin on every push to main, in pull requests, weekly and on demand; release.yml +# calls it for tags. Every check blocks: the release ZIP is only built when all of them are green. +# +# lint php -l on every supported PHP version (7.4 – 8.5) +# phpcs WordPress-Extra, VIP security sniffs, PHPCompatibility (no baseline) + composer audit +# phpstan level 8 against PHP 7.4 – 8.5, WordPress stubs, deprecation rules +# phpunit unit tests (Brain Monkey) on the oldest and newest PHP +# js ESLint incl. DOM-XSS rules (no-unsanitized) + npm audit +# wporg wordpress.org readiness (readme/header/versions/assets), translations, Plugin Check PHPCS rules +# plugin-check official Plugin Check action (what wordpress.org reviewers run) against the plugin folder +# e2e real WordPress via Playground: integration self-test + browser tests, oldest and newest PHP/WP +# secrets gitleaks over the full history +# workflows actionlint + zizmor over .github/ +# build reproducible, verified release ZIP (+ SHA-256, manifest) as artifact +# +# Locally: `npm run verify` (see README.md). Actions are pinned to commit SHAs; Dependabot keeps them current. +# Tool binaries (gitleaks, actionlint, zizmor) are downloaded with a fixed version and verified SHA-256. + +on: + push: + branches: [main] + pull_request: + schedule: + # Mondays 05:23 UTC: new advisories (composer/npm audit), new WordPress/Plugin Check releases. + - cron: '23 5 * * 1' + workflow_dispatch: + workflow_call: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + lint: + name: php -l (PHP ${{ matrix.php }}) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Syntax check of all plugin files + shell: bash + env: + PHP_VERSION: ${{ matrix.php }} + run: | + set -euo pipefail + count=0 + while IFS= read -r -d '' file; do + count=$((count + 1)) + if ! out=$(php -l "$file" 2>&1); then + echo "::error file=${file}::Syntax error on PHP ${PHP_VERSION}" + echo "$out" + exit 1 + fi + done < <(find mailspur-email-log -type f -name '*.php' -print0 | sort -z) + echo "OK: ${count} files on PHP ${PHP_VERSION}" + + phpcs: + name: PHPCS + composer audit + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2, cs2pr + + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + + - name: Known vulnerabilities in the dev tools + run: composer audit --locked + + - name: PHPCS (annotations in the PR diff) + shell: bash + run: | + set -o pipefail + vendor/bin/phpcs -q --report-full --report-checkstyle=phpcs.xml || { cs2pr phpcs.xml; exit 1; } + + phpstan: + name: PHPStan (level 8) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + + - name: Analyse + run: vendor/bin/phpstan analyse --no-progress --memory-limit=2G --error-format=github + + phpunit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.4'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + + - name: Unit tests + run: vendor/bin/phpunit + + js: + name: ESLint + npm audit + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + # No dependency cache: ci.yml also builds release artifacts (cache poisoning). + package-manager-cache: false + + - name: Install + run: npm ci --ignore-scripts + + - name: ESLint + run: npx eslint --max-warnings=0 . + + - name: Known vulnerabilities in the npm packages + run: npm audit --audit-level=moderate + + wporg: + name: wordpress.org readiness + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.4' + coverage: none + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + # No dependency cache: ci.yml also builds release artifacts (cache poisoning). + package-manager-cache: false + + - name: Readme, header, versions, changelog, directory assets + run: node scripts/repo-checks.mjs --online + + - name: Translations complete and generated files up to date + run: node scripts/i18n.mjs --check + + - name: Plugin Check PHPCS rules (pinned Plugin Check version) + run: node scripts/plugin-check.mjs + + plugin-check: + name: Plugin Check (official action) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: write # the action posts its report as a PR comment + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: WordPress/plugin-check-action@10857da14b6c2246d15402b3e69f777edcf8c12e # v1.1.9 + with: + build-dir: ./mailspur-email-log + slug: mailspur-email-log + include-experimental: true + # Runtime/performance checks need a front end; the plugin has none (admin screen only). + exclude-checks: | + enqueued_scripts_size + enqueued_styles_size + enqueued_styles_scope + enqueued_scripts_scope + non_blocking_scripts + + e2e: + name: WordPress ${{ matrix.wp }} / PHP ${{ matrix.php }} (Playground) + runs-on: ubuntu-24.04 + # A healthy run takes ~5 minutes; scripts/e2e.mjs also stops Playground after 10 minutes. + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + # Oldest supported combination (plugin header) and the newest. + - { php: '7.4', wp: '6.5' } + - { php: '8.4', wp: 'latest' } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + # No dependency cache: ci.yml also builds release artifacts (cache poisoning). + package-manager-cache: false + + - name: Install + run: npm ci --ignore-scripts + + - name: Browser for the UI tests + run: npx playwright install --with-deps chromium + + - name: Integration self-test, Plugin Check (static), browser tests + env: + E2E_PHP: ${{ matrix.php }} + E2E_WP: ${{ matrix.wp }} + run: node scripts/e2e.mjs --php "$E2E_PHP" --wp "$E2E_WP" + + - name: Reports + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-${{ matrix.php }}-${{ matrix.wp }} + path: | + .cache/e2e-out/ + .cache/playwright/ + .cache/playwright-report/ + if-no-files-found: ignore + retention-days: 14 + + secrets: + name: Secrets (gitleaks, full history) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install gitleaks (8.30.1, SHA-256 verified) + run: | + curl -sSfL -o gitleaks.tar.gz https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz -C "$RUNNER_TEMP" gitleaks + + - name: Scan history and working tree (values redacted) + run: '"$RUNNER_TEMP/gitleaks" git . --redact --no-banner --verbose' + + workflows: + name: Workflows (actionlint, zizmor) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install actionlint and zizmor (fixed versions, SHA-256 verified) + run: | + curl -sSfL -o actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 actionlint.tar.gz" | sha256sum -c - + tar -xzf actionlint.tar.gz -C "$RUNNER_TEMP" actionlint + curl -sSfL -o zizmor.tar.gz https://github.com/zizmorcore/zizmor/releases/download/v1.30.1/zizmor-x86_64-unknown-linux-gnu.tar.gz + echo "e65324f4430c2717591937edcec90ccbefaf14c174f8ec9415e03ca875b46e1a zizmor.tar.gz" | sha256sum -c - + tar -xzf zizmor.tar.gz -C "$RUNNER_TEMP" + + - name: actionlint + run: '"$RUNNER_TEMP/actionlint" -color' + + - name: zizmor (workflow security audit) + env: + GH_TOKEN: ${{ github.token }} + run: '"$RUNNER_TEMP/zizmor" --format github --min-severity low .github/' + + build: + name: Release ZIP + # Only from commits where every check is green. + needs: [lint, phpcs, phpstan, phpunit, js, wporg, plugin-check, e2e, secrets, workflows] + if: ${{ github.event_name != 'schedule' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + # No dependency cache: ci.yml also builds release artifacts (cache poisoning). + package-manager-cache: false + + - name: Install + run: npm ci --ignore-scripts + + - name: Build and verify the ZIP + run: node scripts/build-zip.mjs + + - name: Summary + run: | + { + echo '### Release ZIP' + echo '```json' + cat dist/manifest.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: plugin-zip + path: | + dist/*.zip + dist/*.sha256 + dist/manifest.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e872c02 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,117 @@ +name: Release + +# Tag push `v1.2.3` → full CI → GitHub release with the verified ZIP → (optional) deploy to wordpress.org SVN. +# +# wordpress.org deploy is off until the plugin has been approved. To switch it on: +# 1. Repository secrets SVN_USERNAME / SVN_PASSWORD (wordpress.org username + the SVN password from +# https://profiles.wordpress.org/me/profile/edit/group/3/?screen=svn-password). +# 2. Repository variable WPORG_DEPLOY = true. +# 3. Optional: protect the `wordpress-org` environment with required reviewers (manual approval per release). +# What is deployed is exactly the content of the ZIP that CI built and verified, plus .wordpress-org/ as assets. + +on: + push: + tags: ['v*.*.*'] + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + ci: + name: CI + # `./` instead of the newer `$/` self-repository syntax: actionlint 1.7.12 does not accept `$/` yet. + uses: ./.github/workflows/ci.yml # zizmor: ignore[self-repository] + + github-release: + name: GitHub release + needs: ci + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write # create the release + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + # No dependency cache: ci.yml also builds release artifacts (cache poisoning). + package-manager-cache: false + + - name: Tag matches the plugin version, "Tested up to" is current + env: + TAG: ${{ github.ref_name }} + run: node scripts/repo-checks.mjs --online --tag "$TAG" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: plugin-zip + path: dist + + - name: Verify checksum + working-directory: dist + run: sha256sum -c ./*.sha256 + + - name: Release notes from the readme changelog + env: + TAG: ${{ github.ref_name }} + run: | + version="${TAG#v}" + awk -v v="$version" ' + /^== Changelog ==/ { in_log = 1; next } + in_log && /^== / { exit } + in_log && $0 ~ "^= " v " =" { take = 1; next } + take && /^= / { exit } + take { print } + ' mailspur-email-log/readme.txt > notes.md + { + echo + echo "**SHA-256:** \`$(cut -d' ' -f1 dist/*.sha256)\`" + } >> notes.md + cat notes.md + + - name: Create the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: gh release create "$TAG" dist/*.zip dist/*.sha256 dist/manifest.json --title "$TAG" --notes-file notes.md --verify-tag + + wordpress-org: + name: Deploy to wordpress.org + needs: github-release + if: ${{ vars.WPORG_DEPLOY == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: + name: wordpress-org + url: https://wordpress.org/plugins/mailspur-email-log/ + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: plugin-zip + path: dist + + - name: Unpack the verified ZIP + run: | + (cd dist && sha256sum -c ./*.sha256) + mkdir -p build + unzip -q dist/*.zip -d build + + - name: SVN deploy (trunk, tags/, assets) + uses: 10up/action-wordpress-plugin-deploy@54bd289b8525fd23a5c365ec369185f2966529c2 # 2.3.0 + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + SLUG: mailspur-email-log + BUILD_DIR: build/mailspur-email-log + ASSETS_DIR: .wordpress-org diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e8b8a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Dependencies (dev tooling only – the plugin itself has none) +/node_modules/ +/vendor/ + +# Tool caches, portable PHP, e2e output, Playwright traces +/.cache/ +/.phpunit.result.cache + +# Build output (release ZIPs are CI artifacts / GitHub release assets) +/dist/ + +# Agent worktrees (local only) +/.claude/worktrees/ + +# OS / editor +.DS_Store +Thumbs.db +*.swp +.idea/ +.vscode/ diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..de04aa5 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,4 @@ +# Accepted gitleaks findings (one fingerprint per line, with the reason above it). + +# Dummy SMTP password of a PHPMailer test double; the test asserts it is masked in the SMTP transcript. +1c28dddb96b717cabe3a30886401136bf10da4ff:tests/unit/TraceMailer.php:generic-api-key:30 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3d64851 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# Always fetch packages over TLS (overrides a global http:// registry setting). +registry=https://registry.npmjs.org/ +fund=false +audit-level=moderate diff --git a/.wordpress-org/banner-1544x500.png b/.wordpress-org/banner-1544x500.png new file mode 100644 index 0000000..f76d097 Binary files /dev/null and b/.wordpress-org/banner-1544x500.png differ diff --git a/.wordpress-org/banner-772x250.png b/.wordpress-org/banner-772x250.png new file mode 100644 index 0000000..884fe58 Binary files /dev/null and b/.wordpress-org/banner-772x250.png differ diff --git a/.wordpress-org/icon-128x128.png b/.wordpress-org/icon-128x128.png new file mode 100644 index 0000000..6a174d9 Binary files /dev/null and b/.wordpress-org/icon-128x128.png differ diff --git a/.wordpress-org/icon-256x256.png b/.wordpress-org/icon-256x256.png new file mode 100644 index 0000000..c30d3e4 Binary files /dev/null and b/.wordpress-org/icon-256x256.png differ diff --git a/.wordpress-org/screenshot-1.png b/.wordpress-org/screenshot-1.png new file mode 100644 index 0000000..0a606e2 Binary files /dev/null and b/.wordpress-org/screenshot-1.png differ diff --git a/.wordpress-org/screenshot-2.png b/.wordpress-org/screenshot-2.png new file mode 100644 index 0000000..d830e20 Binary files /dev/null and b/.wordpress-org/screenshot-2.png differ diff --git a/.wordpress-org/screenshot-3.png b/.wordpress-org/screenshot-3.png new file mode 100644 index 0000000..3f5fc36 Binary files /dev/null and b/.wordpress-org/screenshot-3.png differ diff --git a/.wordpress-org/screenshot-4.png b/.wordpress-org/screenshot-4.png new file mode 100644 index 0000000..869ad3a Binary files /dev/null and b/.wordpress-org/screenshot-4.png differ diff --git a/.wordpress-org/screenshot-5.png b/.wordpress-org/screenshot-5.png new file mode 100644 index 0000000..37b91d2 Binary files /dev/null and b/.wordpress-org/screenshot-5.png differ diff --git a/.wordpress-org/screenshot-6.png b/.wordpress-org/screenshot-6.png new file mode 100644 index 0000000..bae1b29 Binary files /dev/null and b/.wordpress-org/screenshot-6.png differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..959540e --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Mailspur – Email Log + +Leichtgewichtiges WordPress-Plugin, das alle ausgehenden E-Mails protokolliert: Live-Suche, Filter, Status, erneut senden. Sichere Vorschau in einer Sandbox ohne Tracking-Pixel, maskierte Reset-Links, DSGVO-Export. + +- **Plugin-Code:** [`mailspur-email-log/`](mailspur-email-log/) – genau dieser Ordner wird ausgeliefert (ZIP / wordpress.org). +- **Beschreibung für wordpress.org:** [`mailspur-email-log/readme.txt`](mailspur-email-log/readme.txt) +- **Verzeichnis-Grafiken (Icon, Banner, Screenshots):** [`.wordpress-org/`](.wordpress-org/) → landen im SVN unter `/assets`. + +Alles außerhalb von `mailspur-email-log/` ist Entwicklungswerkzeug und wird nie mit ausgeliefert. + +## Lokale Entwicklung + +Voraussetzung: Node ≥ 24 (unter Node 22 stürzt das php-wasm von Playground mit PHP 7.4 ab). PHP muss nicht installiert sein. + +```bash +npm ci +``` + +```bash +npm run setup:php +``` + +`setup:php` lädt unter Windows ein portables PHP 7.4 + Composer nach `.cache/` (Prüfsummen werden verifiziert, nichts wird systemweit installiert). PHP 8.4 lässt sich mit `npm run setup:php -- --php 8.4` ergänzen – Windows Smart App Control blockiert manche Builds; die Werkzeuge nehmen automatisch die neueste lauffähige Version. + +| Befehl | Was | +| --- | --- | +| `npm run verify` | alles, was die CI prüft (inkl. WordPress-Laufzeit- und Browsertests) | +| `npm run verify -- --fast` | statische Prüfungen + Unit-Tests (≈ 30 s, auch als pre-push-Hook) | +| `npm run playground` | WordPress mit Plugin und Beispiel-Mails auf http://127.0.0.1:9400 | +| `npm run test:e2e -- --php 7.4 --wp 6.5` | Laufzeittests gegen eine bestimmte PHP-/WP-Version | +| `npm run phpcs` / `npm run phpcbf` | Coding Standards prüfen / automatisch korrigieren | +| `npm run i18n` | Übersetzungen aus `i18n/*.json` neu erzeugen | +| `npm run build` | Release-ZIP nach `dist/` | +| `node scripts/wporg-assets.mjs` | Icon, Banner und Screenshots für wordpress.org neu erzeugen | + +Pre-push-Hook einmalig aktivieren: + +```bash +git config core.hooksPath .githooks +``` + +## Pipeline + +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) läuft bei jedem Push auf `main`, in Pull Requests, wöchentlich und manuell. Jeder Job blockiert – das Release-ZIP entsteht nur, wenn alle grün sind. + +| Bereich | Prüfung | +| --- | --- | +| **Lauffähigkeit** | `php -l` auf PHP 7.4 – 8.5 · PHPUnit (Brain Monkey) auf 7.4 und 8.4 · Integrations-Selbsttest in echtem WordPress (Aktivierung, alle Versandwege, REST inkl. Rechte, Aufräumen, DSGVO, Deinstallation) · Browsertests (Playwright) – jeweils auf der ältesten (PHP 7.4 / WP 6.5) und neuesten Kombination | +| **Sicherheit** | PHPCS `WordPress.Security`/`DB` + VIP-Security-Sniffs · ESLint `no-unsanitized` (DOM-XSS) · Browsertest: eingeschleustes ` + + diff --git a/scripts/build-zip.mjs b/scripts/build-zip.mjs new file mode 100644 index 0000000..1de3315 --- /dev/null +++ b/scripts/build-zip.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Builds the installable plugin ZIP and verifies it. + * + * npm run build → dist/mailspur-email-log-.zip (+ .sha256, manifest.json) + * + * - Root folder inside the ZIP is the plugin slug (what WordPress and wordpress.org expect). + * - Only an allowlist of file types is packed; dotfiles, maps and dev files never are. + * - Reproducible: sorted entries, fixed timestamps (SOURCE_DATE_EPOCH or last commit), LF line endings – + * the same commit gives the same SHA-256 on Windows and in CI. + * - The written ZIP is read back and checked (structure, header version, size limit, ABSPATH guards). + */ +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import yauzl from 'yauzl'; +import yazl from 'yazl'; +import { PLUGIN_SLUG, pluginDir, root } from './lib/php.mjs'; + +const TEXT = new Set(['.php', '.js', '.css', '.txt', '.po', '.json', '.svg', '.md']); +const BINARY = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.mo']); +const MAX_BYTES = 10 * 1024 * 1024; // wordpress.org upload limit. + +const mainFile = path.join(pluginDir, `${PLUGIN_SLUG}.php`); +const version = /^\s*\*\s*Version:\s*(\S+)/m.exec(fs.readFileSync(mainFile, 'utf8'))?.[1]; +if (!version) throw new Error('No Version header in the main plugin file.'); + +const epoch = Number(process.env.SOURCE_DATE_EPOCH) || + Number(spawnSync('git', ['log', '-1', '--format=%ct'], { cwd: root, encoding: 'utf8' }).stdout.trim()) || + Math.floor(Date.UTC(2026, 0, 1) / 1000); +const mtime = new Date(epoch * 1000); + +function collect(dir, rel = '') { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const relPath = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.name.startsWith('.')) throw new Error(`Hidden file in plugin folder: ${relPath}`); + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...collect(full, relPath)); + continue; + } + const ext = path.extname(entry.name).toLowerCase(); + if (!TEXT.has(ext) && !BINARY.has(ext)) throw new Error(`File type not allowed in the release: ${relPath}`); + out.push({ relPath, full, text: TEXT.has(ext) }); + } + return out; +} + +const files = collect(pluginDir).sort((a, b) => a.relPath.localeCompare(b.relPath)); +const dist = path.join(root, 'dist'); +fs.mkdirSync(dist, { recursive: true }); +const zipName = `${PLUGIN_SLUG}-${version}.zip`; +const zipPath = path.join(dist, zipName); + +await new Promise((resolve, reject) => { + const zip = new yazl.ZipFile(); + for (const f of files) { + let data = fs.readFileSync(f.full); + if (f.text) data = Buffer.from(data.toString('utf8').replace(/\r\n/g, '\n'), 'utf8'); + zip.addBuffer(data, `${PLUGIN_SLUG}/${f.relPath}`, { mtime, mode: 0o100644, compress: true }); + } + zip.end(); + zip.outputStream.pipe(fs.createWriteStream(zipPath)).on('close', resolve).on('error', reject); +}); + +// ---------------------------------------------------------------- verify the written archive. +const entries = await new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { + if (err) return reject(err); + const found = new Map(); + zip.on('entry', (entry) => { + zip.openReadStream(entry, (e, stream) => { + if (e) return reject(e); + const chunks = []; + stream.on('data', (c) => chunks.push(c)); + stream.on('end', () => { + found.set(entry.fileName, Buffer.concat(chunks)); + zip.readEntry(); + }); + }); + }); + zip.on('end', () => resolve(found)); + zip.readEntry(); + }); +}); + +const problems = []; +const size = fs.statSync(zipPath).size; +if (size > MAX_BYTES) problems.push(`ZIP is ${size} bytes – wordpress.org accepts at most ${MAX_BYTES}.`); +for (const name of entries.keys()) { + if (!name.startsWith(`${PLUGIN_SLUG}/`)) problems.push(`Entry outside the plugin folder: ${name}`); + if (/(^|\/)(node_modules|vendor|tests?|\.git)\//.test(name) || /\.(map|zip|log)$/.test(name)) problems.push(`Dev file in ZIP: ${name}`); + if (name.endsWith('.php') && !name.includes('/languages/')) { + const head = entries.get(name).toString('utf8').slice(0, 2000); + const guard = name.endsWith('/uninstall.php') ? /defined\(\s*'WP_UNINSTALL_PLUGIN'\s*\)\s*\|\|\s*exit/ : /defined\(\s*'ABSPATH'\s*\)\s*\|\|\s*exit/; + if (!guard.test(head)) problems.push(`Missing direct-access guard: ${name}`); + } +} +for (const required of [`${PLUGIN_SLUG}/${PLUGIN_SLUG}.php`, `${PLUGIN_SLUG}/readme.txt`, `${PLUGIN_SLUG}/uninstall.php`]) { + if (!entries.has(required)) problems.push(`Missing in ZIP: ${required}`); +} +const zippedVersion = /^\s*\*\s*Version:\s*(\S+)/m.exec(entries.get(`${PLUGIN_SLUG}/${PLUGIN_SLUG}.php`)?.toString('utf8') ?? '')?.[1]; +if (zippedVersion !== version) problems.push(`Version in ZIP (${zippedVersion}) differs from ${version}.`); + +if (problems.length) { + fs.rmSync(zipPath, { force: true }); + console.error('ZIP verification failed:\n ' + problems.join('\n ')); + process.exit(1); +} + +const sha256 = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex'); +fs.writeFileSync(`${zipPath}.sha256`, `${sha256} ${zipName}\n`); +const commit = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim(); +fs.writeFileSync( + path.join(dist, 'manifest.json'), + JSON.stringify({ slug: PLUGIN_SLUG, version, file: zipName, sha256, bytes: size, files: entries.size, commit, built: mtime.toISOString() }, null, 2) + '\n', +); +console.log(`${zipName}: ${entries.size} files, ${(size / 1024).toFixed(1)} KB, sha256 ${sha256}`); diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs new file mode 100644 index 0000000..c1cdbd4 --- /dev/null +++ b/scripts/e2e.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * Runtime tests in a real WordPress (WordPress Playground: PHP-WASM + SQLite, no Docker). + * + * npm run test:e2e # PHP 8.3, latest WordPress: self-test + Plugin Check + browser UI tests + * npm run test:e2e -- --php 7.4 --wp 6.5 # oldest supported combination + * npm run test:e2e -- --no-ui # without the Playwright browser tests + * + * 1. tests/e2e/selftest.blueprint.json activates the plugin, installs the official Plugin Check plugin plus + * WP Mail Logging and Email Log, and runs tests/e2e/plugin-check.php (static Plugin Check checks), + * tests/e2e/import-test.php (import from other plugins) and tests/e2e/selftest.php (integration assertions). + * Results land in .cache/e2e-out/*.json. + * 2. Playwright (playwright.config.js) starts a Playground server with seeded mails and tests the admin screen. + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { root } from './lib/php.mjs'; +import { mountArgs, playground } from './lib/playground.mjs'; + +// Playground's php-wasm needs JSPI (Node 24+); the asyncify fallback on Node 22 crashes with PHP 7.4 +// ("RuntimeError: unreachable"). +if (Number(process.versions.node.split('.')[0]) < 24) { + console.error(`Node ${process.versions.node}: the WordPress runtime tests need Node 24 or newer.`); + process.exit(2); +} + +const args = process.argv.slice(2); +const opt = (name, fallback) => (args.includes(`--${name}`) ? args[args.indexOf(`--${name}`) + 1] : fallback); +const php = opt('php', '8.3'); +const wp = opt('wp', 'latest'); +const ui = !args.includes('--no-ui'); + +const out = path.join(root, '.cache', 'e2e-out'); +fs.rmSync(out, { recursive: true, force: true }); +fs.mkdirSync(out, { recursive: true }); + +let failed = false; + +// The CLI's --php/--wp flags do not reach run-blueprint; preferredVersions in the blueprint does. +const blueprint = JSON.parse(fs.readFileSync(path.join(root, 'tests', 'e2e', 'selftest.blueprint.json'), 'utf8')); +blueprint.preferredVersions = { php, wp }; + +// Module integration tests: tests/e2e/features/.php run before the self-test (which uninstalls the +// plugin at its end) and write /e2e-out/features/.json in the same format as selftest.json. +const featureDir = path.join(root, 'tests', 'e2e', 'features'); +const features = fs.existsSync(featureDir) ? fs.readdirSync(featureDir).filter((f) => f.endsWith('.php')).sort() : []; +fs.mkdirSync(path.join(out, 'features'), { recursive: true }); +const selftestIndex = blueprint.steps.findIndex((step) => 'runPHP' === step.step && step.code.includes('selftest.php')); +blueprint.steps.splice( + selftestIndex, + 0, + ...features.map((file) => ({ step: 'runPHP', code: ` fs.existsSync(path.join(out, 'selftest.json')), + }, +); +if (run.timedOut) console.error('Playground did not finish within 10 minutes.'); + +const read = (file) => { + try { + return JSON.parse(fs.readFileSync(path.join(out, file), 'utf8')); + } catch { + return null; + } +}; + +const selftest = read('selftest.json'); +if (!selftest) { + failed = true; + console.error('No self-test report – Playground output:\n' + run.output.split('\n').filter((l) => !/lockWholeFile|stale Playground/.test(l)).join('\n')); +} else { + console.log(`PHP ${selftest.php}, WordPress ${selftest.wp}: ${selftest.passed} passed, ${selftest.failed} failed`); + for (const r of selftest.results) { + console.log(` ${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : `\n ${JSON.stringify(r.detail)}`}`); + } + failed ||= selftest.failed > 0 || run.code !== 0; +} + +const imports = read('import.json'); +if (!imports) { + failed = true; + console.error('No import report.'); +} else { + console.log(` +Import from other plugins: ${imports.passed} passed, ${imports.failed} failed`); + for (const r of imports.results) { + console.log(` ${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : ` + ${JSON.stringify(r.detail)}`}`); + } + failed ||= imports.failed > 0; +} + +for (const file of features) { + const name = path.basename(file, '.php'); + const report = read(`features/${name}.json`); + if (!report) { + failed = true; + console.error(`\nNo report from tests/e2e/features/${file}.`); + continue; + } + console.log(`\nFeature "${name}": ${report.passed} passed, ${report.failed} failed`); + for (const r of report.results) { + console.log(` ${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : `\n ${JSON.stringify(r.detail)}`}`); + } + failed ||= report.failed > 0; +} + +const pcp = read('plugin-check.json'); +if (!pcp) { + failed = true; + console.error('No Plugin Check report.'); +} else { + // Accepted findings need a reason: tests/e2e/plugin-check-allowlist.json { "": "why" }. + const allow = JSON.parse(fs.readFileSync(path.join(root, 'tests', 'e2e', 'plugin-check-allowlist.json'), 'utf8')); + const open = pcp.findings.filter((f) => !(f.code in allow)); + console.log(`\nPlugin Check (${pcp.checks.length} static checks): ${pcp.findings.length} finding(s), ${open.length} not allow-listed`); + for (const f of pcp.findings) { + console.log(` ${f.code in allow ? '~' : '✗'} ${f.type} ${f.file}:${f.line} ${f.code} – ${f.message}`); + } + failed ||= open.length > 0; +} + +if (ui) { + console.log(`\n=== Browser UI tests (Playwright) ===`); + const res = spawnSync(process.execPath, [path.join(root, 'node_modules', '@playwright', 'test', 'cli.js'), 'test'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, E2E_PHP: php, E2E_WP: wp }, + }); + failed ||= res.status !== 0; +} + +console.log(failed ? '\nE2E: FAILED' : '\nE2E: all green'); +process.exit(failed ? 1 : 0); diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs new file mode 100644 index 0000000..e74d486 --- /dev/null +++ b/scripts/i18n.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Bundled translations: extracts the plugin's strings and builds languages/*.l10n.php + .po from i18n//*.json. + * + * npm run i18n # rebuild the language files from i18n//*.json (one file per module) + * npm run i18n -- --check # CI: fail on missing/unused translations or outdated generated files + * + * wordpress.org language packs (translate.wordpress.org) take precedence at runtime; the bundled files are + * the fallback until a language pack exists (see Plugin::load_textdomain()). + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { PLUGIN_SLUG, pluginDir, root } from './lib/php.mjs'; + +const check = process.argv.includes('--check'); +const re = new RegExp(`\\b(?:__|_e|esc_html__|esc_html_e|esc_attr__|esc_attr_e)\\(\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*,\\s*'${PLUGIN_SLUG}'\\s*\\)`, 'g'); + +function walk(dir, out = []) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (e.name !== 'languages') walk(p, out); + } else if (p.endsWith('.php')) { + out.push(p); + } + } + return out; +} + +const strings = new Set(); +for (const file of walk(pluginDir)) { + for (const m of fs.readFileSync(file, 'utf8').matchAll(re)) strings.add(m[1].replace(/\\'/g, "'")); +} +const sorted = [...strings].sort(); + +const php = (s) => "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; +const po = (s) => '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + +const problems = []; +const files = {}; +// i18n//*.json – one file per module (core.json, trace.json …), merged here. +for (const locale of fs.readdirSync(path.join(root, 'i18n')).filter((f) => fs.statSync(path.join(root, 'i18n', f)).isDirectory())) { + const jsonFile = `${locale}/*.json`; + const map = {}; + for (const part of fs.readdirSync(path.join(root, 'i18n', locale)).filter((f) => f.endsWith('.json')).sort()) { + for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(path.join(root, 'i18n', locale, part), 'utf8')))) { + if (key in map && map[key] !== value) problems.push(`${locale}: "${key}" translated differently in ${part}`); + map[key] = value; + } + } + for (const s of sorted) if (!(s in map)) problems.push(`${locale}: missing translation for "${s}"`); + for (const s of Object.keys(map)) if (!strings.has(s)) problems.push(`${locale}: unused translation "${s}"`); + + const base = path.join(pluginDir, 'languages', `${PLUGIN_SLUG}-${locale}`); + files[`${base}.l10n.php`] = ` '${PLUGIN_SLUG}', + 'plural-forms' => 'nplurals=2; plural=(n != 1);', + 'language' => '${locale}', + 'messages' => array( +${sorted.filter((s) => s in map).map((s) => `\t\t${php(s)} => ${php(map[s])},`).join('\n')} + ), +); +`; + files[`${base}.po`] = 'msgid ""\nmsgstr ""\n"Content-Type: text/plain; charset=UTF-8\\n"\n' + + `"Language: ${locale}\\n"\n"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n"X-Domain: ${PLUGIN_SLUG}\\n"\n\n` + + sorted.filter((s) => s in map).map((s) => `msgid ${po(s)}\nmsgstr ${po(map[s])}\n`).join('\n'); +} + +if (check) { + for (const [file, content] of Object.entries(files)) { + const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') : null; + if (current !== content) problems.push(`${path.relative(root, file)} is outdated – run \`npm run i18n\``); + } + if (problems.length) { + console.error(problems.join('\n')); + process.exit(1); + } + console.log(`i18n: ${strings.size} strings, ${Object.keys(files).length / 2} locale(s) complete and up to date.`); + process.exit(0); +} + +if (problems.length) { + console.error(problems.join('\n')); + process.exit(1); +} +fs.mkdirSync(path.join(pluginDir, 'languages'), { recursive: true }); +for (const [file, content] of Object.entries(files)) fs.writeFileSync(file, content); +console.log(`${strings.size} strings written for ${Object.keys(files).length / 2} locale(s).`); diff --git a/scripts/lib/php.mjs b/scripts/lib/php.mjs new file mode 100644 index 0000000..c263360 --- /dev/null +++ b/scripts/lib/php.mjs @@ -0,0 +1,48 @@ +/** + * Finds the PHP and Composer executables for the local tooling. + * + * Order: $PHP_BINARY → portable PHP from `npm run setup:php` (.cache/php//) → `php` on PATH. + * CI runners have real PHP on PATH; the portable copy only exists on Windows dev machines. + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const PLUGIN_SLUG = 'mailspur-email-log'; +export const pluginDir = path.join(root, PLUGIN_SLUG); + +const runs = (bin) => spawnSync(bin, ['-v'], { encoding: 'utf8', shell: bin === 'php' && process.platform === 'win32' }).status === 0; + +/** + * @param {string} [version] Preferred portable version; falls back to any portable version that runs + * (Windows Smart App Control may block some builds). + * @returns {string|null} + */ +export function findPhp(version) { + if (process.env.PHP_BINARY) return process.env.PHP_BINARY; + const dir = path.join(root, '.cache', 'php'); + const versions = fs.existsSync(dir) ? fs.readdirSync(dir).sort().reverse() : []; + for (const v of version ? [version] : versions) { + const bin = path.join(dir, v, process.platform === 'win32' ? 'php.exe' : 'php'); + if (fs.existsSync(bin) && runs(bin)) return bin; + } + if (version) return null; + return runs('php') ? 'php' : null; +} + +/** @returns {string[]|null} argv prefix to run Composer, e.g. ['php', '.cache/composer.phar'] */ +export function findComposer(php) { + const phar = path.join(root, '.cache', 'composer.phar'); + if (php && fs.existsSync(phar)) return [php, phar]; + const probe = spawnSync('composer', ['--version'], { encoding: 'utf8', shell: process.platform === 'win32' }); + return probe.status === 0 ? ['composer'] : null; +} + +/** Runs a command inheriting stdio; returns the exit code. */ +export function run(cmd, args, options = {}) { + const res = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', ...options }); + if (res.error) throw res.error; + return res.status ?? 1; +} diff --git a/scripts/lib/playground.mjs b/scripts/lib/playground.mjs new file mode 100644 index 0000000..bf2d0a1 --- /dev/null +++ b/scripts/lib/playground.mjs @@ -0,0 +1,76 @@ +/** + * Runs the WordPress Playground CLI (PHP-WASM + SQLite, no Docker needed) from node_modules. + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pluginDir, root, PLUGIN_SLUG } from './php.mjs'; + +const require = createRequire(import.meta.url); +const NOISE = /lockWholeFile|stale Playground temp dirs/; + +function cliEntry() { + const pkgFile = require.resolve('@wp-playground/cli/package.json', { paths: [root] }); + const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8')); + const bin = typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin)[0]; + return path.join(path.dirname(pkgFile), bin); +} + +/** + * Mount arguments in the --mount-dir form (works with Windows drive letters). + * + * @param {Record} extra vfs path → host path + */ +export function mountArgs(extra = {}) { + const mounts = { [`/wordpress/wp-content/plugins/${PLUGIN_SLUG}`]: pluginDir, ...extra }; + return Object.entries(mounts).flatMap(([vfs, host]) => ['--mount-dir', host, vfs]); +} + +/** + * @param {string[]} args CLI arguments, e.g. ['run-blueprint', '--blueprint=…'] + * @param {object} [options] + * @param {boolean} [options.quiet] Do not stream the CLI output. + * @param {number} [options.timeoutMs] Kill the CLI after this long (exit code 124). + * @param {() => boolean} [options.doneWhen] Treat the run as finished once this returns true. On Linux the CLI + * can stay alive after the blueprint completed (open worker handles). + * @returns {Promise<{code: number, output: string, timedOut: boolean}>} + */ +export function playground(args, { quiet = false, timeoutMs = 0, doneWhen = null } = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [cliEntry(), ...args], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + let settled = false; + const timers = []; + + const finish = (code, timedOut = false) => { + if (settled) return; + settled = true; + timers.forEach(clearInterval); + if (child.exitCode === null) child.kill('SIGKILL'); + resolve({ code, output, timedOut }); + }; + + const onData = (chunk) => { + const text = chunk.toString(); + output += text; + const lines = text.split('\n').filter((l) => l.trim() && !NOISE.test(l)); + if (!quiet && lines.length) process.stdout.write(lines.join('\n') + '\n'); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('close', (code) => finish(code ?? 1)); + + if (doneWhen) { + timers.push(setInterval(() => { + // Small grace period so the last step can flush its output before the process is stopped. + if (doneWhen()) setTimeout(() => finish(0), 2000); + }, 1000)); + } + if (timeoutMs) { + timers.push(setInterval(() => finish(124, true), timeoutMs)); + } + }); +} + +export { cliEntry }; diff --git a/scripts/php.mjs b/scripts/php.mjs new file mode 100644 index 0000000..d8653e2 --- /dev/null +++ b/scripts/php.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Runs a PHP tool with whatever PHP is available (see scripts/lib/php.mjs), e.g. + * + * node scripts/php.mjs vendor/bin/phpcs + * node scripts/php.mjs --composer audit --locked + * npm run phpstan + * + * Installs the Composer dev dependencies on first use. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { findComposer, findPhp, root, run } from './lib/php.mjs'; + +const php = findPhp(); +if (!php) { + console.error('No PHP found. Windows: `npm run setup:php` (portable, nothing installed system-wide). Otherwise install PHP ≥ 7.4.'); + process.exit(2); +} + +const composer = findComposer(php); +const args = process.argv.slice(2); +const shell = process.platform === 'win32' && composer?.[0] === 'composer'; // composer.bat needs a shell. + +if (args[0] === '--composer') { + if (!composer) { + console.error('Composer not found – `npm run setup:php` also downloads it.'); + process.exit(2); + } + process.exit(run(composer[0], [...composer.slice(1), ...args.slice(1)], { shell })); +} + +if (!fs.existsSync(path.join(root, 'vendor', 'autoload.php'))) { + if (!composer) { + console.error('Composer not found – `npm run setup:php` also downloads it.'); + process.exit(2); + } + const code = run(composer[0], [...composer.slice(1), 'install', '--no-interaction', '--no-progress'], { shell }); + if (code !== 0) process.exit(code); +} + +process.exit(run(php, args)); diff --git a/scripts/playground-server.mjs b/scripts/playground-server.mjs new file mode 100644 index 0000000..1c50f44 --- /dev/null +++ b/scripts/playground-server.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Playground server with the plugin mounted and the log seeded (tests/e2e/ui.blueprint.json). + * Used by playwright.config.js and for manual testing / screenshots: + * + * node scripts/playground-server.mjs [--port 9400] [--php 8.3] [--wp latest] + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { root } from './lib/php.mjs'; +import { mountArgs, playground } from './lib/playground.mjs'; + +const args = process.argv.slice(2); +const opt = (name, fallback) => (args.includes(`--${name}`) ? args[args.indexOf(`--${name}`) + 1] : fallback); + +// The CLI's --php/--wp flags are not applied reliably; preferredVersions in the blueprint is. +const blueprint = JSON.parse(fs.readFileSync(path.join(root, 'tests', 'e2e', 'ui.blueprint.json'), 'utf8')); +blueprint.preferredVersions = { php: opt('php', process.env.E2E_PHP || '8.3'), wp: opt('wp', process.env.E2E_WP || 'latest') }; +fs.mkdirSync(path.join(root, '.cache'), { recursive: true }); +const blueprintFile = path.join(root, '.cache', 'e2e-ui.blueprint.json'); +fs.writeFileSync(blueprintFile, JSON.stringify(blueprint)); + +// seed.php drops a marker here once the blueprint is done (tests/e2e/wait-for-wordpress.js waits for it). +const out = path.join(root, '.cache', 'e2e-out'); +fs.mkdirSync(out, { recursive: true }); +fs.rmSync(path.join(out, 'seeded'), { force: true }); + +const { code } = await playground([ + 'server', + `--port=${opt('port', process.env.E2E_PORT || '9400')}`, + '--login', + `--blueprint=${blueprintFile}`, + ...mountArgs({ '/e2e': path.join(root, 'tests', 'e2e'), '/e2e-out': out }), +]); +process.exit(code); diff --git a/scripts/plugin-check.mjs b/scripts/plugin-check.mjs new file mode 100644 index 0000000..a892da3 --- /dev/null +++ b/scripts/plugin-check.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * The PHPCS-based rules of the official Plugin Check plugin (what wordpress.org reviewers run), executed with + * real PHP and Plugin Check's own bundled PHPCS + sniffs – pinned version, SHA-256 verified. + * + * node scripts/plugin-check.mjs + * + * The non-PHPCS checks (readme, headers, file types, trademarks …) run inside WordPress via `npm run test:e2e`; + * in CI the official wordpress/plugin-check-action additionally runs everything against the built plugin. + * Accepted findings need a reason in tests/e2e/plugin-check-allowlist.json ({ "": "why" }). + */ +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { findPhp, PLUGIN_SLUG, pluginDir, root } from './lib/php.mjs'; + +const PCP_VERSION = '2.1.0'; +const PCP_SHA256 = '6ff4bd2145f3befcf907df158cc466b1649dafed5686de8369907403c3013fc4'; + +// Sniff selection per Plugin Check check class (includes/Checker/Checks/**), category plugin_repo + security. +const SNIFFS = [ + 'WordPress.WP.I18n', + 'WordPress.Security.EscapeOutput', + 'WordPress.Security.SafeRedirect', + 'WordPress.DB.DirectDatabaseQuery', + 'WordPress.DB.SlowDBQuery', + 'WordPress.NamingConventions.PrefixAllGlobals', + 'WordPress.WP.EnqueuedResources', + 'WordPress.WP.EnqueuedResourceParameters', + 'WordPressVIPMinimum.Performance.WPQueryParams', + 'PluginCheck.CodeAnalysis.EnqueuedResourceOffloading', + 'PluginCheck.CodeAnalysis.Offloading', + 'PluginCheck.CodeAnalysis.Localhost', + 'PluginCheck.CodeAnalysis.PHPErrorReporting', + 'PluginCheck.CodeAnalysis.SettingSanitization', + 'PluginCheck.CodeAnalysis.WriteFile', + 'PluginCheck.Security.DirectDB', + 'PluginCheck.Security.VerifyNonce', +]; + +async function ensurePcp() { + const dir = path.join(root, '.cache', `plugin-check-${PCP_VERSION}`); + if (fs.existsSync(path.join(dir, 'plugin-check', 'vendor', 'bin', 'phpcs'))) return path.join(dir, 'plugin-check'); + const res = await fetch(`https://downloads.wordpress.org/plugin/plugin-check.${PCP_VERSION}.zip`); + if (!res.ok) throw new Error(`Plugin Check download failed: HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + if (crypto.createHash('sha256').update(buf).digest('hex') !== PCP_SHA256) throw new Error('Plugin Check: checksum mismatch – download discarded.'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'pcp.zip'), buf); + // Windows' bsdtar handles zip; on Linux/macOS use unzip. + const unpack = process.platform === 'win32' + ? spawnSync(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'), ['-xf', 'pcp.zip'], { cwd: dir, stdio: 'inherit' }) + : spawnSync('unzip', ['-q', 'pcp.zip'], { cwd: dir, stdio: 'inherit' }); + fs.rmSync(path.join(dir, 'pcp.zip')); + if (unpack.status !== 0) throw new Error('Unpacking Plugin Check failed.'); + return path.join(dir, 'plugin-check'); +} + +const php = findPhp(); +if (!php) { + console.error('No PHP found – run `npm run setup:php` (Windows) or install PHP.'); + process.exit(2); +} + +const pcp = await ensurePcp(); +const vendor = path.join(pcp, 'vendor'); +const installed = [ + 'wp-coding-standards/wpcs', + 'phpcsstandards/phpcsutils', + 'phpcsstandards/phpcsextra', + 'automattic/vipwpcs', + 'sirbrillig/phpcs-variable-analysis', + 'plugin-check/phpcs-sniffs', +].map((p) => path.join(vendor, p)).join(','); + +function phpcs(extra) { + const res = spawnSync(php, [ + path.join(vendor, 'bin', 'phpcs'), '-q', '--report=json', '--no-cache', `--runtime-set`, 'installed_paths', installed, + '--runtime-set', 'text_domain', PLUGIN_SLUG, '--runtime-set', 'minimum_wp_version', '6.5', + '--ignore=*/languages/*', ...extra, pluginDir, + ], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (res.status > 2) throw new Error(`PHPCS crashed (exit ${res.status}): ${res.stderr || res.stdout}`); + return JSON.parse(res.stdout).files; +} + +const reports = [ + phpcs(['--extensions=php,js,css', `--standard=${path.join(pcp, 'phpcs-rulesets', 'plugin-review.xml')}`]), + phpcs(['--extensions=php', '--standard=WordPress,WordPressVIPMinimum,PluginCheck', `--sniffs=${SNIFFS.join(',')}`]), +]; + +const allow = JSON.parse(fs.readFileSync(path.join(root, 'tests', 'e2e', 'plugin-check-allowlist.json'), 'utf8')); +let open = 0; +let total = 0; +for (const files of reports) { + for (const [file, { messages }] of Object.entries(files)) { + for (const m of messages) { + total++; + const allowed = m.source in allow; + if (!allowed) open++; + console.log(`${allowed ? '~' : '✗'} ${m.type} ${path.relative(root, file)}:${m.line} ${m.source} – ${m.message}`); + } + } +} +console.log(`Plugin Check ${PCP_VERSION} (PHPCS rules): ${total} finding(s), ${open} not allow-listed.`); +process.exit(open ? 1 : 0); diff --git a/scripts/repo-checks.mjs b/scripts/repo-checks.mjs new file mode 100644 index 0000000..652de7e --- /dev/null +++ b/scripts/repo-checks.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * wordpress.org readiness: plugin header, readme.txt, versions, changelog and directory assets. + * + * npm run check # offline checks + * npm run check -- --online # also compare "Tested up to" with the current WordPress release + * npm run check -- --tag v1.2.0 # release: the Git tag must match the plugin version + * + * Rules follow the plugin handbook (developer.wordpress.org/plugins/wordpress-org/) and the readme + * validator: e.g. short description ≤ 150 characters, at most 5 tags, Stable tag = Version. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { PLUGIN_SLUG, pluginDir, root } from './lib/php.mjs'; + +const args = process.argv.slice(2); +const online = args.includes('--online'); +const tag = args.includes('--tag') ? args[args.indexOf('--tag') + 1] : null; + +const errors = []; +const warnings = []; +const ok = []; +const expect = (cond, message, level = errors) => (cond ? ok.push(message) : level.push(message)); + +const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8').replace(/\r\n/g, '\n'); +const cmpVersion = (a, b) => { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); + } + return 0; +}; + +// ------------------------------------------------------------------ plugin header. +const main = read(`${PLUGIN_SLUG}/${PLUGIN_SLUG}.php`); +const header = {}; +for (const m of main.matchAll(/^\s*\*\s*([A-Za-z][A-Za-z ]+?):\s*(.+?)\s*$/gm)) header[m[1]] = m[2]; + +for (const field of ['Plugin Name', 'Description', 'Version', 'Requires at least', 'Requires PHP', 'Author', 'License', 'License URI', 'Text Domain']) { + expect(Boolean(header[field]), `header has "${field}"`); +} +expect(/^\d+\.\d+\.\d+$/.test(header.Version || ''), `Version "${header.Version}" is semver (x.y.z)`); +expect(header['Text Domain'] === PLUGIN_SLUG, `Text Domain equals the slug "${PLUGIN_SLUG}"`); +expect(/GPL/i.test(header.License || ''), 'license is GPL-compatible'); +if (header['Domain Path']) expect(fs.existsSync(path.join(pluginDir, header['Domain Path'])), `Domain Path ${header['Domain Path']} exists`); +const constVersion = /const VERSION\s*=\s*'([^']+)'/.exec(main)?.[1]; +expect(constVersion === header.Version, `const VERSION (${constVersion}) equals the header Version (${header.Version})`); +expect((header.Description || '').length <= 150, 'header description ≤ 150 characters', warnings); + +// ----------------------------------------------------------------------- readme. +const readme = read(`${PLUGIN_SLUG}/readme.txt`); +const lines = readme.split('\n'); +const name = /^===\s*(.+?)\s*===$/.exec(lines[0])?.[1]; +expect(name === header['Plugin Name'], `readme title "${name}" equals Plugin Name`); + +const meta = {}; +let i = 1; +for (; i < lines.length && lines[i].trim() !== ''; i++) { + const m = /^([^:]+):\s*(.*)$/.exec(lines[i]); + if (m) meta[m[1].trim()] = m[2].trim(); +} +for (const field of ['Contributors', 'Tags', 'Requires at least', 'Tested up to', 'Requires PHP', 'Stable tag', 'License']) { + expect(Boolean(meta[field]), `readme has "${field}"`); +} +const tags = (meta.Tags || '').split(',').map((t) => t.trim()).filter(Boolean); +expect(tags.length > 0 && tags.length <= 5, `${tags.length} tag(s) (wordpress.org shows at most 5)`); +expect(meta['Stable tag'] === header.Version, `Stable tag (${meta['Stable tag']}) equals Version (${header.Version})`); +expect(meta['Requires at least'] === header['Requires at least'], 'readme/header "Requires at least" match'); +expect(meta['Requires PHP'] === header['Requires PHP'], 'readme/header "Requires PHP" match'); +expect(/^\d+\.\d+$/.test(meta['Tested up to'] || ''), `"Tested up to" (${meta['Tested up to']}) is major.minor`); +expect(cmpVersion(meta['Tested up to'] || '0', meta['Requires at least'] || '0') >= 0, '"Tested up to" ≥ "Requires at least"'); + +while (i < lines.length && lines[i].trim() === '') i++; +const short = lines[i] || ''; +expect(short.length > 0 && !short.startsWith('=='), 'short description present'); +expect(short.length <= 150, `short description is ${short.length}/150 characters`); + +const sections = [...readme.matchAll(/^==\s*([^=]+?)\s*==$/gm)].map((m) => m[1]); +for (const s of ['Description', 'Installation', 'Frequently Asked Questions', 'Changelog']) { + expect(sections.includes(s), `readme section "${s}"`); +} +const changelog = readme.split(/^== Changelog ==$/m)[1] || ''; +expect(new RegExp(`^= ${header.Version.replace(/\./g, '\\.')}`, 'm').test(changelog), `changelog has an entry for ${header.Version}`); + +// ---------------------------------------------------------- wordpress.org assets. +const assetsDir = path.join(root, '.wordpress-org'); +const pngSize = (file) => { + const b = fs.readFileSync(file); + return b.toString('ascii', 1, 4) === 'PNG' ? [b.readUInt32BE(16), b.readUInt32BE(20)] : null; +}; +for (const [file, w, h] of [['icon-128x128.png', 128, 128], ['icon-256x256.png', 256, 256], ['banner-772x250.png', 772, 250], ['banner-1544x500.png', 1544, 500]]) { + const full = path.join(assetsDir, file); + const size = fs.existsSync(full) ? pngSize(full) : null; + expect(size && size[0] === w && size[1] === h, `.wordpress-org/${file} is ${w}×${h}`); +} +const screenshotsInReadme = ((readme.split(/^== Screenshots ==$/m)[1] || '').split(/^== /m)[0].match(/^\d+\./gm) || []).length; +const screenshotFiles = fs.existsSync(assetsDir) ? fs.readdirSync(assetsDir).filter((f) => /^screenshot-\d+\.(png|jpg)$/.test(f)).length : 0; +expect(screenshotsInReadme === screenshotFiles, `${screenshotsInReadme} screenshot caption(s) / ${screenshotFiles} screenshot file(s)`); + +// ---------------------------------------------------------------- plugin assets. +for (const file of fs.readdirSync(path.join(pluginDir, 'assets'))) { + const content = fs.readFileSync(path.join(pluginDir, 'assets', file), 'utf8'); + const remote = content.match(/https?:\/\/[a-z0-9.-]+\.[a-z]{2,}/gi) || []; + expect(remote.length === 0, `assets/${file} loads nothing from external hosts${remote.length ? ` (${remote.join(', ')})` : ''}`); +} + +// --------------------------------------------------------------------- release. +if (tag) expect(tag.replace(/^v/, '') === header.Version, `Git tag ${tag} matches Version ${header.Version}`); + +if (online) { + try { + const res = await fetch('https://api.wordpress.org/core/version-check/1.7/'); + const current = (await res.json()).offers[0].current.split('.').slice(0, 2).join('.'); + const diff = cmpVersion(meta['Tested up to'], current); + expect(diff <= 0, `"Tested up to" ${meta['Tested up to']} is not newer than WordPress ${current}`); + expect(diff >= 0, `"Tested up to" ${meta['Tested up to']} is the current WordPress ${current} – test and bump it`, warnings); + } catch (err) { + warnings.push(`could not fetch the current WordPress version (${err.message})`); + } + + // Reviewers open every URI in the header and readme; dead links get the submission sent back. + const urls = new Set([ + ...['Plugin URI', 'Author URI', 'License URI', 'Update URI'].map((f) => header[f]).filter(Boolean), + ...(readme.match(/https?:\/\/[^\s)<>"'`]+/g) || []).filter((u) => !/example\.|\/\/(localhost|127\.)/.test(u)), + meta['License URI'], + ].filter(Boolean)); + // A dead link (HTTP 4xx/5xx) fails the check. A network error is only a warning: some hosts (e.g. gnu.org) + // throttle or drop requests from CI runners, which says nothing about the link itself. + for (const url of urls) { + let status = null; + let lastError = ''; + for (let attempt = 1; attempt <= 3 && status === null; attempt++) { + try { + const res = await fetch(url, { + redirect: 'follow', + headers: { 'User-Agent': 'Mozilla/5.0 (repo-checks)' }, + signal: AbortSignal.timeout(15_000), + }); + status = res.status; + } catch (err) { + lastError = err.cause?.code || err.message; + await new Promise((r) => setTimeout(r, attempt * 2000)); + } + } + if (status === null) { + warnings.push(`${url} could not be checked (${lastError}) – network issue, not counted as a dead link`); + } else { + expect(status >= 200 && status < 400, `${url} is reachable (HTTP ${status})`); + } + } +} + +console.log(`${ok.length} checks passed.`); +for (const w of warnings) console.log(`⚠ ${w}`); +for (const e of errors) console.log(`✗ ${e}`); +process.exit(errors.length ? 1 : 0); diff --git a/scripts/setup-php.mjs b/scripts/setup-php.mjs new file mode 100644 index 0000000..cfb158a --- /dev/null +++ b/scripts/setup-php.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Portable PHP + Composer for Windows dev machines – nothing is installed system-wide. + * + * npm run setup:php # PHP 7.4 (minimum version) into .cache/php/7.4/ + Composer into .cache/composer.phar + * npm run setup:php -- --php 8.4 # another version; the tooling uses the newest one that runs + * + * Sources: windows.php.net (official builds, SHA-256 from releases.json) and getcomposer.org + * (SHA-256 from the published .sha256sum). Both checksums are verified before anything is unpacked. + * On Linux/macOS install PHP with your package manager instead. + */ +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { root } from './lib/php.mjs'; + +const args = process.argv.slice(2); +const version = args.includes('--php') ? args[args.indexOf('--php') + 1] : '7.4'; +const cache = path.join(root, '.cache'); +const UA = { 'User-Agent': 'mailspur-email-log-setup (node)' }; +const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); + +async function download(url) { + const res = await fetch(url, { headers: UA }); + if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +async function setupPhp() { + const target = path.join(cache, 'php', version); + if (fs.existsSync(path.join(target, 'php.exe'))) { + console.log(`PHP ${version} already present: ${target}`); + return target; + } + if (process.platform !== 'win32') { + throw new Error('Portable PHP is only provided for Windows – install PHP via your package manager.'); + } + + const releases = JSON.parse((await download('https://windows.php.net/downloads/releases/releases.json')).toString('utf8')); + const release = releases[version]; + if (!release) throw new Error(`PHP ${version} not found on windows.php.net (available: ${Object.keys(releases).join(', ')})`); + // Non-thread-safe x64 build: the right one for CLI use. + const key = Object.keys(release).find((k) => /^nts-v[cs]\d+-x64$/.test(k)); + const zip = release[key]?.zip; + if (!zip) throw new Error(`No NTS x64 build for PHP ${version}`); + + console.log(`Downloading PHP ${release.version} (${zip.size}) …`); + const buf = await download(`https://windows.php.net/downloads/releases/${zip.path}`); + if (sha256(buf) !== zip.sha256) throw new Error(`PHP ${release.version}: checksum mismatch – download discarded.`); + + fs.mkdirSync(target, { recursive: true }); + const zipFile = path.join(target, 'php.zip'); + fs.writeFileSync(zipFile, buf); + // bsdtar bundled with Windows 10+ unpacks zip archives (GNU tar from Git Bash cannot, so call it by path). + const bsdtar = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'); + const tar = spawnSync(bsdtar, ['-xf', 'php.zip'], { cwd: target, stdio: 'inherit' }); + fs.rmSync(zipFile); + if (tar.status !== 0) throw new Error('Unpacking PHP failed.'); + + const ini = fs.readFileSync(path.join(target, 'php.ini-development'), 'utf8') + .replace(/^;\s*extension_dir\s*=\s*"ext"/m, 'extension_dir = "ext"') + .replace(/^;(extension=(?:curl|fileinfo|intl|mbstring|openssl|zip|sodium))\s*$/gm, '$1') + .replace(/^memory_limit\s*=.*$/m, 'memory_limit = 2G'); + fs.writeFileSync(path.join(target, 'php.ini'), ini); + const check = spawnSync(path.join(target, 'php.exe'), ['-v'], { encoding: 'utf8' }); + if (check.status !== 0) { + // Typical cause: Windows Smart App Control / WDAC blocks the unsigned-reputation binary. + console.warn(`PHP ${release.version} was downloaded but Windows refuses to run it (${(check.error?.message || check.stderr || '').trim() || 'blocked'}).`); + console.warn('Try another version, e.g. `npm run setup:php -- --php 7.4` – the tooling uses any version that runs.'); + return target; + } + console.log(`PHP ${release.version} ready: ${target}`); + return target; +} + +async function setupComposer() { + const phar = path.join(cache, 'composer.phar'); + if (fs.existsSync(phar)) { + console.log(`Composer already present: ${phar}`); + return; + } + const buf = await download('https://getcomposer.org/download/latest-stable/composer.phar'); + const expected = (await download('https://getcomposer.org/download/latest-stable/composer.phar.sha256sum')).toString('utf8').split(/\s+/)[0]; + if (sha256(buf) !== expected) throw new Error('composer.phar: checksum mismatch – download discarded.'); + fs.mkdirSync(cache, { recursive: true }); + fs.writeFileSync(phar, buf); + console.log(`Composer ready: ${phar}`); +} + +try { + await setupPhp(); + await setupComposer(); +} catch (err) { + console.error(err.message); + process.exit(1); +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..b1573e1 --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Local counterpart of the CI (.github/workflows/ci.yml): runs every check, prints a summary, exits 1 on failure. + * + * npm run verify # everything incl. WordPress runtime tests (Playground) and browser tests + * npm run verify -- --fast # static checks + unit tests only (≈ 30 s, used by the pre-push hook) + * + * Only in CI: php -l on PHP 7.4–8.5, the e2e matrix (oldest/newest PHP+WP), the official plugin-check-action, + * gitleaks over the full history (runs here too if gitleaks is on PATH) and actionlint/zizmor. + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { root } from './lib/php.mjs'; + +const fast = process.argv.includes('--fast'); +const node = process.execPath; +const script = (name, ...args) => [node, [path.join(root, 'scripts', name), ...args]]; + +const steps = [ + { name: 'PHPCS (WPCS, VIP, PHPCompatibility)', run: script('php.mjs', 'vendor/bin/phpcs', '-q') }, + { name: 'PHPStan (level 8)', run: script('php.mjs', 'vendor/bin/phpstan', 'analyse', '--no-progress', '--memory-limit=2G') }, + { name: 'PHPUnit', run: script('php.mjs', 'vendor/bin/phpunit') }, + { name: 'ESLint', run: [node, [path.join(root, 'node_modules', 'eslint', 'bin', 'eslint.js'), '--max-warnings=0', '.']] }, + { name: 'wordpress.org readiness', run: script('repo-checks.mjs') }, + { name: 'Translations', run: script('i18n.mjs', '--check') }, + { name: 'Plugin Check (PHPCS rules)', run: script('plugin-check.mjs'), slow: true }, + { name: 'npm audit', run: ['npm', ['audit', '--audit-level=moderate']], shell: process.platform === 'win32', slow: true }, + { name: 'composer audit', run: script('php.mjs', '--composer', 'audit', '--locked'), slow: true }, + { name: 'Runtime + browser tests (Playground)', run: script('e2e.mjs'), slow: true }, + { name: 'Release ZIP', run: script('build-zip.mjs') }, + { name: 'gitleaks', run: ['gitleaks', ['git', '.', '--redact', '--no-banner', '--log-level', 'warn']], optional: true }, +]; + +const results = []; +for (const step of steps) { + if (fast && step.slow) { + results.push({ name: step.name, status: 'skipped (--fast)' }); + continue; + } + console.log(`\n=== ${step.name} ===`); + const started = Date.now(); + const [cmd, args] = step.run; + const res = step.shell + ? spawnSync([cmd, ...args].join(' '), { cwd: root, stdio: 'inherit', shell: true }) + : spawnSync(cmd, args, { cwd: root, stdio: 'inherit' }); + const secs = ((Date.now() - started) / 1000).toFixed(0); + if (res.error?.code === 'ENOENT' && step.optional) { + results.push({ name: step.name, status: 'skipped (not installed)' }); + } else if (res.error) { + results.push({ name: step.name, status: `ERROR: ${res.error.message}`, failed: true }); + } else { + results.push({ name: step.name, status: res.status === 0 ? `ok (${secs} s)` : `FAILED (exit ${res.status}, ${secs} s)`, failed: res.status !== 0 }); + } +} + +console.log('\n=== Summary ==='); +for (const r of results) console.log(`${r.failed ? '✗' : '✓'} ${r.name.padEnd(40)} ${r.status}`); +const failed = results.filter((r) => r.failed).length; +console.log(failed ? `\n${failed} check(s) failed.` : '\nAll green.'); +process.exit(failed ? 1 : 0); diff --git a/scripts/wporg-assets.mjs b/scripts/wporg-assets.mjs new file mode 100644 index 0000000..d23335a --- /dev/null +++ b/scripts/wporg-assets.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * Generates the wordpress.org directory assets in .wordpress-org/ (deployed to SVN /assets, not into the plugin): + * + * node scripts/wporg-assets.mjs # icons, banners and screenshots + * node scripts/wporg-assets.mjs --no-shots # only icons and banners + * + * Icons/banners come from scripts/assets/wporg-brand.html. Screenshots are taken from the real admin screen in + * WordPress Playground, seeded with example data (tests/e2e/seed.php). Uses the locally installed Edge. + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium } from '@playwright/test'; +import { root } from './lib/php.mjs'; + +const out = path.join(root, '.wordpress-org'); +const shots = !process.argv.includes('--no-shots'); +const browser = await chromium.launch(process.env.CI ? {} : { channel: 'msedge' }); + +// ------------------------------------------------------------------ icons + banners. +const brand = pathToFileURL(path.join(root, 'scripts', 'assets', 'wporg-brand.html')).href; +for (const [file, asset, width, height] of [ + ['icon-128x128.png', 'icon', 128, 128], + ['icon-256x256.png', 'icon', 256, 256], + ['banner-772x250.png', 'banner', 772, 250], + ['banner-1544x500.png', 'banner', 1544, 500], +]) { + const page = await browser.newPage({ viewport: { width, height } }); + await page.goto(`${brand}?asset=${asset}`); + await page.screenshot({ path: path.join(out, file) }); + await page.close(); + console.log(`✓ ${file}`); +} + +// --------------------------------------------------------------------- screenshots. +if (shots) { + const port = 9420; + const marker = path.join(root, '.cache', 'e2e-out', 'seeded'); + fs.rmSync(marker, { force: true }); + const server = spawn(process.execPath, [path.join(root, 'scripts', 'playground-server.mjs'), '--port', String(port)], { cwd: root, stdio: 'ignore' }); + try { + const deadline = Date.now() + 5 * 60_000; + while (!fs.existsSync(marker)) { + if (Date.now() > deadline) throw new Error('Playground did not start.'); + await new Promise((r) => setTimeout(r, 500)); + } + + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }); + const base = `http://127.0.0.1:${port}/wp-admin/admin.php?page=mailspur-email-log`; + const hideNoise = () => page.addStyleTag({ content: '#wpfooter, .notice, .update-nag { display: none !important; }' }); + + // The XSS test entry is for the tests, not for the directory page. + await page.goto(base); + await page.evaluate(async () => { + const c = window.mailspurConfig; + const headers = { 'X-WP-Nonce': c.nonce }; + const list = await (await fetch(`${c.restUrl}/mails?search=XSS`, { headers })).json(); + await Promise.all(list.items.map((i) => fetch(`${c.restUrl}/mails/${i.id}`, { method: 'DELETE', headers }))); + }); + await page.goto(base); + await page.locator('#mailspur-rows tr[data-id]').first().waitFor(); + await hideNoise(); + await page.screenshot({ path: path.join(out, 'screenshot-1.png') }); + console.log('✓ screenshot-1.png (log)'); + + await page.goto(`${base}&s=lena1%40`); + await page.locator('#mailspur-rows .mailspur-open').first().click(); + await page.frameLocator('iframe.mailspur-frame').locator('h1').waitFor(); + await page.evaluate(() => document.activeElement?.blur()); + await hideNoise(); + await page.screenshot({ path: path.join(out, 'screenshot-2.png') }); + console.log('✓ screenshot-2.png (preview)'); + + // Trace of a mail sent by a real web request: the "Lost your password?" form of wp-login.php. + await page.goto(`http://127.0.0.1:${port}/wp-login.php?action=lostpassword`); + await page.locator('#user_login').fill('admin'); + await page.locator('#wp-submit').click(); + await page.waitForLoadState('load'); + await page.goto(`${base}&s=Password%20Reset`); + await page.locator('#mailspur-rows .mailspur-open').first().click(); + await page.locator('#mailspur-dialog [role="tab"][data-view="trace"]').click(); + await page.locator('#mailspur-d-body').getByText(/ms/).first().waitFor(); + await page.evaluate(() => document.activeElement?.blur()); + await page.screenshot({ path: path.join(out, 'screenshot-3.png') }); + console.log('✓ screenshot-3.png (trace)'); + + // The failed newsletter: explained error + notes. + await page.goto(`${base}&s=newsletter`); + await page.locator('#mailspur-rows .mailspur-open').first().click(); + await page.locator('#mailspur-dialog [role="tab"][data-view="notes"]').click(); + await page.waitForTimeout(500); + await page.evaluate(() => document.activeElement?.blur()); + await page.screenshot({ path: path.join(out, 'screenshot-4.png') }); + console.log('✓ screenshot-4.png (notes)'); + + // 30 days of plausible shop traffic (tests/e2e/screenshot-seed.php), only for this screenshot. + await page.goto(`http://127.0.0.1:${port}/wp-admin/?mailspur-e2e-seed=history`); + await page.setViewportSize({ width: 1280, height: 1100 }); + await page.goto(`${base}&tab=stats`); + await page.locator('.mailspur svg').first().waitFor(); + await page.waitForTimeout(500); + await hideNoise(); + await page.screenshot({ path: path.join(out, 'screenshot-5.png') }); + console.log('✓ screenshot-5.png (statistics)'); + + // Tall viewport instead of fullPage, so the admin menu background reaches the bottom. + await page.goto(`${base}&tab=settings`); + await hideNoise(); + await page.screenshot({ path: path.join(out, 'screenshot-6.png') }); + console.log('✓ screenshot-6.png (settings)'); + } finally { + server.kill(); + } +} + +await browser.close(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..6108406 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,23 @@ + page.locator('#mailspur-rows tr[data-id]'); + +test.describe.configure({ mode: 'serial' }); + +/** Accepts confirm()s and answers prompt()s from the queue; collects console errors. */ +function watch(page, answers = []) { + const errors = []; + page.on('console', (msg) => { + // The preview's CSP/sandbox reports blocked content – that is the protection working, not an error. + if (msg.type() === 'error' && !/Content Security Policy|about:srcdoc/.test(msg.text())) errors.push(msg.text()); + }); + page.on('pageerror', (err) => errors.push(err.message)); + page.on('dialog', (dialog) => ('prompt' === dialog.type() ? dialog.accept(answers.shift() ?? '') : dialog.accept())); + return errors; +} + +/** REST call from inside the admin page (uses the page's nonce). */ +async function rest(page, path, method = 'GET') { + return page.evaluate( + async ([p, m]) => { + const cfg = window.mailspurConfig; + const res = await fetch(cfg.restUrl.replace(/\/?$/, '/') + p, { method: m, headers: { 'X-WP-Nonce': cfg.nonce } }); + return res.json(); + }, + [path, method], + ); +} + +async function setMode(page, mode, redirectTo = '') { + await page.goto(SETTINGS); + await page.locator(`input[name="mailspur_settings[staging_mode]"][value="${mode}"]`).check(); + await page.locator('#mailspur-staging-redirect').fill(redirectTo); + await page.getByRole('button', { name: 'Save Changes' }).click(); + await expect(page.locator('#setting-error-settings_updated')).toBeVisible(); +} + +let maxId = 0; + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await page.goto(LOG); + const list = await rest(page, 'mails?per_page=200'); + maxId = Math.max(0, ...list.items.map((i) => i.id)); + await page.close(); +}); + +test.afterAll(async ({ browser }) => { + const page = await browser.newPage(); + await setMode(page, 'off'); + await page.goto(LOG); + const list = await rest(page, 'mails?per_page=200'); + const ids = list.items.map((i) => i.id).filter((id) => id > maxId); + if (ids.length) await rest(page, 'mails?ids=' + ids.join(','), 'DELETE'); + await page.close(); +}); + +test('staging mode holds mails, warns everywhere and releases one by one', async ({ page }) => { + const errors = watch(page); + await setMode(page, 'hold'); + await expect(page.locator('#wp-admin-bar-mailspur-staging')).toContainText('Mailspur: staging mode'); + + await page.goto(`${LOG}&s=lena1%40`); + await expect(page.locator('.mailspur-staging-notice')).toContainText('Staging mode is active: emails are logged but not delivered.'); + await expect(rows(page)).toHaveCount(1); + + // The core "Resend" goes through wp_mail() and is held. + await rows(page).first().locator('.mailspur-open').click(); + await expect(page.locator('[data-module-action="delivery-release"]')).toBeHidden(); + await page.locator('#mailspur-dialog [data-action="resend"]').click(); + await expect(page.locator('#mailspur-toast')).toContainText('Email sent again.'); + await page.keyboard.press('Escape'); + + await page.goto(`${LOG}&s=lena1%40&status=held`); + await expect(rows(page)).toHaveCount(1); + await expect(rows(page).first().locator('.mailspur-badge')).toHaveText('Held'); + await rows(page).first().locator('.mailspur-open').click(); + await expect(page.locator('#mailspur-d-meta')).toContainText('Held by staging mode – not delivered.'); + + // "Send now" bypasses staging mode for this one mail and logs a new entry (Playground cannot deliver → failed). + await page.locator('[data-module-action="delivery-release"]').click(); + await expect(page.locator('#mailspur-toast')).toHaveText(/Held email sent|Sending failed/); + await page.keyboard.press('Escape'); + await page.goto(`${LOG}&s=lena1%40`); + await expect(rows(page)).toHaveCount(3); + await expect(page.locator('[data-count="held"]')).toHaveText('1'); + await expect(rows(page).first().locator('.col-source')).toHaveText('Resent from log'); + await expect(rows(page).first().locator('.mailspur-badge')).not.toHaveText('Held'); + await rows(page).first().locator('.mailspur-open').click(); + await expect(page.locator('#mailspur-d-meta')).toContainText(/Released from staging \(held entry #\d+\)\./); + expect(errors).toEqual([]); +}); + +test('send to another address', async ({ page }) => { + const errors = watch(page, ['nope', 'qa@example.net, dev@example.net']); + await page.goto(`${LOG}&s=mia2%40`); + await rows(page).first().locator('.mailspur-open').click(); + + await page.locator('[data-module-action="delivery-send-to"]').click(); + await expect(page.locator('#mailspur-toast')).toHaveText('Please enter valid email addresses.'); + + await page.locator('[data-module-action="delivery-send-to"]').click(); + // Staging mode (hold) is still on, so wp_mail() reports success and the copy is held. + await expect(page.locator('#mailspur-toast')).toHaveText('Email sent to qa@example.net, dev@example.net.'); + await page.keyboard.press('Escape'); + + await page.goto(`${LOG}&s=qa%40example.net`); + await expect(rows(page)).toHaveCount(1); + await expect(rows(page).first().locator('.col-to')).toContainText('qa@example.net, dev@example.net'); + await expect(rows(page).first().locator('.col-subject')).toContainText('Your order #125602'); + expect(errors).toEqual([]); +}); + +test('redirect mode without a valid address falls back to holding', async ({ page }) => { + watch(page); + await setMode(page, 'redirect', 'not-an-address'); + await expect(page.locator('#mailspur-staging-redirect')).toHaveValue(''); + await expect(page.locator('.mailspur-staging-notice')).toContainText('no valid redirect address is set'); + + await setMode(page, 'redirect', 'dev@example.net'); + await expect(page.locator('#mailspur-staging-redirect')).toHaveValue('dev@example.net'); + await expect(page.locator('.mailspur-staging-notice')).toContainText('all emails are redirected to dev@example.net'); +}); + +test('sender check runs on demand and shows a traffic light per record', async ({ page }) => { + const errors = watch(page); + await page.goto(SETTINGS); + await expect(page.locator('#mailspur-sender-check')).toBeVisible(); + + await page.locator('#mailspur-dkim-selector').fill('bad selector!'); + await page.locator('#mailspur-delivery-run').click(); + await expect(page.locator('#mailspur-delivery-state')).toHaveText('A DKIM selector may only contain letters, digits, dots, hyphens and underscores.'); + + await page.locator('#mailspur-dkim-selector').fill('mysel'); + await page.locator('#mailspur-delivery-run').click(); + await expect(page.locator('#mailspur-delivery-run')).toHaveText('Check again', { timeout: 45_000 }); + await expect(page.locator('#mailspur-delivery-run')).toBeEnabled(); + + // DNS in Playground is not reliable: either per-domain results or a note (unavailable / no domain). + const domains = page.locator('.mailspur-delivery-domain'); + const notes = page.locator('.mailspur-delivery-note'); + await expect(domains.or(notes).first()).toBeVisible(); + const count = await domains.count(); + for (let i = 0; i < count; i++) { + await expect(domains.nth(i).locator('.mailspur-delivery-item')).toHaveCount(4); + await expect(domains.nth(i).locator('.mailspur-light[aria-label]')).toHaveCount(4); + } + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/features/delivery.php b/tests/e2e/features/delivery.php new file mode 100644 index 0000000..72938b8 --- /dev/null +++ b/tests/e2e/features/delivery.php @@ -0,0 +1,285 @@ + (bool) $ok, + 'name' => $name, + 'detail' => $ok ? null : $detail, + ); +} + +/** @return WP_REST_Response */ +function delivery_rest( $method, $route, $params = array() ) { + $request = new WP_REST_Request( $method, '/mailspur-email-log/v1' . $route ); + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + return rest_ensure_response( rest_do_request( $request ) ); +} + +/** @return array|null */ +function delivery_last_row() { + global $wpdb; + $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC LIMIT 1', Repository::table() ), ARRAY_A ); + if ( is_array( $row ) ) { + $row['meta'] = json_decode( (string) $row['meta'], true ); + } + return $row; +} + +function delivery_settings( array $values ) { + update_option( Settings::OPTION, array_merge( Settings::all(), $values ) ); +} + +/** Calls a hooked method of the delivery module instance and returns its output. */ +function delivery_call( $hook, $method, ...$args ) { + global $wp_filter; + foreach ( $wp_filter[ $hook ]->callbacks as $callbacks ) { + foreach ( $callbacks as $callback ) { + if ( is_array( $callback['function'] ) && $callback['function'][0] instanceof Module && $method === $callback['function'][1] ) { + ob_start(); + $callback['function'][0]->$method( ...$args ); + return (string) ob_get_clean(); + } + } + } + return null; +} + +global $wpdb; +$delivery_before = get_option( Settings::OPTION ); +$delivery_start = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COALESCE(MAX(id), 0) FROM %i', Repository::table() ) ); + +try { + wp_set_current_user( 1 ); + + // --------------------------------------------------------------------- hold. + delivery_settings( array( 'staging_mode' => 'hold' ) ); + $sent = wp_mail( 'customer@example.com', 'Delivery hold test', 'Body' ); + $held = delivery_last_row(); + delivery_check( true === $sent, 'held mail: wp_mail() reports success to the caller', $sent ); + delivery_check( (int) Repository::STATUS_HELD === (int) $held['status'], 'held mail: status held', $held ); + delivery_check( 'staging' === ( $held['meta']['delivery']['held'] ?? '' ), 'held mail: reason in meta', $held['meta'] ); + delivery_check( 'customer@example.com' === $held['recipients'], 'held mail: original recipient logged', $held['recipients'] ); + + $list = delivery_rest( 'GET', '/mails', array( 'status' => 'held' ) )->get_data(); + delivery_check( in_array( (int) $held['id'], array_column( (array) ( $list['items'] ?? array() ), 'id' ), true ), 'held filter lists the mail', $list ); + + // Admin bar and notice while active. + require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; + $bar = new WP_Admin_Bar(); + delivery_call( 'admin_bar_menu', 'admin_bar', $bar ); + $node = $bar->get_node( 'mailspur-staging' ); + delivery_check( $node && false !== strpos( (string) $node->title, 'staging mode' ), 'admin bar shows staging mode', $node ); + + $_GET['page'] = 'mailspur-email-log'; + $notice = delivery_call( 'admin_notices', 'notices' ); + delivery_check( false !== strpos( (string) $notice, 'Staging mode is active: emails are logged but not delivered.' ), 'notice on the Mail Log screen', $notice ); + $_GET['page'] = 'other'; + delivery_check( '' === delivery_call( 'admin_notices', 'notices' ), 'no notice on other screens' ); + + // ------------------------------------------------------------------ release. + $res = delivery_rest( 'POST', '/mails/' . $held['id'] . '/release' ); + $new = delivery_last_row(); + delivery_check( 200 === $res->get_status() && array_key_exists( 'sent', (array) $res->get_data() ), 'release: route answers', $res->get_data() ); + delivery_check( (int) $new['id'] > (int) $held['id'] && 'mailspur:resend' === $new['source'], 'release: logged as new entry', $new ); + delivery_check( (int) Repository::STATUS_HELD !== (int) $new['status'] && 'customer@example.com' === $new['recipients'], 'release: delivery attempted to the original recipient', $new ); + $released = (int) ( $new['meta']['delivery']['released_from'] ?? 0 ); + delivery_check( (int) $held['id'] === $released, 'release: meta links the held entry', $new['meta'] ); + delivery_check( 409 === delivery_rest( 'POST', '/mails/' . $new['id'] . '/release' )->get_status(), 'release: only held mails' ); + + $sent = wp_mail( 'customer@example.com', 'Delivery hold test 2', 'Body' ); + delivery_check( (int) Repository::STATUS_HELD === (int) delivery_last_row()['status'], 'release bypass ends after one mail' ); + + // An API mailer on pre_wp_mail (like many SMTP/API plugins) must not deliver on a staging copy: + // staging holds first, well-behaved handlers pass the non-null result through. + $api_sent = 0; + $api = static function ( $result ) use ( &$api_sent ) { + if ( null !== $result ) { + return $result; + } + ++$api_sent; + return true; + }; + add_filter( 'pre_wp_mail', $api, 10 ); + wp_mail( 'customer@example.com', 'Delivery hold vs API mailer', 'Body' ); + remove_filter( 'pre_wp_mail', $api, 10 ); + delivery_check( 0 === $api_sent && (int) Repository::STATUS_HELD === (int) delivery_last_row()['status'], 'hold wins over an API mailer on pre_wp_mail', $api_sent ); + + // ----------------------------------------------------------------- redirect. + delivery_settings( + array( + 'staging_mode' => 'redirect', + 'staging_redirect_to' => 'dev@example.net, qa@example.net', + ) + ); + $seen = array(); + $watcher = static function ( $mailer ) use ( &$seen ) { + $seen = array( + 'to' => array_column( $mailer->getToAddresses(), 0 ), + 'cc' => $mailer->getCcAddresses(), + 'bcc' => $mailer->getBccAddresses(), + ); + }; + add_action( 'phpmailer_init', $watcher ); + wp_mail( array( 'anna@example.com', 'bob@example.org' ), 'Delivery redirect test', 'Body', array( 'Cc: cc@example.com', 'Bcc: bcc@example.com', 'X-Test: 1' ) ); + remove_action( 'phpmailer_init', $watcher ); + $row = delivery_last_row(); + delivery_check( array( 'dev@example.net', 'qa@example.net' ) === ( $seen['to'] ?? null ) && array() === $seen['cc'] && array() === $seen['bcc'], 'redirect: PHPMailer only gets the redirect targets', $seen ); + delivery_check( 'dev@example.net, qa@example.net' === $row['recipients'], 'redirect: log shows the redirect target', $row['recipients'] ); + delivery_check( '[Staging → anna@example.com, bob@example.org] Delivery redirect test' === $row['subject'], 'redirect: subject prefixed', $row['subject'] ); + delivery_check( false === stripos( $row['headers'], 'cc:' ) && false !== strpos( $row['headers'], 'X-Test: 1' ), 'redirect: Cc/Bcc stripped, other headers kept', $row['headers'] ); + $d = $row['meta']['delivery'] ?? array(); + delivery_check( + array( 'anna@example.com', 'bob@example.org' ) === ( $d['original_to'] ?? null ) && array( 'cc@example.com' ) === ( $d['original_cc'] ?? null ) && array( 'bcc@example.com' ) === ( $d['original_bcc'] ?? null ), + 'redirect: originals in meta', + $d + ); + $item = delivery_rest( 'GET', '/mails/' . $row['id'] )->get_data(); + delivery_check( isset( $item['meta']['delivery']['original_to'] ), 'redirect: originals in the REST detail payload', $item['meta'] ?? null ); + + $_GET['page'] = 'mailspur-email-log'; + delivery_check( false !== strpos( (string) delivery_call( 'admin_notices', 'notices' ), 'redirected to dev@example.net, qa@example.net' ), 'redirect notice names the targets' ); + + delivery_settings( array( 'staging_redirect_to' => '' ) ); + $sent = wp_mail( 'anna@example.com', 'Delivery redirect without address', 'Body' ); + $row = delivery_last_row(); + delivery_check( (int) Repository::STATUS_HELD === (int) $row['status'] && 'no_redirect_address' === ( $row['meta']['delivery']['held'] ?? '' ), 'redirect without address holds instead', $row ); + + // ---------------------------------------------------------- resend to others. + delivery_settings( array( 'staging_mode' => 'off' ) ); + $res = delivery_rest( 'POST', '/mails/' . $held['id'] . '/resend', array( 'to' => 'x@example.com, y@example.com' ) ); + $row = delivery_last_row(); + delivery_check( 200 === $res->get_status() && array( 'x@example.com', 'y@example.com' ) === ( $res->get_data()['to'] ?? null ), 'send to: route accepts a comma list', $res->get_data() ); + delivery_check( 'x@example.com, y@example.com' === $row['recipients'] && 'mailspur:resend' === $row['source'], 'send to: new entry with the new recipients', $row ); + delivery_check( 400 === delivery_rest( 'POST', '/mails/' . $held['id'] . '/resend', array( 'to' => 'x@example.com, nope' ) )->get_status(), 'send to: invalid address rejected' ); + $many = array(); + for ( $i = 1; $i <= 11; $i++ ) { + $many[] = "u{$i}@example.com"; + } + $many = implode( ',', $many ); + delivery_check( 400 === delivery_rest( 'POST', '/mails/' . $held['id'] . '/resend', array( 'to' => $many ) )->get_status(), 'send to: more than 10 addresses rejected' ); + delivery_rest( 'POST', '/mails/' . $held['id'] . '/resend' ); + delivery_check( 'customer@example.com' === delivery_last_row()['recipients'], 'resend without "to" keeps the original recipients' ); + + // Editors with log access may resend, but not to other addresses. + $editor = wp_insert_user( + array( + 'user_login' => 'delivery_editor', + 'user_pass' => wp_generate_password(), + 'role' => 'editor', + ) + ); + if ( is_wp_error( $editor ) ) { + delivery_check( false, 'editor created', $editor->get_error_message() ); + } else { + delivery_settings( array( 'capability' => 'edit_others_posts' ) ); + wp_set_current_user( $editor ); + delivery_check( 403 === delivery_rest( 'POST', '/mails/' . $held['id'] . '/resend', array( 'to' => 'x@example.com' ) )->get_status(), 'send to: administrators only' ); + delivery_check( 403 === delivery_rest( 'POST', '/mails/' . $held['id'] . '/release' )->get_status(), 'release: administrators only' ); + delivery_check( 403 === delivery_rest( 'POST', '/delivery/check' )->get_status(), 'sender check: administrators only' ); + wp_set_current_user( 1 ); + require_once ABSPATH . 'wp-admin/includes/user.php'; + wp_delete_user( $editor ); + } + + // --------------------------------------------------------- staging suggestion. + delivery_settings( array( 'staging_mode' => 'off' ) ); + $env = static function () { + return 'staging'; + }; + add_filter( 'mailspur_environment_type', $env ); + $_GET['page'] = 'mailspur-email-log'; + delivery_check( false !== strpos( (string) delivery_call( 'admin_notices', 'notices' ), 'data-mailspur-hint' ), 'staging environment: suggestion shown' ); + delivery_rest( 'POST', '/delivery/hint' ); + delivery_check( '' === delivery_call( 'admin_notices', 'notices' ), 'suggestion dismissed' ); + remove_filter( 'mailspur_environment_type', $env ); + delete_option( 'mailspur_delivery_hint_dismissed' ); + + // --------------------------------------------------------------- sender check. + $res = delivery_rest( 'POST', '/delivery/check', array( 'force' => true ) ); + $data = $res->get_data(); + delivery_check( 200 === $res->get_status() && isset( $data['available'], $data['domains'] ), 'sender check: route answers', $data ); + $shape = true; + foreach ( (array) ( $data['domains'] ?? array() ) as $domain ) { + $shape = $shape && array( 'spf', 'dmarc', 'dkim', 'mx' ) === array_column( $domain['checks'], 'id' ); + } + delivery_check( $shape, 'sender check: four checks per domain', $data ); + $cached = delivery_rest( 'GET', '/delivery/check' )->get_data(); + delivery_check( is_array( $cached ) && true === $cached['cached'], 'sender check: result cached', $cached ); + delivery_check( 400 === delivery_rest( 'POST', '/delivery/check', array( 'selector' => 'bad selector!' ) )->get_status(), 'sender check: selector validated' ); + if ( SenderCheck::available() ) { + // Live DNS is optional: only record what happened (no assertion on the network). + $statuses = array(); + foreach ( (array) $data['domains'] as $domain ) { + $statuses[ $domain['domain'] ] = array_column( $domain['checks'], 'status', 'id' ); + } + delivery_check( true, 'sender check: live DNS ' . wp_json_encode( $statuses ) ); + } +} catch ( Throwable $e ) { + delivery_check( false, 'uncaught ' . get_class( $e ), $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() ); +} + +// Clean up: entries, settings, transients. +try { + $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE id > %d', Repository::table(), $delivery_start ) ) ); + ( new Repository() )->delete( $ids ); + if ( false === $delivery_before ) { + delete_option( Settings::OPTION ); + } else { + update_option( Settings::OPTION, $delivery_before ); + } + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s', $wpdb->options, '%mailspur_delivery_check_%' ) ); + unset( $_GET['page'] ); +} catch ( Throwable $e ) { + delivery_check( false, 'cleanup ' . get_class( $e ), $e->getMessage() ); +} + +$delivery_failed = array_values( + array_filter( + $delivery_results, + static function ( $r ) { + return ! $r['ok']; + } + ) +); +if ( ! is_dir( '/e2e-out/features' ) ) { + mkdir( '/e2e-out/features', 0777, true ); +} +file_put_contents( + '/e2e-out/features/delivery.json', + wp_json_encode( + array( + 'passed' => count( $delivery_results ) - count( $delivery_failed ), + 'failed' => count( $delivery_failed ), + 'results' => $delivery_results, + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE + ) +); diff --git a/tests/e2e/features/insights.php b/tests/e2e/features/insights.php new file mode 100644 index 0000000..8c17d8d --- /dev/null +++ b/tests/e2e/features/insights.php @@ -0,0 +1,405 @@ + (bool) $ok, + 'name' => $name, + 'detail' => $ok ? null : $detail, + ); +} + +/** @return WP_REST_Response */ +function insights_rest( $method, $route, $params = array() ) { + $request = new WP_REST_Request( $method, '/mailspur-email-log/v1' . $route ); + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + return rest_ensure_response( rest_do_request( $request ) ); +} + +/** Inserts a log row at a UTC time. */ +function insights_row( $utc, $status, $source, $to, $subject ) { + global $wpdb; + $wpdb->insert( + Repository::table(), + array( + 'created_at' => $utc, + 'status' => $status, + 'recipients' => $to, + 'subject' => $subject, + 'message' => 'x', + 'headers' => '', + 'attachments' => '', + 'source' => $source, + 'error' => '', + 'meta' => '', + 'raw' => '', + ) + ); + return (int) $wpdb->insert_id; +} + +$insights_settings = get_option( 'mailspur_settings', array() ); +$insights_tz = get_option( 'timezone_string' ); +$insights_captured = array(); + +try { + global $wpdb; + $table = Repository::table(); + update_option( 'timezone_string', 'Europe/Berlin' ); + + $admins = get_users( + array( + 'role' => 'administrator', + 'fields' => 'ID', + 'number' => 1, + ) + ); + wp_set_current_user( (int) $admins[0] ); + + // ------------------------------------------------------------ data set. + // 60 days from 2024-02-01 (crosses the DST switch on 2024-03-31), 1–5 mails per day at 08:15 local time. + $expected_days = array(); + $expected_total = 0; + $expected_fail = 0; + $expected_held = 0; + $berlin = new DateTimeZone( 'Europe/Berlin' ); + for ( $d = 0; $d < 60; $d++ ) { + $local = new DateTimeImmutable( '2024-02-01 08:15:00', $berlin ); + $local = $local->add( new DateInterval( 'P' . $d . 'D' ) ); + $n = $d % 5 + 1; + for ( $k = 0; $k < $n; $k++ ) { + $i = $expected_total; + $status = 0 === $i % 7 ? Repository::STATUS_FAILED : ( 0 === $i % 11 ? Repository::STATUS_HELD : Repository::STATUS_SENT ); + $utc = $local->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); + insights_row( $utc, $status, 0 === $i % 3 ? 'e2e:insights-shop' : 'e2e:insights-forms', "user{$i}@" . ( 0 === $i % 4 ? 'big.example' : 'small.example' ), 'Order #' . ( 1000 + $i ) . ' shipped' ); + ++$expected_total; + $expected_fail += Repository::STATUS_FAILED === $status ? 1 : 0; + $expected_held += Repository::STATUS_HELD === $status ? 1 : 0; + } + $expected_days[ $local->format( 'Y-m-d' ) ] = $n; + } + // Around the DST switch: 00:30 CET on 31 March, and 03:30 CEST on the same day (02:00–03:00 does not exist). + insights_row( '2024-03-30 23:30:00', Repository::STATUS_SENT, 'e2e:insights-dst', 'dst@dst.example', 'DST A' ); + insights_row( '2024-03-31 01:30:00', Repository::STATUS_SENT, 'e2e:insights-dst', 'dst@dst.example', 'DST B' ); + $expected_total += 2; + $expected_days['2024-03-31'] += 2; + + // ------------------------------------------------------------- statistics. + $res = insights_rest( + 'GET', + '/stats', + array( + 'from' => '2024-02-01', + 'to' => '2024-03-31', + ) + ); + $stats = $res->get_data(); + insights_check( 200 === $res->get_status(), 'GET /stats answers', $stats ); + insights_check( $expected_total === $stats['totals']['all'], 'total count', array( $expected_total, $stats['totals'] ) ); + insights_check( $expected_fail === $stats['totals']['failed'] && $expected_held === $stats['totals']['held'], 'status counts', $stats['totals'] ); + insights_check( Stats::rate( $expected_fail, $expected_total ) === (float) $stats['totals']['rate'], 'failure rate', $stats['totals'] ); + insights_check( 60 === count( $stats['days'] ) && 60 === $stats['range']['days'], 'one entry per day', count( $stats['days'] ) ); + + $per_day = array(); + foreach ( $stats['days'] as $day ) { + $per_day[ $day['date'] ] = $day['sent'] + $day['failed'] + $day['held'] + $day['pending']; + } + insights_check( $per_day === $expected_days, 'mails land on their local day', array_diff_assoc( $per_day, $expected_days ) ); + + // 08:15 local is hour 8 before and after the switch (07:15 UTC in winter, 06:15 UTC in summer). + $hour8 = 0; + foreach ( $stats['heatmap'] as $hours ) { + $hour8 += $hours[8]; + } + insights_check( $expected_total - 2 === $hour8, 'heatmap uses local hours across DST', $stats['heatmap'] ); + insights_check( 1 === $stats['heatmap'][0][0] && 1 === $stats['heatmap'][0][3], 'DST night: 00:30 CET and 03:30 CEST on Sunday', array( $stats['heatmap'][0] ) ); + insights_check( '2024-03-31' === $stats['busiest']['date'], 'busiest day', $stats['busiest'] ); + insights_check( 0 === $stats['previous']['all'] && '2023-12-03' === $stats['previous']['from'], 'previous period', $stats['previous'] ); + + $sources = array_column( $stats['top']['sources'], 'count', 'key' ); + insights_check( isset( $sources['e2e:insights-shop'], $sources['e2e:insights-forms'] ) && $sources['e2e:insights-shop'] + $sources['e2e:insights-forms'] + $sources['e2e:insights-dst'] === $expected_total, 'top sources', $stats['top']['sources'] ); + insights_check( 'big.example' === $stats['top']['domains'][1]['label'] || 'big.example' === $stats['top']['domains'][0]['label'], 'top domains', $stats['top']['domains'] ); + insights_check( 'Order #… shipped' === $stats['top']['subjects'][0]['label'] && $expected_total - 2 === $stats['top']['subjects'][0]['count'], 'subjects grouped without numbers', $stats['top']['subjects'][0] ); + insights_check( false === $stats['sample']['limited'], 'no sampling for small ranges', $stats['sample'] ); + + // The search term of a grouped subject finds the mails in the log. + $list = insights_rest( + 'GET', + '/mails', + array( + 'search' => $stats['top']['subjects'][0]['search'], + 'after' => '2024-02-01', + 'before' => '2024-03-31', + ) + )->get_data(); + insights_check( $expected_total - 2 === $list['total'], 'log link of a subject finds its mails', array( $stats['top']['subjects'][0]['search'], $list['total'] ) ); + + // ---------------------------------------------------------------- caching. + $extra = insights_row( '2024-02-10 10:00:00', Repository::STATUS_SENT, 'e2e:insights-shop', 'late@small.example', 'Late insert' ); + $cached = insights_rest( + 'GET', + '/stats', + array( + 'from' => '2024-02-01', + 'to' => '2024-03-31', + ) + )->get_data(); + insights_check( $expected_total === $cached['totals']['all'], 'past ranges are served from the cache', $cached['totals'] ); + + $gen = (int) get_option( Stats::GENERATION_OPTION, 0 ); + insights_rest( 'DELETE', '/mails/' . $extra ); + insights_check( (int) get_option( Stats::GENERATION_OPTION, 0 ) === $gen + 1, 'deleting entries invalidates the cache' ); + insights_row( '2024-02-10 10:00:00', Repository::STATUS_SENT, 'e2e:insights-shop', 'late@small.example', 'Late insert' ); + Stats::flush(); + $fresh = insights_rest( + 'GET', + '/stats', + array( + 'from' => '2024-02-01', + 'to' => '2024-03-31', + ) + )->get_data(); + insights_check( $expected_total + 1 === $fresh['totals']['all'], 'fresh numbers after invalidation', $fresh['totals'] ); + + // ----------------------------------------------------------- validation. + $bad = insights_rest( + 'GET', + '/stats', + array( + 'from' => '2024-03-01', + 'to' => '2024-02-01', + ) + ); + insights_check( 400 === $bad->get_status(), 'reversed range is rejected', $bad->get_data() ); + $bad = insights_rest( 'GET', '/stats', array( 'from' => '2024-02-31' ) ); + insights_check( 400 === $bad->get_status(), 'invalid date is rejected', $bad->get_data() ); + $bad = insights_rest( 'GET', '/stats', array( 'from' => '' + . '

View order

' // gitleaks:allow – fake secret, checks that the log redacts it. + . '
' + . ''; + +$clean = static function ( $name, $order ) { + return '
' + . '

Thanks for your order

' + . '
' + . '

Hi ' . ucfirst( $name ) . ',

we have received your order #' . $order . ' and will ship it within 24 hours.

' + . '' + . '' + . '' + . '
Coaching session (60 min)€49.00
Replay analysis€19.00
Total€68.00
' + . '

View order

' + . '

Example Shop · Musterstraße 1 · 12345 Berlin

'; +}; + +$names = array( 'jan', 'lena', 'mia', 'noah', 'emma', 'paul', 'lea', 'ben', 'hannah', 'finn' ); +for ( $i = 0; $i < 60; $i++ ) { + wp_mail( + $names[ $i % 10 ] . $i . '@example.com', + sprintf( '[Example Shop] Your order #%d has been received', 125600 + $i ), + 0 === $i % 10 ? $attack : $clean( $names[ $i % 10 ], 125600 + $i ), + array( 'Content-Type: text/html; charset=UTF-8', 'From: Example Shop ' ) + ); +} + +wp_mail( + array( 'anna@example.com', 'Bob ' ), + 'Password reset', + "Reset your password: https://site.example/wp-login.php?action=rp&key=AbCdEf123&login=anna\n\nThanks" +); +wp_mail( 'subscribers@example.com', 'Your weekly newsletter', 'This week: new coaching slots.' ); +wp_mail( 'x@example.com', 'XSS subject', 'plain?' ); + +// An old WP Mail Logging table in its own format (literal ",\n" lists, site-local time) for the import UI. +global $wpdb; +$wpdb->query( "CREATE TABLE {$wpdb->prefix}wpml_mails (mail_id INTEGER PRIMARY KEY AUTO_INCREMENT, timestamp DATETIME NOT NULL, host VARCHAR(200) NOT NULL DEFAULT '0', receiver VARCHAR(200) NOT NULL DEFAULT '0', subject VARCHAR(200) NOT NULL DEFAULT '0', message TEXT NULL, headers TEXT NULL, attachments VARCHAR(800) NOT NULL DEFAULT '0', error VARCHAR(400) NULL DEFAULT '', plugin_version VARCHAR(200) NOT NULL DEFAULT '0')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared +foreach ( array( 'Old welcome mail', 'Old invoice', 'Old newsletter' ) as $i => $subject ) { + $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prefix . 'wpml_mails', + array( + 'timestamp' => gmdate( 'Y-m-d H:i:s', time() - ( 3 + $i ) * DAY_IN_SECONDS ), + 'receiver' => 'legacy' . $i . '@example.com', + 'subject' => $subject, + 'message' => '

From the old log

', + 'headers' => 'From: Shop ,\nContent-Type: text/html; charset=UTF-8', + 'attachments' => '', + 'error' => 2 === $i ? 'SMTP Error: Could not authenticate.' : '', + ) + ); +} + +// Module seed data for the browser tests: tests/e2e/seed.d/*.php. +foreach ( (array) glob( __DIR__ . '/seed.d/*.php' ) as $seed_file ) { + require $seed_file; +} + +// Tell tests/e2e/wait-for-wordpress.js that the blueprint has finished. +if ( is_dir( '/e2e-out' ) ) { + file_put_contents( '/e2e-out/seeded', gmdate( 'c' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents +} diff --git a/tests/e2e/selftest.blueprint.json b/tests/e2e/selftest.blueprint.json new file mode 100644 index 0000000..e72f568 --- /dev/null +++ b/tests/e2e/selftest.blueprint.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "steps": [ + { + "step": "activatePlugin", + "pluginPath": "mailspur-email-log/mailspur-email-log.php" + }, + { + "step": "installPlugin", + "pluginData": { + "resource": "wordpress.org/plugins", + "slug": "plugin-check" + } + }, + { + "step": "installPlugin", + "pluginData": { + "resource": "wordpress.org/plugins", + "slug": "wp-mail-logging" + } + }, + { + "step": "installPlugin", + "pluginData": { + "resource": "wordpress.org/plugins", + "slug": "email-log" + } + }, + { + "step": "runPHP", + "code": " (bool) $ok, + 'name' => $name, + 'detail' => $ok ? null : $detail, + ); +} + +/** @return array|null */ +function last_row() { + global $wpdb; + return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC LIMIT 1', Repository::table() ), ARRAY_A ); +} + +/** @return WP_REST_Response */ +function rest( $method, $route, $params = array() ) { + $request = new WP_REST_Request( $method, '/mailspur-email-log/v1' . $route ); + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + return rest_ensure_response( rest_do_request( $request ) ); +} + +try { + global $wpdb; + $table = Repository::table(); + + // ---------------------------------------------------------------- activation. + check( is_plugin_active( 'mailspur-email-log/mailspur-email-log.php' ), 'plugin is active' ); + check( null !== $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ), 'log table exists', $wpdb->last_error ); + check( \Mailspur\DB_VERSION === (int) get_option( 'mailspur_db_version' ), 'schema version stored' ); + check( (bool) wp_next_scheduled( 'mailspur_cleanup' ), 'daily cleanup scheduled' ); + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $table ) ); // Start from a known state. + + // ------------------------------------------------- delivery via pre_wp_mail. + $short = static function ( $result, $atts ) { + return false === strpos( $atts['subject'], 'FAIL' ); + }; + add_filter( 'pre_wp_mail', $short, 10, 2 ); + + $sent = wp_mail( + array( 'anna@example.com', 'Bob ' ), + 'Bestellung #1 abgeschlossen 👍', + '

Hallo

Order', + array( 'Content-Type: text/html; charset=UTF-8' ) + ); + $row = last_row(); + check( true === $sent, 'wp_mail() result unchanged' ); + check( '1' === $row['status'], 'pre_wp_mail success → sent', $row['status'] ); + check( 'anna@example.com, Bob ' === $row['recipients'], 'all recipients stored', $row['recipients'] ); + check( 'Bestellung #1 abgeschlossen 👍' === $row['subject'], 'utf8mb4 subject stored', $row['subject'] ); + check( 'text/html' === $row['content_type'], 'content type from headers', $row['content_type'] ); + check( false === strpos( $row['message'], 'SECRET' ) && false !== strpos( $row['message'], 'key=[redacted]' ), 'secret key redacted', $row['message'] ); + + wp_mail( 'fail@example.com', 'This should FAIL', 'x' ); + $row = last_row(); + check( '2' === $row['status'] && '' !== $row['error'], 'pre_wp_mail false → failed with error', $row ); + remove_filter( 'pre_wp_mail', $short, 10 ); + + // ------------------------------------- real PHPMailer path (no MTA → fails). + $template = static function ( $mailer ) { + $mailer->Body = '
' . $mailer->Body . '
'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + }; + add_action( 'phpmailer_init', $template ); + $nested = static function () { + static $once = false; + if ( ! $once ) { + $once = true; + wp_mail( 'admin@example.com', 'Nested failure notice', 'A mail failed.' ); + } + }; + add_action( 'wp_mail_failed', $nested, 5 ); + + wp_mail( 'carl@example.com', 'Plain text mail', 'Body text', array( 'From: Shop ' ) ); + remove_action( 'phpmailer_init', $template ); + remove_action( 'wp_mail_failed', $nested, 5 ); + + $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC LIMIT 2', $table ), ARRAY_A ); + $main = 'Plain text mail' === $rows[1]['subject'] ? $rows[1] : $rows[0]; + $inner = 'Nested failure notice' === $rows[0]['subject'] ? $rows[0] : $rows[1]; + check( '2' === $main['status'] && '' !== $main['error'], 'PHPMailer failure → failed with error', $main ); + check( '
Body text
' === $main['message'], 'body rewritten in phpmailer_init is stored', $main['message'] ); + check( 'Shop ' === $main['sender'], 'final sender stored', $main['sender'] ); + check( 'text/plain' === $main['content_type'], 'final content type stored', $main['content_type'] ); + check( 'Nested failure notice' === $inner['subject'] && '2' === $inner['status'], 'nested mail resolved separately', $inner ); + + add_filter( 'mailspur_should_log', '__return_false' ); + $before = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); + wp_mail( 'skip@example.com', 'Skipped', 'x' ); + remove_filter( 'mailspur_should_log', '__return_false' ); + check( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ) === $before, 'mailspur_should_log can skip mails' ); + + // ------------------------------------------------------------ REST: access. + wp_set_current_user( 0 ); + check( 401 === rest( 'GET', '/mails' )->get_status(), 'REST rejects anonymous users' ); + $subscriber = wp_insert_user( + array( + 'user_login' => 'e2e_subscriber', + 'user_pass' => wp_generate_password(), + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber ); + check( 403 === rest( 'GET', '/mails' )->get_status(), 'REST rejects subscribers' ); + check( 403 === rest( 'GET', '/mails/1' )->get_status(), 'REST detail rejects subscribers' ); + + // ------------------------------------------------------- REST: as admin. + $admins = get_users( + array( + 'role' => 'administrator', + 'fields' => 'ID', + 'number' => 1, + ) + ); + wp_set_current_user( (int) $admins[0] ); + + $list = rest( 'GET', '/mails' )->get_data(); + // anna (sent), fail, carl and the nested notice (failed); the skipped mail is not logged. + check( 4 === $list['total'], 'list total', $list['total'] ); + check( 1 === $list['counts']['sent'] && 3 === $list['counts']['failed'], 'status counts', $list['counts'] ); + check( ! isset( $list['items'][0]['message'] ), 'list never ships message bodies' ); + + $search = rest( 'GET', '/mails', array( 'search' => 'bob@example' ) )->get_data(); + check( 1 === $search['total'], 'search by recipient', $search['total'] ); + $body = rest( + 'GET', + '/mails', + array( + 'search' => 'template', + 'in_body' => true, + ) + )->get_data(); + // The nested notice is sent while the template filter is still active, so both bodies match. + check( 2 === $body['total'], 'content search', $body['total'] ); + $failed = rest( 'GET', '/mails', array( 'status' => 'failed' ) )->get_data(); + check( 3 === $failed['total'], 'status filter', $failed['total'] ); + check( 400 === rest( 'GET', '/mails', array( 'orderby' => 'id;DROP TABLE x' ) )->get_status(), 'invalid orderby rejected' ); + check( 400 === rest( 'GET', '/mails', array( 'after' => "2026-01-01' OR 1=1" ) )->get_status(), 'invalid date rejected' ); + $sorted = rest( + 'GET', + '/mails', + array( + 'orderby' => 'subject', + 'order' => 'asc', + ) + )->get_data(); + check( 'Bestellung #1 abgeschlossen 👍' === $sorted['items'][0]['subject'], 'sort by subject', wp_list_pluck( $sorted['items'], 'subject' ) ); + + $first = $list['items'][ count( $list['items'] ) - 1 ]; + $detail = rest( 'GET', '/mails/' . $first['id'] )->get_data(); + check( true === $detail['is_html'] && false !== strpos( $detail['message'], '

Hallo

' ), 'detail returns html body', $detail ); + check( 404 === rest( 'GET', '/mails/999999' )->get_status(), 'unknown id → 404' ); + + $resend = rest( 'POST', '/mails/' . $first['id'] . '/resend' ); + $row = last_row(); + check( 200 === $resend->get_status() && 'mailspur:resend' === $row['source'], 'resend creates a new entry', $row ); + + $delete = rest( 'DELETE', '/mails', array( 'ids' => array( (int) $row['id'] ) ) )->get_data(); + check( 1 === $delete['deleted'], 'bulk delete', $delete ); + + // ------------------------------------------------------------- retention. + update_option( 'mailspur_settings', array_merge( get_option( 'mailspur_settings', array() ), array( 'retention_days' => 30 ) ) ); + $old = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(id) FROM %i', $table ) ); + $wpdb->update( $table, array( 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 40 * DAY_IN_SECONDS ) ), array( 'id' => $old ) ); + do_action( 'mailspur_cleanup' ); + check( null === $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE id = %d', $table, $old ) ), 'entries older than retention are deleted' ); + + update_option( 'mailspur_settings', array_merge( get_option( 'mailspur_settings', array() ), array( 'max_entries' => 2 ) ) ); + do_action( 'mailspur_cleanup' ); + check( 2 === (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ), 'max entries enforced' ); + + // --------------------------------------------------------------- privacy. + wp_mail( 'privacy@example.com', 'Privacy test', 'x' ); + wp_mail( 'xprivacy@example.com', 'Similar address', 'x' ); + $exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() ); + $erasers = apply_filters( 'wp_privacy_personal_data_erasers', array() ); + check( isset( $exporters['mailspur-email-log'], $erasers['mailspur-email-log'] ), 'privacy exporter and eraser registered' ); + $export = call_user_func( $exporters['mailspur-email-log']['callback'], 'privacy@example.com', 1 ); + check( 1 === count( $export['data'] ) && $export['done'], 'export finds exact address only', $export ); + $erase = call_user_func( $erasers['mailspur-email-log']['callback'], 'privacy@example.com', 1 ); + check( $erase['items_removed'] && $erase['done'], 'eraser removes entries', $erase ); + check( 0 === count( call_user_func( $exporters['mailspur-email-log']['callback'], 'privacy@example.com', 1 )['data'] ), 'nothing left after erase' ); + check( 1 === (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE recipients = %s', $table, 'xprivacy@example.com' ) ), 'similar address kept' ); + + // ---------------------------------------------------------- purge (admin). + check( 200 === rest( 'DELETE', '/mails', array( 'all' => true ) )->get_status(), 'empty log' ); + check( 0 === (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ), 'log is empty after purge' ); + + // ------------------------------------------------- deactivate + uninstall. + deactivate_plugins( 'mailspur-email-log/mailspur-email-log.php' ); + check( ! wp_next_scheduled( 'mailspur_cleanup' ), 'deactivation removes cron event' ); + + define( 'WP_UNINSTALL_PLUGIN', 'mailspur-email-log/mailspur-email-log.php' ); + include WP_PLUGIN_DIR . '/mailspur-email-log/uninstall.php'; + $wpdb->suppress_errors( true ); + check( null === $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ), 'uninstall drops the table' ); + check( false === get_option( 'mailspur_settings' ) && false === get_option( 'mailspur_db_version' ), 'uninstall deletes options' ); +} catch ( Throwable $e ) { + check( false, 'uncaught ' . get_class( $e ), $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() ); +} + +$failed = array_values( + array_filter( + $results, + static function ( $r ) { + return ! $r['ok']; + } + ) +); +file_put_contents( + '/e2e-out/selftest.json', + wp_json_encode( + array( + 'php' => PHP_VERSION, + 'wp' => get_bloginfo( 'version' ), + 'passed' => count( $results ) - count( $failed ), + 'failed' => count( $failed ), + 'results' => $results, + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE + ) +); + +if ( $failed ) { + throw new RuntimeException( count( $failed ) . ' assertion(s) failed – see selftest.json' ); +} diff --git a/tests/e2e/trace.spec.js b/tests/e2e/trace.spec.js new file mode 100644 index 0000000..cfe192e --- /dev/null +++ b/tests/e2e/trace.spec.js @@ -0,0 +1,171 @@ +// Trace module in a real browser: the "Trace" dialog tab, the .eml download and the settings. +// Uses the core seed (tests/e2e/seed.php) read-only; SMTP transcripts and imported entries are simulated by +// rewriting the detail response, so no extra log entries change the counts other specs expect. +import fs from 'node:fs'; +import { expect, test } from '@playwright/test'; + +const LOG = '/wp-admin/admin.php?page=mailspur-email-log'; +const rows = (page) => page.locator('#mailspur-rows tr[data-id]'); +const DETAIL = /\/mailspur-email-log\/v1\/mails\/\d+(?:\?|$)/; + +function watchErrors(page) { + const errors = []; + page.on('console', (msg) => { + if (msg.type() === 'error' && !/Content Security Policy|about:srcdoc/.test(msg.text())) errors.push(msg.text()); + }); + page.on('pageerror', (err) => errors.push(err.message)); + return errors; +} + +async function openMail(page, search) { + await page.goto(`${LOG}&s=${encodeURIComponent(search)}`); + await expect(rows(page)).toHaveCount(1); + await rows(page).first().locator('.mailspur-open').click(); + await expect(page.locator('#mailspur-dialog')).toBeVisible(); +} + +/** Rewrites the detail payload of the next opened mail. */ +async function patchDetail(page, patch) { + await page.route(DETAIL, async (route) => { + const response = await route.fetch(); + const json = await response.json(); + await route.fulfill({ response, json: patch(json) }); + }); +} + +test.describe.configure({ mode: 'serial' }); + +test('trace tab shows timeline, API delivery, origin and request', async ({ page }) => { + const errors = watchErrors(page); + await openMail(page, 'Password reset'); + + await page.getByRole('tab', { name: 'Trace' }).click(); + const body = page.locator('#mailspur-d-body'); + await expect(body.locator('.mailspur-trace-bar .mailspur-trace-seg')).toHaveCount(1); + await expect(body).toContainText(/Total: [\d.,]+ ms/); + await expect(body.locator('.mailspur-trace-phases li')).toHaveCount(2); + + // The seed delivers through a pre_wp_mail closure in seed.php. + await expect(body.locator('.mailspur-trace-api')).toHaveText('Delivered by seed.php (API)'); + await expect(body).toContainText('…/seed.php'); + await expect(body).toContainText('Origin'); + await expect(body).toContainText('Request'); + await expect(body.locator('.mailspur-trace-raw')).toContainText('rebuilds the message'); + // Not an SMTP mail – no transcript section. + await expect(body).not.toContainText('SMTP transcript'); + + // Arrow keys move between the dialog tabs, including the module tab. + await page.getByRole('tab', { name: 'Trace' }).focus(); + await page.keyboard.press('ArrowLeft'); + await expect(page.getByRole('tab', { name: 'Headers' })).toHaveAttribute('aria-selected', 'true'); + expect(errors).toEqual([]); +}); + +test('failed PHPMailer mail shows transport and phases', async ({ page }) => { + await openMail(page, 'weekly newsletter'); + await page.getByRole('tab', { name: 'Trace' }).click(); + const body = page.locator('#mailspur-d-body'); + await expect(body.locator('.mailspur-trace-seg')).toHaveCount(2); + await expect(body.locator('.mailspur-trace-phases li')).toHaveCount(3); + await expect(body.locator('.mailspur-trace-table').first()).toContainText('PHP mail()'); + await expect(body).toContainText('Not recorded: this email was not sent via SMTP.'); +}); + +test('SMTP transcript is shown as text and can be copied', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const errors = watchErrors(page); + const transcript = 'SERVER -> CLIENT: 220 mail.example.com\nCLIENT -> SERVER: AUTH LOGIN\nSERVER -> CLIENT: 334 [hidden]\n'; + await patchDetail(page, (mail) => { + mail.meta.trace.transport = { mailer: 'smtp', host: 'smtp.example.com', port: 587, secure: 'tls', auth: true, auto_tls: true, user: 'j***@example.com' }; + mail.meta.trace.transcript = transcript; + mail.meta.trace.transcript_status = 'stored'; + return mail; + }); + await openMail(page, 'Password reset'); + await page.getByRole('tab', { name: 'Trace' }).click(); + + const body = page.locator('#mailspur-d-body'); + await expect(body.locator('.mailspur-trace-table').first()).toContainText('smtp.example.com'); + await expect(body.locator('.mailspur-trace-table').first()).toContainText('j***@example.com'); + await expect(body.locator('.mailspur-trace-table').first()).toContainText('TLS'); + const pre = body.locator('pre.mailspur-trace-transcript'); + await expect(pre).toHaveText(transcript); + await expect(body.locator('img')).toHaveCount(0); // Text only, never markup. + + await body.getByRole('button', { name: 'Copy' }).click(); + await expect(page.locator('#mailspur-toast')).toHaveText('Transcript copied.'); + // The Windows clipboard stores CRLF line breaks. + expect((await page.evaluate(() => navigator.clipboard.readText())).replace(/\r\n/g, '\n')).toBe(transcript); + expect(errors).toEqual([]); +}); + +test('entries without trace show a friendly note', async ({ page }) => { + await patchDetail(page, (mail) => { + delete mail.meta.trace; + mail.source = 'import:wp-mail-logging'; + return mail; + }); + await openMail(page, 'Password reset'); + await page.getByRole('tab', { name: 'Trace' }).click(); + await expect(page.locator('#mailspur-d-body .mailspur-trace-empty')).toHaveText('Imported entries have no trace: the other plugin did not record these details.'); + + await page.unroute(DETAIL); + await patchDetail(page, (mail) => { + mail.meta = []; + return mail; + }); + await page.keyboard.press('Escape'); + await openMail(page, 'weekly newsletter'); + await page.getByRole('tab', { name: 'Trace' }).click(); + await expect(page.locator('#mailspur-d-body .mailspur-trace-empty')).toContainText('No trace is available for this entry.'); +}); + +test('download .eml rebuilds the message', async ({ page }) => { + await openMail(page, 'Password reset'); + const id = await page.locator('#mailspur-rows tr.is-current').getAttribute('data-id'); + + const [download] = await Promise.all([page.waitForEvent('download'), page.locator('#mailspur-dialog [data-module-action="trace-eml"]').click()]); + expect(download.suggestedFilename()).toBe(`mailspur-${id}.eml`); + const eml = fs.readFileSync(await download.path(), 'utf8'); + expect(eml).toContain('Subject: Password reset'); + expect(eml).toContain('X-Mailspur-Reconstructed: yes'); + expect(eml).toContain('key=[redacted]'); + expect(eml).not.toContain('AbCdEf123'); + await expect(page.locator('#mailspur-toast')).toHaveText('Download started.'); + + // Without the REST nonce (e.g. a link from elsewhere) the download is refused. + const res = await page.request.get(`/wp-json/mailspur-email-log/v1/mails/${id}/eml`); + expect(res.status()).toBe(401); +}); + +test('trace settings: transcript mode and raw source only with acknowledgement', async ({ page }) => { + await page.goto(`${LOG}&tab=settings`); + const mode = (value) => page.locator(`input[name="mailspur_settings[trace_transcript]"][value="${value}"]`); + const raw = page.locator('#mailspur-trace-raw'); + const ack = page.locator('#mailspur-trace-raw-ack'); + const save = () => page.getByRole('button', { name: 'Save Changes' }).click(); + + await expect(page.getByRole('heading', { name: 'Trace' })).toBeVisible(); + await expect(mode('failed')).toBeChecked(); + await expect(raw).not.toBeChecked(); + + await mode('always').check(); + await raw.check(); + await save(); + await expect(mode('always')).toBeChecked(); + await expect(raw).not.toBeChecked(); // Not acknowledged. + + await raw.check(); + await ack.check(); + await save(); + await expect(raw).toBeChecked(); + await expect(ack).toBeChecked(); + + // Back to the defaults for the other specs. + await mode('failed').check(); + await raw.uncheck(); + await ack.uncheck(); + await save(); + await expect(mode('failed')).toBeChecked(); + await expect(raw).not.toBeChecked(); +}); diff --git a/tests/e2e/ui.blueprint.json b/tests/e2e/ui.blueprint.json new file mode 100644 index 0000000..387639e --- /dev/null +++ b/tests/e2e/ui.blueprint.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "landingPage": "/wp-admin/admin.php?page=mailspur-email-log", + "login": true, + "steps": [ + { + "step": "setSiteOptions", + "options": { + "blogname": "Example Shop", + "permalink_structure": "/%postname%/" + } + }, + { + "step": "activatePlugin", + "pluginPath": "mailspur-email-log/mailspur-email-log.php" + }, + { + "step": "runPHP", + "code": " page.locator('#mailspur-rows tr[data-id]'); + +// Hosts referenced by the seeded mails (tracking pixel, links, form target). WordPress core itself +// may load e.g. Gravatar in the admin bar – that is not the plugin's business. +const MAIL_HOSTS = /(^|\.)(example\.com|example\.org|shop\.example|evil\.example|site\.example)$/; + +// Console messages the browser emits when the preview protections kick in – expected, not errors. +const BLOCKED = /violates the following Content Security Policy directive|Blocked script execution in 'about:srcdoc'/; + +/** + * Collects console errors, protection reports (CSP/sandbox blocks) and every request to a host that only + * appears inside logged mails. + */ +function watch(page) { + const errors = []; + const blocked = []; + const external = []; + page.on('console', (msg) => { + if (msg.type() === 'error') (BLOCKED.test(msg.text()) ? blocked : errors).push(msg.text()); + }); + page.on('pageerror', (err) => errors.push(err.message)); + page.on('request', (req) => { + const url = new URL(req.url()); + if (MAIL_HOSTS.test(url.hostname)) external.push(req.url()); + }); + page.on('dialog', (dialog) => dialog.accept()); + return { errors, blocked, external }; +} + +test.describe.configure({ mode: 'serial' }); + +test('log lists the seeded mails with counts', async ({ page }) => { + const seen = watch(page); + await page.goto(LOG); + + await expect(rows(page)).toHaveCount(25); + await expect(page.locator('[data-count="all"]')).toHaveText('63'); + await expect(page.locator('[data-count="failed"]')).toHaveText('1'); + await expect(page.locator('#mailspur-summary')).toContainText('63'); + + // The XSS subject is shown as text, never parsed. + await expect(rows(page).first().locator('.mailspur-open')).toHaveText('XSS subject'); + await expect(page.locator('#mailspur-rows img')).toHaveCount(0); + + expect(seen.errors).toEqual([]); + expect(seen.external).toEqual([]); + + // The matrix really runs the requested WordPress version (the CLI banner always shows its defaults). + if (process.env.E2E_WP && process.env.E2E_WP !== 'latest') { + // Core assets carry the running version (?ver=6.5.12); the footer would only offer the update. + expect(await page.content()).toMatch(new RegExp(`ver=${process.env.E2E_WP.replace('.', '\\.')}(\\.\\d+)?\\b`)); + } +}); + +test('search, status, sorting and pagination', async ({ page }) => { + await page.goto(LOG); + await expect(rows(page)).toHaveCount(25); + + await page.keyboard.press('/'); + await expect(page.locator('#mailspur-search')).toBeFocused(); + await page.keyboard.type('bob@example'); + await expect(rows(page)).toHaveCount(1); + await expect(page).toHaveURL(/[?&]s=bob%40example/); + await expect(rows(page).first()).toContainText('Password reset'); + + await page.locator('#mailspur-reset').click(); + await expect(rows(page)).toHaveCount(25); + + await page.locator('[data-status="failed"]').click(); + await expect(rows(page)).toHaveCount(1); + await expect(rows(page).first().locator('.mailspur-error')).toContainText('Could not instantiate mail function'); + await page.locator('[data-status="all"]').click(); + + await page.locator('[data-sort="subject"]').click(); + await expect(page.locator('th.col-subject')).toHaveAttribute('aria-sort', 'descending'); + await page.locator('[data-sort="subject"]').click(); + await expect(page.locator('th.col-subject')).toHaveAttribute('aria-sort', 'ascending'); + await expect(rows(page).first()).toContainText(' { + const seen = watch(page); + await page.goto(`${LOG}&s=jan0%40`); + await expect(rows(page)).toHaveCount(1); + await rows(page).first().locator('.mailspur-open').click(); + + const dialog = page.locator('#mailspur-dialog'); + await expect(dialog).toBeVisible(); + await expect(page.locator('#mailspur-d-subject')).toHaveText('[Example Shop] Your order #125600 has been received'); + await expect(page.locator('#mailspur-d-meta')).toContainText('Example Shop '); + + const frame = page.locator('iframe.mailspur-frame'); + await expect(frame).toHaveAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox'); + const srcdoc = await frame.getAttribute('srcdoc'); + expect(srcdoc).toContain("default-src 'none'"); + expect(srcdoc).toContain('key=[redacted]'); + expect(srcdoc).not.toContain('SECRET123'); + // Remote references are defused before rendering, so the pixel is never even requested (CSP is the 2nd layer). + expect(srcdoc).toContain('data-blocked-src="https://example.com/pixel.gif"'); + + const inner = page.frameLocator('iframe.mailspur-frame'); + await expect(inner.locator('h1')).toHaveText('Thanks for your order!'); + + // The injected script must not have touched the admin page. + expect(await page.evaluate(() => window.pwned)).toBeUndefined(); + await expect(page.locator('#wpadminbar')).toBeVisible(); + // Opaque origin: the admin page cannot reach into the frame either (and vice versa). + expect(await frame.evaluate((f) => f.contentDocument)).toBeNull(); + + await expect(page.locator('#mailspur-d-remote')).toBeVisible(); + expect(seen.external).toEqual([]); + + // Opting in relaxes the CSP for this one mail. + await page.locator('#mailspur-d-remote-toggle').click(); + expect(await page.locator('iframe.mailspur-frame').getAttribute('srcdoc')).toMatch(/img-src data: cid: https: http:/); + + await page.getByRole('tab', { name: 'Headers' }).click(); + await page.keyboard.press('Escape'); + + // Ordinary links are not remote content: a clean mail must not show the notice. + await page.goto(`${LOG}&s=lena1%40`); + await rows(page).first().locator('.mailspur-open').click(); + await expect(page.frameLocator('iframe.mailspur-frame').locator('h1')).toHaveText('Thanks for your order'); + await expect(page.locator('#mailspur-d-remote')).toBeHidden(); + await page.getByRole('tab', { name: 'Headers' }).click(); + await expect(page.locator('#mailspur-d-body pre')).toContainText('Content-Type: text/html; charset=UTF-8'); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + expect(seen.errors).toEqual([]); + // The browser confirms the sandbox blocked the injected Bestellung ansehen
'; for ( $i = 0; $i < 60; $i++ ) { wp_mail( 'kunde' . $i . '@example.com', '[playershub.de] Bestellung (#1256' . $i . ') abgeschlossen!', $html, array( 'Content-Type: text/html; charset=UTF-8' ) ); } wp_mail( array( 'anna@example.com', 'Bob ' ), 'Passwort zurücksetzen', \"Klicke hier: https://site.test/wp-login.php?action=rp&key=AbCdEf123&login=anna\\n\\nGruß\" ); wp_mail( 'fail@example.com', 'Das hier FAIL', 'Text' ); wp_mail( 'x@example.com', 'XSS-Betreff', 'plain?' ); echo 'ok';" - } - ] -}