commit d7e1995da3fd834ded2f13804000c52c2857bd58 Author: Adrian Stephens Date: Tue Apr 21 10:49:33 2026 +0100 Initial commit diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64c7ae1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Secrets and local credentials +credentials/.env +credentials/*.env +credentials/*.key +credentials/*_rsa +credentials/*_rsa.pub +credentials/id_* + +# Dependency/vendor artifacts +vendor/ + +# Composer local cache/home in workspace +tools/.composer/ + +# Local runtime artifacts +working/ +*.pid +*.log + +# OS/editor noise +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Local test/coverage output +coverage/ +.phpunit.result.cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9c109e --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# FECA Mailshots Plugin + +WordPress plugin project for defining, previewing, testing, and running mailshots using: + +- data-source definitions stored in a mailshots database, +- recipient/source reads from a separate members database, +- per-user SMTP/IMAP credentials for send and sent-copy behavior. + +## Repository Overview + +- `feca_mailshots_plugin/`: plugin source code. +- `requirements/`: project requirements/specifications. +- `tests/`: integration/unit/fixture tests. +- `scripts/`: local helper scripts (deploy, tunnel, fixture server). +- `credentials/`: local development credentials (ignored in Git). +- `results/`: implementation notes and investigation outputs. + +## Key Features + +- WordPress admin navigation under `FECA Mailshots`. +- CRUD admin pages for: + - Mailshots + - Data Sources + - Attachments + - PDF Assets +- Profile page for per-user SMTP/IMAP credentials with explicit connection tests. +- Setup page for environment DB connection settings (admin-only). +- Mailshot Test and Run pages for render/send execution. +- DSL-based data source model with validation and preview. +- REST and admin-post APIs for data source operations. + +## Runtime Data Routing + +As defined in `requirements/environment.md`: + +- **Write-path** tables (mailshot definitions/assets/run data): `MAILSHOTS_REMOTE_MYSQL_DB` +- **Read-path** recipient/source data: `MEMBERS_REMOTE_MYSQL_DB` + +## Local Setup + +1. Copy env template: + - `cp credentials/.env.example credentials/.env` +2. Populate real values in `credentials/.env`. +3. Start MySQL tunnel (if required): + - `scripts/mysql_tunnel.sh start` +4. (Optional) run fixture server: + - `scripts/run_fixture_server.sh start` + +## Testing + +Examples: + +- `php tests/integration/test_phase2_db_access.php` +- `php tests/integration/test_phase3_admin_crud_ui.php` +- `php tests/integration/test_phase3_db_access.php` +- `php tests/integration/test_phase4_run_flow.php` +- `php tests/integration/test_per_user_credentials.php` + +## Deployment + +- Deploy script: `scripts/deploy_remote.sh` +- Supports dry run: + - `scripts/deploy_remote.sh --dry-run` +- Deployment script bumps plugin patch version automatically before real deploy. + +## Security Notes + +- Do not commit secrets. +- `credentials/.env` and key material are ignored by `.gitignore`. +- Per-user mail credentials are stored encrypted in the mailshots DB. + +## Important References + +- Environment baseline: `requirements/environment.md` +- Mailshot requirements: `requirements/mailshot.md` +- Data source requirements: `requirements/mailshot_data_source.md` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8db726e --- /dev/null +++ b/composer.json @@ -0,0 +1,9 @@ +{ + "name": "feca/mailshot-plugin", + "description": "Mailshot WordPress plugin workspace dependencies", + "type": "project", + "require": { + "dompdf/dompdf": "^3.1", + "twig/twig": "^3.24" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..89cc39d --- /dev/null +++ b/composer.lock @@ -0,0 +1,779 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "1619fb4ae2e56e1d49066ec7e4112ccd", + "packages": [ + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, + { + "name": "twig/twig", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-03-17T21:31:11+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/credentials/.env.example b/credentials/.env.example new file mode 100644 index 0000000..3703532 --- /dev/null +++ b/credentials/.env.example @@ -0,0 +1,42 @@ +# Remote SSH (used by deploy and tunnel scripts) +REMOTE_SSH_HOST=example.com +REMOTE_SSH_PORT=22 +REMOTE_SSH_USER=example_user +REMOTE_SSH_KEY=credentials/id_rsa + +# Remote MySQL target behind SSH tunnel +REMOTE_MYSQL_HOST=localhost +REMOTE_MYSQL_PORT=3306 +REMOTE_MYSQL_USER=mailshots_user +REMOTE_MYSQL_PASSWORD=change_me + +# Database names +MEMBERS_REMOTE_MYSQL_DB=members_db +MAILSHOTS_REMOTE_MYSQL_DB=mailshots_db + +# SMTP (fixture/local test usage) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=editor@example.com +SMTP_PASSWORD=change_me +SMTP_FROM=editor@example.com +# 1 = implicit TLS (typically port 465), 0 = STARTTLS/plain negotiation (typically port 587) +SMTP_REQUIRE_TLS=0 + +# IMAP (fixture/local test usage) +IMAP_HOST=imap.example.com +IMAP_PORT=993 +IMAP_USER=editor@example.com +IMAP_PASSWORD=change_me +IMAP_SENT_FOLDER=Sent +IMAP_MAILBOX_FLAGS=/imap/ssl + +# Mailshot test default recipient (optional) +MAILSHOT_TEST_TO_DEFAULT= + +# Plugin deploy metadata +PLUGIN_NAME="FECA Mailshots" +DEPLOYMENT_DIRECTORY=public_html/wp-content/plugins/feca_mailshots_plugin + +# Local tooling +COMPOSER_HOME=/path/to/project/tools/.composer diff --git a/docs/mailshot_ddl.md b/docs/mailshot_ddl.md new file mode 100644 index 0000000..82c91c3 --- /dev/null +++ b/docs/mailshot_ddl.md @@ -0,0 +1,113 @@ +# Mailshot DDL Reference + +Source: server from `credentials/app2.env` (`MYSQL_HOST=app2.lan`, `MYSQL_PORT=3308`) +Database: `members` +Discovery method: `SHOW CREATE TABLE` for `members.mailshot*` + +## members.mailshots + +```sql +CREATE TABLE `mailshots` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `Purpose` varchar(100) DEFAULT NULL, + `DataSource` varchar(100) NOT NULL, + `CC` varchar(100) DEFAULT NULL, + `BCC` varchar(100) DEFAULT NULL, + `Subject` longtext NOT NULL, + `Message` longtext NOT NULL, + `PDFAttachment` longtext DEFAULT NULL, + `PDFFilenameDerivedFrom` varchar(255) DEFAULT NULL, + `ThemeColourHex` varchar(7) DEFAULT NULL, + `AttachmentNames` longtext DEFAULT NULL, + `WantSubjectProcessed` tinyint(1) DEFAULT NULL, + `WantMessageProcessed` tinyint(1) DEFAULT NULL, + `ReplyTo` varchar(100) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=122 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +``` + +## members.mailshot_attachments + +```sql +CREATE TABLE `mailshot_attachments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `file_name` varchar(255) NOT NULL, + `mime_type` varchar(255) NOT NULL, + `file_bytes` longblob NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `mailshot_attachments_name_uk` (`name`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +``` + +## members.mailshot_last_run + +```sql +CREATE TABLE `mailshot_last_run` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `mailshot_id` int(11) NOT NULL, + `data_source` varchar(100) DEFAULT NULL, + `row_index` int(11) NOT NULL DEFAULT 0, + `recipient_key` varchar(255) DEFAULT NULL, + `recipient_key_field` varchar(64) DEFAULT NULL, + `recipient_email_last` varchar(255) DEFAULT NULL, + `status` varchar(32) NOT NULL, + `error_message` longtext DEFAULT NULL, + `warning_message` longtext DEFAULT NULL, + `attempt_count` int(11) NOT NULL DEFAULT 1, + `run_started_at` timestamp NOT NULL DEFAULT current_timestamp(), + `last_attempt_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `mailshot_last_run_mailshot_idx` (`mailshot_id`), + KEY `mailshot_last_run_status_idx` (`status`), + KEY `mailshot_last_run_recipient_idx` (`recipient_key`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +``` + +## members.mailshot_pdf_assets + +```sql +CREATE TABLE `mailshot_pdf_assets` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `file_name` varchar(255) NOT NULL, + `mime_type` varchar(100) NOT NULL, + `file_bytes` longblob NOT NULL, + `width_mm` decimal(8,2) NOT NULL, + `height_mm` decimal(8,2) NOT NULL, + `justification` enum('left','right','in-place') NOT NULL DEFAULT 'in-place', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `mailshot_pdf_assets_name_uk` (`name`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +``` + +## members.mailshot_queries + +```sql +CREATE TABLE `mailshot_queries` ( + `ID` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `dsl_text` longtext DEFAULT NULL, + `dsl_version` varchar(16) NOT NULL DEFAULT 'v1', + `sql` longtext NOT NULL, + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`ID`), + UNIQUE KEY `mailshot_queries_unique` (`name`), + UNIQUE KEY `mailshot_queries_name_uk` (`name`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +``` + +## members.mailshot_test + +```sql +CREATE TABLE `mailshot_test` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `Name` varchar(100) NOT NULL, + `Email` varchar(100) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +``` diff --git a/feca_mailshots_plugin/feca_mailshots_plugin.php b/feca_mailshots_plugin/feca_mailshots_plugin.php new file mode 100644 index 0000000..e8cf9bb --- /dev/null +++ b/feca_mailshots_plugin/feca_mailshots_plugin.php @@ -0,0 +1,18 @@ +serviceFactory = $serviceFactory; + $this->wp = $wp; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_post_feca_mailshots_attachments_api', [$this, 'handleApi']); + $this->wp->addAction('admin_post_feca_mailshots_attachments_ui_save', [$this, 'handleUiSave']); + $this->wp->addAction('admin_post_feca_mailshots_attachments_ui_delete', [$this, 'handleUiDelete']); + } + + public function registerMenu(): void + { + $this->wp->addSubmenuPage('feca-mailshot', 'Attachments', 'Attachments', self::CAPABILITY, 'feca-mailshots-attachments', [$this, 'render']); + } + + public function render(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + echo 'Permission denied'; + return; + } + $items = $this->service()->list(); + $editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); + $editItem = null; + foreach ($items as $row) { + if ((int) ($row['id'] ?? 0) === $editId) { + $editItem = $row; + break; + } + } + $result = $this->result(); + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + $name = (string) ($editItem['name'] ?? ''); + $fileName = (string) ($editItem['file_name'] ?? ''); + $mime = (string) ($editItem['mime_type'] ?? 'application/octet-stream'); + + echo '

Attachments

'; + if ($result !== null) { + $ok = !empty($result['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + $title = $ok ? 'Attachment saved.' : 'Attachment action failed.'; + echo '
'; + echo '' . htmlspecialchars($title) . ''; + if (!empty($result['errors']) && is_array($result['errors'])) { + echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; + } + echo '
'; + } + + echo '

'; + echo ''; + + echo '

Existing Attachments

'; + echo ''; + foreach ($items as $row) { + $id = (int) ($row['id'] ?? 0); + $editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments&edit_id=' . $id); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + if ($items === []) { + echo ''; + } + echo '
IDNameFileMIMEBytesActions
' . $id . '' . htmlspecialchars((string) ($row['name'] ?? '')) . '' . htmlspecialchars((string) ($row['file_name'] ?? '')) . '' . htmlspecialchars((string) ($row['mime_type'] ?? '')) . '' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . 'Edit '; + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
No attachments found.
'; + echo ''; + echo '
'; + } + + public function handleUiSave(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); + $id = $idRaw === '' ? null : (int) $idRaw; + $base64 = (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''); + if ($base64 === '' && isset($_FILES['file_upload']) && is_array($_FILES['file_upload']) && (int) ($_FILES['file_upload']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) { + $tmp = (string) ($_FILES['file_upload']['tmp_name'] ?? ''); + if ($tmp !== '' && is_file($tmp)) { + $bytes = file_get_contents($tmp); + if ($bytes !== false) { + $base64 = base64_encode($bytes); + } + } + } + $payload = [ + 'name' => (string) ($this->wp->requestParam('name', '') ?? ''), + 'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''), + 'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'), + 'file_bytes_base64' => $base64, + ]; + $result = $this->service()->save($id, $payload); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $editId = $id; + if (!empty($result['ok']) && isset($result['id'])) { + $editId = (int) $result['id']; + } + $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : '')); + if (!headers_sent()) { + header('Location: ' . $url, true, 302); + exit; + } + } + + public function handleUiDelete(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $result = ['ok' => true]; + try { + if ($id > 0) { + $this->service()->delete($id); + } + } catch (\Throwable $e) { + $result = ['ok' => false, 'errors' => [$e->getMessage()]]; + } + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + if (!headers_sent()) { + header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments'), true, 302); + exit; + } + } + + public function handleApi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $op = (string) ($this->wp->requestParam('op', 'list') ?? 'list'); + + try { + if ($op === 'list') { + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]); + return; + } + + if ($op === 'save') { + $idRaw = $this->wp->requestParam('id', ''); + $id = $idRaw === '' ? null : (int) $idRaw; + $payload = [ + 'name' => (string) ($this->wp->requestParam('name', '') ?? ''), + 'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''), + 'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'), + 'file_bytes_base64' => (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''), + ]; + $this->wp->sendJson($this->service()->save($id, $payload)); + return; + } + + if ($op === 'delete') { + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $this->service()->delete($id); + $this->wp->sendJson(['ok' => true]); + return; + } + + $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); + } catch (\Throwable $e) { + $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); + } + } + + private function service(): AttachmentService + { + return ($this->serviceFactory)(); + } + + /** @return array|null */ + private function result(): ?array + { + $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); + return is_array($raw) ? $raw : null; + } +} diff --git a/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php new file mode 100644 index 0000000..f0dc43a --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php @@ -0,0 +1,697 @@ +serviceFactory = $serviceFactory; + $this->wp = $wp; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_menu', [$this, 'hideDuplicateRootSubmenu'], 999); + $this->wp->addAction('admin_post_feca_mailshots_data_sources_api', [$this, 'handleApi']); + $this->wp->addAction('rest_api_init', [$this, 'registerRestRoutes']); + $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_save', [$this, 'handleUiSave']); + $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_delete', [$this, 'handleUiDelete']); + $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_validate', [$this, 'handleUiValidate']); + $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_preview', [$this, 'handleUiPreview']); + } + + public function registerMenu(): void + { + $this->wp->addMenuPage('FECA Mailshots', 'FECA Mailshots', self::CAPABILITY, 'feca-mailshot', [$this, 'renderRoot']); + $this->wp->addSubmenuPage('feca-mailshot', 'Mailshot Data Sources', 'Data Sources', self::CAPABILITY, 'feca-mailshot-data-sources', [$this, 'render']); + } + + public function hideDuplicateRootSubmenu(): void + { + if (function_exists('remove_submenu_page')) { + remove_submenu_page('feca-mailshot', 'feca-mailshot'); + } + } + + public function renderRoot(): void + { + $target = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots'); + if (!headers_sent()) { + header('Location: ' . $target, true, 302); + exit; + } + echo '

FECA Mailshots

Open Mailshots

'; + } + + public function render(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + echo 'Permission denied'; + return; + } + + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + $filter = trim((string) ($this->wp->requestParam('q', '') ?? '')); + $sort = (string) ($this->wp->requestParam('sort', 'name') ?? 'name'); + if (!in_array($sort, ['name', 'updated_desc', 'updated_asc'], true)) { + $sort = 'name'; + } + + $items = $this->filteredAndSortedItems($this->service()->list(), $filter, $sort); + $editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); + $editItem = null; + foreach ($items as $row) { + if ((int) ($row['ID'] ?? 0) === $editId) { + $editItem = $row; + break; + } + } + $name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : ''; + $dsl = is_array($editItem) ? (string) ($editItem['dsl_text'] ?? '') : ''; + $result = $this->result(); + $sourceFieldsMap = $this->service()->sourceFields(); + $schemaList = $this->service()->listSchemas(); + + echo '
'; + echo '

Mailshot Data Sources

'; + + if ($result !== null) { + $ok = !empty($result['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + $title = $ok ? 'Action succeeded.' : 'Action failed.'; + echo '
'; + echo '' . htmlspecialchars($title) . ''; + if (!empty($result['errors']) && is_array($result['errors'])) { + echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; + } + if (!empty($result['warnings']) && is_array($result['warnings'])) { + echo '

Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '

'; + } + if (isset($result['count'])) { + echo '

Preview count: ' . (int) $result['count'] . '

'; + } + echo '
'; + } + + echo '
'; + echo '
Total data sources: ' . count($items) . '
'; + echo '
Selected: ' . ($editId > 0 ? '#' . $editId . ' ' . htmlspecialchars($name) : 'none') . '
'; + echo '
'; + + echo '
'; + echo ''; + echo ' '; + echo ' '; + echo ''; + echo '
'; + + echo '

'; + echo ''; + + echo ''; + + if (is_array($result) && isset($result['rows']) && is_array($result['rows'])) { + $rows = $result['rows']; + echo '
'; + echo '

Preview (first ' . count($rows) . ' rows)

'; + if ($rows === []) { + echo '

No rows returned.

'; + } else { + $columns = []; + foreach ($rows as $r) { + if (!is_array($r)) { + continue; + } + foreach (array_keys($r) as $k) { + $columns[(string) $k] = true; + } + } + $columns = array_keys($columns); + echo ''; + foreach ($columns as $col) { + echo ''; + } + echo ''; + foreach ($rows as $r) { + if (!is_array($r)) { + continue; + } + echo ''; + foreach ($columns as $col) { + echo ''; + } + echo ''; + } + echo '
' . htmlspecialchars($col) . '
' . htmlspecialchars((string) ($r[$col] ?? '')) . '
'; + } + echo '
'; + } + + echo '

Existing Data Sources

'; + echo ''; + foreach ($items as $row) { + $id = (int) ($row['ID'] ?? 0); + $editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources&edit_id=' . $id . '&q=' . rawurlencode($filter) . '&sort=' . rawurlencode($sort)); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + if ($items === []) { + echo ''; + } + echo '
IDNameDSL SentenceUpdated AtActions
' . $id . '' . htmlspecialchars((string) ($row['name'] ?? '')) . '' . htmlspecialchars((string) ($row['dsl_text'] ?? '')) . '' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . 'Edit '; + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
No data sources found.
'; + echo ''; + echo '
'; + } + + public function handleUiSave(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); + $id = $idRaw === '' ? null : (int) $idRaw; + $result = $this->service()->save( + $id, + (string) ($this->wp->requestParam('name', '') ?? ''), + (string) ($this->wp->requestParam('dsl_text', '') ?? '') + ); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $this->redirectToEditId($result, $id); + } + + public function handleUiValidate(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); + $validation = $this->service()->validateDsl($dsl); + $result = [ + 'ok' => ($validation['errors'] ?? []) === [], + 'errors' => $validation['errors'] ?? [], + 'warnings' => $validation['warnings'] ?? [], + ]; + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); + $id = $idRaw === '' ? null : (int) $idRaw; + $this->redirectToEditId($result, $id); + } + + public function handleUiPreview(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); + $preview = $this->service()->preview($dsl, 50); + $result = [ + 'ok' => ($preview['errors'] ?? []) === [], + 'errors' => $preview['errors'] ?? [], + 'warnings' => $preview['warnings'] ?? [], + 'count' => (int) ($preview['count'] ?? 0), + 'rows' => is_array($preview['rows'] ?? null) ? $preview['rows'] : [], + ]; + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); + $id = $idRaw === '' ? null : (int) $idRaw; + $this->redirectToEditId($result, $id); + } + + public function handleUiDelete(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $result = ['ok' => true]; + try { + if ($id > 0) { + $this->service()->delete($id); + } + } catch (\Throwable $e) { + $result = ['ok' => false, 'errors' => [$e->getMessage()]]; + } + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + if (!headers_sent()) { + header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources'), true, 302); + exit; + } + } + + public function handleApi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $op = (string) ($this->wp->requestParam('op', 'list') ?? 'list'); + try { + if ($op === 'list') { + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]); + return; + } + if ($op === 'get') { + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $item = $id > 0 ? $this->service()->get($id) : null; + if ($item === null) { + $this->wp->sendJson(['ok' => false, 'error' => 'Data source not found'], 404); + return; + } + $this->wp->sendJson(['ok' => true, 'item' => $item]); + return; + } + if ($op === 'fields') { + $this->wp->sendJson(['ok' => true, 'fields' => $this->service()->sourceFields()]); + return; + } + if ($op === 'schemas') { + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->listSchemas()]); + return; + } + if ($op === 'tables') { + $schema = (string) ($this->wp->requestParam('schema', '') ?? ''); + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->listTables($schema)]); + return; + } + if ($op === 'source_fields') { + $source = (string) ($this->wp->requestParam('source', '') ?? ''); + $this->wp->sendJson(['ok' => true, 'source' => $source, 'fields' => $this->service()->sourceFieldsForSource($source)]); + return; + } + if ($op === 'validate') { + $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); + $this->wp->sendJson(['ok' => true] + $this->service()->validateDsl($dsl)); + return; + } + if ($op === 'preview') { + $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); + $limit = (int) ($this->wp->requestParam('limit', '50') ?? '50'); + $this->wp->sendJson(['ok' => true] + $this->service()->preview($dsl, $limit)); + return; + } + if ($op === 'save') { + $idRaw = $this->wp->requestParam('id', ''); + $id = $idRaw === '' ? null : (int) $idRaw; + $name = (string) ($this->wp->requestParam('name', '') ?? ''); + $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); + $this->wp->sendJson($this->service()->save($id, $name, $dsl)); + return; + } + if ($op === 'delete') { + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $this->service()->delete($id); + $this->wp->sendJson(['ok' => true]); + return; + } + $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); + } catch (\Throwable $e) { + $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); + } + } + + public function registerRestRoutes(): void + { + if (!function_exists('register_rest_route')) { + return; + } + + register_rest_route('mailshots/v1', '/data-sources', [ + 'methods' => 'GET', + 'callback' => [$this, 'restList'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + register_rest_route('mailshots/v1', '/data-sources', [ + 'methods' => 'POST', + 'callback' => [$this, 'restCreate'], + 'permission_callback' => [$this, 'restCanManage'], + ]); + register_rest_route('mailshots/v1', '/data-sources/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'restGetOne'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + register_rest_route('mailshots/v1', '/data-sources/(?P\d+)', [ + 'methods' => 'PUT', + 'callback' => [$this, 'restUpdate'], + 'permission_callback' => [$this, 'restCanManage'], + ]); + register_rest_route('mailshots/v1', '/data-sources/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'restDelete'], + 'permission_callback' => [$this, 'restCanManage'], + ]); + register_rest_route('mailshots/v1', '/data-sources/validate', [ + 'methods' => 'POST', + 'callback' => [$this, 'restValidate'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + register_rest_route('mailshots/v1', '/data-sources/preview', [ + 'methods' => 'POST', + 'callback' => [$this, 'restPreview'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + register_rest_route('mailshots/v1', '/data-sources/(?P\d+)/preview', [ + 'methods' => 'POST', + 'callback' => [$this, 'restPreviewById'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + register_rest_route('mailshots/v1', '/data-sources/schemas', [ + 'methods' => 'GET', + 'callback' => [$this, 'restSchemas'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + register_rest_route('mailshots/v1', '/data-sources/tables', [ + 'methods' => 'GET', + 'callback' => [$this, 'restTables'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + register_rest_route('mailshots/v1', '/data-sources/source-fields', [ + 'methods' => 'GET', + 'callback' => [$this, 'restSourceFields'], + 'permission_callback' => [$this, 'restCanRead'], + ]); + } + + private function service(): \FecaMailshots\Application\DataSourceService + { + return ($this->serviceFactory)(); + } + + public function restCanRead(): bool + { + return $this->wp->currentUserCan(self::CAPABILITY); + } + + public function restCanManage(): bool + { + return $this->wp->currentUserCan(self::CAPABILITY); + } + + /** @return array */ + public function restList() + { + return ['ok' => true, 'items' => $this->service()->list()]; + } + + /** @return array */ + public function restGetOne($request) + { + $id = (int) $this->reqParam($request, 'id', 0); + $item = $id > 0 ? $this->service()->get($id) : null; + if ($item === null) { + return ['ok' => false, 'error' => 'Data source not found']; + } + return ['ok' => true, 'item' => $item]; + } + + /** @return array */ + public function restCreate($request) + { + $name = (string) $this->reqParam($request, 'name', ''); + $dsl = (string) $this->reqParam($request, 'dsl_text', ''); + return $this->service()->save(null, $name, $dsl); + } + + /** @return array */ + public function restUpdate($request) + { + $id = (int) $this->reqParam($request, 'id', 0); + $name = (string) $this->reqParam($request, 'name', ''); + $dsl = (string) $this->reqParam($request, 'dsl_text', ''); + return $this->service()->save($id > 0 ? $id : null, $name, $dsl); + } + + /** @return array */ + public function restDelete($request) + { + $id = (int) $this->reqParam($request, 'id', 0); + try { + if ($id > 0) { + $this->service()->delete($id); + } + return ['ok' => true]; + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + } + + /** @return array */ + public function restValidate($request) + { + $dsl = (string) $this->reqParam($request, 'dsl_text', ''); + return ['ok' => true] + $this->service()->validateDsl($dsl); + } + + /** @return array */ + public function restPreview($request) + { + $dsl = (string) $this->reqParam($request, 'dsl_text', ''); + $limit = (int) $this->reqParam($request, 'limit', 50); + return ['ok' => true] + $this->service()->preview($dsl, $limit); + } + + /** @return array */ + public function restPreviewById($request) + { + $id = (int) $this->reqParam($request, 'id', 0); + $item = $id > 0 ? $this->service()->get($id) : null; + if (!is_array($item)) { + return ['ok' => false, 'error' => 'Data source not found']; + } + $dsl = (string) ($item['dsl_text'] ?? ''); + $limit = (int) $this->reqParam($request, 'limit', 50); + return ['ok' => true] + $this->service()->preview($dsl, $limit); + } + + /** @return array */ + public function restSchemas() + { + return ['ok' => true, 'items' => $this->service()->listSchemas()]; + } + + /** @return array */ + public function restTables($request) + { + $schema = (string) $this->reqParam($request, 'schema', ''); + return ['ok' => true, 'items' => $this->service()->listTables($schema)]; + } + + /** @return array */ + public function restSourceFields($request) + { + $source = (string) $this->reqParam($request, 'source', ''); + if ($source === '') { + return ['ok' => true, 'fields' => $this->service()->sourceFields()]; + } + return ['ok' => true, 'source' => $source, 'fields' => $this->service()->sourceFieldsForSource($source)]; + } + + /** @param list> $items @return list> */ + private function filteredAndSortedItems(array $items, string $filter, string $sort): array + { + if ($filter !== '') { + $needle = strtolower($filter); + $items = array_values(array_filter($items, static function (array $row) use ($needle): bool { + $name = strtolower((string) ($row['name'] ?? '')); + $dsl = strtolower((string) ($row['dsl_text'] ?? '')); + return strpos($name, $needle) !== false || strpos($dsl, $needle) !== false; + })); + } + + usort($items, static function (array $a, array $b) use ($sort): int { + if ($sort === 'updated_desc' || $sort === 'updated_asc') { + $av = (string) ($a['updated_at'] ?? ''); + $bv = (string) ($b['updated_at'] ?? ''); + $cmp = strcmp($av, $bv); + return $sort === 'updated_desc' ? -$cmp : $cmp; + } + return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); + }); + + return $items; + } + + /** @param array $result */ + private function redirectToEditId(array $result, ?int $fallbackId): void + { + $editId = $fallbackId; + if (!empty($result['ok']) && isset($result['id'])) { + $editId = (int) $result['id']; + } + $url = $this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : '')); + if (!headers_sent()) { + header('Location: ' . $url, true, 302); + exit; + } + } + + /** @return array|null */ + private function result(): ?array + { + $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); + return is_array($raw) ? $raw : null; + } + + /** + * @param mixed $request + * @param mixed $default + * @return mixed + */ + private function reqParam($request, string $key, $default = null) + { + if (is_object($request) && method_exists($request, 'get_param')) { + $value = $request->get_param($key); + return $value === null ? $default : $value; + } + if (is_array($request) && array_key_exists($key, $request)) { + return $request[$key]; + } + return $default; + } +} diff --git a/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php b/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php new file mode 100644 index 0000000..6a0f91f --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php @@ -0,0 +1,242 @@ +runServiceFactory = $runServiceFactory; + $this->mailshotServiceFactory = $mailshotServiceFactory; + $this->wp = $wp; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_post_feca_mailshots_test_api', [$this, 'handleApi']); + $this->wp->addAction('admin_post_feca_mailshots_test_render_ui', [$this, 'handleRenderUi']); + $this->wp->addAction('admin_post_feca_mailshots_test_send_ui', [$this, 'handleSendUi']); + } + + public function registerMenu(): void + { + $this->wp->addSubmenuPage('feca-mailshot', 'Mailshot Test', 'Mailshot Test', self::CAPABILITY, 'feca-mailshots-test', [$this, 'render']); + } + + public function render(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + echo 'Permission denied'; + return; + } + + $mailshots = $this->mailshotService()->list(); + $selectedMailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + if ($selectedMailshotId <= 0 && $mailshots !== []) { + $selectedMailshotId = (int) ($mailshots[0]['id'] ?? 0); + } + + $preview = ['ok' => false, 'rows' => [], 'errors' => ['Select a mailshot.']]; + if ($selectedMailshotId > 0) { + $preview = $this->runService()->previewRecipients($selectedMailshotId, 100); + } + + $selectedRecipientIndex = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); + $defaultEmail = $this->runService()->defaultTestEmail()['default_test_email'] ?? ''; + $testEmail = (string) ($this->wp->requestParam('test_email', (string) $defaultEmail) ?? $defaultEmail); + $result = $this->result(); + + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + + echo '

Mailshot Test

'; + echo '

Render with a selected recipient context, then optionally send one test email.

'; + + if ($result !== null) { + $ok = !empty($result['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + echo '
'; + echo '' . ($ok ? 'Test action succeeded.' : 'Test action failed.') . ''; + if (!empty($result['errors']) && is_array($result['errors'])) { + echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; + } + if (!empty($result['warnings']) && is_array($result['warnings'])) { + echo '

Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '

'; + } + if (!empty($result['sent_to'])) { + echo '

Sent to: ' . htmlspecialchars((string) $result['sent_to']) . '

'; + } + if (!empty($result['sent_at'])) { + echo '

Sent at: ' . htmlspecialchars((string) $result['sent_at']) . '

'; + } + echo '
Show technical details
' . htmlspecialchars(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '
'; + echo '
'; + } + + echo '
'; + echo '

1. Select Mailshot

'; + echo '
'; + echo ''; + echo ' '; + echo '
'; + echo '
'; + + if (!empty($preview['errors'])) { + echo '
' . htmlspecialchars(implode('; ', $preview['errors'])) . '
'; + } + + $rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : []; + echo '
'; + echo '

2. Render / Send Test

'; + echo ''; + + echo ''; + + echo '

'; + + echo ''; + echo '

'; + echo '
'; + + if ($rows !== []) { + echo '
'; + echo '

Recipient Sample (First 20)

'; + foreach (array_slice($rows, 0, 20) as $r) { + echo ''; + } + echo '
#KeyEmail
' . (int) ($r['index'] ?? 0) . '' . htmlspecialchars((string) ($r['recipient_key'] ?? '')) . '' . htmlspecialchars((string) ($r['recipient_email'] ?? '')) . '
'; + echo '
'; + } + + echo '
'; + } + + public function handleApi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $op = (string) ($this->wp->requestParam('op', '') ?? ''); + $service = $this->runService(); + + try { + if ($op === 'default_test_email') { + $this->wp->sendJson(['ok' => true] + $service->defaultTestEmail()); + return; + } + + if ($op === 'render_test') { + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); + $this->wp->sendJson($service->renderTest($mailshotId, $idx)); + return; + } + + if ($op === 'send_test') { + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); + $to = (string) ($this->wp->requestParam('test_email', '') ?? ''); + $this->wp->sendJson($service->sendTest($mailshotId, $idx, $to)); + return; + } + + $this->wp->sendJson(['ok' => false, 'error' => 'Unknown op'], 400); + } catch (\Throwable $e) { + $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); + } + } + + public function handleRenderUi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); + $result = $this->runService()->renderTest($mailshotId, $idx); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? '')); + } + + public function handleSendUi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); + $email = (string) ($this->wp->requestParam('test_email', '') ?? ''); + $result = $this->runService()->sendTest($mailshotId, $idx, $email); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $this->redirect($mailshotId, $idx, $email); + } + + private function redirect(int $mailshotId, int $recipientIndex, string $testEmail): void + { + $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-test&mailshot_id=' . $mailshotId . '&recipient_index=' . $recipientIndex . '&test_email=' . rawurlencode($testEmail)); + if (!headers_sent()) { + header('Location: ' . $url, true, 302); + exit; + } + } + + /** @return array|null */ + private function result(): ?array + { + $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); + return is_array($raw) ? $raw : null; + } + + private function runService(): MailshotRunService + { + return ($this->runServiceFactory)(); + } + + private function mailshotService(): MailshotService + { + return ($this->mailshotServiceFactory)(); + } +} diff --git a/feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php b/feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php new file mode 100644 index 0000000..09b2dd0 --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php @@ -0,0 +1,459 @@ +serviceFactory = $serviceFactory; + $this->wp = $wp; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_post_feca_mailshots_mailshots_api', [$this, 'handleApi']); + $this->wp->addAction('admin_post_feca_mailshots_mailshots_ui_save', [$this, 'handleUiSave']); + $this->wp->addAction('admin_post_feca_mailshots_mailshots_ui_delete', [$this, 'handleUiDelete']); + } + + public function registerMenu(): void + { + $this->wp->addSubmenuPage('feca-mailshot', 'Mailshots', 'Mailshots', self::CAPABILITY, 'feca-mailshots-mailshots', [$this, 'render']); + } + + public function render(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + echo 'Permission denied'; + return; + } + + $items = $this->service()->list(); + $dataSources = $this->service()->dataSourceNames(); + sort($dataSources, SORT_NATURAL | SORT_FLAG_CASE); + $attachmentNames = $this->service()->attachmentNames(); + sort($attachmentNames, SORT_NATURAL | SORT_FLAG_CASE); + + $editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); + $editItem = null; + foreach ($items as $row) { + if ((int) ($row['id'] ?? 0) === $editId) { + $editItem = $row; + break; + } + } + + $dataSource = (string) ($editItem['DataSource'] ?? ''); + $tokenData = $dataSource !== '' ? $this->service()->tokenInsertionData($dataSource) : ['tokens' => [], 'errors' => []]; + $pdfFieldOptions = []; + foreach (($tokenData['tokens'] ?? []) as $t) { + if (is_array($t) && isset($t['field'])) { + $pdfFieldOptions[] = (string) $t['field']; + } + } + + $selectedAttachments = []; + $decoded = json_decode((string) ($editItem['AttachmentNames'] ?? '[]'), true); + if (is_array($decoded)) { + foreach ($decoded as $v) { + $s = trim((string) $v); + if ($s !== '') { + $selectedAttachments[] = $s; + } + } + } + $selectedAttachments = array_values(array_unique($selectedAttachments)); + + $form = [ + 'Purpose' => (string) ($editItem['Purpose'] ?? ''), + 'DataSource' => $dataSource, + 'CC' => (string) ($editItem['CC'] ?? ''), + 'BCC' => (string) ($editItem['BCC'] ?? ''), + 'Subject' => (string) ($editItem['Subject'] ?? ''), + 'Message' => (string) ($editItem['Message'] ?? ''), + 'PDFAttachment' => (string) ($editItem['PDFAttachment'] ?? ''), + 'PDFFilenameDerivedFrom' => (string) ($editItem['PDFFilenameDerivedFrom'] ?? ''), + 'ReplyTo' => (string) ($editItem['ReplyTo'] ?? ''), + ]; + + $result = $this->result(); + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + + echo '

Mailshots

'; + if ($result !== null) { + $ok = !empty($result['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + echo '
'; + echo '' . ($ok ? 'Mailshot saved.' : 'Mailshot action failed.') . ''; + if (!empty($result['errors']) && is_array($result['errors'])) { + echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; + } + echo '
'; + } + + echo '

'; + echo ''; + + echo '

Existing Mailshots

'; + echo ''; + foreach ($items as $row) { + $id = (int) ($row['id'] ?? 0); + $editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $id); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + if ($items === []) { + echo ''; + } + echo '
PurposeData SourceSubjectActions
' . htmlspecialchars((string) ($row['Purpose'] ?? '')) . '' . htmlspecialchars((string) ($row['DataSource'] ?? '')) . '' . htmlspecialchars((string) ($row['Subject'] ?? '')) . 'Edit '; + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
No mailshots found.
'; + + echo ''; + + echo '
'; + } + + public function handleUiSave(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); + $id = $idRaw === '' ? null : (int) $idRaw; + $attachmentCsv = (string) ($this->wp->requestParam('AttachmentNamesCsv', '') ?? ''); + $attachmentNames = array_values(array_filter(array_map('trim', explode(',', $attachmentCsv)), static fn(string $v): bool => $v !== '')); + + $payload = [ + 'Purpose' => (string) ($this->wp->requestParam('Purpose', '') ?? ''), + 'DataSource' => (string) ($this->wp->requestParam('DataSource', '') ?? ''), + 'CC' => (string) ($this->wp->requestParam('CC', '') ?? ''), + 'BCC' => (string) ($this->wp->requestParam('BCC', '') ?? ''), + 'Subject' => (string) ($this->wp->requestParam('Subject', '') ?? ''), + 'Message' => (string) ($this->wp->requestParam('Message', '') ?? ''), + 'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''), + 'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES), + 'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''), + 'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''), + ]; + $result = $this->service()->save($id, $payload); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $editId = $id; + if (!empty($result['ok']) && isset($result['id'])) { + $editId = (int) $result['id']; + } + $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : '')); + if (!headers_sent()) { + header('Location: ' . $url, true, 302); + exit; + } + } + + public function handleUiDelete(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $result = ['ok' => true]; + try { + if ($id > 0) { + $this->service()->delete($id); + } + } catch (\Throwable $e) { + $result = ['ok' => false, 'errors' => [$e->getMessage()]]; + } + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + if (!headers_sent()) { + header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots'), true, 302); + exit; + } + } + + public function handleApi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $op = (string) ($this->wp->requestParam('op', 'list') ?? 'list'); + + try { + if ($op === 'list') { + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]); + return; + } + + if ($op === 'data_sources') { + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->dataSourceNames()]); + return; + } + + if ($op === 'attachment_names') { + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->attachmentNames()]); + return; + } + + if ($op === 'tokens') { + $dataSource = (string) ($this->wp->requestParam('data_source', '') ?? ''); + $this->wp->sendJson(['ok' => true] + $this->service()->tokenInsertionData($dataSource)); + return; + } + + if ($op === 'last_run') { + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->lastRun($mailshotId)]); + return; + } + + if ($op === 'clear_last_run') { + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $this->service()->clearLastRun($mailshotId); + $this->wp->sendJson(['ok' => true]); + return; + } + + if ($op === 'save') { + $idRaw = $this->wp->requestParam('id', ''); + $id = $idRaw === '' ? null : (int) $idRaw; + $payload = [ + 'Purpose' => (string) ($this->wp->requestParam('Purpose', '') ?? ''), + 'DataSource' => (string) ($this->wp->requestParam('DataSource', '') ?? ''), + 'CC' => (string) ($this->wp->requestParam('CC', '') ?? ''), + 'BCC' => (string) ($this->wp->requestParam('BCC', '') ?? ''), + 'Subject' => (string) ($this->wp->requestParam('Subject', '') ?? ''), + 'Message' => (string) ($this->wp->requestParam('Message', '') ?? ''), + 'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''), + 'AttachmentNames' => (string) ($this->wp->requestParam('AttachmentNames', '[]') ?? '[]'), + 'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''), + 'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''), + ]; + $this->wp->sendJson($this->service()->save($id, $payload)); + return; + } + + if ($op === 'delete') { + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $this->service()->delete($id); + $this->wp->sendJson(['ok' => true]); + return; + } + + $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); + } catch (\Throwable $e) { + $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); + } + } + + private function service(): MailshotService + { + return ($this->serviceFactory)(); + } + + /** @return array|null */ + private function result(): ?array + { + $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); + return is_array($raw) ? $raw : null; + } +} diff --git a/feca_mailshots_plugin/src/Admin/PdfAssetsAdminPage.php b/feca_mailshots_plugin/src/Admin/PdfAssetsAdminPage.php new file mode 100644 index 0000000..05a7665 --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/PdfAssetsAdminPage.php @@ -0,0 +1,255 @@ +serviceFactory = $serviceFactory; + $this->wp = $wp; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_post_feca_mailshots_pdf_assets_api', [$this, 'handleApi']); + $this->wp->addAction('admin_post_feca_mailshots_pdf_assets_ui_save', [$this, 'handleUiSave']); + $this->wp->addAction('admin_post_feca_mailshots_pdf_assets_ui_delete', [$this, 'handleUiDelete']); + } + + public function registerMenu(): void + { + $this->wp->addSubmenuPage('feca-mailshot', 'PDF Assets', 'PDF Assets', self::CAPABILITY, 'feca-mailshots-pdf-assets', [$this, 'render']); + } + + public function render(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + echo 'Permission denied'; + return; + } + $items = $this->service()->list(); + $editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); + $editItem = null; + foreach ($items as $row) { + if ((int) ($row['id'] ?? 0) === $editId) { + $editItem = $row; + break; + } + } + $result = $this->result(); + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + $name = (string) ($editItem['name'] ?? ''); + $fileName = (string) ($editItem['file_name'] ?? ''); + $mime = (string) ($editItem['mime_type'] ?? 'application/pdf'); + $width = (string) ($editItem['width_mm'] ?? '210'); + $height = (string) ($editItem['height_mm'] ?? '297'); + $just = (string) ($editItem['justification'] ?? 'in-place'); + + echo '

PDF Assets

'; + if ($result !== null) { + $ok = !empty($result['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + $title = $ok ? 'PDF asset saved.' : 'PDF asset action failed.'; + echo '
'; + echo '' . htmlspecialchars($title) . ''; + if (!empty($result['errors']) && is_array($result['errors'])) { + echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; + } + echo '
'; + } + + echo '

'; + echo ''; + + echo '

Existing PDF Assets

'; + echo ''; + foreach ($items as $row) { + $id = (int) ($row['id'] ?? 0); + $editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets&edit_id=' . $id); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + if ($items === []) { + echo ''; + } + echo '
IDNameFileSize (mm)JustificationBytesActions
' . $id . '' . htmlspecialchars((string) ($row['name'] ?? '')) . '' . htmlspecialchars((string) ($row['file_name'] ?? '')) . '' . htmlspecialchars((string) ($row['width_mm'] ?? '')) . ' x ' . htmlspecialchars((string) ($row['height_mm'] ?? '')) . '' . htmlspecialchars((string) ($row['justification'] ?? '')) . '' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . 'Edit '; + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
No PDF assets found.
'; + echo ''; + echo '
'; + } + + public function handleUiSave(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); + $id = $idRaw === '' ? null : (int) $idRaw; + $base64 = (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''); + if ($base64 === '' && isset($_FILES['file_upload']) && is_array($_FILES['file_upload']) && (int) ($_FILES['file_upload']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) { + $tmp = (string) ($_FILES['file_upload']['tmp_name'] ?? ''); + if ($tmp !== '' && is_file($tmp)) { + $bytes = file_get_contents($tmp); + if ($bytes !== false) { + $base64 = base64_encode($bytes); + } + } + } + $payload = [ + 'name' => (string) ($this->wp->requestParam('name', '') ?? ''), + 'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''), + 'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/pdf') ?? 'application/pdf'), + 'file_bytes_base64' => $base64, + 'width_mm' => (string) ($this->wp->requestParam('width_mm', '0') ?? '0'), + 'height_mm' => (string) ($this->wp->requestParam('height_mm', '0') ?? '0'), + 'justification' => (string) ($this->wp->requestParam('justification', 'in-place') ?? 'in-place'), + ]; + $result = $this->service()->save($id, $payload); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $editId = $id; + if (!empty($result['ok']) && isset($result['id'])) { + $editId = (int) $result['id']; + } + $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : '')); + if (!headers_sent()) { + header('Location: ' . $url, true, 302); + exit; + } + } + + public function handleUiDelete(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $result = ['ok' => true]; + try { + if ($id > 0) { + $this->service()->delete($id); + } + } catch (\Throwable $e) { + $result = ['ok' => false, 'errors' => [$e->getMessage()]]; + } + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + if (!headers_sent()) { + header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets'), true, 302); + exit; + } + } + + public function handleApi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $op = (string) ($this->wp->requestParam('op', 'list') ?? 'list'); + + try { + if ($op === 'list') { + $this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]); + return; + } + + if ($op === 'save') { + $idRaw = $this->wp->requestParam('id', ''); + $id = $idRaw === '' ? null : (int) $idRaw; + $payload = [ + 'name' => (string) ($this->wp->requestParam('name', '') ?? ''), + 'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''), + 'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'), + 'file_bytes_base64' => (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''), + 'width_mm' => (string) ($this->wp->requestParam('width_mm', '0') ?? '0'), + 'height_mm' => (string) ($this->wp->requestParam('height_mm', '0') ?? '0'), + 'justification' => (string) ($this->wp->requestParam('justification', 'in-place') ?? 'in-place'), + ]; + $this->wp->sendJson($this->service()->save($id, $payload)); + return; + } + + if ($op === 'delete') { + $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $this->service()->delete($id); + $this->wp->sendJson(['ok' => true]); + return; + } + + $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); + } catch (\Throwable $e) { + $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); + } + } + + private function service(): PdfAssetService + { + return ($this->serviceFactory)(); + } + + /** @return array|null */ + private function result(): ?array + { + $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); + return is_array($raw) ? $raw : null; + } +} diff --git a/feca_mailshots_plugin/src/Admin/ProfileAdminPage.php b/feca_mailshots_plugin/src/Admin/ProfileAdminPage.php new file mode 100644 index 0000000..ac2000d --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/ProfileAdminPage.php @@ -0,0 +1,445 @@ +wp = $wp; + $this->repoFactory = $repoFactory; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_post_feca_mailshots_profile_save', [$this, 'handleSave']); + $this->wp->addAction('admin_post_feca_mailshots_profile_test', [$this, 'handleTest']); + } + + public function registerMenu(): void + { + $this->wp->addSubmenuPage('feca-mailshot', 'Profile', 'Profile', self::CAPABILITY, 'feca-mailshots-profile', [$this, 'render']); + } + + public function render(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + echo 'Permission denied'; + return; + } + + $uid = $this->wp->currentUserId(); + $saved = $uid > 0 ? ($this->repo()->findByUserId($uid) ?? []) : []; + $status = $this->wp->requestParam('saved', '') === '1' ? 'Profile credentials saved.' : ''; + $test = $uid > 0 ? $this->testResult($uid) : null; + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + + echo '

Mailshot Profile

'; + if ($status !== '') { + echo '
' . htmlspecialchars($status) . '
'; + } + if ($test !== null) { + $ok = !empty($test['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + $kind = strtoupper((string) ($test['kind'] ?? 'CREDENTIAL')); + $title = $kind . ' test ' . ($ok ? 'passed.' : 'failed.'); + echo '
'; + echo '' . htmlspecialchars($title) . ''; + if (!empty($test['messages']) && is_array($test['messages'])) { + echo '
    '; + foreach ($test['messages'] as $line) { + echo '
  • ' . htmlspecialchars((string) $line) . '
  • '; + } + echo '
'; + } + echo '
'; + } + echo '

Configure your personal SMTP and IMAP credentials for test/run operations.

'; + echo '

Passwords are stored encrypted in the mailshots database.

'; + echo '
'; + echo 'Common configuration patterns'; + echo '
    '; + echo '
  • SMTP port 465 usually means Require TLS should be enabled (implicit TLS).
  • '; + echo '
  • SMTP port 587 usually means Require TLS should be disabled so STARTTLS can be negotiated.
  • '; + echo '
  • IMAP port 993 usually uses mailbox flags /imap/ssl.
  • '; + echo '
  • IMAP port 143 usually uses non-SSL flags such as /imap (or your provider specific STARTTLS mode).
  • '; + echo '
'; + echo '
'; + + echo '
'; + + echo '

SMTP

'; + $this->field('SMTP Host', 'smtp_host', $saved['smtp_host'] ?? ''); + $this->field('SMTP Port', 'smtp_port', (string) ($saved['smtp_port'] ?? '587')); + $this->field('SMTP User', 'smtp_user', $saved['smtp_user'] ?? ''); + $this->field('SMTP Password', 'smtp_password', '', 'password', 'Leave blank to keep existing password'); + $this->field('From Email', 'smtp_from_email', $saved['smtp_from_email'] ?? ''); + $this->field('From Name', 'smtp_from_name', $saved['smtp_from_name'] ?? ''); + + $tlsChecked = !empty($saved['smtp_require_tls']) ? 'checked' : ''; + echo ''; + + echo '

IMAP Sent Copy

'; + $this->field('IMAP Host', 'imap_host', $saved['imap_host'] ?? ''); + $this->field('IMAP Port', 'imap_port', (string) ($saved['imap_port'] ?? '993')); + $this->field('IMAP User', 'imap_user', $saved['imap_user'] ?? ''); + $this->field('IMAP Password', 'imap_password', '', 'password', 'Leave blank to keep existing password'); + $this->field('IMAP Sent Folder', 'imap_sent_folder', $saved['imap_sent_folder'] ?? 'Sent'); + $this->field('IMAP Mailbox Flags', 'imap_mailbox_flags', $saved['imap_mailbox_flags'] ?? '/imap/ssl'); + + echo '

'; + echo ' '; + echo ' '; + echo ''; + echo ''; + echo '

'; + echo '
'; + } + + public function handleSave(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $uid = $this->wp->currentUserId(); + if ($uid <= 0) { + $this->wp->sendJson(['ok' => false, 'error' => 'No current user.'], 400); + return; + } + + $payload = [ + 'smtp_host' => trim((string) ($this->wp->requestParam('smtp_host', '') ?? '')), + 'smtp_port' => trim((string) ($this->wp->requestParam('smtp_port', '587') ?? '587')), + 'smtp_user' => trim((string) ($this->wp->requestParam('smtp_user', '') ?? '')), + 'smtp_password' => (string) ($this->wp->requestParam('smtp_password', '') ?? ''), + 'smtp_from_email' => trim((string) ($this->wp->requestParam('smtp_from_email', '') ?? '')), + 'smtp_from_name' => trim((string) ($this->wp->requestParam('smtp_from_name', '') ?? '')), + 'smtp_require_tls' => $this->wp->requestParam('smtp_require_tls', '') === '1', + 'imap_host' => trim((string) ($this->wp->requestParam('imap_host', '') ?? '')), + 'imap_port' => trim((string) ($this->wp->requestParam('imap_port', '993') ?? '993')), + 'imap_user' => trim((string) ($this->wp->requestParam('imap_user', '') ?? '')), + 'imap_password' => (string) ($this->wp->requestParam('imap_password', '') ?? ''), + 'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', 'Sent') ?? 'Sent')), + 'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '/imap/ssl') ?? '/imap/ssl')), + ]; + + $errors = []; + foreach (['smtp_host', 'smtp_user', 'smtp_from_email'] as $required) { + if ($payload[$required] === '') { + $errors[] = 'Missing required field: ' . $required; + } + } + if ($errors !== []) { + $this->wp->sendJson(['ok' => false, 'errors' => $errors], 400); + return; + } + + $this->repo()->upsertForUser($uid, $payload); + + if (!headers_sent()) { + $location = $this->wp->adminUrl('admin.php?page=feca-mailshots-profile&saved=1'); + header('Location: ' . $location, true, 302); + exit; + } + + $this->wp->sendJson(['ok' => true, 'saved' => true]); + } + + public function handleTest(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $uid = $this->wp->currentUserId(); + if ($uid <= 0) { + $this->wp->sendJson(['ok' => false, 'error' => 'No current user.'], 400); + return; + } + + $kind = strtolower(trim((string) ($this->wp->requestParam('test_kind', '') ?? ''))); + if ($kind !== 'smtp' && $kind !== 'imap') { + $this->wp->updateOption($this->testResultOptionKey($uid), [ + 'kind' => 'unknown', + 'ok' => false, + 'messages' => ['Choose either "Test SMTP" or "Test IMAP".'], + ]); + if (!headers_sent()) { + header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-profile&tested=1'), true, 302); + exit; + } + $this->wp->sendJson(['ok' => false, 'error' => 'Invalid test kind.'], 400); + return; + } + + $payload = [ + 'smtp_host' => trim((string) ($this->wp->requestParam('smtp_host', '') ?? '')), + 'smtp_port' => trim((string) ($this->wp->requestParam('smtp_port', '587') ?? '587')), + 'smtp_user' => trim((string) ($this->wp->requestParam('smtp_user', '') ?? '')), + 'smtp_password' => (string) ($this->wp->requestParam('smtp_password', '') ?? ''), + 'smtp_from_email' => trim((string) ($this->wp->requestParam('smtp_from_email', '') ?? '')), + 'smtp_from_name' => trim((string) ($this->wp->requestParam('smtp_from_name', '') ?? '')), + 'smtp_require_tls' => $this->wp->requestParam('smtp_require_tls', '') === '1', + 'imap_host' => trim((string) ($this->wp->requestParam('imap_host', '') ?? '')), + 'imap_port' => trim((string) ($this->wp->requestParam('imap_port', '993') ?? '993')), + 'imap_user' => trim((string) ($this->wp->requestParam('imap_user', '') ?? '')), + 'imap_password' => (string) ($this->wp->requestParam('imap_password', '') ?? ''), + 'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', 'Sent') ?? 'Sent')), + 'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '/imap/ssl') ?? '/imap/ssl')), + ]; + + $existing = $this->repo()->findByUserId($uid); + if (is_array($existing)) { + if ($payload['smtp_password'] === '') { + $payload['smtp_password'] = (string) ($existing['smtp_password'] ?? ''); + } + if ($payload['imap_password'] === '') { + $payload['imap_password'] = (string) ($existing['imap_password'] ?? ''); + } + } + + $result = $kind === 'smtp' ? $this->runSmtpTest($payload) : $this->runImapTest($payload); + $this->wp->updateOption($this->testResultOptionKey($uid), $result); + + if (!headers_sent()) { + header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-profile&tested=1'), true, 302); + exit; + } + + $this->wp->sendJson($result); + } + + private function field(string $label, string $name, string $value, string $type = 'text', string $hint = ''): void + { + echo ''; + echo ''; + echo ''; + } + + private function repo(): MailCredentialRepository + { + return ($this->repoFactory)(); + } + + /** @param array $payload */ + private function runSmtpTest(array $payload): array + { + $messages = []; + foreach (['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email'] as $required) { + if (trim((string) ($payload[$required] ?? '')) === '') { + return ['kind' => 'smtp', 'ok' => false, 'messages' => ['Missing required field: ' . $required]]; + } + } + + $host = (string) $payload['smtp_host']; + $port = (int) $payload['smtp_port']; + $user = (string) $payload['smtp_user']; + $pass = (string) $payload['smtp_password']; + $requireTls = !empty($payload['smtp_require_tls']); + foreach ($this->smtpConfigDiagnostics($port, $requireTls) as $hint) { + $messages[] = 'Config check: ' . $hint; + } + $transport = $requireTls ? 'tls://' : ''; + + $fp = @stream_socket_client($transport . $host . ':' . $port, $errno, $errstr, 15); + if (!is_resource($fp)) { + $messages[] = 'SMTP connect failed: ' . (string) $errstr; + return ['kind' => 'smtp', 'ok' => false, 'messages' => $messages]; + } + + try { + $this->smtpExpect($fp, [220]); + $this->smtpCmd($fp, 'EHLO localhost', [250]); + $messages[] = sprintf('Connected to SMTP server %s:%d.', $host, $port); + + if (!$requireTls) { + $line = $this->smtpCmd($fp, 'STARTTLS', [220], false); + if ($line !== null) { + if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + return ['kind' => 'smtp', 'ok' => false, 'messages' => ['SMTP STARTTLS negotiation failed.']]; + } + $this->smtpCmd($fp, 'EHLO localhost', [250]); + $messages[] = 'STARTTLS negotiated successfully.'; + } else { + $messages[] = 'STARTTLS not offered; continued without TLS (as configured).'; + } + } else { + $messages[] = 'TLS transport required and connection established.'; + } + + $this->smtpCmd($fp, 'AUTH LOGIN', [334]); + $this->smtpCmd($fp, base64_encode($user), [334]); + $this->smtpCmd($fp, base64_encode($pass), [235]); + $messages[] = 'SMTP authentication succeeded.'; + + $from = (string) $payload['smtp_from_email']; + $this->smtpCmd($fp, 'MAIL FROM:<' . $from . '>', [250]); + $messages[] = 'MAIL FROM accepted for configured sender address.'; + $this->smtpCmd($fp, 'QUIT', [221], false); + } catch (\Throwable $e) { + return ['kind' => 'smtp', 'ok' => false, 'messages' => array_merge($messages, [$e->getMessage()])]; + } finally { + fclose($fp); + } + + return ['kind' => 'smtp', 'ok' => true, 'messages' => $messages]; + } + + /** @param array $payload */ + private function runImapTest(array $payload): array + { + $messages = []; + foreach (['imap_host', 'imap_port', 'imap_user', 'imap_password'] as $required) { + if (trim((string) ($payload[$required] ?? '')) === '') { + return ['kind' => 'imap', 'ok' => false, 'messages' => ['Missing required field: ' . $required]]; + } + } + + if (!function_exists('imap_open')) { + return ['kind' => 'imap', 'ok' => false, 'messages' => ['IMAP extension is not available in PHP runtime.']]; + } + + $host = (string) $payload['imap_host']; + $port = (int) $payload['imap_port']; + $user = (string) $payload['imap_user']; + $pass = (string) $payload['imap_password']; + $folder = trim((string) ($payload['imap_sent_folder'] ?? 'Sent')); + $flags = trim((string) ($payload['imap_mailbox_flags'] ?? '/imap/ssl')); + foreach ($this->imapConfigDiagnostics($port, $flags) as $hint) { + $messages[] = 'Config check: ' . $hint; + } + $mailbox = sprintf('{%s:%d%s}%s', $host, $port, $flags, $folder); + + $imap = @imap_open($mailbox, $user, $pass); + if ($imap === false) { + $messages[] = 'IMAP login failed: ' . (imap_last_error() ?: 'unknown error'); + return ['kind' => 'imap', 'ok' => false, 'messages' => $messages]; + } + + imap_close($imap); + + return [ + 'kind' => 'imap', + 'ok' => true, + 'messages' => array_merge($messages, [ + sprintf('Connected and authenticated to IMAP server %s:%d.', $host, $port), + sprintf('Mailbox opened successfully: %s', $mailbox), + ]), + ]; + } + + /** @return list */ + private function smtpConfigDiagnostics(int $port, bool $requireTls): array + { + $hints = []; + if ($port === 465 && !$requireTls) { + $hints[] = 'Port 465 usually requires "Require TLS" enabled (implicit TLS).'; + } + if ($port === 587 && $requireTls) { + $hints[] = 'Port 587 is usually STARTTLS; try disabling "Require TLS" so STARTTLS can be negotiated.'; + } + if ($port === 25 && $requireTls) { + $hints[] = 'Port 25 with implicit TLS is uncommon; check provider docs for the expected TLS mode.'; + } + return $hints; + } + + /** @return list */ + private function imapConfigDiagnostics(int $port, string $flags): array + { + $hints = []; + $flagsLower = strtolower($flags); + if ($port === 993 && strpos($flagsLower, '/ssl') === false) { + $hints[] = 'IMAP port 993 usually expects SSL flags (for example /imap/ssl).'; + } + if ($port === 143 && strpos($flagsLower, '/ssl') !== false) { + $hints[] = 'IMAP port 143 is usually non-SSL; /ssl flags may cause login/connect failures.'; + } + return $hints; + } + + /** @param resource $fp @param list $codes */ + private function smtpCmd($fp, string $cmd, array $codes, bool $throwOnMismatch = true): ?string + { + fwrite($fp, $cmd . "\r\n"); + return $this->smtpExpect($fp, $codes, $throwOnMismatch); + } + + /** @param resource $fp @param list $codes */ + private function smtpExpect($fp, array $codes, bool $throwOnMismatch = true): ?string + { + $lastLine = null; + while (true) { + $line = fgets($fp, 4096); + if ($line === false) { + if ($throwOnMismatch) { + throw new \RuntimeException('SMTP read failed while testing credentials.'); + } + return null; + } + + $lastLine = $line; + if (!preg_match('/^(\d{3})([\s-])/', $line, $m)) { + if ($throwOnMismatch) { + throw new \RuntimeException('SMTP malformed response: ' . trim($line)); + } + return null; + } + + $code = (int) $m[1]; + if (!in_array($code, $codes, true)) { + if ($throwOnMismatch) { + throw new \RuntimeException('SMTP unexpected response: ' . trim($line)); + } + return null; + } + + $continuation = $m[2] === '-'; + if (!$continuation) { + return $lastLine; + } + } + } + + private function testResultOptionKey(int $uid): string + { + return self::TEST_RESULT_OPTION_PREFIX . $uid; + } + + /** @return array{kind:string,ok:bool,messages:list}|null */ + private function testResult(int $uid): ?array + { + $raw = $this->wp->getOption($this->testResultOptionKey($uid), null); + if (!is_array($raw) || !isset($raw['kind']) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) { + return null; + } + + return [ + 'kind' => (string) $raw['kind'], + 'ok' => (bool) $raw['ok'], + 'messages' => array_map(static fn($m): string => (string) $m, $raw['messages']), + ]; + } +} diff --git a/feca_mailshots_plugin/src/Admin/RunMailshotAdminPage.php b/feca_mailshots_plugin/src/Admin/RunMailshotAdminPage.php new file mode 100644 index 0000000..4443c2a --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/RunMailshotAdminPage.php @@ -0,0 +1,245 @@ +runServiceFactory = $runServiceFactory; + $this->mailshotServiceFactory = $mailshotServiceFactory; + $this->wp = $wp; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_post_feca_mailshots_run_api', [$this, 'handleApi']); + $this->wp->addAction('admin_post_feca_mailshots_run_execute_ui', [$this, 'handleRunUi']); + $this->wp->addAction('admin_post_feca_mailshots_retry_failed_ui', [$this, 'handleRetryFailedUi']); + $this->wp->addAction('admin_post_feca_mailshots_retry_recipient_ui', [$this, 'handleRetryRecipientUi']); + } + + public function registerMenu(): void + { + $this->wp->addSubmenuPage('feca-mailshot', 'Run Mailshot', 'Run Mailshot', self::CAPABILITY, 'feca-mailshots-run', [$this, 'render']); + } + + public function render(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + echo 'Permission denied'; + return; + } + + $mailshots = $this->mailshotService()->list(); + $selectedMailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + if ($selectedMailshotId <= 0 && $mailshots !== []) { + $selectedMailshotId = (int) ($mailshots[0]['id'] ?? 0); + } + + $result = $this->result(); + $lastRunRows = $selectedMailshotId > 0 ? $this->mailshotService()->lastRun($selectedMailshotId) : []; + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + + echo '

Run Mailshot

'; + echo '

Execute full sends and retry failures from the current mailshot last-run rows.

'; + + if ($result !== null) { + $ok = !empty($result['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + echo '
'; + echo '' . ($ok ? 'Run action completed.' : 'Run action failed.') . ''; + if ($ok) { + echo '

attempted=' . (int) ($result['attempted'] ?? 0) + . ', sent=' . (int) ($result['sent'] ?? 0) + . ', failed=' . (int) ($result['failed'] ?? 0) + . ', warnings=' . (int) ($result['warnings'] ?? 0) + . ', skipped=' . (int) ($result['skipped'] ?? 0) + . '

'; + } + if (!empty($result['errors']) && is_array($result['errors'])) { + echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; + } + echo '
Show technical details
' . htmlspecialchars(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '
'; + echo '
'; + } + + echo '
'; + echo '

1. Select Mailshot

'; + echo '
'; + echo ''; + echo ' '; + echo '
'; + echo '
'; + + echo '
'; + echo '

2. Run Actions

'; + echo ''; + echo ' '; + echo ''; + echo '
'; + + echo '
'; + echo '

3. Last Run Rows

'; + if ($lastRunRows === []) { + echo '

No rows in mailshot_last_run for this mailshot.

'; + } else { + echo ''; + foreach ($lastRunRows as $row) { + $recipientKey = (string) ($row['recipient_key'] ?? ''); + $status = (string) ($row['status'] ?? ''); + $msg = (string) (($row['error_message'] ?? '') ?: ($row['warning_message'] ?? '')); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
Recipient KeyEmailStatusAttemptsError/WarningRetry
' . htmlspecialchars($recipientKey) . '' . htmlspecialchars((string) ($row['recipient_email_last'] ?? '')) . '' . htmlspecialchars($status) . '' . (int) ($row['attempt_count'] ?? 0) . '' . htmlspecialchars($msg) . ''; + if ($status === 'failed') { + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
'; + } + echo '
'; + } + echo '
'; + + echo '
'; + } + + public function handleApi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $op = (string) ($this->wp->requestParam('op', '') ?? ''); + $service = $this->runService(); + + try { + if ($op === 'run_mailshot') { + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $this->wp->sendJson($service->runMailshot($mailshotId)); + return; + } + + if ($op === 'retry_failed') { + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $this->wp->sendJson($service->retryFailed($mailshotId)); + return; + } + + if ($op === 'retry_recipient') { + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $recipientKey = (string) ($this->wp->requestParam('recipient_key', '') ?? ''); + $this->wp->sendJson($service->retryRecipient($mailshotId, $recipientKey)); + return; + } + + $this->wp->sendJson(['ok' => false, 'error' => 'Unknown op'], 400); + } catch (\Throwable $e) { + $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); + } + } + + public function handleRunUi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $result = $this->runService()->runMailshot($mailshotId); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $this->redirect($mailshotId); + } + + public function handleRetryFailedUi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $result = $this->runService()->retryFailed($mailshotId); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $this->redirect($mailshotId); + } + + public function handleRetryRecipientUi(): void + { + if (!$this->wp->currentUserCan(self::CAPABILITY)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); + $recipientKey = (string) ($this->wp->requestParam('recipient_key', '') ?? ''); + $result = $this->runService()->retryRecipient($mailshotId, $recipientKey); + $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $this->redirect($mailshotId); + } + + private function redirect(int $mailshotId): void + { + $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-run&mailshot_id=' . $mailshotId); + if (!headers_sent()) { + header('Location: ' . $url, true, 302); + exit; + } + } + + /** @return array|null */ + private function result(): ?array + { + $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); + return is_array($raw) ? $raw : null; + } + + private function runService(): MailshotRunService + { + return ($this->runServiceFactory)(); + } + + private function mailshotService(): MailshotService + { + return ($this->mailshotServiceFactory)(); + } +} diff --git a/feca_mailshots_plugin/src/Admin/SetupAdminPage.php b/feca_mailshots_plugin/src/Admin/SetupAdminPage.php new file mode 100644 index 0000000..af99553 --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/SetupAdminPage.php @@ -0,0 +1,239 @@ +wp = $wp; + } + + public function register(): void + { + $this->wp->addAction('admin_menu', [$this, 'registerMenu']); + $this->wp->addAction('admin_post_feca_mailshots_setup_save', [$this, 'handleSave']); + $this->wp->addAction('admin_post_feca_mailshots_setup_test', [$this, 'handleTest']); + } + + public function registerMenu(): void + { + $this->wp->addSubmenuPage('feca-mailshot', 'Setup', 'Setup', 'manage_options', 'feca-mailshots-setup', [$this, 'render']); + } + + public function render(): void + { + if (!$this->wp->currentUserCan('manage_options')) { + echo 'Permission denied'; + return; + } + + $saved = $this->settings(); + $status = $this->wp->requestParam('saved', '') === '1' ? 'Settings saved.' : ''; + $test = $this->testResult(); + $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); + + echo '
'; + echo '

FECA Mailshots Setup

'; + if ($status !== '') { + echo '
' . htmlspecialchars($status) . '
'; + } + if ($test !== null) { + $ok = !empty($test['ok']); + $bg = $ok ? '#f1f8e9' : '#ffebee'; + $border = $ok ? '#8bc34a' : '#ef9a9a'; + $title = $ok ? 'Connection test passed.' : 'Connection test failed.'; + echo '
'; + echo '' . htmlspecialchars($title) . ''; + if (!empty($test['messages']) && is_array($test['messages'])) { + echo '
    '; + foreach ($test['messages'] as $line) { + echo '
  • ' . htmlspecialchars((string) $line) . '
  • '; + } + echo '
'; + } + echo '
'; + } + echo '

Configure database credentials used by FECA Mailshots in this WordPress environment.

'; + echo '
'; + + $this->field('Host', 'db_host', $saved['db_host'] ?? ''); + $this->field('Port', 'db_port', $saved['db_port'] ?? '3306'); + $this->field('User', 'db_user', $saved['db_user'] ?? ''); + $this->field('Password', 'db_password', $saved['db_password'] ?? '', 'password'); + $this->field('Mailshots DB Name', 'mailshots_db_name', $saved['mailshots_db_name'] ?? ''); + $this->field('Members DB Name', 'members_db_name', $saved['members_db_name'] ?? ''); + + echo '

'; + echo ' '; + echo ''; + echo '

'; + echo '
'; + echo '
'; + } + + public function handleSave(): void + { + if (!$this->wp->currentUserCan('manage_options')) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $settings = [ + 'db_host' => trim((string) ($this->wp->requestParam('db_host', '') ?? '')), + 'db_port' => trim((string) ($this->wp->requestParam('db_port', '3306') ?? '3306')), + 'db_user' => trim((string) ($this->wp->requestParam('db_user', '') ?? '')), + 'db_password' => (string) ($this->wp->requestParam('db_password', '') ?? ''), + 'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')), + 'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')), + ]; + + $this->wp->updateOption(self::OPTION_KEY, $settings); + + if (!headers_sent()) { + $location = $this->wp->adminUrl('admin.php?page=feca-mailshots-setup&saved=1'); + header('Location: ' . $location, true, 302); + exit; + } + + $this->wp->sendJson(['ok' => true, 'saved' => true]); + } + + public function handleTest(): void + { + if (!$this->wp->currentUserCan('manage_options')) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return; + } + + $settings = [ + 'db_host' => trim((string) ($this->wp->requestParam('db_host', '') ?? '')), + 'db_port' => trim((string) ($this->wp->requestParam('db_port', '3306') ?? '3306')), + 'db_user' => trim((string) ($this->wp->requestParam('db_user', '') ?? '')), + 'db_password' => (string) ($this->wp->requestParam('db_password', '') ?? ''), + 'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')), + 'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')), + ]; + + $result = $this->runConnectionTest($settings); + $this->wp->updateOption(self::TEST_RESULT_OPTION_KEY, $result); + + if (!headers_sent()) { + $location = $this->wp->adminUrl('admin.php?page=feca-mailshots-setup&tested=1'); + header('Location: ' . $location, true, 302); + exit; + } + + $this->wp->sendJson($result); + } + + /** @return array */ + private function settings(): array + { + $raw = $this->wp->getOption(self::OPTION_KEY, []); + if (!is_array($raw)) { + return []; + } + $out = []; + foreach (['db_host', 'db_port', 'db_user', 'db_password', 'mailshots_db_name', 'members_db_name'] as $key) { + $out[$key] = isset($raw[$key]) ? (string) $raw[$key] : ''; + } + return $out; + } + + private function field(string $label, string $name, string $value, string $type = 'text'): void + { + echo ''; + echo ''; + echo ''; + echo ''; + } + + /** @return array{ok:bool,messages:list} */ + private function runConnectionTest(array $settings): array + { + $messages = []; + foreach (['db_host', 'db_port', 'db_user', 'mailshots_db_name', 'members_db_name'] as $required) { + if (($settings[$required] ?? '') === '') { + $messages[] = sprintf('Missing required field: %s', $required); + } + } + if ($messages !== []) { + return ['ok' => false, 'messages' => $messages]; + } + + if (!class_exists(\PDO::class)) { + return ['ok' => false, 'messages' => ['PDO extension is not available on this server.']]; + } + + try { + $base = sprintf( + 'mysql:host=%s;port=%s;charset=utf8mb4', + $settings['db_host'], + $settings['db_port'] + ); + $pdo = new \PDO( + $base, + (string) $settings['db_user'], + (string) ($settings['db_password'] ?? ''), + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ] + ); + $pdo->query('SELECT 1'); + $messages[] = 'Connected to MySQL server successfully.'; + } catch (\Throwable $e) { + return ['ok' => false, 'messages' => ['MySQL server connection failed: ' . $e->getMessage()]]; + } + + foreach (['mailshots_db_name' => 'Mailshots DB', 'members_db_name' => 'Members DB'] as $key => $label) { + try { + $dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', + $settings['db_host'], + $settings['db_port'], + $settings[$key] + ); + $pdoDb = new \PDO( + $dsn, + (string) $settings['db_user'], + (string) ($settings['db_password'] ?? ''), + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ] + ); + $pdoDb->query('SELECT 1'); + $messages[] = $label . ' is reachable: ' . $settings[$key]; + } catch (\Throwable $e) { + $messages[] = $label . ' failed: ' . $e->getMessage(); + return ['ok' => false, 'messages' => $messages]; + } + } + + return ['ok' => true, 'messages' => $messages]; + } + + /** @return array{ok:bool,messages:list}|null */ + private function testResult(): ?array + { + $raw = $this->wp->getOption(self::TEST_RESULT_OPTION_KEY, null); + if (!is_array($raw) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) { + return null; + } + return [ + 'ok' => (bool) $raw['ok'], + 'messages' => array_map(static fn($m): string => (string) $m, $raw['messages']), + ]; + } +} diff --git a/feca_mailshots_plugin/src/Application/AttachmentService.php b/feca_mailshots_plugin/src/Application/AttachmentService.php new file mode 100644 index 0000000..429d0f0 --- /dev/null +++ b/feca_mailshots_plugin/src/Application/AttachmentService.php @@ -0,0 +1,69 @@ +repo = $repo; + } + + /** @return list> */ + public function list(): array + { + return $this->repo->all(); + } + + /** @return array */ + public function save(?int $id, array $payload): array + { + $name = trim((string) ($payload['name'] ?? '')); + $fileName = trim((string) ($payload['file_name'] ?? '')); + $mimeType = trim((string) ($payload['mime_type'] ?? 'application/octet-stream')); + $base64 = (string) ($payload['file_bytes_base64'] ?? ''); + + $errors = []; + if ($name === '') { + $errors[] = 'name is required.'; + } + if ($fileName === '') { + $errors[] = 'file_name is required.'; + } + + $fileBytes = base64_decode($base64, true); + if ($base64 === '' || $fileBytes === false) { + $errors[] = 'file_bytes_base64 is required and must be valid base64.'; + } + + if ($errors !== []) { + return ['ok' => false, 'errors' => $errors]; + } + + $row = [ + 'name' => $name, + 'file_name' => $fileName, + 'mime_type' => $mimeType, + 'file_bytes' => $fileBytes, + ]; + + if ($id === null) { + $id = $this->repo->create($row); + } else { + $this->repo->update($id, $row); + } + + return ['ok' => true, 'id' => $id]; + } + + public function delete(int $id): void + { + $this->repo->delete($id); + } +} diff --git a/feca_mailshots_plugin/src/Application/DataSourceService.php b/feca_mailshots_plugin/src/Application/DataSourceService.php new file mode 100644 index 0000000..7141199 --- /dev/null +++ b/feca_mailshots_plugin/src/Application/DataSourceService.php @@ -0,0 +1,210 @@ +queries = $queries; + $this->router = $router; + $this->parser = $parser; + $this->validator = $validator; + $this->compiler = $compiler; + $this->metadata = $metadata; + $this->mailshots = $mailshots; + } + + /** @return list> */ + public function list(): array + { + return $this->queries->all(); + } + + /** @return array|null */ + public function get(int $id): ?array + { + return $this->queries->find($id); + } + + /** @return array */ + public function validateDsl(string $dsl): array + { + $ast = $this->parser->parse($dsl); + $validation = $this->validator->validate($ast); + $compiled = null; + if ($validation['errors'] === []) { + $compiled = $this->compiler->compile($ast); + } + + return [ + 'ast' => $ast, + 'errors' => $validation['errors'], + 'warnings' => $validation['warnings'], + 'expected_fields' => $validation['expected_fields'], + 'compiled_sql' => $compiled['sql'] ?? null, + ]; + } + + /** @return array */ + public function save(?int $id, string $name, string $dsl): array + { + $name = trim($name); + $dsl = trim($dsl); + + if ($name === '') { + return ['ok' => false, 'errors' => ['Name is required.']]; + } + if ($dsl === '') { + return ['ok' => false, 'errors' => ['DSL sentence is required.']]; + } + + $validation = $this->validateDsl($dsl); + if ($validation['errors'] !== []) { + return ['ok' => false, 'errors' => $validation['errors'], 'warnings' => $validation['warnings']]; + } + + if ($id === null) { + $id = $this->queries->create(['name' => $name, 'dsl_text' => $dsl]); + } else { + $this->queries->update($id, ['name' => $name, 'dsl_text' => $dsl]); + } + + return ['ok' => true, 'id' => $id, 'warnings' => $validation['warnings']]; + } + + public function delete(int $id): void + { + $row = $this->queries->find($id); + if ($row === null) { + return; + } + $name = trim((string) ($row['name'] ?? '')); + if ($name !== '' && $this->mailshots->countByDataSource($name) > 0) { + throw new \RuntimeException('Cannot delete data source: one or more mailshots reference this data source.'); + } + $this->queries->delete($id); + } + + /** @return array */ + public function preview(string $dsl, int $limit = 50): array + { + $limit = max(1, min(200, $limit)); + $validation = $this->validateDsl($dsl); + if ($validation['errors'] !== []) { + return ['errors' => $validation['errors'], 'warnings' => $validation['warnings'], 'rows' => [], 'count' => 0, 'columns' => []]; + } + + $ast = $validation['ast']; + $compiled = $this->compiler->compile($ast); + + $countSql = 'SELECT COUNT(*) FROM (' . $compiled['sql'] . ') AS q'; + $stmtCount = $this->router->membersPdo()->prepare($countSql); + $stmtCount->execute($compiled['params']); + $count = (int) $stmtCount->fetchColumn(); + + $previewSql = $compiled['sql'] . ' LIMIT ' . $limit; + $stmtRows = $this->router->membersPdo()->prepare($previewSql); + $stmtRows->execute($compiled['params']); + $rows = $stmtRows->fetchAll(PDO::FETCH_ASSOC); + + $columnSet = []; + foreach ($validation['expected_fields'] as $field) { + $columnSet[$field] = true; + } + foreach ($rows as $row) { + foreach (array_keys($row) as $key) { + $columnSet[$key] = true; + } + } + + return [ + 'errors' => [], + 'warnings' => $validation['warnings'], + 'count' => $count, + 'rows' => $rows, + 'columns' => array_keys($columnSet), + 'expected_fields' => $validation['expected_fields'], + ]; + } + + /** @return array> */ + public function sourceFields(): array + { + $result = []; + foreach ($this->metadata->allKnownSources() as $source) { + $result[$source] = $this->metadata->sourceFields($source); + } + return $result; + } + + /** @return list */ + public function listSchemas(): array + { + $schemas = []; + $membersSchema = $this->router->membersDbName(); + if ($membersSchema !== '') { + $schemas[] = $membersSchema; + } + + $sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name'; + $rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $name = (string) ($row['schema_name'] ?? ''); + if ($name === '' || in_array($name, ['information_schema', 'performance_schema', 'mysql', 'sys'], true)) { + continue; + } + $schemas[] = $name; + } + + $schemas = array_values(array_unique($schemas)); + sort($schemas, SORT_NATURAL | SORT_FLAG_CASE); + return $schemas; + } + + /** @return list */ + public function listTables(string $schema): array + { + $schema = trim($schema); + if ($schema === '') { + return []; + } + $sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = :schema ORDER BY table_name'; + $stmt = $this->router->membersPdo()->prepare($sql); + $stmt->execute(['schema' => $schema]); + return array_map(static fn(array $row): string => (string) ($row['table_name'] ?? ''), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return list */ + public function sourceFieldsForSource(string $source): array + { + $source = trim($source); + if ($source === '') { + return []; + } + return $this->metadata->sourceFields($source); + } +} diff --git a/feca_mailshots_plugin/src/Application/DslCompiler.php b/feca_mailshots_plugin/src/Application/DslCompiler.php new file mode 100644 index 0000000..0d012f2 --- /dev/null +++ b/feca_mailshots_plugin/src/Application/DslCompiler.php @@ -0,0 +1,181 @@ +metadata = $metadata; + } + + /** + * @param array{sources:list, where:array} $ast + * @return array{sql:string,params:list} + */ + public function compile(array $ast): array + { + $sources = $ast['sources']; + if ($sources === []) { + throw new AppError('dsl_compile', 'No sources provided'); + } + + $from = $this->quoteSource($sources[0]) . ' AS ' . $this->alias($sources[0]); + $joins = []; + + for ($i = 1; $i < count($sources); $i++) { + $left = $sources[$i - 1]; + $right = $sources[$i]; + $path = $this->metadata->joinPath($left, $right); + if ($path === null) { + throw new AppError('dsl_compile', sprintf('No join path between %s and %s', $left, $right)); + } + + [$leftRef, $rightRef] = $this->rewriteJoinRef($path['left'], $path['right']); + $joins[] = 'INNER JOIN ' . $this->quoteSource($right) . ' AS ' . $this->alias($right) + . ' ON ' . $leftRef . ' = ' . $rightRef; + } + + $params = []; + $whereParts = []; + foreach ($ast['where'] as $predicate) { + $whereParts[] = $this->compilePredicate($predicate, $params); + } + + $whereSql = $whereParts === [] ? '' : ' WHERE ' . implode(' AND ', $whereParts); + + $sql = 'SELECT * FROM ' . $from; + if ($joins !== []) { + $sql .= ' ' . implode(' ', $joins); + } + $sql .= $whereSql; + + return ['sql' => $sql, 'params' => $params]; + } + + /** @param list &$params */ + private function compilePredicate(array $predicate, array &$params): string + { + if ($predicate['type'] === 'group') { + $parts = []; + foreach ($predicate['items'] as $item) { + $parts[] = $this->compilePredicate($item, $params); + } + $sql = '(' . implode(' AND ', $parts) . ')'; + return $predicate['not'] ? 'NOT ' . $sql : $sql; + } + + if ($predicate['type'] === 'filter') { + $sql = $this->compileFilter($predicate['name']); + return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql; + } + + if ($predicate['type'] === 'comparison_value') { + $lhs = $this->fieldRefSql($predicate['lhs']); + $op = $predicate['op']; + $value = $predicate['rhs']; + if ($op === 'contains') { + $op = 'LIKE'; + $value = '%' . $value . '%'; + } elseif ($op === 'starts-with') { + $op = 'LIKE'; + $value = $value . '%'; + } elseif ($op === 'ends-with') { + $op = 'LIKE'; + $value = '%' . $value; + } + $params[] = $value; + $sql = sprintf('%s %s ?', $lhs, $op); + return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql; + } + + if ($predicate['type'] === 'comparison_field') { + $lhs = $this->fieldRefSql($predicate['lhs']); + $rhs = $this->fieldRefSql($predicate['rhs']); + $sql = sprintf('%s %s %s', $lhs, $predicate['op'], $rhs); + return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql; + } + + if ($predicate['type'] === 'set_values') { + $lhs = $this->fieldRefSql($predicate['lhs']); + $placeholders = []; + foreach ($predicate['values'] as $value) { + $placeholders[] = '?'; + $params[] = $value; + } + $op = $predicate['op'] === 'not in' ? 'NOT IN' : 'IN'; + $sql = sprintf('%s %s (%s)', $lhs, $op, implode(',', $placeholders)); + return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql; + } + + if ($predicate['type'] === 'set_ref') { + $lhs = $this->fieldRefSql($predicate['lhs']); + $rhs = $this->fieldRefSql($predicate['rhs']); + $op = $predicate['op'] === 'not in' ? 'NOT IN' : 'IN'; + $sql = sprintf('%s %s (SELECT %s)', $lhs, $op, $rhs); + return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql; + } + + throw new AppError('dsl_compile', 'Unknown predicate type', ['type' => $predicate['type']]); + } + + private function compileFilter(string $name): string + { + if ($name === 'selected-renewal') { + return $this->alias('renewals') . '.`selected` = 1'; + } + if ($name === 'pending-renewal') { + return $this->alias('renewals') . '.`status` = \'pending\''; + } + if ($name === 'fen1-contact') { + return $this->alias('contacts') . '.`FENContact1` = 1'; + } + if ($name === 'fen2-contact') { + return $this->alias('contacts') . '.`FENContact2` = 1'; + } + if ($name === 'member-or-affiliate-or-parish-council') { + $acc = $this->alias('accounts'); + return sprintf("(%s.`Type` IN ('Member','Affiliate') OR %s.`Name` LIKE '%%Parish Council%%')", $acc, $acc); + } + + throw new AppError('dsl_compile', 'Unknown filter', ['filter' => $name]); + } + + /** @return array{string,string} */ + private function rewriteJoinRef(string $left, string $right): array + { + $leftParts = explode('.', $left, 2); + $rightParts = explode('.', $right, 2); + + return [ + $this->alias($leftParts[0]) . '.`' . $leftParts[1] . '`', + $this->alias($rightParts[0]) . '.`' . $rightParts[1] . '`', + ]; + } + + /** @param array{source:string,field:string} $fieldRef */ + private function fieldRefSql(array $fieldRef): string + { + return $this->alias($fieldRef['source']) . '.`' . $fieldRef['field'] . '`'; + } + + private function alias(string $source): string + { + return 's_' . preg_replace('/[^a-z0-9_]/', '_', strtolower($source)); + } + + private function quoteSource(string $source): string + { + if (str_contains($source, '.')) { + [$schema, $table] = explode('.', $source, 2); + return '`' . $schema . '`.`' . $table . '`'; + } + return '`' . $source . '`'; + } +} diff --git a/feca_mailshots_plugin/src/Application/DslValidator.php b/feca_mailshots_plugin/src/Application/DslValidator.php new file mode 100644 index 0000000..4e813ef --- /dev/null +++ b/feca_mailshots_plugin/src/Application/DslValidator.php @@ -0,0 +1,158 @@ +> */ + private array $filterSourceRequirements = [ + 'selected-renewal' => ['renewals'], + 'pending-renewal' => ['renewals'], + 'fen1-contact' => ['contacts'], + 'fen2-contact' => ['contacts'], + 'member-or-affiliate-or-parish-council' => ['accounts'], + ]; + + public function __construct(SourceMetadataProvider $metadata) + { + $this->metadata = $metadata; + } + + /** + * @param array{sources:list, where:array} $ast + * @return array{errors:list,warnings:list,expected_fields:list} + */ + public function validate(array $ast): array + { + $errors = []; + $warnings = []; + + $sources = $ast['sources']; + if ($sources === []) { + $errors[] = 'At least one source must be provided.'; + return ['errors' => $errors, 'warnings' => $warnings, 'expected_fields' => []]; + } + + $hasBuiltIn = false; + $hasCustom = false; + foreach ($sources as $source) { + if (!$this->metadata->sourceExists($source)) { + $errors[] = 'Unknown source: ' . $source; + continue; + } + if (str_contains($source, '.')) { + $hasCustom = true; + } else { + $hasBuiltIn = true; + } + } + + if ($hasBuiltIn && $hasCustom) { + $errors[] = 'Mixing built-in and custom sources is not supported in v1.5.'; + } + + for ($i = 1; $i < count($sources); $i++) { + if ($this->metadata->joinPath($sources[$i - 1], $sources[$i]) === null) { + $errors[] = sprintf('No approved join path between %s and %s', $sources[$i - 1], $sources[$i]); + } + } + + $expectedFields = []; + foreach ($sources as $source) { + foreach ($this->metadata->sourceFields($source) as $field) { + $expectedFields[] = $source . '.' . $field; + } + } + + if (!$this->hasAnyEmailField($sources)) { + $warnings[] = 'Data Source does not have an email field, it cannot be used for a Mailshot'; + } + + foreach ($ast['where'] as $predicate) { + $this->validatePredicate($predicate, $sources, $errors); + } + + return [ + 'errors' => $errors, + 'warnings' => $warnings, + 'expected_fields' => $expectedFields, + ]; + } + + /** @param list $sources @param list &$errors */ + private function validatePredicate(array $predicate, array $sources, array &$errors): void + { + if ($predicate['type'] === 'group') { + foreach ($predicate['items'] as $item) { + $this->validatePredicate($item, $sources, $errors); + } + return; + } + + if ($predicate['type'] === 'filter') { + $name = $predicate['name']; + if (!isset($this->filterSourceRequirements[$name])) { + $errors[] = 'Unknown filter: ' . $name; + return; + } + foreach ($this->filterSourceRequirements[$name] as $requiredSource) { + if (!in_array($requiredSource, $sources, true)) { + $errors[] = sprintf('Filter %s requires source %s', $name, $requiredSource); + } + } + return; + } + + if (isset($predicate['lhs'])) { + $this->validateFieldRef($predicate['lhs'], $sources, $errors); + } + if (isset($predicate['rhs']) && is_array($predicate['rhs']) && isset($predicate['rhs']['source'])) { + $this->validateFieldRef($predicate['rhs'], $sources, $errors); + } + + if ($predicate['type'] === 'comparison_field' && !in_array($predicate['op'], ['=', '!='], true)) { + $errors[] = 'Field-to-field comparison supports only = and !='; + } + + if ($predicate['type'] === 'comparison_field' && in_array($predicate['op'], ['contains', 'starts-with', 'ends-with'], true)) { + $errors[] = $predicate['op'] . ' requires a literal right-hand value'; + } + } + + /** @param array{source:string,field:string} $fieldRef @param list $sources @param list &$errors */ + private function validateFieldRef(array $fieldRef, array $sources, array &$errors): void + { + $source = $fieldRef['source']; + if (!in_array($source, $sources, true) && !str_contains($source, '.')) { + $errors[] = 'Field source is not in selected sources: ' . $source; + return; + } + + if (!$this->metadata->sourceExists($source)) { + $errors[] = 'Unknown field source: ' . $source; + return; + } + + $fields = $this->metadata->sourceFields($source); + if (!in_array($fieldRef['field'], $fields, true)) { + $errors[] = sprintf('Unknown field %s on %s', $fieldRef['field'], $source); + } + } + + /** @param list $sources */ + private function hasAnyEmailField(array $sources): bool + { + foreach ($sources as $source) { + if ($this->metadata->hasEmailField($source)) { + return true; + } + } + return false; + } +} diff --git a/feca_mailshots_plugin/src/Application/ImapAppender.php b/feca_mailshots_plugin/src/Application/ImapAppender.php new file mode 100644 index 0000000..bb3ef42 --- /dev/null +++ b/feca_mailshots_plugin/src/Application/ImapAppender.php @@ -0,0 +1,11 @@ + $credentials */ + public function appendSent(array $credentials, string $rawMime, string $attemptId): void; +} diff --git a/feca_mailshots_plugin/src/Application/MailCredentialsProvider.php b/feca_mailshots_plugin/src/Application/MailCredentialsProvider.php new file mode 100644 index 0000000..56f5cb2 --- /dev/null +++ b/feca_mailshots_plugin/src/Application/MailCredentialsProvider.php @@ -0,0 +1,11 @@ +|null */ + public function credentials(): ?array; +} diff --git a/feca_mailshots_plugin/src/Application/MailshotRunService.php b/feca_mailshots_plugin/src/Application/MailshotRunService.php new file mode 100644 index 0000000..cdc1cb0 --- /dev/null +++ b/feca_mailshots_plugin/src/Application/MailshotRunService.php @@ -0,0 +1,437 @@ +mailshots = $mailshots; + $this->queries = $queries; + $this->dataSources = $dataSources; + $this->renderer = $renderer; + $this->smtp = $smtp; + $this->imap = $imap; + $this->credentials = $credentials; + $this->lastRun = $lastRun; + } + + /** @return array */ + public function defaultTestEmail(): array + { + $raw = getenv('MAILSHOT_TEST_TO_DEFAULT'); + return ['default_test_email' => is_string($raw) ? trim($raw) : '']; + } + + /** @return array */ + public function previewRecipients(int $mailshotId, int $limit = 100): array + { + try { + [, $rows] = $this->loadMailshotAndRows($mailshotId); + } catch (\Throwable $e) { + return ['ok' => false, 'errors' => [$e->getMessage()], 'rows' => []]; + } + + $limit = max(1, min(500, $limit)); + $rows = array_slice($rows, 0, $limit); + $out = []; + foreach (array_values($rows) as $i => $row) { + [$keyField, $keyValue] = $this->detectRecipientKey($row, $i); + $out[] = [ + 'index' => $i, + 'recipient_key_field' => $keyField, + 'recipient_key' => $keyValue, + 'recipient_email' => $this->detectEmail($row), + 'row' => $row, + ]; + } + + return ['ok' => true, 'rows' => $out, 'count' => count($out)]; + } + + /** @return array */ + public function renderTest(int $mailshotId, int $recipientIndex): array + { + [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); + if (!isset($rows[$recipientIndex])) { + return ['ok' => false, 'errors' => ['Selected recipient row was not found.']]; + } + + try { + $rendered = $this->renderer->render( + (string) $mailshot['Subject'], + (string) $mailshot['Message'], + (string) ($mailshot['PDFAttachment'] ?? ''), + (array) $rows[$recipientIndex] + ); + } catch (\Throwable $e) { + return ['ok' => false, 'errors' => ['Template render failed: ' . $e->getMessage()]]; + } + + return ['ok' => true, 'rendered' => $rendered, 'recipient' => $rows[$recipientIndex]]; + } + + /** @return array */ + public function sendTest(int $mailshotId, int $recipientIndex, string $testEmail): array + { + $testEmail = trim($testEmail); + if ($testEmail === '') { + return ['ok' => false, 'errors' => ['Test email address is required.']]; + } + + $creds = $this->credentials->credentials(); + if ($creds === null) { + return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']]; + } + + $render = $this->renderTest($mailshotId, $recipientIndex); + if (($render['ok'] ?? false) !== true) { + return $render; + } + + $mailshot = $this->mailshots->find($mailshotId); + if ($mailshot === null) { + return ['ok' => false, 'errors' => ['Mailshot not found.']]; + } + + $cc = $this->splitAddresses((string) ($mailshot['CC'] ?? '')); + $bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? '')); + + $attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis'); + + try { + $send = $this->smtp->send( + $creds, + [$testEmail], + $cc, + $bcc, + (string) $render['rendered']['subject'], + (string) $render['rendered']['message'], + (string) ($mailshot['ReplyTo'] ?? '') + ); + } catch (\Throwable $e) { + return ['ok' => false, 'errors' => ['SMTP send failed: ' . $e->getMessage()]]; + } + + $warnings = []; + try { + $this->imap->appendSent($creds, (string) $send['raw_mime'], $attemptId); + } catch (\Throwable $e) { + $warnings[] = 'IMAP sent-copy failed: ' . $e->getMessage(); + } + + return [ + 'ok' => true, + 'warnings' => $warnings, + 'sent_to' => $testEmail, + 'sent_at' => gmdate('c'), + 'rendered' => $render['rendered'], + ]; + } + + /** @return array */ + public function runMailshot(int $mailshotId): array + { + $creds = $this->credentials->credentials(); + if ($creds === null) { + return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']]; + } + + [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); + if ($rows === []) { + return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']]; + } + + $this->lastRun->clearForMailshot($mailshotId); + return $this->executeSendLoop($mailshotId, $mailshot, $rows, $creds, true); + } + + /** @return array */ + public function retryFailed(int $mailshotId): array + { + $creds = $this->credentials->credentials(); + if ($creds === null) { + return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']]; + } + + [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); + $failed = array_values(array_filter($this->lastRun->listForMailshot($mailshotId), static fn(array $r): bool => (string) ($r['status'] ?? '') === 'failed')); + + if ($failed === []) { + return ['ok' => true, 'attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0, 'log' => []]; + } + + $retryRows = []; + foreach ($failed as $f) { + $keyField = (string) ($f['recipient_key_field'] ?? ''); + $key = (string) ($f['recipient_key'] ?? ''); + $matched = $this->findRecipientByKey($rows, $keyField, $key); + if ($matched !== null) { + $retryRows[] = $matched; + } + } + + return $this->executeSendLoop($mailshotId, $mailshot, $retryRows, $creds, false); + } + + /** @return array */ + public function retryRecipient(int $mailshotId, string $recipientKey): array + { + $recipientKey = trim($recipientKey); + if ($recipientKey === '') { + return ['ok' => false, 'errors' => ['recipient_key is required.']]; + } + + $creds = $this->credentials->credentials(); + if ($creds === null) { + return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']]; + } + + [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); + $entries = $this->lastRun->listForMailshot($mailshotId); + $candidate = null; + foreach ($entries as $e) { + if ((string) ($e['recipient_key'] ?? '') === $recipientKey) { + $candidate = $e; + break; + } + } + if ($candidate === null) { + return ['ok' => false, 'errors' => ['No last-run row found for recipient_key.']]; + } + + $row = $this->findRecipientByKey($rows, (string) ($candidate['recipient_key_field'] ?? ''), $recipientKey); + if ($row === null) { + return ['ok' => false, 'errors' => ['Recipient not found in current data source result.']]; + } + + return $this->executeSendLoop($mailshotId, $mailshot, [$row], $creds, false); + } + + /** @return array{0:array,1:list>} */ + private function loadMailshotAndRows(int $mailshotId): array + { + $mailshot = $this->mailshots->find($mailshotId); + if ($mailshot === null) { + throw new \RuntimeException('Mailshot not found.'); + } + + $dataSourceName = trim((string) ($mailshot['DataSource'] ?? '')); + $query = $this->queries->findByName($dataSourceName); + if ($query === null) { + throw new \RuntimeException('Mailshot data source is missing.'); + } + + $dsl = trim((string) ($query['dsl_text'] ?? '')); + if ($dsl === '') { + throw new \RuntimeException('Data source DSL is empty.'); + } + + $preview = $this->dataSources->preview($dsl, 5000); + if (($preview['errors'] ?? []) !== []) { + throw new \RuntimeException('Data source preview failed: ' . implode('; ', $preview['errors'])); + } + + return [$mailshot, $preview['rows'] ?? []]; + } + + /** + * @param list> $rows + * @param array $mailshot + * @param array $creds + * @return array + */ + private function executeSendLoop(int $mailshotId, array $mailshot, array $rows, array $creds, bool $freshRun): array + { + $cc = $this->splitAddresses((string) ($mailshot['CC'] ?? '')); + $bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? '')); + + $counters = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0, 'skipped' => 0]; + $log = []; + + foreach (array_values($rows) as $index => $row) { + $counters['attempted']++; + $recipientEmail = $this->detectEmail($row); + [$recipientKeyField, $recipientKey] = $this->detectRecipientKey($row, $index); + + if ($recipientEmail === null) { + $counters['failed']++; + $log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => 'No email field found in recipient row.']; + $this->lastRun->create([ + 'mailshot_id' => $mailshotId, + 'data_source' => (string) ($mailshot['DataSource'] ?? ''), + 'row_index' => $index, + 'recipient_key' => $recipientKey, + 'recipient_key_field' => $recipientKeyField, + 'recipient_email_last' => null, + 'status' => 'failed', + 'error_message' => 'No email field found in recipient row.', + 'attempt_count' => $freshRun ? 1 : 2, + ]); + continue; + } + + try { + $render = $this->renderer->render( + (string) $mailshot['Subject'], + (string) $mailshot['Message'], + (string) ($mailshot['PDFAttachment'] ?? ''), + $row + ); + } catch (\Throwable $e) { + $counters['failed']++; + $log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => 'Template render failed: ' . $e->getMessage()]; + $this->lastRun->create([ + 'mailshot_id' => $mailshotId, + 'data_source' => (string) ($mailshot['DataSource'] ?? ''), + 'row_index' => $index, + 'recipient_key' => $recipientKey, + 'recipient_key_field' => $recipientKeyField, + 'recipient_email_last' => $recipientEmail, + 'status' => 'failed', + 'error_message' => 'Template render failed: ' . $e->getMessage(), + 'attempt_count' => $freshRun ? 1 : 2, + ]); + continue; + } + + try { + $smtp = $this->smtp->send( + $creds, + [$recipientEmail], + $cc, + $bcc, + (string) $render['subject'], + (string) $render['message'], + (string) ($mailshot['ReplyTo'] ?? '') + ); + + $warning = null; + try { + $attemptId = sprintf('run_%d_%s_%d', $mailshotId, $recipientKey, time()); + $this->imap->appendSent($creds, (string) $smtp['raw_mime'], $attemptId); + } catch (\Throwable $imapErr) { + $warning = 'IMAP sent-copy failed: ' . $imapErr->getMessage(); + $counters['warnings']++; + } + + $counters['sent']++; + $log[] = ['recipient_key' => $recipientKey, 'status' => 'sent', 'warning' => $warning]; + + $this->lastRun->create([ + 'mailshot_id' => $mailshotId, + 'data_source' => (string) ($mailshot['DataSource'] ?? ''), + 'row_index' => $index, + 'recipient_key' => $recipientKey, + 'recipient_key_field' => $recipientKeyField, + 'recipient_email_last' => $recipientEmail, + 'status' => $warning === null ? 'sent' : 'warning', + 'warning_message' => $warning, + 'attempt_count' => $freshRun ? 1 : 2, + ]); + } catch (\Throwable $e) { + $counters['failed']++; + $log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => $e->getMessage()]; + $this->lastRun->create([ + 'mailshot_id' => $mailshotId, + 'data_source' => (string) ($mailshot['DataSource'] ?? ''), + 'row_index' => $index, + 'recipient_key' => $recipientKey, + 'recipient_key_field' => $recipientKeyField, + 'recipient_email_last' => $recipientEmail, + 'status' => 'failed', + 'error_message' => $e->getMessage(), + 'attempt_count' => $freshRun ? 1 : 2, + ]); + } + } + + return ['ok' => true] + $counters + ['log' => $log]; + } + + /** @param array $row @return array{0:string,1:string} */ + private function detectRecipientKey(array $row, int $index): array + { + foreach (['ID', 'id', 'Accountid', 'account_id'] as $keyField) { + if (isset($row[$keyField]) && trim((string) $row[$keyField]) !== '') { + return [$keyField, trim((string) $row[$keyField])]; + } + } + if (($email = $this->detectEmail($row)) !== null) { + return ['email', $email]; + } + return ['row_index', (string) $index]; + } + + /** @param list> $rows */ + private function findRecipientByKey(array $rows, string $keyField, string $key): ?array + { + foreach ($rows as $r) { + if ($keyField !== '' && isset($r[$keyField]) && trim((string) $r[$keyField]) === $key) { + return $r; + } + if ($keyField === 'email') { + $email = $this->detectEmail($r); + if ($email !== null && $email === $key) { + return $r; + } + } + } + return null; + } + + /** @param array $row */ + private function detectEmail(array $row): ?string + { + foreach (['Email', 'email', 'contact_email', 'account_email', 'recipient_email', 'recipient_email_last'] as $key) { + if (isset($row[$key])) { + $v = trim((string) $row[$key]); + if ($v !== '' && filter_var($v, FILTER_VALIDATE_EMAIL)) { + return $v; + } + } + } + return null; + } + + /** @return list */ + private function splitAddresses(string $list): array + { + if (trim($list) === '') { + return []; + } + + $parts = preg_split('/[;,]+/', $list) ?: []; + $out = []; + foreach ($parts as $p) { + $v = trim((string) $p); + if ($v !== '' && filter_var($v, FILTER_VALIDATE_EMAIL)) { + $out[] = $v; + } + } + return array_values(array_unique($out)); + } +} diff --git a/feca_mailshots_plugin/src/Application/MailshotService.php b/feca_mailshots_plugin/src/Application/MailshotService.php new file mode 100644 index 0000000..cc9ac7f --- /dev/null +++ b/feca_mailshots_plugin/src/Application/MailshotService.php @@ -0,0 +1,185 @@ +mailshots = $mailshots; + $this->queries = $queries; + $this->attachments = $attachments; + $this->lastRun = $lastRun; + $this->dataSources = $dataSources; + } + + /** @return list> */ + public function list(): array + { + return $this->mailshots->all(); + } + + /** @return list */ + public function dataSourceNames(): array + { + $rows = $this->queries->all(); + return array_map(static fn(array $r): string => (string) $r['name'], $rows); + } + + /** @return list */ + public function attachmentNames(): array + { + return $this->attachments->names(); + } + + /** @return array */ + public function save(?int $id, array $payload): array + { + $purpose = trim((string) ($payload['Purpose'] ?? '')); + $dataSource = trim((string) ($payload['DataSource'] ?? '')); + $subject = trim((string) ($payload['Subject'] ?? '')); + $message = trim((string) ($payload['Message'] ?? '')); + + $errors = []; + if ($dataSource === '') { + $errors[] = 'DataSource is required.'; + } elseif ($this->queries->findByName($dataSource) === null) { + $errors[] = 'DataSource must match an existing mailshot data source.'; + } + if ($subject === '') { + $errors[] = 'Subject is required.'; + } + if ($message === '') { + $errors[] = 'Message is required.'; + } + + $attachmentNames = $this->normalizeAttachmentNames($payload['AttachmentNames'] ?? null); + + if ($errors !== []) { + return ['ok' => false, 'errors' => $errors]; + } + + $row = [ + 'Purpose' => $purpose, + 'DataSource' => $dataSource, + 'CC' => trim((string) ($payload['CC'] ?? '')), + 'BCC' => trim((string) ($payload['BCC'] ?? '')), + 'Subject' => $subject, + 'Message' => $message, + 'PDFAttachment' => (string) ($payload['PDFAttachment'] ?? ''), + 'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES), + 'PDFFilenameDerivedFrom' => trim((string) ($payload['PDFFilenameDerivedFrom'] ?? '')), + 'ReplyTo' => trim((string) ($payload['ReplyTo'] ?? '')), + ]; + + if ($id === null) { + $id = $this->mailshots->create($row); + } else { + $this->mailshots->update($id, $row); + } + + return ['ok' => true, 'id' => $id]; + } + + public function delete(int $id): void + { + $this->mailshots->delete($id); + } + + /** @return list> */ + public function lastRun(int $mailshotId): array + { + return $this->lastRun->listForMailshot($mailshotId); + } + + public function clearLastRun(int $mailshotId): void + { + $this->lastRun->clearForMailshot($mailshotId); + } + + /** @return array{tokens:list>, errors:list} */ + public function tokenInsertionData(string $dataSourceName): array + { + $query = $this->queries->findByName($dataSourceName); + if ($query === null) { + return ['tokens' => [], 'errors' => ['Unknown data source.']]; + } + + $dsl = trim((string) ($query['dsl_text'] ?? '')); + if ($dsl === '') { + return ['tokens' => [], 'errors' => ['Data source has no DSL text.']]; + } + + $validation = $this->dataSources->validateDsl($dsl); + if (($validation['errors'] ?? []) !== []) { + return ['tokens' => [], 'errors' => $validation['errors']]; + } + + $tokens = []; + $seen = []; + foreach (($validation['expected_fields'] ?? []) as $qualifiedField) { + $tokenName = $this->canonicalTokenName((string) $qualifiedField); + if ($tokenName === '' || isset($seen[$tokenName])) { + continue; + } + $seen[$tokenName] = true; + $tokens[] = [ + 'field' => (string) $qualifiedField, + 'token_name' => $tokenName, + 'token' => '{{ ' . $tokenName . ' }}', + ]; + } + + return ['tokens' => $tokens, 'errors' => []]; + } + + /** @return list */ + private function normalizeAttachmentNames($raw): array + { + $decoded = []; + if (is_string($raw) && trim($raw) !== '') { + $fromJson = json_decode($raw, true); + if (is_array($fromJson)) { + $decoded = $fromJson; + } + } elseif (is_array($raw)) { + $decoded = $raw; + } + + $result = []; + foreach ($decoded as $item) { + $v = trim((string) $item); + if ($v !== '') { + $result[] = $v; + } + } + return array_values(array_unique($result)); + } + + private function canonicalTokenName(string $qualifiedField): string + { + $name = strtolower($qualifiedField); + $name = str_replace('.', '_', $name); + $name = preg_replace('/[^a-z0-9_]+/', '_', $name) ?? ''; + $name = preg_replace('/_+/', '_', $name) ?? ''; + return trim($name, '_'); + } +} diff --git a/feca_mailshots_plugin/src/Application/PdfAssetService.php b/feca_mailshots_plugin/src/Application/PdfAssetService.php new file mode 100644 index 0000000..9b2e5d0 --- /dev/null +++ b/feca_mailshots_plugin/src/Application/PdfAssetService.php @@ -0,0 +1,81 @@ +repo = $repo; + } + + /** @return list> */ + public function list(): array + { + return $this->repo->all(); + } + + /** @return array */ + public function save(?int $id, array $payload): array + { + $name = trim((string) ($payload['name'] ?? '')); + $fileName = trim((string) ($payload['file_name'] ?? '')); + $mimeType = trim((string) ($payload['mime_type'] ?? 'application/octet-stream')); + $justification = trim((string) ($payload['justification'] ?? 'in-place')); + $width = (float) ($payload['width_mm'] ?? 0); + $height = (float) ($payload['height_mm'] ?? 0); + $base64 = (string) ($payload['file_bytes_base64'] ?? ''); + + $errors = []; + if ($name === '') { + $errors[] = 'name is required.'; + } + if ($fileName === '') { + $errors[] = 'file_name is required.'; + } + if ($width <= 0 || $height <= 0) { + $errors[] = 'width_mm and height_mm must be > 0.'; + } + if (!in_array($justification, ['left', 'right', 'in-place'], true)) { + $errors[] = 'justification must be one of: left, right, in-place.'; + } + + $fileBytes = base64_decode($base64, true); + if ($base64 === '' || $fileBytes === false) { + $errors[] = 'file_bytes_base64 is required and must be valid base64.'; + } + + if ($errors !== []) { + return ['ok' => false, 'errors' => $errors]; + } + + $row = [ + 'name' => $name, + 'file_name' => $fileName, + 'mime_type' => $mimeType, + 'file_bytes' => $fileBytes, + 'width_mm' => $width, + 'height_mm' => $height, + 'justification' => $justification, + ]; + + if ($id === null) { + $id = $this->repo->create($row); + } else { + $this->repo->update($id, $row); + } + + return ['ok' => true, 'id' => $id]; + } + + public function delete(int $id): void + { + $this->repo->delete($id); + } +} diff --git a/feca_mailshots_plugin/src/Application/PerUserMailCredentialsProvider.php b/feca_mailshots_plugin/src/Application/PerUserMailCredentialsProvider.php new file mode 100644 index 0000000..fe7edac --- /dev/null +++ b/feca_mailshots_plugin/src/Application/PerUserMailCredentialsProvider.php @@ -0,0 +1,41 @@ +repo = $repo; + $this->wp = $wp; + } + + public function credentials(): ?array + { + $uid = $this->wp->currentUserId(); + if ($uid <= 0) { + return null; + } + + $row = $this->repo->findByUserId($uid); + if ($row === null) { + return null; + } + + foreach (['smtp_host', 'smtp_user', 'smtp_password', 'smtp_from_email'] as $required) { + if (trim((string) ($row[$required] ?? '')) === '') { + return null; + } + } + + return $row; + } +} diff --git a/feca_mailshots_plugin/src/Application/SecretKeyProvider.php b/feca_mailshots_plugin/src/Application/SecretKeyProvider.php new file mode 100644 index 0000000..23bcb2f --- /dev/null +++ b/feca_mailshots_plugin/src/Application/SecretKeyProvider.php @@ -0,0 +1,10 @@ + $credentials + * @param list $to + * @param list $cc + * @param list $bcc + * @return array{raw_mime:string} + */ + public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null): array; +} diff --git a/feca_mailshots_plugin/src/Application/SourceMetadataProvider.php b/feca_mailshots_plugin/src/Application/SourceMetadataProvider.php new file mode 100644 index 0000000..d68daab --- /dev/null +++ b/feca_mailshots_plugin/src/Application/SourceMetadataProvider.php @@ -0,0 +1,21 @@ + */ + public function sourceFields(string $source): array; + + public function hasEmailField(string $source): bool; + + /** @return array{left:string,right:string}|null */ + public function joinPath(string $left, string $right): ?array; + + /** @return list */ + public function allKnownSources(): array; +} diff --git a/feca_mailshots_plugin/src/Application/TemplateRenderer.php b/feca_mailshots_plugin/src/Application/TemplateRenderer.php new file mode 100644 index 0000000..8ef274b --- /dev/null +++ b/feca_mailshots_plugin/src/Application/TemplateRenderer.php @@ -0,0 +1,55 @@ + $context + * @return array{subject:string,message:string,pdf_attachment:string,warnings:list} + */ + public function render(string $subjectTpl, string $messageTpl, string $pdfTpl, array $context): array + { + if (!class_exists('Twig\\Environment')) { + throw new \RuntimeException('Twig is not available. Ensure vendor dependencies are installed.'); + } + + $loader = new \Twig\Loader\ArrayLoader([ + 'subject' => $subjectTpl, + 'message' => $messageTpl, + 'pdf' => $pdfTpl, + ]); + $twig = new \Twig\Environment($loader, [ + 'autoescape' => 'html', + 'strict_variables' => false, + 'cache' => false, + ]); + + // Provide both original keys and canonicalized token keys. + $safeContext = $this->canonicalizeContext($context); + + return [ + 'subject' => (string) $twig->render('subject', $safeContext), + 'message' => (string) $twig->render('message', $safeContext), + 'pdf_attachment' => (string) $twig->render('pdf', $safeContext), + 'warnings' => [], + ]; + } + + /** @param array $row @return array */ + private function canonicalizeContext(array $row): array + { + $out = $row; + foreach ($row as $key => $value) { + $lower = strtolower((string) $key); + $canon = preg_replace('/[^a-z0-9_]+/', '_', $lower) ?? $lower; + $canon = trim((string) preg_replace('/_+/', '_', $canon), '_'); + if ($canon !== '' && !array_key_exists($canon, $out)) { + $out[$canon] = $value; + } + } + return $out; + } +} diff --git a/feca_mailshots_plugin/src/Domain/AppError.php b/feca_mailshots_plugin/src/Domain/AppError.php new file mode 100644 index 0000000..b15cb44 --- /dev/null +++ b/feca_mailshots_plugin/src/Domain/AppError.php @@ -0,0 +1,32 @@ + */ + private array $context; + + /** @param array $context */ + public function __construct(string $codeName, string $message, array $context = [], int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + $this->codeName = $codeName; + $this->context = $context; + } + + public function codeName(): string + { + return $this->codeName; + } + + /** @return array */ + public function context(): array + { + return $this->context; + } +} diff --git a/feca_mailshots_plugin/src/Domain/DslLexer.php b/feca_mailshots_plugin/src/Domain/DslLexer.php new file mode 100644 index 0000000..00d858a --- /dev/null +++ b/feca_mailshots_plugin/src/Domain/DslLexer.php @@ -0,0 +1,108 @@ + */ + public function lex(string $input): array + { + $tokens = []; + $len = strlen($input); + $i = 0; + + while ($i < $len) { + $ch = $input[$i]; + if (ctype_space($ch)) { + $i++; + continue; + } + + if ($ch === '(' || $ch === ')' || $ch === ',' || $ch === '.') { + $tokens[] = new DslToken($ch, $ch, $i); + $i++; + continue; + } + + if ($ch === '!' && ($i + 1 < $len) && $input[$i + 1] === '=') { + $tokens[] = new DslToken('OP', '!=', $i); + $i += 2; + continue; + } + + if ($ch === '=') { + $tokens[] = new DslToken('OP', '=', $i); + $i++; + continue; + } + + if ($ch === '\'') { + $start = $i; + $i++; + $value = ''; + while ($i < $len && $input[$i] !== '\'') { + $value .= $input[$i]; + $i++; + } + if ($i >= $len) { + throw new AppError('dsl_lex', 'Unterminated string literal', ['offset' => $start]); + } + $i++; + $tokens[] = new DslToken('STRING', $value, $start); + continue; + } + + if ($ch === '`') { + $start = $i; + $i++; + $value = ''; + while ($i < $len && $input[$i] !== '`') { + $value .= $input[$i]; + $i++; + } + if ($i >= $len) { + throw new AppError('dsl_lex', 'Unterminated quoted identifier', ['offset' => $start]); + } + $i++; + $tokens[] = new DslToken('IDENT', $value, $start); + continue; + } + + if (ctype_digit($ch)) { + $start = $i; + $value = ''; + while ($i < $len && ctype_digit($input[$i])) { + $value .= $input[$i]; + $i++; + } + $tokens[] = new DslToken('NUMBER', $value, $start); + continue; + } + + if (ctype_alpha($ch) || $ch === '_') { + $start = $i; + $value = ''; + while ($i < $len && (ctype_alnum($input[$i]) || $input[$i] === '_' || $input[$i] === '-')) { + $value .= $input[$i]; + $i++; + } + $lower = strtolower($value); + $keywords = [ + 'and', 'where', 'not', 'in', 'contains', 'starts-with', 'ends-with', + 'true', 'false', + ]; + $tokens[] = in_array($lower, $keywords, true) + ? new DslToken('KW', $lower, $start) + : new DslToken('IDENT', $value, $start); + continue; + } + + throw new AppError('dsl_lex', 'Unexpected character in DSL', ['offset' => $i, 'char' => $ch]); + } + + $tokens[] = new DslToken('EOF', '', $len); + return $tokens; + } +} diff --git a/feca_mailshots_plugin/src/Domain/DslParser.php b/feca_mailshots_plugin/src/Domain/DslParser.php new file mode 100644 index 0000000..6e6a71b --- /dev/null +++ b/feca_mailshots_plugin/src/Domain/DslParser.php @@ -0,0 +1,253 @@ + */ + private array $tokens = []; + private int $pos = 0; + + /** + * @return array{sources:list, where:array} + */ + public function parse(string $input): array + { + $lexer = new DslLexer(); + $this->tokens = $lexer->lex($input); + $this->pos = 0; + + $sources = $this->parseSourceExpr(); + $where = []; + + if ($this->matchKw('where')) { + $where = $this->parsePredicateExpr(); + } + + $this->expect('EOF'); + + return [ + 'sources' => $sources, + 'where' => $where, + ]; + } + + /** @return list */ + private function parseSourceExpr(): array + { + $sources = []; + $sources[] = $this->parseSourceRef(); + + while ($this->matchKw('and')) { + $sources[] = $this->parseSourceRef(); + } + + return array_values(array_unique($sources)); + } + + private function parseSourceRef(): string + { + $left = $this->expect('IDENT')->value; + if ($this->match('.')) { + $right = $this->expect('IDENT')->value; + return $left . '.' . $right; + } + return strtolower($left); + } + + /** @return array */ + private function parsePredicateExpr(): array + { + $predicates = []; + $predicates[] = $this->parsePredicateTerm(); + + while ($this->matchKw('and')) { + $predicates[] = $this->parsePredicateTerm(); + } + + return $predicates; + } + + /** @return array */ + private function parsePredicateTerm(): array + { + $negated = $this->matchKw('not'); + + if ($this->match('(')) { + $inner = $this->parsePredicateExpr(); + $this->expect(')'); + return ['type' => 'group', 'not' => $negated, 'items' => $inner]; + } + + $ident = $this->expect('IDENT')->value; + + if ($this->isBareFilter($ident)) { + return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => []]; + } + + if ($this->match('(')) { + $args = []; + if (!$this->match(')')) { + $args = $this->parseValueList(); + $this->expect(')'); + } + return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => $args]; + } + + $lhs = $this->parseFieldRefFromIdent($ident); + + if ($this->matchKw('in')) { + $this->expect('('); + if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') { + $rhs = $this->parseFieldRef(); + $this->expect(')'); + return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'in']; + } + + $rhsValues = $this->parseValueList(); + $this->expect(')'); + return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'in']; + } + + if ($this->matchKw('not')) { + $this->expectKw('in'); + $this->expect('('); + if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') { + $rhs = $this->parseFieldRef(); + $this->expect(')'); + return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'not in']; + } + + $rhsValues = $this->parseValueList(); + $this->expect(')'); + return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'not in']; + } + + $opToken = $this->peek(); + $op = ''; + if ($opToken->type === 'OP') { + $op = $this->next()->value; + } elseif ($opToken->type === 'KW' && in_array($opToken->value, ['contains', 'starts-with', 'ends-with'], true)) { + $op = $this->next()->value; + } else { + throw new AppError('dsl_parse', 'Expected comparison operator', ['offset' => $opToken->offset]); + } + + if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') { + $rhsField = $this->parseFieldRef(); + return ['type' => 'comparison_field', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhsField, 'op' => $op]; + } + + $value = $this->parseValue(); + return ['type' => 'comparison_value', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $value, 'op' => $op]; + } + + /** @return array{source:string, field:string} */ + private function parseFieldRefFromIdent(string $ident): array + { + $this->expect('.'); + $field = $this->expect('IDENT')->value; + return ['source' => strtolower($ident), 'field' => $field]; + } + + /** @return array{source:string, field:string} */ + private function parseFieldRef(): array + { + $src = strtolower($this->expect('IDENT')->value); + $this->expect('.'); + $field = $this->expect('IDENT')->value; + return ['source' => $src, 'field' => $field]; + } + + /** @return list */ + private function parseValueList(): array + { + $values = [$this->parseValue()]; + while ($this->match(',')) { + $values[] = $this->parseValue(); + } + return $values; + } + + /** @return mixed */ + private function parseValue() + { + $token = $this->next(); + if ($token->type === 'STRING') { + return $token->value; + } + if ($token->type === 'NUMBER') { + return (int) $token->value; + } + if ($token->type === 'KW' && ($token->value === 'true' || $token->value === 'false')) { + return $token->value === 'true'; + } + + throw new AppError('dsl_parse', 'Expected literal value', ['offset' => $token->offset]); + } + + private function match(string $type): bool + { + if ($this->peek()->type === $type) { + $this->pos++; + return true; + } + return false; + } + + private function matchKw(string $value): bool + { + $token = $this->peek(); + if ($token->type === 'KW' && $token->value === $value) { + $this->pos++; + return true; + } + return false; + } + + private function expect(string $type): DslToken + { + $token = $this->next(); + if ($token->type !== $type) { + throw new AppError('dsl_parse', 'Unexpected token', ['expected' => $type, 'actual' => $token->type, 'offset' => $token->offset]); + } + return $token; + } + + private function expectKw(string $value): DslToken + { + $token = $this->next(); + if ($token->type !== 'KW' || $token->value !== $value) { + throw new AppError('dsl_parse', 'Expected keyword', ['expected' => $value, 'actual' => $token->value, 'offset' => $token->offset]); + } + return $token; + } + + private function next(): DslToken + { + $token = $this->tokens[$this->pos] ?? new DslToken('EOF', '', 0); + $this->pos++; + return $token; + } + + private function peek(int $offset = 0): DslToken + { + return $this->tokens[$this->pos + $offset] ?? new DslToken('EOF', '', 0); + } + + private function isBareFilter(string $ident): bool + { + $filters = [ + 'selected-renewal', + 'pending-renewal', + 'fen1-contact', + 'fen2-contact', + 'member-or-affiliate-or-parish-council', + ]; + $next = $this->peek(); + $blockedNext = ($next->type === '.') || ($next->type === 'OP') || ($next->type === 'KW' && in_array($next->value, ['in', 'not', 'contains', 'starts-with', 'ends-with'], true)); + return in_array(strtolower($ident), $filters, true) && !$blockedNext; + } +} diff --git a/feca_mailshots_plugin/src/Domain/DslToken.php b/feca_mailshots_plugin/src/Domain/DslToken.php new file mode 100644 index 0000000..009ff48 --- /dev/null +++ b/feca_mailshots_plugin/src/Domain/DslToken.php @@ -0,0 +1,19 @@ +type = $type; + $this->value = $value; + $this->offset = $offset; + } +} diff --git a/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php b/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php new file mode 100644 index 0000000..2e2cb4c --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php @@ -0,0 +1,132 @@ +expect($fp, [220]); + $this->cmd($fp, 'EHLO localhost', [250]); + if (empty($credentials['smtp_require_tls'])) { + // try opportunistic STARTTLS + $line = $this->cmd($fp, 'STARTTLS', [220], false); + if ($line !== null) { + if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new \RuntimeException('Failed to enable STARTTLS crypto.'); + } + $this->cmd($fp, 'EHLO localhost', [250]); + } + } + + $this->cmd($fp, 'AUTH LOGIN', [334]); + $this->cmd($fp, base64_encode($user), [334]); + $this->cmd($fp, base64_encode($pass), [235]); + $this->cmd($fp, 'MAIL FROM:<' . $from . '>', [250]); + + $allRecipients = array_values(array_unique(array_merge($to, $cc, $bcc))); + foreach ($allRecipients as $recipient) { + $this->cmd($fp, 'RCPT TO:<' . trim($recipient) . '>', [250, 251]); + } + + $this->cmd($fp, 'DATA', [354]); + + $raw = $this->buildMime($from, $fromName, $to, $cc, $bcc, $subject, $htmlBody, $replyTo); + fwrite($fp, $raw . "\r\n.\r\n"); + $this->expect($fp, [250]); + $this->cmd($fp, 'QUIT', [221], false); + + return ['raw_mime' => $raw]; + } finally { + fclose($fp); + } + } + + /** @param list $to @param list $cc @param list $bcc */ + private function buildMime(string $from, string $fromName, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo): string + { + $headers = []; + $fromHeader = $fromName !== '' ? sprintf('%s <%s>', $fromName, $from) : $from; + $headers[] = 'From: ' . $fromHeader; + $headers[] = 'To: ' . implode(', ', $to); + if ($cc !== []) { + $headers[] = 'Cc: ' . implode(', ', $cc); + } + if ($bcc !== []) { + $headers[] = 'Bcc: ' . implode(', ', $bcc); + } + if ($replyTo !== null && trim($replyTo) !== '') { + $headers[] = 'Reply-To: ' . trim($replyTo); + } + $headers[] = 'Subject: ' . $subject; + $headers[] = 'MIME-Version: 1.0'; + $headers[] = 'Content-Type: text/html; charset=UTF-8'; + + return implode("\r\n", $headers) . "\r\n\r\n" . $htmlBody; + } + + /** @param list $codes */ + private function cmd($fp, string $cmd, array $codes, bool $throwOnMismatch = true): ?string + { + fwrite($fp, $cmd . "\r\n"); + return $this->expect($fp, $codes, $throwOnMismatch); + } + + /** @param list $codes */ + private function expect($fp, array $codes, bool $throwOnMismatch = true): ?string + { + $lastLine = null; + while (true) { + $line = fgets($fp, 4096); + if ($line === false) { + if ($throwOnMismatch) { + throw new \RuntimeException('SMTP read failed.'); + } + return null; + } + + $lastLine = $line; + if (!preg_match('/^(\d{3})([\s-])/', $line, $m)) { + if ($throwOnMismatch) { + throw new \RuntimeException('SMTP malformed response: ' . trim($line)); + } + return null; + } + + $code = (int) $m[1]; + if (!in_array($code, $codes, true)) { + if ($throwOnMismatch) { + throw new \RuntimeException('SMTP unexpected response: ' . trim($line)); + } + return null; + } + + $continuation = $m[2] === '-'; + if (!$continuation) { + return $lastLine; + } + } + } +} diff --git a/feca_mailshots_plugin/src/Infrastructure/DatabaseRouter.php b/feca_mailshots_plugin/src/Infrastructure/DatabaseRouter.php new file mode 100644 index 0000000..04004e1 --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/DatabaseRouter.php @@ -0,0 +1,18 @@ +> */ + private array $builtInFields; + + /** @var array */ + private array $joinMap; + + public function __construct(DatabaseRouter $router) + { + $this->router = $router; + $this->builtInFields = [ + 'contacts' => ['ID', 'Accountid', 'First', 'Last', 'Email', 'FENContact1', 'FENContact2'], + 'accounts' => ['ID', 'Name', 'Type', 'Email'], + 'renewals' => ['id', 'account_id', 'status', 'selected', 'email'], + ]; + + $this->joinMap = [ + 'contacts|accounts' => ['left' => 'contacts.Accountid', 'right' => 'accounts.ID'], + 'accounts|contacts' => ['left' => 'accounts.ID', 'right' => 'contacts.Accountid'], + 'renewals|accounts' => ['left' => 'renewals.account_id', 'right' => 'accounts.ID'], + 'accounts|renewals' => ['left' => 'accounts.ID', 'right' => 'renewals.account_id'], + 'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.Accountid'], + 'contacts|renewals' => ['left' => 'contacts.Accountid', 'right' => 'renewals.account_id'], + ]; + } + + public function sourceExists(string $source): bool + { + if (isset($this->builtInFields[$source])) { + return true; + } + + if (!str_contains($source, '.')) { + return false; + } + + [$schema, $table] = explode('.', $source, 2); + $sql = 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = :schema AND table_name = :table'; + $stmt = $this->router->membersPdo()->prepare($sql); + $stmt->execute(['schema' => $schema, 'table' => $table]); + return ((int) $stmt->fetchColumn()) > 0; + } + + public function sourceFields(string $source): array + { + if (isset($this->builtInFields[$source])) { + return $this->builtInFields[$source]; + } + + if (!str_contains($source, '.')) { + return []; + } + + [$schema, $table] = explode('.', $source, 2); + $sql = 'SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = :schema AND table_name = :table ORDER BY ORDINAL_POSITION'; + $stmt = $this->router->membersPdo()->prepare($sql); + $stmt->execute(['schema' => $schema, 'table' => $table]); + return array_map(static fn(array $row): string => (string) $row['COLUMN_NAME'], $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function hasEmailField(string $source): bool + { + $fields = $this->sourceFields($source); + $lower = array_map('strtolower', $fields); + return in_array('email', $lower, true); + } + + public function joinPath(string $left, string $right): ?array + { + return $this->joinMap[$left . '|' . $right] ?? null; + } + + public function allKnownSources(): array + { + return ['contacts', 'accounts', 'renewals']; + } +} diff --git a/feca_mailshots_plugin/src/Infrastructure/Env.php b/feca_mailshots_plugin/src/Infrastructure/Env.php new file mode 100644 index 0000000..6924607 --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/Env.php @@ -0,0 +1,52 @@ + */ + public static function load(string $path): array + { + if (!is_file($path)) { + return []; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return []; + } + + $result = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + $parts = explode('=', $line, 2); + if (count($parts) !== 2) { + continue; + } + $key = trim($parts[0]); + $value = trim($parts[1]); + $value = trim($value, "\"'"); + $result[$key] = $value; + if (getenv($key) === false) { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + return $result; + } + + public static function require(string $name): string + { + $value = getenv($name); + if ($value === false || $value === '') { + throw new \RuntimeException('Missing required env var: ' . $name); + } + return (string) $value; + } +} diff --git a/feca_mailshots_plugin/src/Infrastructure/EnvSecretKeyProvider.php b/feca_mailshots_plugin/src/Infrastructure/EnvSecretKeyProvider.php new file mode 100644 index 0000000..68bc9c1 --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/EnvSecretKeyProvider.php @@ -0,0 +1,26 @@ +envKey = $envKey; + } + + public function keyMaterial(): string + { + $value = getenv($this->envKey); + if (!is_string($value) || trim($value) === '') { + throw new \RuntimeException('Missing fixture secret key env: ' . $this->envKey); + } + return trim($value); + } +} diff --git a/feca_mailshots_plugin/src/Infrastructure/PdoDatabaseRouter.php b/feca_mailshots_plugin/src/Infrastructure/PdoDatabaseRouter.php new file mode 100644 index 0000000..891e81c --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/PdoDatabaseRouter.php @@ -0,0 +1,79 @@ + $config */ + public function __construct(array $config) + { + $host = self::required($config, 'MYSQL_HOST'); + $port = self::required($config, 'MYSQL_PORT'); + $user = self::required($config, 'MYSQL_USER'); + $pass = self::required($config, 'MYSQL_PASSWORD'); + + $this->mailshotsDbName = self::required($config, 'MAILSHOTS_REMOTE_MYSQL_DB'); + $this->membersDbName = self::required($config, 'MEMBERS_REMOTE_MYSQL_DB'); + + $common = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', + ]; + + $this->mailshotsPdo = new PDO( + sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $this->mailshotsDbName), + $user, + $pass, + $common + ); + + $this->membersPdo = new PDO( + sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $this->membersDbName), + $user, + $pass, + $common + ); + } + + public function mailshotsPdo(): PDO + { + return $this->mailshotsPdo; + } + + public function membersPdo(): PDO + { + return $this->membersPdo; + } + + public function mailshotsDbName(): string + { + return $this->mailshotsDbName; + } + + public function membersDbName(): string + { + return $this->membersDbName; + } + + /** @param array $config */ + private static function required(array $config, string $key): string + { + if (!isset($config[$key]) || $config[$key] === '') { + throw new \RuntimeException('Missing DB config key: ' . $key); + } + return $config[$key]; + } +} diff --git a/feca_mailshots_plugin/src/Infrastructure/PhpImapAppender.php b/feca_mailshots_plugin/src/Infrastructure/PhpImapAppender.php new file mode 100644 index 0000000..3021307 --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/PhpImapAppender.php @@ -0,0 +1,50 @@ + */ + private array $attemptCache = []; + + public function appendSent(array $credentials, string $rawMime, string $attemptId): void + { + if (isset($this->attemptCache[$attemptId])) { + return; + } + + $host = (string) ($credentials['imap_host'] ?? ''); + $port = (int) ($credentials['imap_port'] ?? 993); + $user = (string) ($credentials['imap_user'] ?? ''); + $pass = (string) ($credentials['imap_password'] ?? ''); + $folder = (string) ($credentials['imap_sent_folder'] ?? 'Sent'); + $flags = (string) ($credentials['imap_mailbox_flags'] ?? '/imap/ssl'); + + if ($host === '' || $user === '' || $pass === '') { + throw new \RuntimeException('IMAP credentials are missing.'); + } + + if (!function_exists('imap_open') || !function_exists('imap_append')) { + throw new \RuntimeException('IMAP extension is not available in PHP runtime.'); + } + + $mailbox = sprintf('{%s:%d%s}%s', $host, $port, $flags, $folder); + $imap = @imap_open($mailbox, $user, $pass); + if ($imap === false) { + throw new \RuntimeException('Failed to open IMAP mailbox: ' . (imap_last_error() ?: 'unknown error')); + } + + try { + if (!@imap_append($imap, $mailbox, $rawMime . "\r\n", "\\Seen")) { + throw new \RuntimeException('Failed to append to IMAP Sent folder: ' . (imap_last_error() ?: 'unknown error')); + } + $this->attemptCache[$attemptId] = true; + } finally { + imap_close($imap); + } + } +} diff --git a/feca_mailshots_plugin/src/Infrastructure/WordPressSaltSecretKeyProvider.php b/feca_mailshots_plugin/src/Infrastructure/WordPressSaltSecretKeyProvider.php new file mode 100644 index 0000000..205aeaf --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/WordPressSaltSecretKeyProvider.php @@ -0,0 +1,26 @@ +set('logger', static fn() => new ErrorLogLogger()); + + $c->set(DatabaseRouter::class, static fn() => new PdoDatabaseRouter($dbConfig)); + + $c->set(DatabaseSourceMetadataProvider::class, static fn(Container $c) => new DatabaseSourceMetadataProvider($c->get(DatabaseRouter::class))); + + $c->set(DslParser::class, static fn() => new DslParser()); + $c->set(DslValidator::class, static fn(Container $c) => new DslValidator($c->get(DatabaseSourceMetadataProvider::class))); + $c->set(DslCompiler::class, static fn(Container $c) => new DslCompiler($c->get(DatabaseSourceMetadataProvider::class))); + + $c->set(MailshotQueryRepository::class, static fn(Container $c) => new MailshotQueryRepository($c->get(DatabaseRouter::class))); + $c->set(MailshotRepository::class, static fn(Container $c) => new MailshotRepository($c->get(DatabaseRouter::class))); + $c->set(AttachmentRepository::class, static fn(Container $c) => new AttachmentRepository($c->get(DatabaseRouter::class))); + $c->set(PdfAssetRepository::class, static fn(Container $c) => new PdfAssetRepository($c->get(DatabaseRouter::class))); + $c->set(LastRunRepository::class, static fn(Container $c) => new LastRunRepository($c->get(DatabaseRouter::class))); + $c->set(SecretKeyProvider::class, static fn() => new WordPressSaltSecretKeyProvider()); + $c->set(MailCredentialRepository::class, static fn(Container $c) => new MailCredentialRepository( + $c->get(DatabaseRouter::class), + $c->get(SecretKeyProvider::class) + )); + + $c->set(DataSourceService::class, static fn(Container $c) => new DataSourceService( + $c->get(MailshotQueryRepository::class), + $c->get(DatabaseRouter::class), + $c->get(DslParser::class), + $c->get(DslValidator::class), + $c->get(DslCompiler::class), + $c->get(DatabaseSourceMetadataProvider::class), + $c->get(MailshotRepository::class) + )); + + $c->set(MailshotService::class, static fn(Container $c) => new MailshotService( + $c->get(MailshotRepository::class), + $c->get(MailshotQueryRepository::class), + $c->get(AttachmentRepository::class), + $c->get(LastRunRepository::class), + $c->get(DataSourceService::class) + )); + + $c->set(AttachmentService::class, static fn(Container $c) => new AttachmentService($c->get(AttachmentRepository::class))); + $c->set(PdfAssetService::class, static fn(Container $c) => new PdfAssetService($c->get(PdfAssetRepository::class))); + $c->set(TemplateRenderer::class, static fn() => new TemplateRenderer()); + $c->set(SmtpSender::class, static fn() => new BasicSmtpSender()); + $c->set(ImapAppender::class, static fn() => new PhpImapAppender()); + $c->set(MailCredentialsProvider::class, static fn(Container $c) => new PerUserMailCredentialsProvider( + $c->get(MailCredentialRepository::class), + $wp + )); + $c->set(MailshotRunService::class, static fn(Container $c) => new MailshotRunService( + $c->get(MailshotRepository::class), + $c->get(MailshotQueryRepository::class), + $c->get(DataSourceService::class), + $c->get(TemplateRenderer::class), + $c->get(SmtpSender::class), + $c->get(ImapAppender::class), + $c->get(MailCredentialsProvider::class), + $c->get(LastRunRepository::class) + )); + + $c->set(DataSourcesAdminPage::class, static fn(Container $c) => new DataSourcesAdminPage( + static fn(): DataSourceService => $c->get(DataSourceService::class), + $wp + )); + + $c->set(MailshotsAdminPage::class, static fn(Container $c) => new MailshotsAdminPage( + static fn(): MailshotService => $c->get(MailshotService::class), + $wp + )); + + $c->set(AttachmentsAdminPage::class, static fn(Container $c) => new AttachmentsAdminPage( + static fn(): AttachmentService => $c->get(AttachmentService::class), + $wp + )); + + $c->set(PdfAssetsAdminPage::class, static fn(Container $c) => new PdfAssetsAdminPage( + static fn(): PdfAssetService => $c->get(PdfAssetService::class), + $wp + )); + + $c->set(SetupAdminPage::class, static fn() => new SetupAdminPage($wp)); + $c->set(ProfileAdminPage::class, static fn(Container $c) => new ProfileAdminPage( + $wp, + static fn(): MailCredentialRepository => $c->get(MailCredentialRepository::class) + )); + $c->set(MailshotTestAdminPage::class, static fn(Container $c) => new MailshotTestAdminPage( + static fn(): MailshotRunService => $c->get(MailshotRunService::class), + static fn(): MailshotService => $c->get(MailshotService::class), + $wp + )); + $c->set(RunMailshotAdminPage::class, static fn(Container $c) => new RunMailshotAdminPage( + static fn(): MailshotRunService => $c->get(MailshotRunService::class), + static fn(): MailshotService => $c->get(MailshotService::class), + $wp + )); + + return $c; + } +} diff --git a/feca_mailshots_plugin/src/Repository/AttachmentRepository.php b/feca_mailshots_plugin/src/Repository/AttachmentRepository.php new file mode 100644 index 0000000..6ab7d79 --- /dev/null +++ b/feca_mailshots_plugin/src/Repository/AttachmentRepository.php @@ -0,0 +1,64 @@ +router = $router; + } + + /** @return list> */ + public function all(): array + { + $sql = 'SELECT id, name, file_name, mime_type, OCTET_LENGTH(file_bytes) AS byte_size, created_at, updated_at FROM mailshot_attachments ORDER BY name ASC'; + return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + /** @param array $row */ + public function create(array $row): int + { + $sql = 'INSERT INTO mailshot_attachments (name, file_name, mime_type, file_bytes) VALUES (:name, :file_name, :mime_type, :file_bytes)'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->bindValue(':name', trim((string) ($row['name'] ?? ''))); + $stmt->bindValue(':file_name', trim((string) ($row['file_name'] ?? ''))); + $stmt->bindValue(':mime_type', trim((string) ($row['mime_type'] ?? ''))); + $stmt->bindValue(':file_bytes', $row['file_bytes'] ?? '', PDO::PARAM_LOB); + $stmt->execute(); + return (int) $this->router->mailshotsPdo()->lastInsertId(); + } + + /** @param array $row */ + public function update(int $id, array $row): void + { + $sql = 'UPDATE mailshot_attachments SET name = :name, file_name = :file_name, mime_type = :mime_type, file_bytes = :file_bytes WHERE id = :id'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->bindValue(':name', trim((string) ($row['name'] ?? ''))); + $stmt->bindValue(':file_name', trim((string) ($row['file_name'] ?? ''))); + $stmt->bindValue(':mime_type', trim((string) ($row['mime_type'] ?? ''))); + $stmt->bindValue(':file_bytes', $row['file_bytes'] ?? '', PDO::PARAM_LOB); + $stmt->execute(); + } + + public function delete(int $id): void + { + $stmt = $this->router->mailshotsPdo()->prepare('DELETE FROM mailshot_attachments WHERE id = :id'); + $stmt->execute(['id' => $id]); + } + + /** @return list */ + public function names(): array + { + $rows = $this->router->mailshotsPdo()->query('SELECT name FROM mailshot_attachments ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC); + return array_map(static fn(array $r): string => (string) $r['name'], $rows); + } +} diff --git a/feca_mailshots_plugin/src/Repository/LastRunRepository.php b/feca_mailshots_plugin/src/Repository/LastRunRepository.php new file mode 100644 index 0000000..2674fe9 --- /dev/null +++ b/feca_mailshots_plugin/src/Repository/LastRunRepository.php @@ -0,0 +1,62 @@ +router = $router; + } + + /** @return list> */ + public function listForMailshot(int $mailshotId): array + { + $sql = 'SELECT id, mailshot_id, recipient_key, recipient_key_field, recipient_email_last, status, error_message, warning_message, attempt_count, run_started_at, last_attempt_at FROM mailshot_last_run WHERE mailshot_id = :mailshot_id ORDER BY last_attempt_at DESC, id DESC'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->execute(['mailshot_id' => $mailshotId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function clearForMailshot(int $mailshotId): void + { + $stmt = $this->router->mailshotsPdo()->prepare('DELETE FROM mailshot_last_run WHERE mailshot_id = :mailshot_id'); + $stmt->execute(['mailshot_id' => $mailshotId]); + } + + /** @param array $row */ + public function create(array $row): int + { + $sql = 'INSERT INTO mailshot_last_run (mailshot_id, data_source, row_index, recipient_key, recipient_key_field, recipient_email_last, status, error_message, warning_message, attempt_count) VALUES (:mailshot_id, :data_source, :row_index, :recipient_key, :recipient_key_field, :recipient_email_last, :status, :error_message, :warning_message, :attempt_count)'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->execute([ + 'mailshot_id' => (int) ($row['mailshot_id'] ?? 0), + 'data_source' => $this->textOrNull($row['data_source'] ?? null), + 'row_index' => (int) ($row['row_index'] ?? 0), + 'recipient_key' => $this->textOrNull($row['recipient_key'] ?? null), + 'recipient_key_field' => $this->textOrNull($row['recipient_key_field'] ?? null), + 'recipient_email_last' => $this->textOrNull($row['recipient_email_last'] ?? null), + 'status' => trim((string) ($row['status'] ?? 'failed')), + 'error_message' => $this->textOrNull($row['error_message'] ?? null), + 'warning_message' => $this->textOrNull($row['warning_message'] ?? null), + 'attempt_count' => (int) ($row['attempt_count'] ?? 1), + ]); + return (int) $this->router->mailshotsPdo()->lastInsertId(); + } + + private function textOrNull($value): ?string + { + if ($value === null) { + return null; + } + $v = trim((string) $value); + return $v === '' ? null : $v; + } +} diff --git a/feca_mailshots_plugin/src/Repository/MailCredentialRepository.php b/feca_mailshots_plugin/src/Repository/MailCredentialRepository.php new file mode 100644 index 0000000..6056ed7 --- /dev/null +++ b/feca_mailshots_plugin/src/Repository/MailCredentialRepository.php @@ -0,0 +1,159 @@ +router = $router; + $this->keyProvider = $keyProvider; + } + + /** @return array|null */ + public function findByUserId(int $userId): ?array + { + $this->ensureTable(); + + $stmt = $this->router->mailshotsPdo()->prepare('SELECT wp_user_id, smtp_host, smtp_port, smtp_user, smtp_password_enc, smtp_from_email, smtp_from_name, smtp_require_tls, imap_host, imap_port, imap_user, imap_password_enc, imap_sent_folder, imap_mailbox_flags, updated_at FROM mailshot_credentials WHERE wp_user_id = :uid'); + $stmt->execute(['uid' => $userId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row === false) { + return null; + } + + return [ + 'wp_user_id' => (int) $row['wp_user_id'], + 'smtp_host' => (string) $row['smtp_host'], + 'smtp_port' => (int) $row['smtp_port'], + 'smtp_user' => (string) $row['smtp_user'], + 'smtp_password' => $this->decrypt((string) $row['smtp_password_enc']), + 'smtp_from_email' => (string) $row['smtp_from_email'], + 'smtp_from_name' => (string) ($row['smtp_from_name'] ?? ''), + 'smtp_require_tls' => (int) ($row['smtp_require_tls'] ?? 0) === 1, + 'imap_host' => (string) ($row['imap_host'] ?? ''), + 'imap_port' => (int) ($row['imap_port'] ?? 993), + 'imap_user' => (string) ($row['imap_user'] ?? ''), + 'imap_password' => $this->decrypt((string) ($row['imap_password_enc'] ?? '')), + 'imap_sent_folder' => (string) ($row['imap_sent_folder'] ?? 'Sent'), + 'imap_mailbox_flags' => (string) ($row['imap_mailbox_flags'] ?? '/imap/ssl'), + 'updated_at' => (string) ($row['updated_at'] ?? ''), + ]; + } + + /** @param array $payload */ + public function upsertForUser(int $userId, array $payload): void + { + $this->ensureTable(); + + $existing = $this->findByUserId($userId); + + $smtpPassword = (string) ($payload['smtp_password'] ?? ''); + $imapPassword = (string) ($payload['imap_password'] ?? ''); + + if ($smtpPassword === '' && is_array($existing)) { + $smtpPassword = (string) ($existing['smtp_password'] ?? ''); + } + if ($imapPassword === '' && is_array($existing)) { + $imapPassword = (string) ($existing['imap_password'] ?? ''); + } + + $sql = 'INSERT INTO mailshot_credentials (wp_user_id, smtp_host, smtp_port, smtp_user, smtp_password_enc, smtp_from_email, smtp_from_name, smtp_require_tls, imap_host, imap_port, imap_user, imap_password_enc, imap_sent_folder, imap_mailbox_flags) + VALUES (:wp_user_id, :smtp_host, :smtp_port, :smtp_user, :smtp_password_enc, :smtp_from_email, :smtp_from_name, :smtp_require_tls, :imap_host, :imap_port, :imap_user, :imap_password_enc, :imap_sent_folder, :imap_mailbox_flags) + ON DUPLICATE KEY UPDATE + smtp_host = VALUES(smtp_host), + smtp_port = VALUES(smtp_port), + smtp_user = VALUES(smtp_user), + smtp_password_enc = VALUES(smtp_password_enc), + smtp_from_email = VALUES(smtp_from_email), + smtp_from_name = VALUES(smtp_from_name), + smtp_require_tls = VALUES(smtp_require_tls), + imap_host = VALUES(imap_host), + imap_port = VALUES(imap_port), + imap_user = VALUES(imap_user), + imap_password_enc = VALUES(imap_password_enc), + imap_sent_folder = VALUES(imap_sent_folder), + imap_mailbox_flags = VALUES(imap_mailbox_flags), + updated_at = CURRENT_TIMESTAMP'; + + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->execute([ + 'wp_user_id' => $userId, + 'smtp_host' => trim((string) ($payload['smtp_host'] ?? '')), + 'smtp_port' => (int) ($payload['smtp_port'] ?? 587), + 'smtp_user' => trim((string) ($payload['smtp_user'] ?? '')), + 'smtp_password_enc' => $this->encrypt($smtpPassword), + 'smtp_from_email' => trim((string) ($payload['smtp_from_email'] ?? '')), + 'smtp_from_name' => trim((string) ($payload['smtp_from_name'] ?? '')), + 'smtp_require_tls' => !empty($payload['smtp_require_tls']) ? 1 : 0, + 'imap_host' => trim((string) ($payload['imap_host'] ?? '')), + 'imap_port' => (int) ($payload['imap_port'] ?? 993), + 'imap_user' => trim((string) ($payload['imap_user'] ?? '')), + 'imap_password_enc' => $this->encrypt($imapPassword), + 'imap_sent_folder' => trim((string) ($payload['imap_sent_folder'] ?? 'Sent')), + 'imap_mailbox_flags' => trim((string) ($payload['imap_mailbox_flags'] ?? '/imap/ssl')), + ]); + } + + private function ensureTable(): void + { + $sql = "CREATE TABLE IF NOT EXISTS mailshot_credentials ( + wp_user_id BIGINT UNSIGNED NOT NULL, + smtp_host VARCHAR(255) NOT NULL, + smtp_port INT NOT NULL, + smtp_user VARCHAR(255) NOT NULL, + smtp_password_enc LONGTEXT NOT NULL, + smtp_from_email VARCHAR(255) NOT NULL, + smtp_from_name VARCHAR(255) DEFAULT NULL, + smtp_require_tls TINYINT(1) NOT NULL DEFAULT 0, + imap_host VARCHAR(255) DEFAULT NULL, + imap_port INT DEFAULT 993, + imap_user VARCHAR(255) DEFAULT NULL, + imap_password_enc LONGTEXT DEFAULT NULL, + imap_sent_folder VARCHAR(255) DEFAULT 'Sent', + imap_mailbox_flags VARCHAR(64) DEFAULT '/imap/ssl', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (wp_user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"; + $this->router->mailshotsPdo()->exec($sql); + } + + private function encrypt(string $plaintext): string + { + $key = hash('sha256', $this->keyProvider->keyMaterial(), true); + $iv = random_bytes(16); + $ciphertext = openssl_encrypt($plaintext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); + if (!is_string($ciphertext)) { + throw new \RuntimeException('Credential encryption failed.'); + } + return base64_encode($iv . $ciphertext); + } + + private function decrypt(string $encoded): string + { + if (trim($encoded) === '') { + return ''; + } + $raw = base64_decode($encoded, true); + if (!is_string($raw) || strlen($raw) < 17) { + throw new \RuntimeException('Credential decryption failed (invalid payload).'); + } + $iv = substr($raw, 0, 16); + $ciphertext = substr($raw, 16); + $key = hash('sha256', $this->keyProvider->keyMaterial(), true); + $plaintext = openssl_decrypt($ciphertext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); + if (!is_string($plaintext)) { + throw new \RuntimeException('Credential decryption failed.'); + } + return $plaintext; + } +} diff --git a/feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php b/feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php new file mode 100644 index 0000000..2929f4b --- /dev/null +++ b/feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php @@ -0,0 +1,70 @@ +router = $router; + } + + /** @return list> */ + public function all(): array + { + $sql = 'SELECT ID, name, dsl_text, updated_at FROM mailshot_queries ORDER BY name ASC'; + return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + public function find(int $id): ?array + { + $stmt = $this->router->mailshotsPdo()->prepare('SELECT ID, name, dsl_text, updated_at FROM mailshot_queries WHERE ID = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row === false ? null : $row; + } + + public function findByName(string $name): ?array + { + $stmt = $this->router->mailshotsPdo()->prepare('SELECT ID, name, dsl_text, updated_at FROM mailshot_queries WHERE name = :name'); + $stmt->execute(['name' => $name]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row === false ? null : $row; + } + + /** @param array{name:string,dsl_text:string} $data */ + public function create(array $data): int + { + $stmt = $this->router->mailshotsPdo()->prepare('INSERT INTO mailshot_queries (name, dsl_text, `sql`) VALUES (:name, :dsl_text, :sql)'); + $stmt->execute([ + 'name' => $data['name'], + 'dsl_text' => $data['dsl_text'], + 'sql' => '', + ]); + return (int) $this->router->mailshotsPdo()->lastInsertId(); + } + + /** @param array{name:string,dsl_text:string} $data */ + public function update(int $id, array $data): void + { + $stmt = $this->router->mailshotsPdo()->prepare('UPDATE mailshot_queries SET name = :name, dsl_text = :dsl_text WHERE ID = :id'); + $stmt->execute([ + 'id' => $id, + 'name' => $data['name'], + 'dsl_text' => $data['dsl_text'], + ]); + } + + public function delete(int $id): void + { + $stmt = $this->router->mailshotsPdo()->prepare('DELETE FROM mailshot_queries WHERE ID = :id'); + $stmt->execute(['id' => $id]); + } +} diff --git a/feca_mailshots_plugin/src/Repository/MailshotRepository.php b/feca_mailshots_plugin/src/Repository/MailshotRepository.php new file mode 100644 index 0000000..087c930 --- /dev/null +++ b/feca_mailshots_plugin/src/Repository/MailshotRepository.php @@ -0,0 +1,96 @@ +router = $router; + } + + /** @return list> */ + public function all(): array + { + $sql = 'SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo FROM mailshots ORDER BY Purpose ASC'; + return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + public function find(int $id): ?array + { + $stmt = $this->router->mailshotsPdo()->prepare('SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo FROM mailshots WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row === false ? null : $row; + } + + /** @param array $row */ + public function create(array $row): int + { + $sql = 'INSERT INTO mailshots (Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo) VALUES (:Purpose, :DataSource, :CC, :BCC, :Subject, :Message, :PDFAttachment, :AttachmentNames, :PDFFilenameDerivedFrom, :ReplyTo)'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->execute($this->payload($row)); + return (int) $this->router->mailshotsPdo()->lastInsertId(); + } + + /** @param array $row */ + public function update(int $id, array $row): void + { + $sql = 'UPDATE mailshots SET Purpose = :Purpose, DataSource = :DataSource, CC = :CC, BCC = :BCC, Subject = :Subject, Message = :Message, PDFAttachment = :PDFAttachment, AttachmentNames = :AttachmentNames, PDFFilenameDerivedFrom = :PDFFilenameDerivedFrom, ReplyTo = :ReplyTo WHERE id = :id'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $payload = $this->payload($row); + $payload['id'] = $id; + $stmt->execute($payload); + } + + public function delete(int $id): void + { + $stmt = $this->router->mailshotsPdo()->prepare('DELETE FROM mailshots WHERE id = :id'); + $stmt->execute(['id' => $id]); + } + + public function countByDataSource(string $dataSourceName): int + { + $stmt = $this->router->mailshotsPdo()->prepare('SELECT COUNT(*) FROM mailshots WHERE DataSource = :name'); + $stmt->execute(['name' => trim($dataSourceName)]); + return (int) $stmt->fetchColumn(); + } + + /** @param array $row @return array */ + private function payload(array $row): array + { + return [ + 'Purpose' => $this->textOrNull($row['Purpose'] ?? null), + 'DataSource' => $this->text($row['DataSource'] ?? ''), + 'CC' => $this->textOrNull($row['CC'] ?? null), + 'BCC' => $this->textOrNull($row['BCC'] ?? null), + 'Subject' => $this->text($row['Subject'] ?? ''), + 'Message' => $this->text($row['Message'] ?? ''), + 'PDFAttachment' => $this->textOrNull($row['PDFAttachment'] ?? null), + 'AttachmentNames' => $this->textOrNull($row['AttachmentNames'] ?? null), + 'PDFFilenameDerivedFrom' => $this->textOrNull($row['PDFFilenameDerivedFrom'] ?? null), + 'ReplyTo' => $this->textOrNull($row['ReplyTo'] ?? null), + ]; + } + + private function text($value): string + { + return trim((string) $value); + } + + private function textOrNull($value): ?string + { + if ($value === null) { + return null; + } + $v = trim((string) $value); + return $v === '' ? null : $v; + } +} diff --git a/feca_mailshots_plugin/src/Repository/PdfAssetRepository.php b/feca_mailshots_plugin/src/Repository/PdfAssetRepository.php new file mode 100644 index 0000000..7c6d25c --- /dev/null +++ b/feca_mailshots_plugin/src/Repository/PdfAssetRepository.php @@ -0,0 +1,63 @@ +router = $router; + } + + /** @return list> */ + public function all(): array + { + $sql = 'SELECT id, name, file_name, mime_type, width_mm, height_mm, justification, OCTET_LENGTH(file_bytes) AS byte_size, created_at, updated_at FROM mailshot_pdf_assets ORDER BY name ASC'; + return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + /** @param array $row */ + public function create(array $row): int + { + $sql = 'INSERT INTO mailshot_pdf_assets (name, file_name, mime_type, file_bytes, width_mm, height_mm, justification) VALUES (:name, :file_name, :mime_type, :file_bytes, :width_mm, :height_mm, :justification)'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->bindValue(':name', trim((string) ($row['name'] ?? ''))); + $stmt->bindValue(':file_name', trim((string) ($row['file_name'] ?? ''))); + $stmt->bindValue(':mime_type', trim((string) ($row['mime_type'] ?? ''))); + $stmt->bindValue(':file_bytes', $row['file_bytes'] ?? '', PDO::PARAM_LOB); + $stmt->bindValue(':width_mm', (float) ($row['width_mm'] ?? 0.0)); + $stmt->bindValue(':height_mm', (float) ($row['height_mm'] ?? 0.0)); + $stmt->bindValue(':justification', (string) ($row['justification'] ?? 'in-place')); + $stmt->execute(); + return (int) $this->router->mailshotsPdo()->lastInsertId(); + } + + /** @param array $row */ + public function update(int $id, array $row): void + { + $sql = 'UPDATE mailshot_pdf_assets SET name = :name, file_name = :file_name, mime_type = :mime_type, file_bytes = :file_bytes, width_mm = :width_mm, height_mm = :height_mm, justification = :justification WHERE id = :id'; + $stmt = $this->router->mailshotsPdo()->prepare($sql); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->bindValue(':name', trim((string) ($row['name'] ?? ''))); + $stmt->bindValue(':file_name', trim((string) ($row['file_name'] ?? ''))); + $stmt->bindValue(':mime_type', trim((string) ($row['mime_type'] ?? ''))); + $stmt->bindValue(':file_bytes', $row['file_bytes'] ?? '', PDO::PARAM_LOB); + $stmt->bindValue(':width_mm', (float) ($row['width_mm'] ?? 0.0)); + $stmt->bindValue(':height_mm', (float) ($row['height_mm'] ?? 0.0)); + $stmt->bindValue(':justification', (string) ($row['justification'] ?? 'in-place')); + $stmt->execute(); + } + + public function delete(int $id): void + { + $stmt = $this->router->mailshotsPdo()->prepare('DELETE FROM mailshot_pdf_assets WHERE id = :id'); + $stmt->execute(['id' => $id]); + } +} diff --git a/feca_mailshots_plugin/src/Support/Container.php b/feca_mailshots_plugin/src/Support/Container.php new file mode 100644 index 0000000..e121775 --- /dev/null +++ b/feca_mailshots_plugin/src/Support/Container.php @@ -0,0 +1,33 @@ + */ + private array $factories = []; + + /** @var array */ + private array $instances = []; + + public function set(string $id, callable $factory): void + { + $this->factories[$id] = $factory; + } + + public function get(string $id) + { + if (array_key_exists($id, $this->instances)) { + return $this->instances[$id]; + } + + if (!array_key_exists($id, $this->factories)) { + throw new \RuntimeException('Service not found: ' . $id); + } + + $this->instances[$id] = ($this->factories[$id])($this); + return $this->instances[$id]; + } +} diff --git a/feca_mailshots_plugin/src/Support/ErrorLogLogger.php b/feca_mailshots_plugin/src/Support/ErrorLogLogger.php new file mode 100644 index 0000000..cf5df58 --- /dev/null +++ b/feca_mailshots_plugin/src/Support/ErrorLogLogger.php @@ -0,0 +1,25 @@ +format('INFO', $message, $context)); + } + + public function error(string $message, array $context = []): void + { + error_log($this->format('ERROR', $message, $context)); + } + + /** @param array $context */ + private function format(string $level, string $message, array $context): string + { + $payload = $context === [] ? '' : ' ' . json_encode($context, JSON_UNESCAPED_SLASHES); + return sprintf('[feca-mailshots][%s] %s%s', $level, $message, $payload); + } +} diff --git a/feca_mailshots_plugin/src/Support/Logger.php b/feca_mailshots_plugin/src/Support/Logger.php new file mode 100644 index 0000000..8f97c66 --- /dev/null +++ b/feca_mailshots_plugin/src/Support/Logger.php @@ -0,0 +1,14 @@ + $context */ + public function info(string $message, array $context = []): void; + + /** @param array $context */ + public function error(string $message, array $context = []): void; +} diff --git a/feca_mailshots_plugin/src/WordPress/FixtureWordPressFacade.php b/feca_mailshots_plugin/src/WordPress/FixtureWordPressFacade.php new file mode 100644 index 0000000..a504ab6 --- /dev/null +++ b/feca_mailshots_plugin/src/WordPress/FixtureWordPressFacade.php @@ -0,0 +1,93 @@ +> */ + private array $actions = []; + /** @var array */ + private array $options = []; + private int $currentUserId = 1; + + public function addAction(string $hook, callable $callback): void + { + if (!isset($this->actions[$hook])) { + $this->actions[$hook] = []; + } + $this->actions[$hook][] = $callback; + } + + public function addMenuPage(string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void + { + $this->addAction('fixture_menu:' . $slug, $callback); + } + + public function addSubmenuPage(string $parentSlug, string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void + { + $this->addAction('fixture_submenu:' . $slug, $callback); + } + + public function currentUserCan(string $capability): bool + { + return true; + } + + public function verifyNonce(string $nonce, string $action): bool + { + return $nonce !== ''; + } + + public function requestParam(string $name, ?string $default = null): ?string + { + if (isset($_POST[$name])) { + return (string) $_POST[$name]; + } + if (isset($_GET[$name])) { + return (string) $_GET[$name]; + } + return $default; + } + + public function sendJson(array $payload, int $statusCode = 200): void + { + http_response_code($statusCode); + header('Content-Type: application/json'); + echo json_encode($payload, JSON_UNESCAPED_SLASHES); + } + + public function adminUrl(string $path = ''): string + { + return '/wp-admin/' . ltrim($path, '/'); + } + + public function currentUserId(): int + { + return $this->currentUserId; + } + + public function getOption(string $name, $default = null) + { + return $this->options[$name] ?? $default; + } + + public function updateOption(string $name, $value): bool + { + $this->options[$name] = $value; + return true; + } + + public function dispatch(string $hook): void + { + foreach ($this->actions[$hook] ?? [] as $callback) { + $callback(); + } + } + + public function setCurrentUserId(int $userId): void + { + $this->currentUserId = max(1, $userId); + } +} diff --git a/feca_mailshots_plugin/src/WordPress/ProductionWordPressFacade.php b/feca_mailshots_plugin/src/WordPress/ProductionWordPressFacade.php new file mode 100644 index 0000000..277cc2d --- /dev/null +++ b/feca_mailshots_plugin/src/WordPress/ProductionWordPressFacade.php @@ -0,0 +1,70 @@ + $payload */ + public function sendJson(array $payload, int $statusCode = 200): void; + + public function adminUrl(string $path = ''): string; + + public function currentUserId(): int; + + /** + * @param mixed $default + * @return mixed + */ + public function getOption(string $name, $default = null); + + /** + * @param mixed $value + */ + public function updateOption(string $name, $value): bool; +} diff --git a/feca_mailshots_plugin/src/autoload.php b/feca_mailshots_plugin/src/autoload.php new file mode 100644 index 0000000..70cd727 --- /dev/null +++ b/feca_mailshots_plugin/src/autoload.php @@ -0,0 +1,22 @@ + $getOption('db_host'), + 'MYSQL_PORT' => $getOption('db_port'), + 'MYSQL_USER' => $getOption('db_user'), + 'MYSQL_PASSWORD' => $getOption('db_password'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => $getOption('mailshots_db_name'), + 'MEMBERS_REMOTE_MYSQL_DB' => $getOption('members_db_name'), +]; + +$wp = new ProductionWordPressFacade(); +$container = Plugin::buildContainer($dbConfig, $wp); +$container->get(FecaMailshots\Admin\DataSourcesAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\MailshotsAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\AttachmentsAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\PdfAssetsAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\SetupAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\MailshotTestAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\RunMailshotAdminPage::class)->register(); diff --git a/requirements/dompdf_investigation.md b/requirements/dompdf_investigation.md new file mode 100644 index 0000000..5592184 --- /dev/null +++ b/requirements/dompdf_investigation.md @@ -0,0 +1,69 @@ +# dompdf Investigation Requirement + +## Document Context + +This investigation requirement is governed by `requirements/environment.md` and `requirements/top-level.md`. +No fallback behavior is permitted without explicit instruction. + +## Purpose + +Determine whether `dompdf` is suitable for generating production-quality mailshot PDFs from the sample templates: + +- `samples/renewal_draft.html` +- `samples/invoicing.html` + +## Inputs + +1. Template sources: + - `samples/renewal_draft.html` + - `samples/invoicing.html` +2. Representative field values for all template tokens. +3. FECA logo asset content equivalent to `pdf_asset('FenEdgeLogoOrange')`. + +## Investigation Route + +1. Render-ready normalization + - Produce render-ready HTML for each sample by replacing template tokens/macros with fixed test data. + - Ensure `pdf_asset('FenEdgeLogoOrange')` resolves to concrete HTML/image content. + +2. dompdf spike execution + - Provide an executable spike script in `working/codex-spikes/` (agent artefacts, not user operational scripts) that: + - loads each normalized template, + - renders via dompdf, + - writes PDFs to `working/`, + - fails with non-zero exit code on render errors. + +3. Structured suitability checks + - `renewal_draft.html` checks: + - watermark (`position: fixed`, `transform`, transparency) + - table borders, spacing, and mm-based layout + - logo placement and title/header integrity + - `invoicing.html` checks: + - line-break behavior from macro expansion + - currency/symbol rendering (`£`, `✉`, `📞`) + - spacing, wrapping, and pagination + +4. Baseline comparison + - Generate browser print-to-PDF baselines for both templates. + - Compare dompdf output to browser baseline using a fixed checklist. + +5. Decision criteria + - `Suitable`: all critical checks pass with no blocking defects. + - `Suitable with constraints`: non-blocking defects exist; template constraints/workarounds are documented. + - `Not suitable`: one or more blocking defects remain. + +6. Contingency (only if explicitly instructed) + - Evaluate one alternative renderer against the same inputs/checklist for comparison. + +## Deliverables + +1. Rendered HTML artefacts for both sample templates. +2. dompdf-generated PDFs for both sample templates. +3. Completed suitability checklist with pass/fail evidence. +4. Recommendation outcome: suitable / suitable with constraints / not suitable. +5. Documented required template constraints (if any). + +## Execution Notes + +- Investigation outputs should be isolated under `working/`. +- Any generated test artefacts must be cleaned up by the test process when they are no longer needed. diff --git a/requirements/environment.md b/requirements/environment.md new file mode 100644 index 0000000..8b132b5 --- /dev/null +++ b/requirements/environment.md @@ -0,0 +1,67 @@ +# Mailshot Plugin Environment Baseline + +## Purpose + +Define the persistent environment assumptions for this plugin so all requirements and implementation work use the same baseline. + +## Global Requirement Context + +This document will be referenced from every requirements specification document in this repository. +This document remains in the working context for all operations. + +## Core Policy + +"Do not use fallbacks without specific instruction" + +No fallback behavior may be introduced or enabled unless a requirement explicitly instructs it. + +## Repository Context + +- Primary workspace: `mailshot-plugin` +- Reference implementation patterns: `../members-list-plugin` +- Requirements folder: `requirements/` +- Credentials folder (local dev only): `credentials/` +- Results folder for test outputs and investigation findings: `results/` + +## Development Assumptions + +- Development should support a local fixture/harness workflow where practical. +- Runtime code paths used in tests should match production plugin code paths as closely as possible. +- Environment-specific behavior must be explicit, documented, and testable. + +## Configuration Principles + +- Use a single clear active configuration path per environment. +- Prefer explicit configuration over implicit defaults. +- Keep secrets outside committed source files. + +## Database Routing Contract + +- Mailshot feature writable state (definitions, assets, run logs, and related mailshot tables) must be stored in the database named by `.env` variable `MAILSHOTS_REMOTE_MYSQL_DB`. +- Recipient/source data reads (for DSL execution against contacts/accounts/renewals and approved custom source tables) must use the database named by `.env` variable `MEMBERS_REMOTE_MYSQL_DB`. +- Requirements documents must state whether each table/operation is a write-path (`MAILSHOTS_REMOTE_MYSQL_DB`) or read-path (`MEMBERS_REMOTE_MYSQL_DB`). + +## Testing Hygiene + +- Every test must clean up its own test artefacts. +- No test may leave persistent data/files/state behind unless a requirement explicitly allows it. +- Test results and investigation findings must be written under `results/`. + +## Build And Release Scripts + +- `scripts/` must contain a deployment script for deploying to the production server. +- `scripts/` must contain a packaging script that creates an uploadable WordPress plugin package. + +## Versioning Requirements + +- The code must expose a WordPress-reportable semantic version number in `x.y.z` format. +- `x` is major, `y` is minor, `z` is patch level. +- Every deployment must increment patch level `z`. + +## Documentation Contract + +Each future requirements document should: + +1. Link or refer to `requirements/environment.md`. +2. Declare any additional environment constraints beyond this baseline. +3. State if a requirement is local-only, test-only, or production-relevant. diff --git a/requirements/implementation_plan.md b/requirements/implementation_plan.md new file mode 100644 index 0000000..85f98b5 --- /dev/null +++ b/requirements/implementation_plan.md @@ -0,0 +1,214 @@ +# Mailshot Implementation Plan + +## 1. Purpose + +Define the implementation approach for delivering the Mailshot feature as a WordPress plugin while keeping plugin runtime code pure and minimally coupled to fixture/test concerns. + +This plan is governed by: + +* `requirements/environment.md` +* `requirements/mailshot.md` +* `requirements/mailshot_data_source.md` +* `requirements/ui_design.md` +* `requirements/test_environments.md` + +## 2. Implementation Principles + +1. Pure plugin runtime + - Production plugin code must not branch on fixture/test mode. + - No fixture-only conditionals in production classes. + - Fixture behavior must be provided by bootstrap/injection, not by runtime feature flags. + +2. Explicit dependency boundaries + - Isolate WordPress APIs behind thin adapters/services. + - Isolate DB access by repository + DB-router layers. + - Isolate transport concerns (SMTP/IMAP, PDF, templating) behind interfaces. + +3. Environment routing must be explicit + - Write-path state/assets/logs in `MAILSHOTS_REMOTE_MYSQL_DB`. + - Recipient/source-data reads in `MEMBERS_REMOTE_MYSQL_DB`. + +4. Same code paths in fixture and production + - Fixture should execute the same plugin service layer and SQL compilation paths. + - Differences are only in bootstrap wiring and data source choice. + +## 3. Target Architecture + +## 3.1 Modules + +* `Admin/UI`: + - WordPress admin pages and handlers. +* `Application Services`: + - Mailshot CRUD service + - Data source DSL service (parse/validate/compile/preview) + - Test/Run orchestration service + - PDF generation/download service +* `Infrastructure`: + - DB router (`MAILSHOTS_REMOTE_MYSQL_DB` writes, `MEMBERS_REMOTE_MYSQL_DB` reads) + - SMTP sender + - IMAP sent-copy appender + - Twig renderer + - Dompdf renderer +* `Repositories`: + - Mailshots, queries, attachments, pdf assets, last run, credentials + +## 3.2 WordPress API boundary + +Create wrapper interfaces for frequently used WordPress functions (for example current user lookup, capability checks, nonce checks, response helpers, URL builders), and inject implementations at bootstrap. + +This keeps fixture setup simple and keeps plugin code independent of direct global-function calls where practical. + +## 4. Credentials Strategy (aligned to fixture goals) + +To minimize fixture complexity and avoid full WordPress-user emulation: + +1. Store SMTP/IMAP credentials in a mailshots-side table in `MAILSHOTS_REMOTE_MYSQL_DB` (for example `mailshot_credentials`). +2. Resolve the active credential row via a credential-resolver service: + - Production resolver: by current WordPress user id/capability context. + - Fixture resolver: by explicit env key (for example `MAILSHOTS_FIXTURE_CREDENTIAL_KEY`) or a designated default row. +3. Keep resolver selection in bootstrap wiring only; service-layer send/run logic remains unchanged. + +This preserves pure plugin logic while making fixture execution straightforward. + +## 5. Fixture Environment Design + +## 5.1 Fixture responsibilities + +* Provide WordPress-specific runtime expectations needed by plugin code: + - function availability + - hook registration behavior + - request context stubs +* Execute plugin endpoints/services against real remote DBs where available. +* Optionally run against local emulated datasets for faster iteration. + +## 5.2 Fixture composition + +* `tests/fixture/wp_shim.php` + - Provide minimal implementations for required WP functions used by plugin code. +* `tests/fixture/bootstrap.php` + - Load env, initialize adapters, include plugin entrypoints. +* `tests/fixture/router.php` (or equivalent) + - Route fixture HTTP/API requests into plugin handlers. +* `tests/fixture/data/` + - Optional local SQL snapshots/seed data. + +## 5.3 Data access modes + +Mode A: Remote-backed (default for integration confidence) + +* Reads from `MEMBERS_REMOTE_MYSQL_DB` +* Writes to `MAILSHOTS_REMOTE_MYSQL_DB` + +Mode B: Local-emulated (optional for fast dev loops) + +* Local DB seeded from fixture snapshots +* Same SQL/compiler/service code paths as Mode A +* Used for rapid debugging and CI speed where remote access is constrained + +## 6. Delivery Phases + +## Phase 0: Foundation and contracts + +* Finalize table contracts (including new credential table and migration SQL scripts). +* Finalize DB router config contracts. +* Define plugin capability names and admin-page slugs. +* Add implementation traceability map from requirements -> modules/tests. + +Exit criteria: + +* DB and API contracts documented and approved. + +## Phase 1: Infrastructure skeleton + +* Implement DB router and repository scaffolds. +* Implement WP adapter interfaces + production implementations. +* Implement fixture bootstrap + WP shim baseline. +* Implement structured error model and logging helpers. + +Exit criteria: + +* Plugin boots in WordPress and fixture with shared service container wiring. + +## Phase 2: Data source pipeline + +* Implement DSL tokenizer/parser/validator/compiler modules. +* Implement source-field metadata provider and preview endpoints. +* Implement data-source CRUD APIs and admin page. +* Add parser/validator/compile tests and preview shape tests. + +Exit criteria: + +* Data source CRUD + validate + preview working in both WordPress and fixture. + +## Phase 3: Mailshot management and assets + +* Implement mailshot CRUD page and APIs. +* Implement attachment and PDF asset CRUD + file handling. +* Implement token insertion helpers for editor UI. +* Implement last-run repository operations. + +Exit criteria: + +* Operators can configure mailshots and assets end-to-end. + +## Phase 4: Test send, run send, and retry + +* Implement Twig rendering pipeline (`Subject`, `Message`, `PDFAttachment`). +* Implement SMTP send + IMAP sent-copy contract behavior. +* Implement run orchestration, counters, logging, and retry semantics. +* Implement `MAILSHOT_TEST_TO_DEFAULT` blank/default validation behavior. + +Exit criteria: + +* Test send and run send pass integration tests with warning/error behavior aligned to requirements. + +## Phase 5: PDF output and packaging + +* Implement per-recipient PDF generation, zip and merged output. +* Finalize deployment/package scripts and release checks. +* Capture verification evidence in `results/`. + +Exit criteria: + +* Full feature acceptance in staging/pre-production checklist. + +## 7. Testing Plan + +1. Unit/static tests + - DSL parser/validator/compiler, repository-level behavior, renderer safety. + +2. Fixture integration tests + - WordPress shim + plugin services + - Remote-backed and optional local-emulated modes + - Send/run/retry and preview flows + +3. WordPress integration tests + - Admin-page registration, capability checks, nonce/auth behavior, endpoint wiring. + +4. Staging tests + - Real connectivity, credential resolution, IMAP sent-copy, operational observability. + +## 8. Purity Guardrails (must-pass) + +* No `if fixture/test` branching in production services. +* No fixture-specific env checks in core orchestration classes. +* Fixture-specific code limited to `tests/fixture/*` (or equivalent harness folder). +* All production runtime dependencies resolved via adapters/interfaces. + +## 9. Deliverables + +* Migration SQL scripts for `MAILSHOTS_REMOTE_MYSQL_DB` tables (including credentials table). +* Plugin modules/pages/APIs per requirements. +* Fixture harness with WordPress function shim and remote/local data modes. +* Automated test suite and verification evidence in `results/`. +* Deployment/package scripts and release checklist updates. + +## 10. Open Decisions To Confirm Before Phase 1 Completion + +1. Final name/schema of credentials table in `MAILSHOTS_REMOTE_MYSQL_DB`. +2. Credential resolver rule precedence: + - user-bound first vs explicit profile key first. +3. Local emulation scope: + - full `members` subset vs targeted minimal fixture datasets. +4. CI strategy: + - remote-backed only vs dual-mode (remote + local emulated). diff --git a/requirements/mailshot.md b/requirements/mailshot.md new file mode 100644 index 0000000..cf80468 --- /dev/null +++ b/requirements/mailshot.md @@ -0,0 +1,407 @@ +# 1 Mailshot + +Use and comply with `requirements/ui_design.md`. + +## 1.0 Environment Baseline + +* All writable mailshot tables and mailshot asset binaries are stored in the database named by `.env` variable `MAILSHOTS_REMOTE_MYSQL_DB`. +* This includes `mailshots`, `mailshot_queries`, `mailshot_attachments`, `mailshot_last_run`, and any mailshot asset tables created for PDF/image assets. +* In this document, references in the form `MAILSHOTS_REMOTE_MYSQL_DB.` are normative. +* Recipient/source data reads used to resolve mailshot recipients and DSL source fields must use `.env` variable `MEMBERS_REMOTE_MYSQL_DB`. +* SMTP/IMAP credentials are sourced from WordPress-side per-user mailshot credential storage. +* If user credentials are missing, send/run actions must be blocked and the UI must direct users to the WordPress mailshot credentials page. + +## 1.1 Purpose + +Provide a Mailshot feature to: + +* manage mailshot definitions +* preview recipient data and template rendering +* test mailshots safely +* execute full mailshot runs + +## 1.2 Data Model and Dependencies + +* Use `MAILSHOTS_REMOTE_MYSQL_DB.mailshots` as the source of mailshot definitions. +* Use `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries` as the source of recipient/data-source queries. +* Send mail using per-user SMTP/IMAP credentials from WordPress-side mailshot credential storage. +* If user credentials are missing, block send/run and direct user to the WordPress mailshot credentials page. +* Copy successfully sent messages to configured IMAP `Sent` folder. + +### 1.2.1 Schema Reference: `MAILSHOTS_REMOTE_MYSQL_DB.mailshots` + +| Column | Type | Null | Key | Notes | +| -------------------------- | ---------------- | ---- | --- | ----------------------------------------------------------------------------------- | +| `id` | `int(11)` | NO | PK | Auto-increment | +| `Purpose` | `varchar(100)` | YES | | Human-readable mailshot purpose | +| `DataSource` | `varchar(100)` | NO | | Data-source name matching `mailshot_queries.name` | +| `CC` | `varchar(100)` | YES | | Optional CC list | +| `BCC` | `varchar(100)` | YES | | Optional BCC list | +| `Subject` | `longtext` | NO | | Message subject template | +| `Message` | `longtext` | NO | | Message body template | +| `PDFAttachment` | `longtext` | YES | | Optional PDF attachment html template | +| `AttachmentNames` | `longtext` | YES | | JSON array of named entries from `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_attachments` | +| `PDFFilenameDerivedFrom` | `varchar(128)` | YES | | Optional data-source field used as base filename for generated PDFs | +| `ReplyTo` | `varchar(100)` | YES | | Optional reply-to address | + +### 1.2.2 Schema Reference: `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries` + +| Column | Type | Null | Key | Notes | +| ------------ | ---------------- | ---- | ------ | -------------------------------------------------------- | +| `ID` | `int(11)` | NO | PK | Auto-increment | +| `name` | `varchar(100)` | NO | UNIQUE | Data-source name used by `mailshots.DataSource` | +| `dsl_text` | `longtext` | YES | | DSL sentence (source of truth) | +| `sql` | `longtext` | NO | | Legacy column retained; compile target not user-authored | + +## 1.3 Navigation and Pages + +Under `Mailshot`, provide child pages: + +* `Mailshots` +* `Assets` +* `Mailshot Recipients Preview` +* `Mailshot Test` +* `Run Mailshot` +* `Download PDF` + +Under `Mailshot > Assets`, provide: + +* `Mailshot Data Source > Data Sources` +* `PDF Assets` +* `Attachments` + +## 1.3.1 Attachments Data Model + +Use `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_attachments` as reusable uploaded attachments with: + +* `id` +* `name` (unique) +* `file_name` +* `mime_type` +* `file_bytes` +* `created_at` +* `updated_at` + +Persist selected named attachments on each mailshot in `MAILSHOTS_REMOTE_MYSQL_DB.mailshots.AttachmentNames` (JSON array of attachment names). + +## 1.4 Mailshots Page (`MAILSHOTS_REMOTE_MYSQL_DB.mailshots` CRUD) + +### 1.4.1 Data Pane + +* Display rows from `MAILSHOTS_REMOTE_MYSQL_DB.mailshots`. +* In the list/table view, hide internal `id` and display user-facing columns (`Purpose`, `DataSource`, `Subject`). +* Sort the mailshot list by `Purpose` (ascending). +* Provide editing for all mutable fields. +* Highlight unsaved changed fields. +* Use a separate pane for editing the fields.  Note that the Message field is multiline html-formatted and should be edited using an html edit control.  The design of the page should maximise room for this field +* Provide a separate html edit control for `PDFAttachment` (optional template) using the same html editing features as `Message`. +* Message and PDF Attachment html editors should open in overlay panes to maximise editing room. +* Provide `PDF Filename Derived From` dropdown populated from available data-source fields for the selected `DataSource`. +* Persist `PDF Filename Derived From` as `MAILSHOTS_REMOTE_MYSQL_DB.mailshots.PDFFilenameDerivedFrom`. +* The Data Pane also include a table of mailshots,  width limited to the page width,  with truncation of fields. +* Keep editing responsive; do not trigger per-keystroke save/reload. +* Data-source pulldowns must be sorted by data-source `name` (ascending). +* Show available data-source fields in the editor and allow one-click token insertion into `Subject`, `Message`, and `PDFAttachment`. +* Token insertion must generate Twig tokens using `{{ ... }}` syntax (not `m_...` syntax). +* Field insert token format default is `{{ field_name }}` where `field_name` is a canonical safe field token derived from the selected source field. +* `Subject`, `Message`, and `PDFAttachment` are always rendered through Twig at render/send time. +* In editor pane, provide `Attachments` dropdown (initially blank) sourced from `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_attachments.name`. +* Provide `Add selected attachment` button to append selected item into mailshot attachment list. +* Allow removing previously selected attachments from the mailshot attachment list. + +### 1.4.2 Action Pane + +* Provide `Save` button. Enable only when unsaved changes exist. +* Provide `Discard Changes` button. Enable only when unsaved changes exist. +* Provide `New` button to add a new `MAILSHOTS_REMOTE_MYSQL_DB.mailshots` row. +* Provide `Delete` button. Enable only when one row is selected. +* On `Delete`, require confirmation before delete is applied. + +### 1.4.3 Validation + +* Require `DataSource` to match an existing `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries.name`. +* Prevent save when `Subject` or `Message` are blank. +* Display validation errors in the information pane. + +## 1.5 Template Engine (Twig) + +* Use Twig as the template engine for mailshot rendering. +* All template tokens and expressions use Twig syntax. +* Template context is the selected recipient row (plus approved helper functions/filters). +* Default undefined-variable behavior is non-fatal and renders as empty string while recording a warning. +* Default escaping policy is: + * `Subject`: unescaped text output. + * `Message`: unescaped only when explicitly requested in template (`| raw`), otherwise escaped. + * `PDFAttachment`: unescaped only when explicitly requested in template (`| raw`), otherwise escaped. +* Control-flow tags (`{% if %}`, `{% for %}`, `{% set %}`) are allowed in `Message` and `PDFAttachment`, but disallowed in `Subject` by default. + +## Mailshot Data Sources Page + +This is defined in `requirements/mailshot_data_source.md`. + +## 1.6 Mailshot Recipients Preview Page + +### 1.6.1 Selection and Query Execution + +* Provide control to select a data source from `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries`. +* On preview request, execute the selected data-source query and display resulting recipient rows. + +### 1.6.2 Preview Table + +* Display columns from a deterministic field contract derived from all DSL-cited sources, plus any additional source-native columns present in preview output. +* The deterministic field contract must include all allowed fields for each cited source (built-in or custom). +* Provide row count and sample-limit indicator in the information pane. +* Provide generalized recipient sorting controls (`Sort by` and sort direction) and apply them to the displayed preview rows. +* If query returns zero rows, display zero-result message (no error state). +* Provide `Download spreadsheet` action. +* On `Download spreadsheet`, generate `recipients.xlsx` from rows currently shown in the data pane and download it. +* In `recipients.xlsx`, format the header row in bold. +* In `recipients.xlsx`, auto-size each column width to fit header/data content. + +### 1.6.3 Safety Rules + +* Preview does not send email. +* Preview does not persist changes to recipient-source tables. + +## 1.7 Mailshot Test Page + +### 1.7.1 Test Setup + +* Provide control to select a mailshot. +* Provide control to select one preview/test recipient row from the resolved query output. +* Provide `Test email address` input for explicit destination override, defaulting to configured `.env` value `MAILSHOT_TEST_TO_DEFAULT` when set. +* If `MAILSHOT_TEST_TO_DEFAULT` is unset/blank, initialize `Test email address` as blank. +* Rendered Message control must display the html message according to the html formatting. +* Provide a rendered `PDFAttachment` html preview pane below the rendered message preview pane. +* The rendered Message and rendered PDF Attachment preview panes should default to equal heights and support drag-resize via a divider. + +### 1.7.2 Test Mode: Render Only (No Send) + +* Provide action `Render Test (No Send)`. +* Perform full Twig template rendering for selected row. +* Display rendered `Subject`, `Message`, and `PDFAttachment`. +* Do not send SMTP mail. +* Do not write IMAP `Sent` copy. + +### 1.7.3 Test Mode: Send Test Email + +* Provide action `Send Test Email`. +* Require non-blank `Test email address`. +* When `Test email address` is blank, block send and show validation error in the information pane. +* Use selected test row only for template rendering context. +* Send exactly one email to `Test email address` (not to original recipient address). +* Copy sent test email to IMAP `Sent`. +* Display success/failure status and timestamp. +* Keep rendered `Subject`, `Message`, and `PDFAttachment` visible after send. + +## 1.8 Run Mailshot Page + +### 1.8.1 Pre-Run Validation + +* Require selected mailshot. +* Require resolved recipient query. +* Block run when recipient query returns zero recipients. +* Display validation failures and do not start run when validation fails. + +### 1.8.2 Execution Semantics + +* On `Run Mailshot`, execute recipient query and iterate recipients. +* For each recipient row: + * perform Twig template rendering + * send one email to the recipient destination email + * copy successful sends to IMAP `Sent` +* Continue processing remaining recipients after per-recipient failures. + +### 1.8.3 Run Results + +* Display live or final counters: + * attempted + * sent + * failed + * skipped (if applicable) +* Provide downloadable or copyable run log in the UI. +* Include per-recipient failure reason where available. + +### 1.8.4 Last Run Retry Table + +* Persist the outcome of the most recent run only in `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_last_run`. +* Clear `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_last_run` when starting a new `Run Mailshot` execution. +* Each row in `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_last_run` must store: + * `mailshot_id` + * `recipient_key` (stable non-email identifier) + * `recipient_key_field` (source field used to derive key) + * `recipient_email_last` (most recent resolved destination email) + * `status` + * `error_message` and/or `warning_message` + * `attempt_count` + * `last_attempt_at` +* Mailshots page must display the latest rows for the selected mailshot from `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_last_run`. +* Mailshots page must provide: + * `Retry Failed Sends` button to retry all failed rows from the selected mailshot. + * per-row `Retry` button to retry one failed row by recipient key. +* Retry must resolve recipient data from the current data source and current template values at retry time (not snapshot payload from original run). +* Email address must not be used as the retry key. + +## 1.9 Download PDF Page + +### 1.9.1 Setup and Validation + +* Provide control to select a mailshot. +* Require selected mailshot. +* Require resolved recipient query. +* Block generation when recipient query returns zero recipients. +* Block generation when selected mailshot has blank `PDFAttachment` template. + +### 1.9.2 Output Options + +* Provide action to generate and download individual recipient PDFs as a single `.zip`. +* Provide action to generate and download a single merged PDF containing all recipient PDFs. +* Use html-to-pdf conversion of rendered `PDFAttachment` content per recipient row. + +### 1.9.3 Results and Logging + +* Display generation success/failure status in the UI. +* Generate downloadable files directly (no per-row log table required on this page). + +## 1.10 Mailshot Queries Page + +Provide a CRUD page for the `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries` table. + +### 1.10.1 DSL Built-in Sources + +Built-in DSL sources include: + +* `contacts` +* `accounts` +* `renewals` + +`renewals` is a first-class source and must be joinable to: + +* `accounts` via `renewals.account_id = accounts.ID` +* `contacts` through accounts (`renewals.account_id = contacts.Accountid`) + +### 1.10.2 DSL Renewal and Membership Filters + +DSL filters include: + +* `selected-renewal` +* `pending-renewal` +* `fen1-contact` +* `fen2-contact` +* `member-or-affiliate-or-parish-council` + +`selected-renewal` uses saved field values (`renewals.selected = true`). +`pending-renewal` filters rows to `renewals.status = 'pending'`. + +## 2.0 No-Effect and Error Handling + +* If `Save` is clicked with no changes, no effect. +* If `Discard Changes` is clicked with no changes, no effect. +* If a selected mailshot references a missing `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries` row, block preview/test/run and display error. +* If SMTP send fails for one recipient, record error and continue with next recipient. +* If IMAP copy fails after successful SMTP send, mark warning for that recipient. +* For any DSL sentence, all fields from all cited sources must be available for token insertion and template rendering; missing values may render empty, but fields must not disappear from mailshot field availability. +* Twig template syntax errors must not crash the page or run; show clear error identifying mailshot and template field (`Subject`, `Message`, or `PDFAttachment`). +* Default behavior on template syntax error is fail-fast for that recipient (skip send for recipient, continue run for others) and record error. +* Retry actions must update `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_last_run` rows in-place (incrementing `attempt_count` and updating status/messages/timestamp). + +### 2.0.1 IMAP Sent-Copy Contract + +* Use per-user configured IMAP sent-folder path from WordPress mailshot credentials. +* IMAP folder names must be encoded/decoded using IMAP UTF-7 rules. +* For each successful SMTP send, attempt a single IMAP append for sent-copy persistence. +* IMAP append failure must not change SMTP send outcome; classify as `warning`, not `failed`. +* IMAP append retries are not automatic in run loop; retry occurs only via explicit operator retry action. +* Sent-copy operations must be idempotent per attempt id; duplicate append for the same attempt id is not allowed. + +## 2.1 PDF Assets + +### 2.1.1 Purpose + +Provide reusable image assets that can be inserted into `PDFAttachment` html templates using Twig syntax and resolved at PDF render time. + +### 2.1.2 Navigation and Page + +Under `Mailshot`, provide page `PDF Assets`. + +### 2.1.3 Data Model + +Provide CRUD storage for PDF assets with at least: + +* `id` (primary key) +* `name` (unique asset identifier used in token marker) +* `file_name` (original upload filename) +* `mime_type` +* `file_bytes` (or storage reference) +* `width_mm` +* `height_mm` +* `justification` (`left`, `right`, `in-place`) +* `created_at` +* `updated_at` + +### 2.1.4 PDF Assets Page Behavior + +* List assets in a table. +* Support `Create`, `Read`, `Update`, `Delete`. +* Upload graphic file during create/update. +* Edit dimensions in millimetres. +* Edit justification (`left`, `right`, `in-place`). +* Validate file type and size constraints. + +### 2.1.5 Mailshots PDF Editor Integration + +* In the `PDF Attachment` editor overlay, add an insert-asset action with dropdown of available assets. +* On insert, place Twig token syntax at cursor position. +* Default insert format is `{{ pdf_asset('asset_name') }}`. +* Asset names used in inserted tokens must be normalized to safe token characters. + +### 2.1.6 Rendering Behavior + +* On rendering `PDFAttachment` html (Mailshot Test preview/send, Run Mailshot, Download PDF), resolve each inserted PDF asset Twig token to rendered image html for that asset. +* Render image using configured dimensions (`width_mm`, `height_mm`) and configured justification. +* If template references missing asset, do not fail entire run; record warning and render explicit missing-asset placeholder html. +* Missing asset placeholder default is `[missing pdf asset: ]`. + +### 2.1.7 PDF Filename Derivation + +* For each rendered recipient pdf (download and email attachment), if `PDFFilenameDerivedFrom` is set, use row value from that field as base filename. +* Sanitize filename to safe filename characters. +* If `PDFFilenameDerivedFrom` is blank or field value resolves blank, use fallback naming strategy. +* Ensure filename uniqueness within a generated batch (append numeric suffix as needed). + +## 2.2 PDF Assets Decisions + +* Asset binaries are stored in DB table (`LONGBLOB`) for portability and backup simplicity. +* Allowed upload formats: `png`, `jpg/jpeg`, `svg`. +* Scope/ownership: global asset library, all authenticated users may CRUD. +* Asset naming: unique case-insensitive `name`; renaming does not auto-migrate existing asset insertions in templates and should warn user. +* Marker syntax is removed. PDF asset insertion uses Twig syntax. +* Helper name is `pdf_asset(name)` as a global Twig helper, not a filter. +* Dimension/aspect behavior: fixed width+height in mm with `object-fit: contain` (no crop/stretch distortion). +* Justification semantics: `in-place` renders inline at marker location; `left` and `right` render block-aligned elements on their own line (no text-wrap behavior). +* Upload limits: max file size `2MB`, max image dimensions `4000x4000`. +* Missing asset token behavior: continue processing, emit warning, render explicit missing-asset placeholder. +* Delete behavior for in-use assets: block delete when asset helper usage is referenced by any `PDFAttachment` template and show referencing mailshot count. + +## 2.3 Mailshot Attachments + +### 2.3.1 Page and Layout + +Under `Mailshot > Assets`, provide page `Attachments`. + +* Data pane: table of attachments sorted by `name`. +* CRUD pane (right): edit `name` and uploaded file. +* Provide actions: `Save`, `New`, `Delete`. + +### 2.3.2 CRUD and Validation + +* `name` is required and unique. +* Create requires uploaded file bytes. +* Update allows metadata-only edits without replacing file. +* Delete must be blocked while referenced by any `MAILSHOTS_REMOTE_MYSQL_DB.mailshots.AttachmentNames`. + +### 2.3.3 Send/Run Behavior + +* On `Mailshot Test > Send Test Email`, include all selected named attachments. +* On `Run Mailshot`, include all selected named attachments for each recipient email. +* If a selected attachment name is missing, continue send/run and record warning. diff --git a/requirements/mailshot_data_source.md b/requirements/mailshot_data_source.md new file mode 100644 index 0000000..a5bb96f --- /dev/null +++ b/requirements/mailshot_data_source.md @@ -0,0 +1,564 @@ +# Mailshot Data Source page + +## Purpose + +Document requirements for a safe, non-admin-friendly mailshot data source definition model. + +## Environment baseline for this specification + +`members` as a database name means the value of `MEMBERS_REMOTE_MYSQL_DB`. + +For this specification: + +* Data-source definition persistence (create/update/delete/list of `mailshot_queries`) uses `MAILSHOTS_REMOTE_MYSQL_DB`. +* Source-data query execution (built-in/custom recipient sources) uses `MEMBERS_REMOTE_MYSQL_DB`. + +## Problem with prior approach + +Creating a new data source currently requires: + +* knowledge of MySQL schema details, or +* admin-level access to create DB views/queries. + +This blocks non-expert and non-admin users. + +## Direction + +Use a strict, constrained query language (DSL) for mailshot audience selection. + +The DSL text is stored as the data-source definition in `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries`, then parsed and compiled into safe SQL executed against approved source-data tables in `MEMBERS_REMOTE_MYSQL_DB`. + +## Goals + +* Allow non-admin users to define useful audiences. +* Prevent arbitrary SQL. +* Keep execution efficient (push filtering to SQL, avoid large unnecessary joins). +* Make queries explainable and previewable. + +## Example requirements (in natural language) + +* All contacts with name like "smith". +* All contacts joined to member accounts where contact FEN1 is true. +* All renewal rows marked as selected. +* All pending renewals. +* All accounts where name is not in an exclusion table. + +## Strict grammar (v1.5) + +Use lowercase keywords only. + +### EBNF + +```ebnf +query = source_expr , [ where_clause ] ; + +source_expr = source_term , { source_op , source_term } ; +source_term = source_ref | "(" , source_expr , ")" ; +source_op = "and" ; + +source_ref = built_in_source | table_ref ; +built_in_source = "contacts" | "accounts" | "renewals" ; +table_ref = ident_part , "." , ident_part ; +ident_part = identifier | quoted_identifier ; + +where_clause = "where" , predicate_expr ; +predicate_expr = predicate_term , { bool_op , predicate_term } ; +predicate_term = [ "not" ] , predicate_atom + | "(" , predicate_expr , ")" ; +bool_op = "and" ; + +predicate_atom = filter_expr + | field_ref , compare_op , value + | field_ref , ("=" | "!=") , field_ref + | field_ref , [ "not" ] , "in" , "(" , field_ref , ")" + | field_ref , "in" , "(" , value_list , ")" ; + +filter_expr = filter_name + | filter_name , "(" , value_list , ")" ; + +filter_name = "selected-renewal" + | "pending-renewal" + | "fen1-contact" + | "fen2-contact" + | "member-or-affiliate-or-parish-council" ; + +field_ref = source_ref , "." , field_name ; +field_name = identifier | quoted_identifier ; +compare_op = "=" | "!=" | "contains" | "starts-with" | "ends-with" ; + +value_list = value , { "," , value } ; +value = quoted_string | number | "true" | "false" ; + +identifier = letter , { letter | digit | "_" } ; +quoted_identifier = "`" , { character - "`" } , "`" ; +quoted_string = "'" , { character - "'" } , "'" ; +number = digit , { digit } ; +letter = "a"…"z" | "A"…"Z" ; +digit = "0"…"9" ; +``` + +## Semantics (v1.5) + +* `built_in_source` maps to a predefined table alias. +* `table_ref` maps directly to a table in the remote members database (`schema.table`). +* For `table_ref`, validation must confirm the table exists. +* Query result shape must be source-faithful and deterministic per DSL: + * each cited source contributes its full allowed field set to an expected-field contract; + * preview and template-token resolution must preserve availability of every field in that contract, even when raw SQL row keys are sparse; + * source-native columns from query output may also be present; + * no unrelated synthetic recipient projection columns are allowed. +* If the resolved source does not expose an email field, validation must return a warning: + * `Data Source does not have an email field, it cannot be used for a Mailshot` + * The sentence is still valid and can be saved and previewed. +* `and` between sources means relational intersection using configured join paths (not email-only matching). +* For custom sources, an explicit field equality predicate can provide join semantics. +* `where` applies after source composition. +* `not` negates only the next predicate/group. +* Predefined predicates are `selected-renewal`, `pending-renewal`, `fen1-contact`, `fen2-contact`, and `member-or-affiliate-or-parish-council`. +* `renewals` is a built-in source mapped to membership renewal rows. +* `pending-renewal` applies only when source set includes `renewals` and means `renewals.status = 'pending'`. +* `selected-renewal` applies only when source set includes `renewals` and means `renewals.selected = true`. +* `member-or-affiliate-or-parish-council` applies to account data and means: + * `accounts.type` is `Member` or `Affiliate`, or + * `accounts.name` contains `Parish Council`. +* `field_ref` is restricted by whitelist per built-in source. +* `field_ref` is also supported for selected custom sources when table metadata is available. +* Field-to-field comparisons are supported with `=` and `!=` only. +* Set-membership against another field source is supported via: + * `field_ref in (field_ref)` + * `field_ref not in (field_ref)` +* For `in(field_ref)` / `not in(field_ref)`, the RHS source is treated as a reference source (subquery semantics), not a joined source. +* `contains`, `starts-with`, and `ends-with` require a literal right-hand side value (not a field reference). +* In v1.5, mixing built-in sources and custom sources in the same sentence is not supported; validation must fail with a clear error. +* For multi-custom-source sentences, all selected custom sources must be connected by explicit `=` field-to-field predicates (graph-connected join semantics), otherwise validation fails. +* `in(field_ref)` / `not in(field_ref)` must compile as subquery membership; the RHS source is a reference source and does not need to appear in `source_expr`. +* Canonical naming in this document uses exact field names as defined in database metadata for the selected source. The implementation must reject invalid names and must not add aliasing/normalization fallback. + +### Join-path model (required) + +The compiler must use an explicit join graph per source pair. Example v1 join paths: + +* `contacts` -> `accounts`: `contacts.Accountid = accounts.ID` +* `accounts` -> `contacts`: `accounts.ID = contacts.Accountid` +* `renewals` -> `accounts`: `renewals.account_id = accounts.ID` +* `accounts` -> `renewals`: `accounts.ID = renewals.account_id` +* `renewals` -> `contacts`: `renewals.account_id = contacts.Accountid` +* `contacts` -> `renewals`: `contacts.Accountid = renewals.account_id` + +If no approved join path exists between two sources for `and`, parsing/validation must fail with a clear error. + +No implicit join behavior is allowed: + +* Selecting a source such as `accounts` must not silently join `contacts` (or any other source). +* Output columns must come only from sources explicitly requested in the sentence. + +## Example valid queries + +* `contacts` +* `contacts and accounts where fen1-contact` +* `contacts and accounts where fen2-contact` +* `accounts and contacts where contacts.Accountid = accounts.ID` +* `contacts where contacts.Last contains 'smith'` +* `contacts and accounts where accounts.Type = 'Member' and contacts.FENContact1 = true` +* `accounts where member-or-affiliate-or-parish-council` +* `renewals where pending-renewal` +* `renewals where selected-renewal` +* `renewals and accounts and contacts where pending-renewal` +* `accounts where accounts.Name not in (members.ExcludedAccounts.ExcludedAccount)` +* `members.mailshot_test` + +## Example invalid queries + +* `select * from members` (raw SQL not allowed) +* `contacts where drop table` (unknown tokens) +* `contacts where accounts.Name = 'x'` (invalid if `accounts` is not included in source expression) +* `contacts or accounts` (`or` is not supported; use `and` only) +* `accounts where pending-renewal` (invalid: filter requires `renewals` source) +* `accounts where fen1-contact` (invalid: filter requires `contacts` source) +* `renewals where member-or-affiliate-or-parish-council` (invalid: filter requires `accounts` source) +* `accounts and contacts where contacts.Accountid contains accounts.ID` (invalid: field-to-field supports only `=`/`!=`) +* `accounts where accounts.Name contains members.ExcludedAccounts.ExcludedAccount` (invalid: `contains` requires a literal RHS) + +## Compilation and performance requirements + +* Parse DSL to AST. +* Validate AST against allowed sources, fields, and operators. +* Validate all `and` source combinations against approved join graph metadata. +* Do not perform implicit source joins during compilation. +* Compile AST to parameterized SQL only (no string-concatenated SQL). +* Push filters into SQL `WHERE` / join conditions. +* Preview must not assume fixed contact/account projection columns. +* Compiler/runtime must enforce deterministic source-field availability from DSL-cited sources. +* Preview column derivation must include: + * all fields from the DSL expected-field contract, and + * union of keys from returned rows (not just first row keys), to avoid hiding sparse/source-native columns. +* For mailshot execution, email remains the only required field. +* Provide preview endpoints: count + sample rows before execution. + +## Storage model + +Store: + +* `dsl_text` (editable source). +* `dsl_text` is the single source of truth for execution, preview, and validation. +* No SQL fallback path is permitted when `dsl_text` is missing/empty; this must be treated as an error. + +Optional: + +* normalized AST JSON for diagnostics/explain. +* `sql` retained as blank/deprecated for backward compatibility when the legacy column exists, and never executed. + +## User interface requirements + +Provide a UI to view, create, update, validate, and preview DSL sentences. + +### Mailshot data source page + +Add a dedicated page "Data Sources" under FECA Mailshots admin page for managing data source sentences. + +Top regions: + +* Context pane: + * page title `Mailshot Data Sources` +* Information pane: + * validation errors, save status, preview status +* Statistics pane: + * total saved data sources + * selected data source id/name + +### Data source list (view) + +Provide a table/list showing existing data sources with columns: + +* `ID` +* `Name` +* `DSL sentence` +* `Updated at` + +List behavior: + +* single-row selection +* text filter by `Name` or sentence content +* sort by `Name` and `Updated at` + +Selection to persist between page loads. + +### Editor (create/update sentence) + +Provide an editor panel with: + +* `Name` input (required) +* multi-line `DSL sentence` input (required) +* read-only `Last validated status` + +Actions: + +* `New`: + * clear editor and start create mode +* `Save`: + * create or update current record + * enabled only when form is valid and has unsaved changes +* `Discard changes`: + * revert editor to last saved state +* `Delete`: + * confirmation required + * blocked when the data source is referenced by one or more mailshots + +### Validation UX + +Provide explicit validation before save and on-demand: + +* `Validate sentence` button +* parse + semantic validation against: + * grammar + * source/field/operator whitelist + * join-path rules + +On validation result: + +* show `Valid` / `Invalid` +* show `Warnings` separately from errors +* for invalid: + * show user-friendly error message + * include line/column when available + * do not save invalid sentence +* for warning-only cases (for example missing email field): + * allow save + * allow preview + * indicate the data source cannot be used for sending a mailshot + +### Preview UX + +Provide `Preview recipients` action for selected sentence: + +* show recipient count +* show sample rows (for example first 50) +* derive displayed columns from the deterministic expected-field contract plus returned row shape +* do not assume fixed contact/account projection columns +* show execution time +* do not execute/send mailshot from this page + +### DSL Sentence Builder (required) + +Provide a `Build DSL` action in the data-source editor. + +On click, open a modal/overlay builder that allows users to construct full valid DSL without memorising grammar keywords, filter names, or field names. + +Builder layout: + +* Top sub-pane: `Data Sources` +* Bottom sub-pane: `Constraints / Filters` +* Footer: generated DSL preview + actions + +#### Top sub-pane: Data Sources + +Provide source selection controls for: + +* `contacts` +* `accounts` +* `renewals` +* custom table source (schema/table selector or validated text input) + +Behavior: + +* multiple source selection is combined using `and` +* order of selected sources is preserved in generated sentence +* selecting an invalid source combination is blocked in UI with clear message + +#### Bottom sub-pane: Constraints / Filters + +Provide controls to add one or more constraint rows. + +Each row supports one of: + +* predefined filter +* field comparison +* grouped expression with `not` + +Predefined filter control: + +* dropdown label uses user-friendly text, not raw DSL token +* examples: + * `Renewal is selected` -> `selected-renewal` + * `Renewal is pending` -> `pending-renewal` + * `Contact is FEN1` -> `fen1-contact` + * `Contact is FEN2` -> `fen2-contact` + * `Account is member/affiliate/parish council` -> `member-or-affiliate-or-parish-council` + +Field comparison row: + +* source dropdown (restricted to selected sources) +* field dropdown (restricted to fields allowed for selected source) +* operator dropdown (`=`, `!=`, `contains`, `starts-with`, `ends-with`, `in`) +* RHS mode dropdown: + * literal value input (typed by expected value type), or + * source+field selector for field-to-field comparisons and set-membership (`in`) +* for `in`, provide multi-value token input + +Logical composition: + +* all rows combine with `and` in v1.5 +* each row can be negated via checkbox (`not`) +* optional group rows allow nested bracketed expressions + +#### Generated DSL + Sync + +Footer must show: + +* read-only generated DSL sentence (live update) +* validation status +* parse/semantic errors with line/column mapped to relevant builder controls + +Actions: + +* `Apply` writes generated DSL to the editor text area +* `Cancel` closes overlay with no change +* `Reset` clears builder selections + +Sync rules: + +* opening builder from existing DSL pre-populates controls when sentence is parseable +* if existing DSL is not parseable, show message and allow user to start from clean builder +* direct text edits remain supported; builder is not the only editing path + +#### Safety and usability requirements + +* UI must prevent creating syntactically invalid DSL states +* UI must prevent selecting filters incompatible with current source set +* custom table sources must be validated before Apply +* generated DSL must always conform to strict grammar in this spec + +### API expectations for UI + +The UI expects backend endpoints for: + +* list data sources +* get one data source +* validate sentence +* create data source +* update data source +* delete data source +* preview sentence (count + sample) +* list schemas for custom source selection +* list tables for selected schema +* list fields for selected source (built-in or custom) + +WordPress REST route contract: + +* `GET /wp-json/mailshots/v1/data-sources` +* `GET /wp-json/mailshots/v1/data-sources/{id}` +* `POST /wp-json/mailshots/v1/data-sources/validate` +* `POST /wp-json/mailshots/v1/data-sources` +* `PUT /wp-json/mailshots/v1/data-sources/{id}` +* `DELETE /wp-json/mailshots/v1/data-sources/{id}` +* `POST /wp-json/mailshots/v1/data-sources/preview` +* `POST /wp-json/mailshots/v1/data-sources/{id}/preview` +* `GET /wp-json/mailshots/v1/data-sources/schemas` +* `GET /wp-json/mailshots/v1/data-sources/tables?schema=...` +* `GET /wp-json/mailshots/v1/data-sources/source-fields?source=...` + +Auth/capability requirements: + +* all routes require authenticated WordPress users; +* create/update/delete routes require a mailshot management capability (for example `manage_mailshots`); +* read/preview/validate routes require a mailshot read capability (for example `read_mailshots`). + +All endpoints must enforce server-side validation even if client validates first. + +## Additional functional requirements (in scope) + +* Mailshot Data Source preview, Mailshot Recipients Preview, and Mailshot Test preview must all display source-faithful columns using the same result-shape rule. +* DSL Builder must support custom sources via schema + table selection (not text-only entry), with manual text fallback only when metadata APIs are unavailable. +* After a custom source is added in Builder, it must be immediately available in: + * source dropdowns, + * LHS field dropdowns, + * RHS field dropdowns (for field comparisons). +* Builder must support `field not in (other_source.other_field)` generation using RHS source + field controls (not value-type literal controls). +* Builder prefill must parse and restore field references where source names are qualified (for example `members.ExcludedAccounts.ExcludedAccount`). +* Validation errors must be explicit for unsupported/ineffective predicates; no silent no-op predicate behavior. + +## Implementation details + +This section is normative guidance for regenerating the feature from scratch. + +### 1. Data model + +* Table: `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries` +* Required columns: + * `ID` (PK) + * `name` + * `dsl_text` + * `updated_at` (recommended) +* `dsl_text` is the only executable representation. +* Persist `sql` as blank/deprecated if column still exists for backward compatibility; do not execute it. + +### 2. Backend architecture + +Build a strict pipeline: + +1. Tokenize DSL text. +2. Parse to AST. +3. Validate AST (grammar + semantics). +4. Compile AST to parameterized SQL and parameters. +5. Execute SQL for preview/send paths. + +Keep parser/validator/compiler in a dedicated module (for example `mailshotDsl.js`), and route handlers thin. + +### 3. Parser/AST contract + +* AST should separate `source_expr` and `where`. +* Predicates should distinguish: + * named filters, + * compare literal, + * compare field-to-field, + * `in(value_list)`, + * `in(field_ref)` / `not in(field_ref)`. +* Preserve token line/column for diagnostics. + +### 4. Validation rules (server-side) + +* Validate source compatibility via explicit join-path metadata. +* Validate filter applicability against selected sources. +* Validate field refs against per-source field whitelist: + * built-ins: static map + * custom: metadata-provider abstraction (not direct SQL dependency in validator logic) +* Enforce: + * field-to-field only `=` / `!=` + * literal RHS only for `contains` / `starts-with` / `ends-with` +* Return warnings (not errors) for no-email-source sentences. +* Metadata-provider minimum DB grants must be documented and kept minimal for the read-only DB user. + +### 5. SQL compilation strategy + +Use parameterized SQL only. + +* Built-in-only source expressions: + * compile from fixed source metadata (table + alias + base where). + * compile approved joins only. +* Custom-only source expressions: + * single source: `SELECT * FROM schema.table WHERE ...` + * multi source: require explicit `=` field join connectivity and compile with aliased joins. +* `in(field_ref)` / `not in(field_ref)`: + * compile as `EXISTS` / `NOT EXISTS` subqueries. + +### 6. Preview/result-shape rules + +* Normalize row values to JSON-safe scalars. +* Enforce expected-field contract from DSL-cited sources. +* Keep source-native returned columns available. +* It is allowed to expose normalized source-field aliases required to satisfy the expected-field contract. +* Do not add unrelated synthetic recipient projection columns. +* Derive display columns from expected-field contract plus union of keys in returned rows. +* Prune-all-empty columns only if explicitly configured and documented; default should preserve source-faithful shape. + +### 7. API surface + +Implement at minimum: + +* `GET /wp-json/mailshots/v1/data-sources` +* `GET /wp-json/mailshots/v1/data-sources/{id}` +* `POST /wp-json/mailshots/v1/data-sources/validate` +* `POST /wp-json/mailshots/v1/data-sources` +* `PUT /wp-json/mailshots/v1/data-sources/{id}` +* `DELETE /wp-json/mailshots/v1/data-sources/{id}` +* `POST /wp-json/mailshots/v1/data-sources/preview` +* `POST /wp-json/mailshots/v1/data-sources/{id}/preview` +* `GET /wp-json/mailshots/v1/data-sources/schemas` +* `GET /wp-json/mailshots/v1/data-sources/tables?schema=...` +* `GET /wp-json/mailshots/v1/data-sources/source-fields?source=...` + +All endpoints must validate server-side and return structured errors: +`{ ok:false, error, line?, column? }`. + +### 8. UI generation guidance + +Mailshot Data Source page: + +* left: data source list with select/filter/sort. +* right: editor + validation + preview. +* include `Build DSL` modal. + +Builder modal: + +* source selection section (built-in + schema/table custom add). +* constraints section with row editor (filter/comparison). +* responsive row layout that wraps controls on narrow widths. +* RHS field mode must expose source + field selectors. +* show generated DSL live; validate with debounce. +* `Apply` writes DSL text; `Cancel` leaves editor unchanged. + +### 9. Safety and regression test guidance + +Minimum automated tests: + +* parser positive/negative cases (line/column assertions). +* semantic validation failures (join path, filter applicability, field whitelist). +* compile output shape for: + * built-in joins, + * custom joins, + * `in(field_ref)` and `not in(field_ref)`. +* preview shape: + * includes all expected fields from cited sources, and + * does not introduce unrelated synthetic recipient projection columns. +* builder prefill for qualified source field refs. +* no-op predicate prevention (must error, not silently ignore). diff --git a/requirements/test_environments.md b/requirements/test_environments.md new file mode 100644 index 0000000..999dbff --- /dev/null +++ b/requirements/test_environments.md @@ -0,0 +1,38 @@ +# Mailshot Plugin Test Environments + +## Purpose + +Define the standard environments used for validation, and what each environment is expected to prove. + +## Environment Matrix + +1. Local Unit/Static Environment + - Scope: fast syntax and isolated logic checks. + - Goal: catch regressions early before integration testing. + - Notes: no fallback behavior unless specifically instructed in requirements. + +2. Local Fixture/Preview Environment + - Scope: browser-visible plugin behavior without requiring full production infrastructure. + - Goal: verify rendering, configuration handling, and operator workflows. + - Notes: should execute the same plugin runtime paths as production code where possible. + +3. WordPress Integration Environment + - Scope: plugin activation and execution inside a WordPress runtime. + - Goal: validate hooks, shortcode/admin integration, and settings persistence. + - Notes: use representative plugin settings; avoid hidden defaults. + +4. Staging/Pre-Production Environment + - Scope: production-like infrastructure and data connectivity. + - Goal: final behavioral confidence before release. + - Notes: verify operational concerns (configuration, permissions, observability, rollback readiness). + +## Minimum Validation Expectations + +- All changed files pass language-level syntax checks. +- Any new requirement has at least one corresponding validation step. +- Test evidence should identify which environment produced it. + +## Traceability + +- Requirement definitions are anchored by `requirements/environment.md`. +- Test notes should reference requirement IDs/sections when they exist. diff --git a/requirements/top-level.md b/requirements/top-level.md new file mode 100644 index 0000000..927fb39 --- /dev/null +++ b/requirements/top-level.md @@ -0,0 +1,86 @@ +# Mailshot Top-Level Requirements + +## Document Context + +This document is governed by `requirements/environment.md` and must be interpreted with that baseline in scope. + +## Purpose + +Define the migration goals for delivering the Mailshot feature as a WordPress plugin capability in this repository, replacing the prior Node.js server/front-end implementation approach. + +## Inputs + +Primary input specifications: + +- `../feca2-app/requirements/mailshot.md` +- `../feca2-app/requirements/mailshot_data_source.md` + +Local governing baseline: + +- `requirements/environment.md` +- `requirements/test_environments.md` + +## Migration Direction + +1. Re-platform + - Move from the `feca2-app` Node.js runtime model to a WordPress plugin runtime model in this workspace. + - Preserve business intent of the mailshot feature while adapting implementation to WordPress/PHP architecture. + +2. Source-of-truth shift + - Remove support for local database entities used previously as source-of-truth for mailshot data: + - issue + - page + - article + - advertiser + - ads + - Do not keep compatibility fallbacks for these local entities unless explicitly required later. + +3. Data access model + - Access accounts, contacts, and renewals directly from the remote WordPress-side database environment. + - Remove dependency on local replicated data for those entities. + +4. Mailshot persistence + - Create a dedicated database on the remote server to hold mailshot-specific tables and assets. + - Treat this remote mailshot database as authoritative for mailshot definitions/assets in the plugin implementation. + +## Environment and Testing Goals + +1. Remote data path in testing + - During testing, access remote database data via SSH tunnel. + - Test environments must explicitly show tunnel configuration and expected connectivity validation. + +2. Verification scope + - Validate read access for accounts, contacts, and renewals from remote source. + - Validate read/write access for mailshot tables/assets in the new remote mailshot database. + - Validate that removed local-domain sources (issue/page/article/advertiser/ads) are not required by the migrated flow. + +## Research Outcomes (2026-04-20) + +1. PDF engine suitability (`dompdf`) + - Outcome: **Accepted: suitable with constraints**. + - Decision basis is recorded in: + - `results/dompdf_investigation_findings_2026-04-20.md` + - Accepted constraint: + - non-appearing special characters are currently acceptable. + +2. Nunjucks replacement + - Outcome: **Twig selected** as the PHP-side replacement direction. + - Decision basis is recorded in: + - `results/nunjucks_replacement_findings_2026-04-20.md` + - Template policy decision: + - use a minimal-helper approach; + - prefer inline template logic (for example `{% if x is not empty %}...{% endif %}`); + - avoid introducing helper functions unless explicitly required. + +## Deliverables from This Requirement + +1. Confirmed architecture decision for remote-only data access (accounts/contacts/renewals). +2. Confirmed plan for remote mailshot database creation and usage. +3. Research findings for `dompdf` viability and decision outcome. +4. Research findings for Nunjucks replacement and decision outcome. + +## Out of Scope (Current Top-Level Phase) + +- Detailed schema definitions for the new remote mailshot database. +- Full page-by-page UI requirements rewrite. +- Full implementation of the selected rendering/template stack. diff --git a/requirements/ui_design.md b/requirements/ui_design.md new file mode 100644 index 0000000..4b1d42c --- /dev/null +++ b/requirements/ui_design.md @@ -0,0 +1,76 @@ +# UI Design + +## Environment Baseline + +* Confirmation popup source identity is configurable via `MAILSHOTS_UI_SOURCE_LABEL`. +* Default value for `MAILSHOTS_UI_SOURCE_LABEL` is `Mailshots`. + +## Purpose + +Define user interface requirements common to all pages. + +## Top-Level Layout + +* Use WordPress admin-page navigation as the canonical navigation model. +* Each plugin page should render its own content region within the standard WordPress admin page layout. + +## Navigation + +* Implement navigation as a set of WordPress Admin Pages under the FECA Mailshots plugin admin entry. + +## Content Pane + +* Constrain content pane to one visible screen page. +* When content exceeds available space, scroll inside the content pane. +* Structure the pane into these regions as applicable: + * context pane (top left) + * information pane (optional) + * statistics pane (optional) + * actions / filter pane (optional) + * data pane (optional) + +## Standard Page Regions + +For pages using info/statistics/actions/data regions: + +* Place information pane at top-right for hints, feedback, and errors. +* Place statistics pane at top between context area and information pane. +* Place actions pane below top panes. +* Place data pane below top panes/actions. +* Keep controls visible while allowing internal scrolling in the data pane. +* Avoid horizontal scrolling when content width can be adjusted to prevent it. + +### Multi-Pane Data Areas + +* If a data area contains two or more sibling panes, provide a draggable divider between adjacent panes. +* Use vertical dividers for left/right panes and horizontal dividers for top/bottom panes. +* Persist user-adjusted split ratios per page. +* On narrow viewports, collapse split layouts to a vertical stack and disable drag interaction. +* Keep each pane independently scrollable when content exceeds pane bounds. + +## Common UX Rules + +* Use configured `MAILSHOTS_UI_SOURCE_LABEL` as source identity in confirmation popups. +* Hide CRUD forms when not in use. +* Use red visual treatment for destructive actions. +* Provide spacing between action buttons and panel edges. + +## Default Table Behavior + +* Allow user-resizable widths for all displayed table columns, including in Firefox-derived browsers. +* Use edge-drag column resizing with no explicit/visible drag-handle element. +* Persist column widths per table. +* Where a table supports sorting, provide: + * a `Sort by` dropdown listing sortable columns + * an adjacent direction dropdown with options `Ascending` and `Descending` +* Do not use per-column header sort controls. +* Keep `Actions` column non-sortable. +* If a page-specific rule conflicts with default table behavior, page-specific rule takes precedence. + +## Resize Stability Requirements + +* Do not trigger data reloads during column resize interactions. +* Keep table visible while dragging a column-resize handle. +* Do not show transient loading states during resize unless a real refresh is explicitly requested. +* Ensure resize-state updates (for example column widths) do not alter API request dependencies for table-load effects. +* Keep API client functions used in effect dependencies referentially stable across normal UI rerenders (for example resize, hover, selection changes). diff --git a/results/dompdf_investigation_findings_2026-04-20.md b/results/dompdf_investigation_findings_2026-04-20.md new file mode 100644 index 0000000..6d4c280 --- /dev/null +++ b/results/dompdf_investigation_findings_2026-04-20.md @@ -0,0 +1,122 @@ +# dompdf Investigation Findings (2026-04-20) + +## Scope + +Investigation run against: + +- `samples/renewal_draft.html` +- `samples/invoicing.html` + +Using spike script: + +- `working/codex-spikes/dompdf_spike.php` + +## Execution Summary + +- dompdf installed via Composer: `dompdf/dompdf v3.1.5`. +- Spike rendered both templates to PDF successfully (exit code 0). +- Output PDF metadata confirms: + - producer `dompdf 3.1.5 + CPDF` + - `Pages: 1` + - `A4` page size for both files. + +Generated artefacts: + +- `working/dompdf/rendered_html/renewal_draft.rendered.html` +- `working/dompdf/rendered_html/invoicing.rendered.html` +- `working/dompdf/pdfs/renewal_draft.pdf` +- `working/dompdf/pdfs/invoicing.pdf` + +## Objective Checks + +1. Render success + - Result: PASS + - Evidence: spike script completed and wrote both PDFs. + +2. Unicode/currency symbols in invoicing output + - Result: PARTIAL + - Evidence: + - `£` extracted correctly from PDF text. + - `✉` extracted correctly from PDF text. + - phone symbol `📞` extracted as replacement glyphs (`��`) in text extraction. + - Interpretation: likely font/glyph coverage issue for this symbol. + +3. Renewal draft watermark and complex layout + - Result: PARTIAL + - Evidence: + - watermark text appears in extracted PDF text stream as split/reordered letters, indicating transformed/fixed-position content was processed. + - Interpretation: confirms render path handled transformed watermark content, but fidelity must be visually verified. + +4. Tables/spacing/mm layout constraints + - Result: NOT YET VERIFIED + - Evidence: no browser baseline comparison completed in this run. + +## Recorded File Fingerprints + +- `working/dompdf/pdfs/invoicing.pdf` + - sha256: `881ce70e95621423c7b0629e777190ee6c6e64079103e7c81ae18f46bbf5111a` +- `working/dompdf/pdfs/renewal_draft.pdf` + - sha256: `a92e64b4ebe7553068163f07d108d1cfadfd8955f1ec4940811dfafd540a73e7` +- `working/dompdf/rendered_html/invoicing.rendered.html` + - sha256: `9f5182485e21e979cf9b0eb1ed0500381a62d3d9269e5a2d19f080f4a8a34a1c` +- `working/dompdf/rendered_html/renewal_draft.rendered.html` + - sha256: `b81b8fea7f07bcdf32667adc27f88cc060c1d7a1c741fbacd87b494bff2568e9` + +## Current Suitability Status + +Provisional outcome: **Suitable with constraints (pending visual baseline comparison)**. + +Rationale: + +- Core render pipeline works for both templates. +- At least one symbol (`📞`) shows a likely glyph-support problem. +- Watermark/transformed layout needs visual pass/fail against browser print baseline before final sign-off. + +## Stakeholder Visual Review Outcome + +Review date: `2026-04-20`. + +Outcome recorded from user review: + +- Visual output is acceptable for current templates, excluding missing production mailshot assets in this spike context. +- Non-appearing special characters are acceptable for now. + +Decision update: + +- Suitability status is now **Accepted: suitable with constraints**. + +## Constraints / Risks Identified + +1. Emoji/supplementary symbols may not render reliably with default font setup. +2. CSS transform/fixed-position behavior is active but not yet visually validated for final layout fidelity. + +## Required Next Validation + +1. Generate browser print-to-PDF baseline for both sample templates. +2. Perform side-by-side visual check for: + - watermark position/opacity/rotation + - table borders and spacing + - line wrapping and paragraph flow + - logo placement +3. Decide whether to: + - accept template constraints (for unsupported symbols), or + - change fonts/renderer strategy. + +## Rerun Record (Corrected invoicing template) + +Rerun timestamp: `2026-04-20 14:05:44 BST`. + +Reason: + +- `samples/invoicing.html` was corrected after the initial run (original sample was malformed). + +Updated artefact fingerprints: + +- `working/dompdf/pdfs/invoicing.pdf` + - sha256: `ef5f12648cd29fde01398efe81e4873ce70b273fe3587d3de56596740af6c765` +- `working/dompdf/pdfs/renewal_draft.pdf` + - sha256: `b2d2babc4f6918db8067f02b9a81ca4d7eb0e9e25bbda8223b59ae0d433b869f` +- `working/dompdf/rendered_html/invoicing.rendered.html` + - sha256: `2536cbc2eecfa6eb09ddc2c1832e2c2b8ae98200f42f31537182d3baf555845c` +- `working/dompdf/rendered_html/renewal_draft.rendered.html` + - sha256: `b81b8fea7f07bcdf32667adc27f88cc060c1d7a1c741fbacd87b494bff2568e9` diff --git a/results/nunjucks_replacement_findings_2026-04-20.md b/results/nunjucks_replacement_findings_2026-04-20.md new file mode 100644 index 0000000..462c678 --- /dev/null +++ b/results/nunjucks_replacement_findings_2026-04-20.md @@ -0,0 +1,57 @@ +# Nunjucks Replacement Findings (2026-04-20) + +## Scope + +Investigate a PHP templating replacement for Nunjucks with emphasis on required behavior from `samples/invoicing.html`: + +- conditional line break behavior (`br(text)` suppresses `
` when absent) +- helper-style asset insertion (`pdf_asset(...)`) + +## Candidate Tested + +- `twig/twig v3.24.0` + +## Spike Implementation + +Spike script: + +- `working/codex-spikes/twig_spike.php` + +Rendered output: + +- `working/twig/invoicing.twig.rendered.html` + +What was implemented in the spike: + +1. `pdf_asset(name)` as a Twig helper function (HTML-safe return). +2. `br(text)` as a Twig helper function with Nunjucks-equivalent effect: + - returns `""` when value is empty/missing + - returns `"
"` when present + +## Findings + +1. Template rendered successfully with Twig. +2. `pdf_asset('FenEdgeLogoOrange')` helper call rendered correctly as helper output placeholder. +3. `br(address_2)` with empty value produced no `
`, meeting the required behavior. +4. This satisfies the functional requirement that details of implementation do not matter as long as behavior matches. + +## Compatibility Notes + +- Twig syntax is very close to Nunjucks for variable interpolation, conditionals, filters, and control flow. +- Direct Nunjucks-style macro invocation may differ in strict syntax expectations. +- Where needed, helper functions can be provided to preserve required template behaviors without template redesign. + +## Recommendation + +Adopt **Twig** as the PHP-side Nunjucks replacement, with a **minimal-helper policy**: + +1. Prefer inline template logic over custom helpers (for example `{% if x is not empty %}...{% endif %}` instead of `br(...)`). +2. Minimize or avoid custom helper APIs to reduce discoverability/documentation burden. +3. Where possible, supply required dynamic values directly in render context variables rather than introducing template functions. + +Status: **Accepted for migration direction** based on current requirements and sample behavior. + +## Decision Update (User Guidance) + +User guidance clarified that helper proliferation creates discoverability problems. +Therefore, this investigation records a preference for simple, explicit template code and minimal custom helper surface. diff --git a/results/phase1_phase2_implementation_verification_2026-04-21.md b/results/phase1_phase2_implementation_verification_2026-04-21.md new file mode 100644 index 0000000..26f2ae0 --- /dev/null +++ b/results/phase1_phase2_implementation_verification_2026-04-21.md @@ -0,0 +1,182 @@ +# Phase 1 & 2 Implementation Verification (2026-04-21) + +## Scope implemented + +- Phase 1 infrastructure skeleton + - Service container and plugin bootstrap wiring. + - DB router with explicit members/mailshots database separation. + - WordPress facade interfaces and production adapter. + - Fixture harness (`tests/fixture/wp_shim.php`, `bootstrap.php`, `router.php`). + - Structured error and logging primitives. + +- Phase 2 data source pipeline + - DSL lexer/parser. + - Semantic validator (source checks, filter-source rules, join-path validation, missing-email warning). + - Parameterized SQL compiler. + - Source metadata provider (built-ins + custom table metadata via information schema). + - `mailshot_queries` repository and application service. + - Data Sources admin page + API operations (`list`, `fields`, `validate`, `preview`, `save`, `delete`). + +## Validation commands and outcomes + +1. `find feca_mailshots_plugin/src tests -name '*.php' -print0 | xargs -0 -n1 php -l` +- Result: pass (no syntax errors). + +2. `php tests/unit/test_dsl_pipeline.php` +- Result: pass. +- Verifies parser/validator/compiler happy path and key invalid-path checks. + +3. `php tests/unit/test_preview_shape.php` +- Result: pass. +- Verifies deterministic expected-field contract derivation. + +4. `php tests/fixture/test_api_smoke.php` +- Result: pass. +- Verifies fixture bootstrap + WP shim + admin menu/page registration path. + +## Notes + +- Fixture smoke test intentionally avoids remote DB calls in sandboxed execution. +- API operations that execute SQL (`preview`, `list`, `save`, `delete`) require DB connectivity at runtime. + +## Extended Phase 2 DB-access validation (2026-04-21) + +5. `php tests/integration/test_phase2_db_access.php` +- Result: pass. +- Environment: tunnelled MySQL on `127.0.0.1:13306`. +- Verified: + - Live DB read-path preview via `MEMBERS_REMOTE_MYSQL_DB` (source discovered dynamically from `information_schema`). + - Live DB write-path create/read/delete via `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries`. + - Test artefact cleanup confirmed (temporary data source row deleted in `finally`). + +### Fix discovered by DB test + +- `mailshot_queries` repository insert SQL used unquoted `sql` column name, which fails on MySQL. +- Fixed by quoting as `` `sql` `` in: + - `feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php` + +## Phase 3 implementation and validation (2026-04-21) + +### Implemented + +- Mailshot CRUD service/repository and admin API/page. +- Attachment CRUD + binary file handling service/repository and admin API/page. +- PDF asset CRUD + binary file handling service/repository and admin API/page. +- Last-run repository operations (`listForMailshot`, `clearForMailshot`, `create`). +- Token insertion helper generation from selected datasource expected fields. +- Fixture user-test pages under `tests/fixture/`. +- Fixture runner script `scripts/run_fixture_server.sh` (start/stop/status/restart). + +### Validation + +1. `php tests/integration/test_phase3_db_access.php` +- Result: pass. +- Verified end-to-end on tunnelled DB: + - datasource creation for test, + - attachment create/delete, + - pdf asset create/delete, + - mailshot create/delete with datasource validation, + - token helper output generation, + - last-run insert/list/clear behavior. +- Cleanup: test artefacts removed in `finally` block. + +2. `php tests/fixture/test_api_smoke.php` +- Result: pass. +- Verified fixture bootstrap and menu/page registration with shared plugin wiring. + +3. `bash -n scripts/run_fixture_server.sh` +- Result: pass. +- Script shell syntax validated. + +## Phase 4 implementation and validation (2026-04-21) + +### Implemented + +- Twig template rendering pipeline service for `Subject`, `Message`, `PDFAttachment`. +- SMTP sender abstraction with concrete SMTP client implementation. +- IMAP sent-copy abstraction with concrete `imap_append` implementation. +- Mail credentials provider backed by WordPress options (`feca_mailshots_mail_credentials`). +- `MailshotRunService` with: + - `renderTest` (no send), + - `sendTest` (validation + SMTP + IMAP warning semantics), + - `runMailshot` (clear last run + send loop + counters + per-recipient last_run rows), + - `retryFailed`, + - `retryRecipient`, + - `MAILSHOT_TEST_TO_DEFAULT` default exposure. +- Admin pages/endpoints added for: + - Credentials, + - Mailshot Test API, + - Run Mailshot API. + +### Validation + +1. `php tests/integration/test_phase4_run_flow.php` +- Result: pass. +- Verified: + - render-test behavior, + - blank test-email validation, + - test-send path through injected SMTP/IMAP transports, + - run-mailshot counters and last-run row persistence, + - retry-failed endpoint behavior. + +2. Existing regression checks +- `php tests/unit/test_dsl_pipeline.php` pass +- `php tests/unit/test_preview_shape.php` pass +- `php tests/fixture/test_api_smoke.php` pass +- `php tests/integration/test_phase2_db_access.php` pass +- `php tests/integration/test_phase3_db_access.php` pass + +### Runtime fix included + +- Added Composer autoload loading in plugin autoload bootstrap so Twig/Dompdf classes resolve when vendor deps exist. + +### Phase 4 UI completion (Mailshot Test + Run Mailshot) + +- Replaced placeholder pages with server-rendered WordPress admin UI for: + - Mailshot selection, + - Recipient selection/sample display, + - `Render Test (No Send)` action, + - `Send Test Email` action, + - Run controls (`Run Mailshot`, `Retry Failed Sends`), + - Last-run table with per-row `Retry` actions. +- UI actions use admin-post handlers and persist most recent action result in option-backed result panes. +- Added recipient-preview helper API in run service to support UI recipient picker. + +Validation: syntax + fixture smoke + phase4 integration test all pass after UI implementation. + +## Change set: per-user encrypted credentials + access split + no-fallback runtime config (2026-04-21) + +### Implemented + +1. Per-user SMTP/IMAP credential storage in `MAILSHOTS_REMOTE_MYSQL_DB` +- Added `mailshot_credentials` repository with table ensure/create and per-user upsert/load. +- Password fields are stored encrypted (`smtp_password_enc`, `imap_password_enc`) using AES-256-CBC with key material derived from WordPress salts. + +2. New Profile admin page for per-user credentials +- Added `Profile` page under FECA Mailshots (editor-capable users). +- Credentials are saved/loaded for the current user id. +- Password inputs support blank-as-keep-existing behavior. + +3. Runtime credential resolver switched to per-user DB-backed provider +- Replaced global option-based provider with `PerUserMailCredentialsProvider`. +- Run/test send paths now resolve credentials by current WordPress user. + +4. Production DB config path made explicit (fallbacks removed) +- Bootstrap now reads DB settings from Setup page option only. +- No fallback chain to env vars in production bootstrap. +- Fixture remains explicitly env-driven via `tests/fixture/bootstrap.php`. + +5. Access control split +- Setup page remains admin-only (`manage_options`). +- Operational pages/APIs use editor-level capability (`edit_pages`): + - Data Sources, Mailshots, Attachments, PDF Assets, + - Profile, + - Mailshot Test, + - Run Mailshot. + +### Validation + +- `php tests/fixture/test_api_smoke.php` pass +- `php tests/integration/test_per_user_credentials.php` pass +- `php tests/integration/test_phase4_run_flow.php` pass +- syntax checks pass for updated files diff --git a/samples/invoicing.html b/samples/invoicing.html new file mode 100644 index 0000000..d49594e --- /dev/null +++ b/samples/invoicing.html @@ -0,0 +1,124 @@ +{% macro br(text) %} +{% if text %} +{{ text }}
+{% endif %} +{% endmacro %} + + +
+Fen Edge Community Association {{ pdf_asset('FenEdgeLogoOrange') }}
+Community Office
+Cottenham Village College
+Cottenham
+CB24 8UA
+ +
+{{ br(contact_name) }} +{{ br(AdvertiserName) }} +{{ br(address_1) }} +{{ br(address_2) }} +{{ br(town) }} +{{ br(post_code) }} +
+ +
+
INVOICE: FEN{{ invoice_number }}
+
{{ invoice_date }}
+
+ + +

Please find enclosed a copy of the latest issue of Fen Edge News feature your {{ invoice_size }} advertisement on {{ invoice_page }}.

+ +

+An amount of £{{ price }} is now due.

+

+Please pay by:

+BACS
+Fen Edge Community Association
+Account No: 40349178
+Sort Code: 20-17-22
+Reference: FEN{{ invoice_number }}
+

+Cheque
+made payable to Fen Edge Community Assocation and sent to the above address.
+Please write FEN{{ invoice_number }} on the back of your cheque. +

+ +

+Thank you for supporting the Fen Edge Community Association +

+ +

+Advertising: Bob Turner
+✉ advertising@fenedge.co.uk
+📞 01954 250082 +

+ +
+
+ +
+Registered Charity No. 293020 +
diff --git a/samples/renewal_draft.html b/samples/renewal_draft.html new file mode 100644 index 0000000..5025442 --- /dev/null +++ b/samples/renewal_draft.html @@ -0,0 +1,301 @@ + +
DRAFT
+
+ + +
+
Membership Renewal 2026–2027
+
Fen Edge Community Association
+
+ +
Group Membership Fee: £15
+ +
Please amend any details that have changed before returning this form.
+ + + +
Primary Contact Details
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Number of members{{ account_no_of_members }}
Contact name{{ contact_1_name }}
Position{{ contact_1_position }}
Address{{ contact_1_address_1 }} {{ contact_1_address_2 }} {{ contact_1_town }} {{ contact_1_postcode }}
Email{{ contact_1_email }}
Phone / Mobile{{ contact_1_phone }} {{ contact_1_mobile }}
+ +
Second Contact
+ + + + + + + + + + + + + + + + + + + +
Name{{ contact_2_name }}
Position{{ contact_2_position }}
Email{{ contact_2_email }}
Phone / Mobile{{ contact_2_phone }} {{ contact_2_mobile }}
+ +
Fen Edge News Contact
+ + + + + + + + + + + +
Name{{ fen_contact_name }}
Email{{ fen_contact_email }}
+ +
+ Please check your group’s public contact details on the FECA website: + www.fenedge.co.uk/full-members-list and advise any changes. +
+ + + + + + + + +
+
Signed
+
+
+
Date
+
+
+ +
+
Payment Options
+ +
+ 1. Cheque: + payable to Fen Edge Community Association, sent with this form to + FECA Community Office, Cottenham Village College, Cottenham, CB24 8UA +
+ +
+ 2. BACS: + Sort Code 20-17-22, Account No. 40349178; + then scan and email the form to info@fenedge.co.uk +
+
+ \ No newline at end of file diff --git a/scripts/deploy_remote.sh b/scripts/deploy_remote.sh new file mode 100755 index 0000000..95487f9 --- /dev/null +++ b/scripts/deploy_remote.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +ENV_FILE="${REPO_ROOT}/credentials/.env" + +PLUGIN_SLUG="${PLUGIN_SLUG:-feca_mailshots_plugin}" +SOURCE_DIR="${SOURCE_DIR:-${REPO_ROOT}/${PLUGIN_SLUG}}" +DRY_RUN=0 + +usage() { + cat <<'USAGE' +Usage: scripts/deploy_remote.sh [--dry-run] [--source /path/to/plugin] + +Deploys plugin files to DEPLOYMENT_DIRECTORY on the remote host using +REMOTE_SSH_* values from credentials/.env. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --source) + SOURCE_DIR="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "Error: credentials file not found: ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "Error: source directory not found: ${SOURCE_DIR}" >&2 + exit 1 +fi + +PLUGIN_BASENAME="$(basename "${SOURCE_DIR}")" +PLUGIN_MAIN_FILE="${SOURCE_DIR}/${PLUGIN_BASENAME}.php" +if [[ ! -f "${PLUGIN_MAIN_FILE}" ]]; then + echo "Error: plugin main file not found: ${PLUGIN_MAIN_FILE}" >&2 + exit 1 +fi + +load_env_value() { + local key="$1" + local line + line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" + line="${line#*=}" + line="${line%\"}" + line="${line#\"}" + line="${line%\'}" + line="${line#\'}" + printf '%s' "${line}" +} + +SSH_HOST="$(load_env_value REMOTE_SSH_HOST)" +SSH_PORT="$(load_env_value REMOTE_SSH_PORT)" +SSH_USER="$(load_env_value REMOTE_SSH_USER)" +SSH_KEY_RAW="$(load_env_value REMOTE_SSH_KEY)" +REMOTE_DIR_RAW="$(load_env_value DEPLOYMENT_DIRECTORY)" + +if [[ -z "${SSH_HOST}" || -z "${SSH_USER}" || -z "${SSH_KEY_RAW}" ]]; then + echo "Error: missing REMOTE_SSH_* values in ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ -z "${REMOTE_DIR_RAW}" ]]; then + echo "Error: missing DEPLOYMENT_DIRECTORY in ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ -z "${SSH_PORT}" ]]; then + SSH_PORT=22 +fi + +if [[ "${SSH_KEY_RAW}" = /* ]]; then + SSH_KEY_PATH="${SSH_KEY_RAW}" +else + SSH_KEY_PATH="${REPO_ROOT}/${SSH_KEY_RAW}" +fi + +if [[ ! -f "${SSH_KEY_PATH}" ]]; then + echo "Error: SSH key not found: ${SSH_KEY_PATH}" >&2 + exit 1 +fi + +SSH_TARGET="${SSH_USER}@${SSH_HOST}" + +if [[ "${REMOTE_DIR_RAW}" = /* ]]; then + REMOTE_DIR="${REMOTE_DIR_RAW}" +else + REMOTE_DIR="/home/${SSH_USER}/${REMOTE_DIR_RAW}" +fi + +CURRENT_VERSION="$(awk ' + /Version:/ { + line = $0 + sub(/^.*Version:[[:space:]]*/, "", line) + if (match(line, /^[0-9]+\.[0-9]+\.[0-9]+/)) { + print substr(line, RSTART, RLENGTH) + exit + } + } +' "${PLUGIN_MAIN_FILE}")" +if [[ -z "${CURRENT_VERSION}" ]]; then + echo "Error: could not parse Version: x.y.z from ${PLUGIN_MAIN_FILE}" >&2 + exit 1 +fi + +IFS='.' read -r VER_MAJOR VER_MINOR VER_PATCH <<< "${CURRENT_VERSION}" +NEW_PATCH=$((VER_PATCH + 1)) +NEW_VERSION="${VER_MAJOR}.${VER_MINOR}.${NEW_PATCH}" + +if [[ "${DRY_RUN}" -eq 1 ]]; then + echo "Version bump (dry-run): ${CURRENT_VERSION} -> ${NEW_VERSION}" +else + TMP_FILE="${PLUGIN_MAIN_FILE}.tmp.$$" + awk -v new_version="${NEW_VERSION}" ' + BEGIN { done = 0 } + { + if (!done && $0 ~ /^[[:space:]]*\*[[:space:]]*Version:[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*$/) { + sub(/[0-9]+\.[0-9]+\.[0-9]+/, new_version) + done = 1 + } + print + } + END { + if (!done) { + exit 2 + } + } + ' "${PLUGIN_MAIN_FILE}" > "${TMP_FILE}" + mv "${TMP_FILE}" "${PLUGIN_MAIN_FILE}" + echo "Version bumped: ${CURRENT_VERSION} -> ${NEW_VERSION}" +fi + +echo "Remote host: ${SSH_TARGET}:${SSH_PORT}" +echo "Source dir: ${SOURCE_DIR}" +echo "Deploy target: ${REMOTE_DIR}" + +ssh -p "${SSH_PORT}" \ + -i "${SSH_KEY_PATH}" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=accept-new \ + "${SSH_TARGET}" \ + "mkdir -p '${REMOTE_DIR}'" + +RSYNC_SSH="ssh -p ${SSH_PORT} -i ${SSH_KEY_PATH} -o BatchMode=yes -o StrictHostKeyChecking=accept-new" +RSYNC_ARGS=( + -avz + --delete + --exclude '.git/' + --exclude '.codex/' + --exclude '.DS_Store' +) + +if [[ "${DRY_RUN}" -eq 1 ]]; then + RSYNC_ARGS+=(-n) +fi + +rsync "${RSYNC_ARGS[@]}" \ + -e "${RSYNC_SSH}" \ + "${SOURCE_DIR}/" \ + "${SSH_TARGET}:${REMOTE_DIR}/" + +if [[ "${DRY_RUN}" -eq 1 ]]; then + echo "Dry-run complete. No remote files were changed." +else + echo "Deploy complete." +fi diff --git a/scripts/mysql_tunnel.sh b/scripts/mysql_tunnel.sh new file mode 100755 index 0000000..6b690c9 --- /dev/null +++ b/scripts/mysql_tunnel.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +resolve_repo_root() { + local candidate + for candidate in "${SCRIPT_DIR}/.." "${SCRIPT_DIR}/../.." "${PWD}"; do + if [[ -f "${candidate}/credentials/.env" ]]; then + (cd "${candidate}" && pwd) + return 0 + fi + done + return 1 +} + +if ! REPO_ROOT="$(resolve_repo_root)"; then + echo "Error: could not locate repository root containing credentials/.env" >&2 + exit 1 +fi + +ENV_FILE="${REPO_ROOT}/credentials/.env" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "Error: credentials file not found: ${ENV_FILE}" >&2 + exit 1 +fi + +load_env_value() { + local key="$1" + local line + line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" + line="${line#*=}" + line="${line%\"}" + line="${line#\"}" + line="${line%\'}" + line="${line#\'}" + printf '%s' "${line}" +} + +SSH_HOST="$(load_env_value REMOTE_SSH_HOST)" +SSH_PORT="$(load_env_value REMOTE_SSH_PORT)" +SSH_USER="$(load_env_value REMOTE_SSH_USER)" +SSH_KEY_RAW="$(load_env_value REMOTE_SSH_KEY)" +REMOTE_MYSQL_HOST="$(load_env_value REMOTE_MYSQL_HOST)" +REMOTE_MYSQL_PORT="$(load_env_value REMOTE_MYSQL_PORT)" +LOCAL_PORT="${LOCAL_PORT:-13306}" + +if [[ -z "${SSH_HOST}" || -z "${SSH_USER}" || -z "${SSH_KEY_RAW}" ]]; then + echo "Error: missing REMOTE_SSH_* values in ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ -z "${SSH_PORT}" ]]; then SSH_PORT=22; fi +if [[ -z "${REMOTE_MYSQL_HOST}" ]]; then REMOTE_MYSQL_HOST=127.0.0.1; fi +if [[ -z "${REMOTE_MYSQL_PORT}" ]]; then REMOTE_MYSQL_PORT=3306; fi + +if [[ "${SSH_KEY_RAW}" = /* ]]; then + SSH_KEY_PATH="${SSH_KEY_RAW}" +else + SSH_KEY_PATH="${REPO_ROOT}/${SSH_KEY_RAW}" +fi + +if [[ ! -f "${SSH_KEY_PATH}" ]]; then + echo "Error: SSH key not found: ${SSH_KEY_PATH}" >&2 + exit 1 +fi + +CONTROL_SOCKET="/tmp/mailshot-mysql-tunnel-${LOCAL_PORT}.sock" +SSH_TARGET="${SSH_USER}@${SSH_HOST}" + +start_tunnel() { + if ssh -S "${CONTROL_SOCKET}" -p "${SSH_PORT}" -O check "${SSH_TARGET}" >/dev/null 2>&1; then + echo "Tunnel already running on 127.0.0.1:${LOCAL_PORT}" + return 0 + fi + + ssh -f -N \ + -M -S "${CONTROL_SOCKET}" \ + -o BatchMode=yes \ + -o ExitOnForwardFailure=yes \ + -o StrictHostKeyChecking=accept-new \ + -i "${SSH_KEY_PATH}" \ + -p "${SSH_PORT}" \ + -L "127.0.0.1:${LOCAL_PORT}:${REMOTE_MYSQL_HOST}:${REMOTE_MYSQL_PORT}" \ + "${SSH_TARGET}" + + echo "Tunnel started: 127.0.0.1:${LOCAL_PORT} -> ${REMOTE_MYSQL_HOST}:${REMOTE_MYSQL_PORT} via ${SSH_TARGET}:${SSH_PORT}" + echo "Use in DBeaver: host=127.0.0.1 port=${LOCAL_PORT}" +} + +stop_tunnel() { + if ssh -S "${CONTROL_SOCKET}" -p "${SSH_PORT}" -O exit "${SSH_TARGET}" >/dev/null 2>&1; then + rm -f "${CONTROL_SOCKET}" || true + echo "Tunnel stopped for 127.0.0.1:${LOCAL_PORT}" + else + echo "No active tunnel found for 127.0.0.1:${LOCAL_PORT}" + fi +} + +status_tunnel() { + if ssh -S "${CONTROL_SOCKET}" -p "${SSH_PORT}" -O check "${SSH_TARGET}" >/dev/null 2>&1; then + echo "Tunnel is running on 127.0.0.1:${LOCAL_PORT}" + else + echo "Tunnel is not running on 127.0.0.1:${LOCAL_PORT}" + fi +} + +CMD="${1:-start}" +case "${CMD}" in + start) start_tunnel ;; + stop) stop_tunnel ;; + status) status_tunnel ;; + *) + echo "Usage: $(basename "$0") [start|stop|status]" + exit 1 + ;; +esac diff --git a/scripts/run_fixture_server.sh b/scripts/run_fixture_server.sh new file mode 100755 index 0000000..69f7b79 --- /dev/null +++ b/scripts/run_fixture_server.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURE_HOST="${FIXTURE_HOST:-127.0.0.1}" +FIXTURE_PORT="${FIXTURE_PORT:-8080}" +TUNNEL_PORT="${MYSQL_TUNNEL_LOCAL_PORT:-13306}" +FIXTURE_DOCROOT="${ROOT_DIR}/tests/fixture" +PID_FILE="${ROOT_DIR}/working/fixture_server_${FIXTURE_PORT}.pid" +LOG_FILE="${ROOT_DIR}/working/fixture_server_${FIXTURE_PORT}.log" + +mkdir -p "${ROOT_DIR}/working" + +start_server() { + if [[ -f "${PID_FILE}" ]] && kill -0 "$(cat "${PID_FILE}")" >/dev/null 2>&1; then + echo "Fixture server already running on http://${FIXTURE_HOST}:${FIXTURE_PORT} (pid $(cat "${PID_FILE}"))" + return 0 + fi + + export MYSQL_TUNNEL_LOCAL_PORT="${TUNNEL_PORT}" + nohup php -S "${FIXTURE_HOST}:${FIXTURE_PORT}" -t "${FIXTURE_DOCROOT}" >"${LOG_FILE}" 2>&1 & + echo $! >"${PID_FILE}" + + sleep 0.4 + if kill -0 "$(cat "${PID_FILE}")" >/dev/null 2>&1; then + echo "Fixture server started: http://${FIXTURE_HOST}:${FIXTURE_PORT}" + echo "Fixture index: http://${FIXTURE_HOST}:${FIXTURE_PORT}/index.php" + echo "Mailshots UI: http://${FIXTURE_HOST}:${FIXTURE_PORT}/mailshots_fixture.php" + echo "Using MySQL tunnel port: ${MYSQL_TUNNEL_LOCAL_PORT}" + else + echo "Failed to start fixture server; check ${LOG_FILE}" >&2 + exit 1 + fi +} + +stop_server() { + if [[ ! -f "${PID_FILE}" ]]; then + echo "Fixture server not running (no pid file)." + return 0 + fi + + local pid + pid="$(cat "${PID_FILE}")" + if kill -0 "${pid}" >/dev/null 2>&1; then + kill "${pid}" >/dev/null 2>&1 || true + sleep 0.2 + if kill -0 "${pid}" >/dev/null 2>&1; then + kill -9 "${pid}" >/dev/null 2>&1 || true + fi + fi + + rm -f "${PID_FILE}" + echo "Fixture server stopped." +} + +status_server() { + if [[ -f "${PID_FILE}" ]] && kill -0 "$(cat "${PID_FILE}")" >/dev/null 2>&1; then + echo "Fixture server running on http://${FIXTURE_HOST}:${FIXTURE_PORT} (pid $(cat "${PID_FILE}"))" + else + echo "Fixture server not running on http://${FIXTURE_HOST}:${FIXTURE_PORT}" + fi +} + +case "${1:-start}" in + start) start_server ;; + stop) stop_server ;; + status) status_server ;; + restart) stop_server; start_server ;; + *) + echo "Usage: $(basename "$0") [start|stop|status|restart]" + exit 2 + ;; +esac diff --git a/tests/fixture/bootstrap.php b/tests/fixture/bootstrap.php new file mode 100644 index 0000000..021d3d0 --- /dev/null +++ b/tests/fixture/bootstrap.php @@ -0,0 +1,38 @@ + $host, + 'MYSQL_PORT' => $port, + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), +]; + +$wp = new FixtureWordPressFacade(); +$container = Plugin::buildContainer($dbConfig, $wp); +$container->get(FecaMailshots\Admin\DataSourcesAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\MailshotsAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\AttachmentsAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\PdfAssetsAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\SetupAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\MailshotTestAdminPage::class)->register(); +$container->get(FecaMailshots\Admin\RunMailshotAdminPage::class)->register(); + +return ['container' => $container, 'wp' => $wp]; diff --git a/tests/fixture/index.php b/tests/fixture/index.php new file mode 100644 index 0000000..2b62307 --- /dev/null +++ b/tests/fixture/index.php @@ -0,0 +1,42 @@ + + + + + + + FECA Mailshots Fixture + + + +

FECA Mailshots Fixture

+
+

This fixture harness runs plugin code without WordPress install.

+ +
+
+

Example API calls:

+
POST /router.php?hook=admin_post_feca_mailshots_mailshots_api
+op=save
+Purpose=Renewal Reminder
+DataSource=...existing datasource name...
+Subject=Hello
+Message=<p>Body</p>
+AttachmentNames=["terms.pdf"]
+
+
+ + diff --git a/tests/fixture/mailshots_fixture.php b/tests/fixture/mailshots_fixture.php new file mode 100644 index 0000000..31402f8 --- /dev/null +++ b/tests/fixture/mailshots_fixture.php @@ -0,0 +1,129 @@ + [ + 'method' => 'POST', + 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", + 'content' => http_build_query($query), + 'ignore_errors' => true, + ], + ]; + $ctx = stream_context_create($opts); + $raw = file_get_contents($url, false, $ctx); + if ($raw === false) { + return ['ok' => false, 'error' => 'Request failed']; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'Invalid JSON', 'raw' => $raw]; +} + +$message = null; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $op = $_POST['fixture_op'] ?? ''; + if ($op === 'save_mailshot') { + $payload = [ + 'op' => 'save', + 'id' => $_POST['id'] ?? '', + 'Purpose' => $_POST['Purpose'] ?? '', + 'DataSource' => $_POST['DataSource'] ?? '', + 'Subject' => $_POST['Subject'] ?? '', + 'Message' => $_POST['Message'] ?? '', + 'AttachmentNames' => $_POST['AttachmentNames'] ?? '[]', + 'PDFAttachment' => $_POST['PDFAttachment'] ?? '', + 'PDFFilenameDerivedFrom' => $_POST['PDFFilenameDerivedFrom'] ?? '', + 'ReplyTo' => $_POST['ReplyTo'] ?? '', + ]; + $message = call_api('admin_post_feca_mailshots_mailshots_api', $payload); + } + if ($op === 'delete_mailshot') { + $message = call_api('admin_post_feca_mailshots_mailshots_api', ['op' => 'delete', 'id' => $_POST['id'] ?? '0']); + } +} + +$dataSources = call_api('admin_post_feca_mailshots_mailshots_api', ['op' => 'data_sources']); +$mailshots = call_api('admin_post_feca_mailshots_mailshots_api', ['op' => 'list']); +?> + + + + + + Mailshots Fixture UI + + + +

Mailshots Fixture UI

+ + +
API response
+ + +
+

Create / Update Mailshot

+
+ + + + + + + + + + + + + + + + + + + + +
+
+ +
+

Current Mailshots

+ + + + + + + + + + + + + +
IDPurposeDataSourceSubjectDelete
+
+ + + +
+
+
+ + diff --git a/tests/fixture/router.php b/tests/fixture/router.php new file mode 100644 index 0000000..5cc7b3f --- /dev/null +++ b/tests/fixture/router.php @@ -0,0 +1,11 @@ +dispatch($hook); diff --git a/tests/fixture/test_api_smoke.php b/tests/fixture/test_api_smoke.php new file mode 100644 index 0000000..0e3e5f4 --- /dev/null +++ b/tests/fixture/test_api_smoke.php @@ -0,0 +1,20 @@ +dispatch('admin_menu'); + +ob_start(); +$wp->dispatch('fixture_submenu:feca-mailshot-data-sources'); +$out = trim((string) ob_get_clean()); + +if (strpos($out, 'Mailshot Data Sources') === false) { + fwrite(STDERR, "Fixture smoke failed: expected admin page output\n" . $out . "\n"); + exit(1); +} + +echo "Fixture smoke passed\n"; diff --git a/tests/fixture/wp_shim.php b/tests/fixture/wp_shim.php new file mode 100644 index 0000000..3cb3407 --- /dev/null +++ b/tests/fixture/wp_shim.php @@ -0,0 +1,80 @@ + '127.0.0.1', + 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), +]; + +$router = new PdoDatabaseRouter($dbConfig); +$repo = new MailCredentialRepository($router, new WordPressSaltSecretKeyProvider()); +$wp = new FixtureWordPressFacade(); +$provider = new PerUserMailCredentialsProvider($repo, $wp); + +$userA = 101; +$userB = 102; +try { +$repo->upsertForUser($userA, [ + 'smtp_host' => 'smtp.usera.example', + 'smtp_port' => 587, + 'smtp_user' => 'usera', + 'smtp_password' => 'secret-a', + 'smtp_from_email' => 'usera@example.org', + 'smtp_from_name' => 'User A', + 'smtp_require_tls' => false, + 'imap_host' => 'imap.usera.example', + 'imap_port' => 993, + 'imap_user' => 'usera', + 'imap_password' => 'imap-a', + 'imap_sent_folder' => 'Sent', + 'imap_mailbox_flags' => '/imap/ssl', +]); + +$repo->upsertForUser($userB, [ + 'smtp_host' => 'smtp.userb.example', + 'smtp_port' => 587, + 'smtp_user' => 'userb', + 'smtp_password' => 'secret-b', + 'smtp_from_email' => 'userb@example.org', + 'smtp_from_name' => 'User B', + 'smtp_require_tls' => true, + 'imap_host' => 'imap.userb.example', + 'imap_port' => 993, + 'imap_user' => 'userb', + 'imap_password' => 'imap-b', + 'imap_sent_folder' => 'Sent', + 'imap_mailbox_flags' => '/imap/ssl', +]); + +$pdo = $router->mailshotsPdo(); +$stmt = $pdo->prepare('SELECT smtp_password_enc FROM mailshot_credentials WHERE wp_user_id = :uid'); +$stmt->execute(['uid' => $userA]); +$encA = (string) $stmt->fetchColumn(); +if ($encA === '' || str_contains($encA, 'secret-a')) { + fwrite(STDERR, "Expected encrypted smtp password for user A\n"); + exit(1); +} + +$wp->setCurrentUserId($userA); +$credsA = $provider->credentials(); +if (!is_array($credsA) || (string) ($credsA['smtp_user'] ?? '') !== 'usera' || (string) ($credsA['smtp_password'] ?? '') !== 'secret-a') { + fwrite(STDERR, "Per-user credentials resolution failed for user A\n"); + exit(1); +} + +$wp->setCurrentUserId($userB); +$credsB = $provider->credentials(); +if (!is_array($credsB) || (string) ($credsB['smtp_user'] ?? '') !== 'userb' || (string) ($credsB['smtp_password'] ?? '') !== 'secret-b') { + fwrite(STDERR, "Per-user credentials resolution failed for user B\n"); + exit(1); +} + +echo "Per-user encrypted credential integration test passed\n"; +} finally { + try { $router->mailshotsPdo()->prepare('DELETE FROM mailshot_credentials WHERE wp_user_id = :uid')->execute(['uid' => $userA]); } catch (Throwable $e) {} + try { $router->mailshotsPdo()->prepare('DELETE FROM mailshot_credentials WHERE wp_user_id = :uid')->execute(['uid' => $userB]); } catch (Throwable $e) {} +} diff --git a/tests/integration/test_phase2_db_access.php b/tests/integration/test_phase2_db_access.php new file mode 100644 index 0000000..1cce9be --- /dev/null +++ b/tests/integration/test_phase2_db_access.php @@ -0,0 +1,129 @@ + (string) $host, + 'MYSQL_PORT' => (string) $port, + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), +]; + +$router = new PdoDatabaseRouter($dbConfig); +$metadata = new DatabaseSourceMetadataProvider($router); +$parser = new DslParser(); +$validator = new DslValidator($metadata); +$compiler = new DslCompiler($metadata); +$queries = new MailshotQueryRepository($router); +$mailshots = new MailshotRepository($router); +$service = new DataSourceService($queries, $router, $parser, $validator, $compiler, $metadata, $mailshots); + +$membersDb = $router->membersDbName(); +$mailshotsDb = $router->mailshotsDbName(); + +$stmt = $router->membersPdo()->prepare( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = :schema AND table_name REGEXP :rx ORDER BY table_name ASC LIMIT 1' +); +$stmt->execute(['schema' => $membersDb, 'rx' => '^[A-Za-z][A-Za-z0-9_]*$']); +$firstTable = $stmt->fetchColumn(); + +if ($firstTable === false || $firstTable === null || $firstTable === '') { + fwrite(STDERR, "No usable tables found in MEMBERS db for integration preview test.\n"); + exit(1); +} + +$source = $membersDb . '.' . $firstTable; +$dsl = $source; + +$validation = $service->validateDsl($dsl); +if (($validation['errors'] ?? []) !== []) { + fwrite(STDERR, "Validation failed for source {$source}: " . json_encode($validation['errors']) . "\n"); + exit(1); +} + +$preview = $service->preview($dsl, 5); +if (($preview['errors'] ?? []) !== []) { + fwrite(STDERR, "Preview failed for source {$source}: " . json_encode($preview['errors']) . "\n"); + exit(1); +} + +if (!isset($preview['count']) || !is_int($preview['count'])) { + fwrite(STDERR, "Preview response missing integer count.\n"); + exit(1); +} + +if (!isset($preview['columns']) || !is_array($preview['columns'])) { + fwrite(STDERR, "Preview response missing columns array.\n"); + exit(1); +} + +$name = 'phase2_db_access_' . gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3)); +$savedId = null; + +try { + $save = $service->save(null, $name, $dsl); + if (($save['ok'] ?? false) !== true) { + fwrite(STDERR, "Save failed: " . json_encode($save) . "\n"); + exit(1); + } + + $savedId = (int) ($save['id'] ?? 0); + if ($savedId <= 0) { + fwrite(STDERR, "Save did not return a valid ID.\n"); + exit(1); + } + + $row = $queries->find($savedId); + if ($row === null) { + fwrite(STDERR, "Saved mailshot_queries row not found for ID {$savedId}.\n"); + exit(1); + } + + if (($row['name'] ?? '') !== $name || trim((string) ($row['dsl_text'] ?? '')) !== $dsl) { + fwrite(STDERR, "Saved row contents mismatch for ID {$savedId}.\n"); + exit(1); + } + + $service->delete($savedId); + $savedId = null; + + echo "Phase 2 DB integration test passed\n"; + echo "members_db={$membersDb}\n"; + echo "mailshots_db={$mailshotsDb}\n"; + echo "preview_source={$source}\n"; + echo "preview_count=" . $preview['count'] . "\n"; +} finally { + if ($savedId !== null) { + try { + $service->delete($savedId); + } catch (Throwable $e) { + fwrite(STDERR, "Cleanup warning for ID {$savedId}: {$e->getMessage()}\n"); + } + } +} diff --git a/tests/integration/test_phase3_admin_crud_ui.php b/tests/integration/test_phase3_admin_crud_ui.php new file mode 100644 index 0000000..0d81fdb --- /dev/null +++ b/tests/integration/test_phase3_admin_crud_ui.php @@ -0,0 +1,85 @@ + '127.0.0.1', + 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), +]; + +$wp = new FixtureWordPressFacade(); +$container = Plugin::buildContainer($dbConfig, $wp); + +/** @param callable():void $fn */ +$capture = static function (callable $fn): string { + ob_start(); + try { + $fn(); + return (string) ob_get_clean(); + } catch (\Throwable $e) { + ob_end_clean(); + throw $e; + } +}; + +/** @param string $needle */ +$assertContains = static function (string $needle, string $haystack, string $label): void { + if (strpos($haystack, $needle) === false) { + fwrite(STDERR, "Missing expected text in {$label}: {$needle}\n"); + exit(1); + } +}; + +/** @param string $needle */ +$assertNotContains = static function (string $needle, string $haystack, string $label): void { + if (strpos($haystack, $needle) !== false) { + fwrite(STDERR, "Unexpected text in {$label}: {$needle}\n"); + exit(1); + } +}; + +$mailshotsHtml = $capture(static function () use ($container): void { + $container->get(FecaMailshots\Admin\MailshotsAdminPage::class)->render(); +}); +$assertContains('New Mailshot', $mailshotsHtml, 'Mailshots'); +$assertContains('feca_mailshots_mailshots_ui_save', $mailshotsHtml, 'Mailshots'); +$assertContains('Existing Mailshots', $mailshotsHtml, 'Mailshots'); +$assertNotContains('Use API endpoint', $mailshotsHtml, 'Mailshots'); + +$dataSourcesHtml = $capture(static function () use ($container): void { + $container->get(FecaMailshots\Admin\DataSourcesAdminPage::class)->render(); +}); +$assertContains('New Data Source', $dataSourcesHtml, 'Data Sources'); +$assertContains('feca_mailshots_data_sources_ui_save', $dataSourcesHtml, 'Data Sources'); +$assertContains('Existing Data Sources', $dataSourcesHtml, 'Data Sources'); +$assertNotContains('Use endpoint', $dataSourcesHtml, 'Data Sources'); + +$attachmentsHtml = $capture(static function () use ($container): void { + $container->get(FecaMailshots\Admin\AttachmentsAdminPage::class)->render(); +}); +$assertContains('Create Attachment', $attachmentsHtml, 'Attachments'); +$assertContains('feca_mailshots_attachments_ui_save', $attachmentsHtml, 'Attachments'); +$assertContains('Existing Attachments', $attachmentsHtml, 'Attachments'); +$assertNotContains('Use API endpoint', $attachmentsHtml, 'Attachments'); + +$pdfAssetsHtml = $capture(static function () use ($container): void { + $container->get(FecaMailshots\Admin\PdfAssetsAdminPage::class)->render(); +}); +$assertContains('Create PDF Asset', $pdfAssetsHtml, 'PDF Assets'); +$assertContains('feca_mailshots_pdf_assets_ui_save', $pdfAssetsHtml, 'PDF Assets'); +$assertContains('Existing PDF Assets', $pdfAssetsHtml, 'PDF Assets'); +$assertNotContains('Use API endpoint', $pdfAssetsHtml, 'PDF Assets'); + +echo "Phase 3 admin CRUD UI integration test passed\n"; diff --git a/tests/integration/test_phase3_db_access.php b/tests/integration/test_phase3_db_access.php new file mode 100644 index 0000000..4a719fb --- /dev/null +++ b/tests/integration/test_phase3_db_access.php @@ -0,0 +1,169 @@ + '127.0.0.1', + 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), +]; + +$router = new PdoDatabaseRouter($dbConfig); +$metadata = new DatabaseSourceMetadataProvider($router); +$parser = new DslParser(); +$validator = new DslValidator($metadata); +$compiler = new DslCompiler($metadata); +$queryRepo = new MailshotQueryRepository($router); +$mailshotRepo = new MailshotRepository($router); +$dataSourceService = new DataSourceService($queryRepo, $router, $parser, $validator, $compiler, $metadata, $mailshotRepo); +$attachmentRepo = new AttachmentRepository($router); +$pdfRepo = new PdfAssetRepository($router); +$lastRunRepo = new LastRunRepository($router); + +$mailshotService = new MailshotService($mailshotRepo, $queryRepo, $attachmentRepo, $lastRunRepo, $dataSourceService); +$attachmentService = new AttachmentService($attachmentRepo); +$pdfService = new PdfAssetService($pdfRepo); + +$unique = gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3)); +$queryName = 'phase3_ds_' . $unique; +$attachmentId = null; +$pdfId = null; +$mailshotId = null; +$queryId = null; +$lastRunId = null; + +$membersDb = $router->membersDbName(); +$stmt = $router->membersPdo()->prepare('SELECT table_name FROM information_schema.tables WHERE table_schema = :schema ORDER BY table_name ASC LIMIT 1'); +$stmt->execute(['schema' => $membersDb]); +$firstTable = $stmt->fetchColumn(); +if (!is_string($firstTable) || $firstTable === '') { + fwrite(STDERR, "No source table in members db\n"); + exit(1); +} +$dsl = $membersDb . '.' . $firstTable; + +try { + $saveDs = $dataSourceService->save(null, $queryName, $dsl); + if (($saveDs['ok'] ?? false) !== true) { + fwrite(STDERR, 'Failed to save datasource: ' . json_encode($saveDs) . "\n"); + exit(1); + } + $queryId = (int) ($saveDs['id'] ?? 0); + + $saveAtt = $attachmentService->save(null, [ + 'name' => 'phase3_att_' . $unique, + 'file_name' => 'terms.txt', + 'mime_type' => 'text/plain', + 'file_bytes_base64' => base64_encode('phase3 attachment bytes'), + ]); + if (($saveAtt['ok'] ?? false) !== true) { + fwrite(STDERR, 'Failed to save attachment: ' . json_encode($saveAtt) . "\n"); + exit(1); + } + $attachmentId = (int) ($saveAtt['id'] ?? 0); + + $savePdf = $pdfService->save(null, [ + 'name' => 'phase3_pdf_' . $unique, + 'file_name' => 'logo.png', + 'mime_type' => 'image/png', + 'file_bytes_base64' => base64_encode('fakepngbytes'), + 'width_mm' => '20', + 'height_mm' => '10', + 'justification' => 'left', + ]); + if (($savePdf['ok'] ?? false) !== true) { + fwrite(STDERR, 'Failed to save pdf asset: ' . json_encode($savePdf) . "\n"); + exit(1); + } + $pdfId = (int) ($savePdf['id'] ?? 0); + + $saveMailshot = $mailshotService->save(null, [ + 'Purpose' => 'Phase3 Mailshot ' . $unique, + 'DataSource' => $queryName, + 'Subject' => 'Subject {{ ' . strtolower($firstTable) . '_id }}', + 'Message' => '

Hello

', + 'AttachmentNames' => json_encode(['phase3_att_' . $unique], JSON_UNESCAPED_SLASHES), + ]); + if (($saveMailshot['ok'] ?? false) !== true) { + fwrite(STDERR, 'Failed to save mailshot: ' . json_encode($saveMailshot) . "\n"); + exit(1); + } + $mailshotId = (int) ($saveMailshot['id'] ?? 0); + + $tokenData = $mailshotService->tokenInsertionData($queryName); + if (($tokenData['errors'] ?? []) !== []) { + fwrite(STDERR, 'Token generation failed: ' . json_encode($tokenData) . "\n"); + exit(1); + } + if (count($tokenData['tokens'] ?? []) === 0) { + fwrite(STDERR, "Expected token list for datasource\n"); + exit(1); + } + + $lastRunId = $lastRunRepo->create([ + 'mailshot_id' => $mailshotId, + 'data_source' => $queryName, + 'row_index' => 1, + 'recipient_key' => 'phase3-key', + 'recipient_key_field' => 'ID', + 'recipient_email_last' => 'a@example.org', + 'status' => 'failed', + 'error_message' => 'smtp failure', + 'attempt_count' => 1, + ]); + + $rows = $mailshotService->lastRun($mailshotId); + if (count($rows) < 1) { + fwrite(STDERR, "Expected last_run rows\n"); + exit(1); + } + + $mailshotService->clearLastRun($mailshotId); + $lastRunId = null; + + $rowsAfter = $mailshotService->lastRun($mailshotId); + if (count($rowsAfter) !== 0) { + fwrite(STDERR, "Expected no last_run rows after clear\n"); + exit(1); + } + + echo "Phase 3 DB integration test passed\n"; + echo "query_name={$queryName}\n"; + echo "mailshot_id={$mailshotId}\n"; +} finally { + if ($mailshotId !== null) { + try { $mailshotService->delete($mailshotId); } catch (Throwable $e) {} + } + if ($attachmentId !== null) { + try { $attachmentService->delete($attachmentId); } catch (Throwable $e) {} + } + if ($pdfId !== null) { + try { $pdfService->delete($pdfId); } catch (Throwable $e) {} + } + if ($queryId !== null) { + try { $queryRepo->delete($queryId); } catch (Throwable $e) {} + } +} diff --git a/tests/integration/test_phase4_run_flow.php b/tests/integration/test_phase4_run_flow.php new file mode 100644 index 0000000..443ddf1 --- /dev/null +++ b/tests/integration/test_phase4_run_flow.php @@ -0,0 +1,184 @@ + 'smtp.test.local', + 'smtp_port' => 587, + 'smtp_user' => 'user', + 'smtp_password' => 'pass', + 'smtp_from_email' => 'editor@example.org', + 'smtp_from_name' => 'Editor', + 'smtp_require_tls' => false, + 'imap_host' => 'imap.test.local', + 'imap_port' => 993, + 'imap_user' => 'user', + 'imap_password' => 'pass', + 'imap_sent_folder' => 'Sent', + 'imap_mailbox_flags' => '/imap/ssl', + ]; + } +} + +final class FakeSmtp implements SmtpSender { + public int $count = 0; + public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null): array { + $this->count++; + if ($to === []) { + throw new RuntimeException('No recipients'); + } + return ['raw_mime' => "To: " . implode(',', $to) . "\r\nSubject: {$subject}\r\n\r\n{$htmlBody}"]; + } +} + +final class FakeImap implements ImapAppender { + public int $count = 0; + public function appendSent(array $credentials, string $rawMime, string $attemptId): void { + $this->count++; + } +} + +Env::load(dirname(__DIR__, 2) . '/credentials/.env'); + +$dbConfig = [ + 'MYSQL_HOST' => '127.0.0.1', + 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), +]; + +$router = new PdoDatabaseRouter($dbConfig); +$metadata = new DatabaseSourceMetadataProvider($router); +$parser = new DslParser(); +$validator = new DslValidator($metadata); +$compiler = new DslCompiler($metadata); +$queryRepo = new MailshotQueryRepository($router); +$mailshotRepo = new MailshotRepository($router); +$dataSourceService = new DataSourceService($queryRepo, $router, $parser, $validator, $compiler, $metadata, $mailshotRepo); +$lastRunRepo = new LastRunRepository($router); + +$smtp = new FakeSmtp(); +$imap = new FakeImap(); +$run = new MailshotRunService( + $mailshotRepo, + $queryRepo, + $dataSourceService, + new TemplateRenderer(), + $smtp, + $imap, + new FakeCreds(), + $lastRunRepo +); + +$membersDb = $router->membersDbName(); +$stmt = $router->membersPdo()->prepare('SELECT table_name FROM information_schema.tables WHERE table_schema = :schema ORDER BY table_name ASC LIMIT 1'); +$stmt->execute(['schema' => $membersDb]); +$table = $stmt->fetchColumn(); +if (!is_string($table) || $table === '') { + fwrite(STDERR, "No source table in members db\n"); + exit(1); +} + +$dsl = $membersDb . '.' . $table; +$uniq = gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3)); +$queryName = 'phase4_ds_' . $uniq; +$queryId = null; +$mailshotId = null; + +try { + $savedQ = $dataSourceService->save(null, $queryName, $dsl); + if (($savedQ['ok'] ?? false) !== true) { + fwrite(STDERR, 'Failed saving datasource: ' . json_encode($savedQ) . "\n"); + exit(1); + } + $queryId = (int) $savedQ['id']; + + $savedM = $mailshotRepo->create([ + 'Purpose' => 'Phase4 ' . $uniq, + 'DataSource' => $queryName, + 'CC' => '', + 'BCC' => '', + 'Subject' => 'Hello {{ id|default(ID|default("recipient")) }}', + 'Message' => '

Hi {{ name|default(Name|default("there")) }}

', + 'PDFAttachment' => '

PDF {{ id|default("none") }}

', + 'AttachmentNames' => '[]', + 'PDFFilenameDerivedFrom' => '', + 'ReplyTo' => '', + ]); + $mailshotId = (int) $savedM; + + $render = $run->renderTest($mailshotId, 0); + if (($render['ok'] ?? false) !== true) { + fwrite(STDERR, 'renderTest failed: ' . json_encode($render) . "\n"); + exit(1); + } + + $sendBlank = $run->sendTest($mailshotId, 0, ''); + if (($sendBlank['ok'] ?? true) !== false) { + fwrite(STDERR, "sendTest blank email should fail\n"); + exit(1); + } + + $sendTest = $run->sendTest($mailshotId, 0, 'receiver@example.org'); + if (($sendTest['ok'] ?? false) !== true) { + fwrite(STDERR, 'sendTest failed: ' . json_encode($sendTest) . "\n"); + exit(1); + } + + $runRes = $run->runMailshot($mailshotId); + if (($runRes['ok'] ?? false) !== true) { + fwrite(STDERR, 'runMailshot failed: ' . json_encode($runRes) . "\n"); + exit(1); + } + if ((int) ($runRes['attempted'] ?? 0) <= 0) { + fwrite(STDERR, "runMailshot attempted should be > 0\n"); + exit(1); + } + + $rows = $lastRunRepo->listForMailshot($mailshotId); + if (count($rows) !== (int) $runRes['attempted']) { + fwrite(STDERR, 'lastRun row count mismatch\n'); + exit(1); + } + + $retry = $run->retryFailed($mailshotId); + if (($retry['ok'] ?? false) !== true) { + fwrite(STDERR, 'retryFailed failed: ' . json_encode($retry) . "\n"); + exit(1); + } + + echo "Phase 4 run-flow integration test passed\n"; + echo "attempted=" . (int) $runRes['attempted'] . "\n"; + echo "smtp_calls={$smtp->count}\n"; + echo "imap_calls={$imap->count}\n"; +} finally { + if ($mailshotId !== null) { + try { $lastRunRepo->clearForMailshot($mailshotId); } catch (Throwable $e) {} + try { $mailshotRepo->delete($mailshotId); } catch (Throwable $e) {} + } + if ($queryId !== null) { + try { $queryRepo->delete($queryId); } catch (Throwable $e) {} + } +} diff --git a/tests/unit/FakeMetadataProvider.php b/tests/unit/FakeMetadataProvider.php new file mode 100644 index 0000000..8c52237 --- /dev/null +++ b/tests/unit/FakeMetadataProvider.php @@ -0,0 +1,52 @@ +> */ + private array $fields; + + /** @param array> $fields */ + public function __construct(array $fields) + { + $this->fields = $fields; + } + + public function sourceExists(string $source): bool + { + return isset($this->fields[$source]); + } + + public function sourceFields(string $source): array + { + return $this->fields[$source] ?? []; + } + + public function hasEmailField(string $source): bool + { + return in_array('Email', $this->fields[$source] ?? [], true) || in_array('email', $this->fields[$source] ?? [], true); + } + + public function joinPath(string $left, string $right): ?array + { + $pairs = [ + 'contacts|accounts' => ['left' => 'contacts.Accountid', 'right' => 'accounts.ID'], + 'accounts|contacts' => ['left' => 'accounts.ID', 'right' => 'contacts.Accountid'], + 'renewals|accounts' => ['left' => 'renewals.account_id', 'right' => 'accounts.ID'], + 'accounts|renewals' => ['left' => 'accounts.ID', 'right' => 'renewals.account_id'], + 'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.Accountid'], + 'contacts|renewals' => ['left' => 'contacts.Accountid', 'right' => 'renewals.account_id'], + ]; + return $pairs[$left . '|' . $right] ?? null; + } + + public function allKnownSources(): array + { + return array_keys($this->fields); + } +} diff --git a/tests/unit/test_dsl_pipeline.php b/tests/unit/test_dsl_pipeline.php new file mode 100644 index 0000000..8f7b605 --- /dev/null +++ b/tests/unit/test_dsl_pipeline.php @@ -0,0 +1,64 @@ + ['ID', 'Accountid', 'Last', 'Email', 'FENContact1'], + 'accounts' => ['ID', 'Name', 'Type'], + 'renewals' => ['id', 'account_id', 'status', 'selected'], + 'members.ExcludedAccounts' => ['ExcludedAccount'], +]); + +$parser = new DslParser(); +$validator = new DslValidator($metadata); +$compiler = new DslCompiler($metadata); + +$cases = [ + [ + 'dsl' => "contacts and accounts where contacts.Last contains 'smith' and contacts.Accountid = accounts.ID", + 'expectValid' => true, + ], + [ + 'dsl' => 'accounts where pending-renewal', + 'expectValid' => false, + ], + [ + 'dsl' => 'contacts and members.ExcludedAccounts', + 'expectValid' => false, + ], +]; + +$failures = []; + +foreach ($cases as $case) { + $ast = $parser->parse($case['dsl']); + $validation = $validator->validate($ast); + $isValid = $validation['errors'] === []; + + if ($isValid !== $case['expectValid']) { + $failures[] = ['dsl' => $case['dsl'], 'errors' => $validation['errors']]; + continue; + } + + if ($isValid) { + $compiled = $compiler->compile($ast); + if (strpos($compiled['sql'], 'SELECT * FROM') !== 0) { + $failures[] = ['dsl' => $case['dsl'], 'errors' => ['Compilation did not produce SELECT']]; + } + } +} + +if ($failures !== []) { + fwrite(STDERR, "DSL pipeline test failures:\n" . json_encode($failures, JSON_PRETTY_PRINT) . "\n"); + exit(1); +} + +echo "DSL pipeline tests passed\n"; diff --git a/tests/unit/test_preview_shape.php b/tests/unit/test_preview_shape.php new file mode 100644 index 0000000..4563d47 --- /dev/null +++ b/tests/unit/test_preview_shape.php @@ -0,0 +1,50 @@ + ['ID', 'Accountid', 'Last', 'Email', 'FENContact1'], + 'accounts' => ['ID', 'Name', 'Type'], +]); + +$parser = new DslParser(); +$validator = new DslValidator($metadata); + +$ast = $parser->parse('contacts and accounts where contacts.Accountid = accounts.ID'); +$validation = $validator->validate($ast); + +if ($validation['errors'] !== []) { + fwrite(STDERR, 'Expected no validation errors, got: ' . json_encode($validation['errors']) . "\n"); + exit(1); +} + +$expected = [ + 'contacts.ID', + 'contacts.Accountid', + 'contacts.Last', + 'contacts.Email', + 'contacts.FENContact1', + 'accounts.ID', + 'accounts.Name', + 'accounts.Type', +]; + +sort($expected); +$actual = $validation['expected_fields']; +sort($actual); + +if ($actual !== $expected) { + fwrite(STDERR, 'Expected-field contract mismatch\n'); + fwrite(STDERR, 'Expected: ' . json_encode($expected) . "\n"); + fwrite(STDERR, 'Actual: ' . json_encode($actual) . "\n"); + exit(1); +} + +echo "Preview shape contract test passed\n"; diff --git a/tools/composer b/tools/composer new file mode 100755 index 0000000..ae06efc Binary files /dev/null and b/tools/composer differ