Initial commit

This commit is contained in:
Adrian Stephens 2026-04-21 10:49:33 +01:00
commit d7e1995da3
89 changed files with 11099 additions and 0 deletions

0
.codex Normal file
View File

31
.gitignore vendored Normal file
View File

@ -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

76
README.md Normal file
View File

@ -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`

9
composer.json Normal file
View File

@ -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"
}
}

779
composer.lock generated Normal file
View File

@ -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"
}

42
credentials/.env.example Normal file
View File

@ -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

113
docs/mailshot_ddl.md Normal file
View File

@ -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;
```

View File

@ -0,0 +1,18 @@
<?php
/**
* Plugin Name: FECA Mailshots
* Plugin URI: https://fenedge.co.uk/
* Description: FECA mailshots plugin.
* Version: 0.1.6
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: FECA
* Author URI: https://fenedge.co.uk/
* Text Domain: feca-mailshots-plugin
*/
if (!defined('ABSPATH')) {
exit;
}
require_once __DIR__ . '/src/bootstrap.php';

View File

@ -0,0 +1,11 @@
=== FECA Mailshots Plugin ===
Contributors: feca
Requires at least: 6.0
Tested up to: 6.5
Requires PHP: 7.4
Stable tag: 0.0.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Barebones plugin scaffold.

View File

@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\AttachmentService;
use FecaMailshots\WordPress\WordPressFacade;
final class AttachmentsAdminPage
{
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_attachments_ui_result';
/** @var callable(): AttachmentService */
private $serviceFactory;
private WordPressFacade $wp;
/** @param callable(): AttachmentService $serviceFactory */
public function __construct(callable $serviceFactory, WordPressFacade $wp)
{
$this->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 '<div class="wrap"><h1>Attachments</h1>';
if ($result !== null) {
$ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'Attachment saved.' : 'Attachment action failed.';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
}
echo '</div>';
}
echo '<p><button type="button" class="button button-primary" id="att-open-new">New Attachment</button></p>';
echo '<div id="att-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:900px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="att-close-editor">Close</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>';
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">';
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="att-editor-id">';
} else {
echo '<input type="hidden" name="id" value="" id="att-editor-id">';
}
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="att_name">Name</label></th><td><input class="regular-text" type="text" id="att_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="att_file_name">File Name</label></th><td><input class="regular-text" type="text" id="att_file_name" name="file_name" value="' . htmlspecialchars($fileName, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="att_mime">MIME Type</label></th><td><input class="regular-text" type="text" id="att_mime" name="mime_type" value="' . htmlspecialchars($mime, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="att_file_upload">Upload File</label></th><td><input type="file" id="att_file_upload" name="file_upload"></td></tr>';
echo '<tr><th scope="row"><label for="att_base64">File Bytes (Base64)</label></th><td><textarea id="att_base64" name="file_bytes_base64" rows="4" class="large-text code"></textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update Attachment' : 'Create Attachment') . '</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-attachments')) . '">Cancel Edit</a>';
}
echo '</p></form>';
echo '</div></div>';
echo '<h2>Existing Attachments</h2>';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>File</th><th>MIME</th><th>Bytes</th><th>Actions</th></tr></thead><tbody>';
foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0);
$editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments&edit_id=' . $id);
echo '<tr>';
echo '<td>' . $id . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['name'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['file_name'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['mime_type'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_delete">';
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this attachment?\');">Delete</button>';
echo '</form></td>';
echo '</tr>';
}
if ($items === []) {
echo '<tr><td colspan="6">No attachments found.</td></tr>';
}
echo '</tbody></table>';
echo '<script>(function(){var modal=document.getElementById("att-editor-modal");var openBtn=document.getElementById("att-open-new");var closeBtn=document.getElementById("att-close-editor");if(openBtn&&modal){openBtn.addEventListener("click",function(){var id=document.getElementById("att-editor-id");if(id){id.value="";}["att_name","att_file_name","att_mime","att_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}});modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';if(hasEdit&&modal){modal.style.display="block";}})();</script>';
echo '</div>';
}
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<string,mixed>|null */
private function result(): ?array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
return is_array($raw) ? $raw : null;
}
}

View File

@ -0,0 +1,697 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\WordPress\WordPressFacade;
final class DataSourcesAdminPage
{
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_data_sources_ui_result';
/** @var callable(): \FecaMailshots\Application\DataSourceService */
private $serviceFactory;
private WordPressFacade $wp;
/** @param callable(): \FecaMailshots\Application\DataSourceService $serviceFactory */
public function __construct(callable $serviceFactory, WordPressFacade $wp)
{
$this->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 '<div class="wrap"><h1>FECA Mailshots</h1><p><a href="' . htmlspecialchars($target) . '">Open Mailshots</a></p></div>';
}
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 '<div class="wrap">';
echo '<h1>Mailshot Data Sources</h1>';
if ($result !== null) {
$ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'Action succeeded.' : 'Action failed.';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
}
if (!empty($result['warnings']) && is_array($result['warnings'])) {
echo '<p style="margin:6px 0 0 0;">Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '</p>';
}
if (isset($result['count'])) {
echo '<p style="margin:6px 0 0 0;">Preview count: ' . (int) $result['count'] . '</p>';
}
echo '</div>';
}
echo '<div style="display:flex;gap:12px;margin:12px 0;">';
echo '<div style="flex:1;padding:10px;border:1px solid #dcdcde;background:#fff;"><strong>Total data sources:</strong> ' . count($items) . '</div>';
echo '<div style="flex:1;padding:10px;border:1px solid #dcdcde;background:#fff;"><strong>Selected:</strong> ' . ($editId > 0 ? '#' . $editId . ' ' . htmlspecialchars($name) : 'none') . '</div>';
echo '</div>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="padding:10px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<input type="hidden" name="page" value="feca-mailshot-data-sources">';
echo '<label>Filter <input class="regular-text" type="text" name="q" value="' . htmlspecialchars($filter, ENT_QUOTES) . '" placeholder="Name or sentence"></label> ';
echo '<label>Sort <select name="sort">';
foreach (['name' => 'Name', 'updated_desc' => 'Updated (newest)', 'updated_asc' => 'Updated (oldest)'] as $value => $label) {
$selected = $sort === $value ? ' selected' : '';
echo '<option value="' . htmlspecialchars($value, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($label) . '</option>';
}
echo '</select></label> ';
echo '<button class="button" type="submit">Apply</button>';
echo '</form>';
echo '<p><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
echo '<div id="ds-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="ds-close-editor">Close</button></p>';
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="ds-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="ds-editor-id">';
} else {
echo '<input type="hidden" name="id" value="" id="ds-editor-id">';
}
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="ds_name">Name</label></th><td><input class="regular-text" type="text" id="ds_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '" data-initial="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="ds_dsl">DSL Sentence</label></th><td><textarea id="ds_dsl" name="dsl_text" rows="7" class="large-text code" data-initial="' . htmlspecialchars($dsl, ENT_QUOTES) . '">' . htmlspecialchars($dsl) . '</textarea></td></tr>';
echo '</table>';
echo '<p>';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">' . ($editId > 0 ? 'Save' : 'Create') . '</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_data_sources_ui_validate">Validate Sentence</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_data_sources_ui_preview">Preview Recipients</button> ';
echo '<button type="button" class="button" id="ds-build-dsl-open">Build DSL</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources&q=' . rawurlencode($filter) . '&sort=' . rawurlencode($sort))) . '">Discard Changes</a>';
}
echo '</p></form>';
echo '</div></div>';
echo '<div id="ds-builder-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:14px;border-radius:4px;max-height:88vh;overflow:auto;">';
echo '<h2 style="margin-top:0;">Build DSL</h2>';
echo '<p style="margin-top:0;">Construct a DSL sentence from sources and constraints.</p>';
echo '<div style="display:flex;gap:14px;flex-wrap:wrap;">';
echo '<div style="flex:1;min-width:260px;border:1px solid #dcdcde;padding:10px;">';
echo '<h3 style="margin-top:0;">Data Sources</h3>';
echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br><br>';
echo '<strong>Add custom source</strong><br>';
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
foreach ($schemaList as $schema) {
echo '<option value="' . htmlspecialchars((string) $schema, ENT_QUOTES) . '">' . htmlspecialchars((string) $schema) . '</option>';
}
echo '</select></label> ';
echo '<label>Table <select id="ds-builder-table"><option value="">Select table</option></select></label> ';
echo '<button type="button" class="button button-small" id="ds-builder-add-custom">Add</button>';
echo '<ul id="ds-builder-custom-list"></ul>';
echo '</div>';
echo '<div style="flex:2;min-width:340px;border:1px solid #dcdcde;padding:10px;">';
echo '<h3 style="margin-top:0;">Constraints / Filters</h3>';
echo '<div id="ds-builder-rows"></div>';
echo '<p><button type="button" class="button button-small" id="ds-builder-add-row">Add Constraint Row</button></p>';
echo '</div></div>';
echo '<div style="margin-top:12px;border:1px solid #dcdcde;padding:10px;background:#f9f9f9;">';
echo '<strong>Generated DSL</strong>';
echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>';
echo '<p id="ds-builder-error" style="color:#a00;margin:6px 0 0 0;"></p>';
echo '</div>';
echo '<p style="margin-top:12px;">';
echo '<button type="button" class="button button-primary" id="ds-builder-apply">Apply</button> ';
echo '<button type="button" class="button" id="ds-builder-reset">Reset</button> ';
echo '<button type="button" class="button" id="ds-builder-cancel">Cancel</button>';
echo '</p></div></div>';
if (is_array($result) && isset($result['rows']) && is_array($result['rows'])) {
$rows = $result['rows'];
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">Preview (first ' . count($rows) . ' rows)</h2>';
if ($rows === []) {
echo '<p>No rows returned.</p>';
} 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 '<table class="widefat striped"><thead><tr>';
foreach ($columns as $col) {
echo '<th>' . htmlspecialchars($col) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($rows as $r) {
if (!is_array($r)) {
continue;
}
echo '<tr>';
foreach ($columns as $col) {
echo '<td>' . htmlspecialchars((string) ($r[$col] ?? '')) . '</td>';
}
echo '</tr>';
}
echo '</tbody></table>';
}
echo '</div>';
}
echo '<h2>Existing Data Sources</h2>';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>DSL Sentence</th><th>Updated At</th><th>Actions</th></tr></thead><tbody>';
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 '<tr>';
echo '<td>' . $id . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['name'] ?? '')) . '</td>';
echo '<td style="max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><code>' . htmlspecialchars((string) ($row['dsl_text'] ?? '')) . '</code></td>';
echo '<td>' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_delete">';
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this data source?\');">Delete</button>';
echo '</form></td>';
echo '</tr>';
}
if ($items === []) {
echo '<tr><td colspan="5">No data sources found.</td></tr>';
}
echo '</tbody></table>';
echo '<script>';
echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([
'sourceFields' => $sourceFieldsMap,
'restBase' => '/wp-json/mailshots/v1/data-sources',
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
echo '(function(){';
echo 'var cfg=window.fecaDataSourcesBuilderConfig||{};';
echo 'var dslInput=document.getElementById("ds_dsl");';
echo 'var modal=document.getElementById("ds-builder-modal");';
echo 'var openBtn=document.getElementById("ds-build-dsl-open");';
echo 'var cancelBtn=document.getElementById("ds-builder-cancel");';
echo 'var applyBtn=document.getElementById("ds-builder-apply");';
echo 'var resetBtn=document.getElementById("ds-builder-reset");';
echo 'var addRowBtn=document.getElementById("ds-builder-add-row");';
echo 'var rowsWrap=document.getElementById("ds-builder-rows");';
echo 'var out=document.getElementById("ds-builder-output");';
echo 'var err=document.getElementById("ds-builder-error");';
echo 'var editorModal=document.getElementById("ds-editor-modal");';
echo 'var openNew=document.getElementById("ds-open-new");';
echo 'var closeEditor=document.getElementById("ds-close-editor");';
echo 'var editorId=document.getElementById("ds-editor-id");';
echo 'var editorName=document.getElementById("ds_name");';
echo 'var builtChecks=[].slice.call(document.querySelectorAll(".ds-source-built"));';
echo 'var customList=document.getElementById("ds-builder-custom-list");';
echo 'var schemaSel=document.getElementById("ds-builder-schema");';
echo 'var tableSel=document.getElementById("ds-builder-table");';
echo 'var addCustomBtn=document.getElementById("ds-builder-add-custom");';
echo 'var customSources=[];';
echo 'var constraints=[];';
echo 'var filters=[["selected-renewal","Renewal is selected"],["pending-renewal","Renewal is pending"],["fen1-contact","Contact is FEN1"],["fen2-contact","Contact is FEN2"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council"]];';
echo 'function selectedSources(){var s=[];builtChecks.forEach(function(c){if(c.checked){s.push(c.value);}});customSources.forEach(function(v){s.push(v);});return s;}';
echo 'function updateCustomList(){customList.innerHTML="";customSources.forEach(function(src,i){var li=document.createElement("li");li.textContent=src+" ";var b=document.createElement("button");b.type="button";b.className="button-link-delete";b.textContent="Remove";b.onclick=function(){customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}';
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";fetch(cfg.restBase+"/tables?schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(j){var items=(j&&j.items)||[];tableSel.innerHTML="<option value=\"\">Select table</option>";items.forEach(function(t){var o=document.createElement("option");o.value=t;o.textContent=t;tableSel.appendChild(o);});}).catch(function(){tableSel.innerHTML="<option value=\"\">Select table</option>";});}';
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}return [];}';
echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}';
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.style.border="1px solid #dcdcde";box.style.padding="8px";box.style.marginBottom="8px";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.style.marginLeft="8px";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.onchange=function(){row.filter=f.value;updateDsl();};box.appendChild(f);}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}';
echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}';
echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}t=r.filter;}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=r.rhsSource+"."+r.rhsField;if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}';
echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];constraints=[];updateCustomList();renderRows();updateDsl();}';
echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var whereIndex=text.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var bits=srcPart.split(" and ");bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}var built=false;builtChecks.forEach(function(c){if(c.value===src){c.checked=true;built=true;}});if(!built&&customSources.indexOf(src)===-1){customSources.push(src);}});updateCustomList();renderRows();updateDsl();}';
echo 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};}';
echo 'if(addCustomBtn){addCustomBtn.onclick=function(){if(!schemaSel.value||!tableSel.value){return;}var src=schemaSel.value+"."+tableSel.value;if(customSources.indexOf(src)===-1){customSources.push(src);}updateCustomList();renderRows();updateDsl();};}';
echo 'builtChecks.forEach(function(c){c.onchange=updateDsl;});';
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
echo 'if(openBtn){openBtn.onclick=function(){resetBuilder();prefillFromDsl();modal.style.display="block";};}';
echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}';
echo 'if(resetBtn){resetBtn.onclick=function(){resetBuilder();};}';
echo 'if(applyBtn){applyBtn.onclick=function(){if(dslInput){dslInput.value=out.value;}modal.style.display="none";};}';
echo 'if(openNew){openNew.onclick=function(){if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}editorModal.style.display="block";};}';
echo 'if(closeEditor){closeEditor.onclick=function(){editorModal.style.display="none";};}';
echo 'var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';';
echo 'if(hasEdit&&editorModal){editorModal.style.display="block";}';
echo '})();';
echo '</script>';
echo '</div>';
}
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<id>\d+)', [
'methods' => 'GET',
'callback' => [$this, 'restGetOne'],
'permission_callback' => [$this, 'restCanRead'],
]);
register_rest_route('mailshots/v1', '/data-sources/(?P<id>\d+)', [
'methods' => 'PUT',
'callback' => [$this, 'restUpdate'],
'permission_callback' => [$this, 'restCanManage'],
]);
register_rest_route('mailshots/v1', '/data-sources/(?P<id>\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<id>\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<string,mixed> */
public function restList()
{
return ['ok' => true, 'items' => $this->service()->list()];
}
/** @return array<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
public function restValidate($request)
{
$dsl = (string) $this->reqParam($request, 'dsl_text', '');
return ['ok' => true] + $this->service()->validateDsl($dsl);
}
/** @return array<string,mixed> */
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<string,mixed> */
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<string,mixed> */
public function restSchemas()
{
return ['ok' => true, 'items' => $this->service()->listSchemas()];
}
/** @return array<string,mixed> */
public function restTables($request)
{
$schema = (string) $this->reqParam($request, 'schema', '');
return ['ok' => true, 'items' => $this->service()->listTables($schema)];
}
/** @return array<string,mixed> */
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<array<string,mixed>> $items @return list<array<string,mixed>> */
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<string,mixed> $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<string,mixed>|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;
}
}

View File

@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\MailshotRunService;
use FecaMailshots\Application\MailshotService;
use FecaMailshots\WordPress\WordPressFacade;
final class MailshotTestAdminPage
{
private const RESULT_OPTION_KEY = 'feca_mailshots_test_ui_result';
private const CAPABILITY = 'edit_pages';
/** @var callable(): MailshotRunService */
private $runServiceFactory;
/** @var callable(): MailshotService */
private $mailshotServiceFactory;
private WordPressFacade $wp;
/** @param callable(): MailshotRunService $runServiceFactory @param callable(): MailshotService $mailshotServiceFactory */
public function __construct(callable $runServiceFactory, callable $mailshotServiceFactory, WordPressFacade $wp)
{
$this->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 '<div class="wrap"><h1>Mailshot Test</h1>';
echo '<p>Render with a selected recipient context, then optionally send one test email.</p>';
if ($result !== null) {
$ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'Test action succeeded.' : 'Test action failed.') . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
}
if (!empty($result['warnings']) && is_array($result['warnings'])) {
echo '<p style="margin:6px 0 0 0;">Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '</p>';
}
if (!empty($result['sent_to'])) {
echo '<p style="margin:6px 0 0 0;">Sent to: <code>' . htmlspecialchars((string) $result['sent_to']) . '</code></p>';
}
if (!empty($result['sent_at'])) {
echo '<p style="margin:6px 0 0 0;">Sent at: <code>' . htmlspecialchars((string) $result['sent_at']) . '</code></p>';
}
echo '<details style="margin-top:8px;"><summary>Show technical details</summary><pre style="margin-top:8px;">' . htmlspecialchars(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '</pre></details>';
echo '</div>';
}
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;">';
echo '<input type="hidden" name="page" value="feca-mailshots-test">';
echo '<label>Mailshot: <select name="mailshot_id">';
foreach ($mailshots as $m) {
$id = (int) ($m['id'] ?? 0);
$sel = $id === $selectedMailshotId ? ' selected' : '';
$label = trim((string) ($m['Purpose'] ?? ''));
if ($label === '') {
$label = 'Mailshot #' . $id;
}
echo '<option value="' . $id . '"' . $sel . '>' . htmlspecialchars($label) . '</option>';
}
echo '</select></label> <button class="button" type="submit">Load recipients</button>';
echo '</form>';
echo '</div>';
if (!empty($preview['errors'])) {
echo '<div style="padding:10px;border:1px solid #ef9a9a;background:#ffebee;margin:12px 0;">' . htmlspecialchars(implode('; ', $preview['errors'])) . '</div>';
}
$rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : [];
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">2. Render / Send Test</h2>';
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<label>Recipient row: <select name="recipient_index">';
foreach ($rows as $r) {
$idx = (int) ($r['index'] ?? 0);
$sel = $idx === $selectedRecipientIndex ? ' selected' : '';
$email = (string) ($r['recipient_email'] ?? '');
$key = (string) ($r['recipient_key'] ?? '');
$field = (string) ($r['recipient_key_field'] ?? '');
$text = sprintf('#%d | %s=%s | %s', $idx, $field, $key, $email !== '' ? $email : '(no email)');
echo '<option value="' . $idx . '"' . $sel . '>' . htmlspecialchars($text) . '</option>';
}
echo '</select></label>';
echo '<p><button class="button" type="submit" name="action" value="feca_mailshots_test_render_ui">Render Test (No Send)</button></p>';
echo '<label>Test email address: <input class="regular-text" type="email" name="test_email" value="' . htmlspecialchars($testEmail, ENT_QUOTES) . '"></label>';
echo '<p><button class="button button-primary" type="submit" name="action" value="feca_mailshots_test_send_ui" onclick="return confirm(\'Send one test email to the entered address?\');">Send Test Email</button></p>';
echo '</form>';
if ($rows !== []) {
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;">';
echo '<h2 style="margin-top:0;">Recipient Sample (First 20)</h2><table class="widefat striped"><thead><tr><th>#</th><th>Key</th><th>Email</th></tr></thead><tbody>';
foreach (array_slice($rows, 0, 20) as $r) {
echo '<tr><td>' . (int) ($r['index'] ?? 0) . '</td><td>' . htmlspecialchars((string) ($r['recipient_key'] ?? '')) . '</td><td>' . htmlspecialchars((string) ($r['recipient_email'] ?? '')) . '</td></tr>';
}
echo '</tbody></table>';
echo '</div>';
}
echo '</div>';
}
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<string,mixed>|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)();
}
}

View File

@ -0,0 +1,459 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\MailshotService;
use FecaMailshots\WordPress\WordPressFacade;
final class MailshotsAdminPage
{
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_mailshots_ui_result';
/** @var callable(): MailshotService */
private $serviceFactory;
private WordPressFacade $wp;
/** @param callable(): MailshotService $serviceFactory */
public function __construct(callable $serviceFactory, WordPressFacade $wp)
{
$this->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 '<div class="wrap"><h1>Mailshots</h1>';
if ($result !== null) {
$ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'Mailshot saved.' : 'Mailshot action failed.') . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
}
echo '</div>';
}
echo '<p><button type="button" class="button button-primary" id="ms-open-new">New Mailshot</button></p>';
echo '<div id="ms-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:1100px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="ms-close-editor">Close</button></p>';
echo '<form method="post" action="' . $action . '" id="mailshot-editor" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>';
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">';
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="ms-editor-id">';
} else {
echo '<input type="hidden" name="id" value="" id="ms-editor-id">';
}
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="ms_purpose">Purpose</label></th><td><input class="regular-text" type="text" id="ms_purpose" name="Purpose" value="' . htmlspecialchars($form['Purpose'], ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="ms_ds">Data Source</label></th><td><select id="ms_ds" name="DataSource"><option value="">Select data source</option>';
foreach ($dataSources as $ds) {
$sel = $ds === $form['DataSource'] ? ' selected' : '';
echo '<option value="' . htmlspecialchars($ds, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($ds) . '</option>';
}
echo '</select> ';
if ($form['DataSource'] !== '') {
echo '<a class="button button-small" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $editId)) . '">Refresh Source Fields</a>';
}
echo '</td></tr>';
echo '<tr><th scope="row">Available Tokens</th><td>';
if (($tokenData['errors'] ?? []) !== []) {
echo '<p class="description" style="color:#a00;">' . htmlspecialchars(implode('; ', (array) $tokenData['errors'])) . '</p>';
} elseif (($tokenData['tokens'] ?? []) === []) {
echo '<p class="description">Select a data source to view tokens.</p>';
} else {
foreach ((array) $tokenData['tokens'] as $token) {
if (!is_array($token)) {
continue;
}
$tokenText = (string) ($token['token'] ?? '');
$fieldText = (string) ($token['field'] ?? '');
echo '<button type="button" class="button button-small ms-token-btn" data-token="' . htmlspecialchars($tokenText, ENT_QUOTES) . '" style="margin-right:6px;margin-bottom:6px;" title="' . htmlspecialchars($fieldText, ENT_QUOTES) . '">' . htmlspecialchars($tokenText) . '</button>';
}
echo '<p class="description">Click a token to insert it at cursor in Subject/Message/PDF fields.</p>';
}
echo '</td></tr>';
echo '<tr><th scope="row"><label for="ms_subject">Subject</label></th><td><textarea id="ms_subject" name="Subject" rows="2" class="large-text">' . htmlspecialchars($form['Subject']) . '</textarea></td></tr>';
echo '<tr><th scope="row"><label for="ms_message">Message (HTML)</label></th><td><textarea id="ms_message" name="Message" rows="10" class="large-text code">' . htmlspecialchars($form['Message']) . '</textarea></td></tr>';
echo '<tr><th scope="row"><label for="ms_pdfa">PDF Attachment Template (HTML)</label></th><td><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code">' . htmlspecialchars($form['PDFAttachment']) . '</textarea></td></tr>';
echo '<tr><th scope="row"><label for="ms_pdfname">PDF Filename Derived From</label></th><td><select id="ms_pdfname" name="PDFFilenameDerivedFrom"><option value="">(none)</option>';
foreach ($pdfFieldOptions as $f) {
$sel = $f === $form['PDFFilenameDerivedFrom'] ? ' selected' : '';
echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>';
}
echo '</select></td></tr>';
echo '<tr><th scope="row">Attachments</th><td>';
echo '<select id="ms_attachment_pick"><option value="">Select attachment</option>';
foreach ($attachmentNames as $name) {
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . '</option>';
}
echo '</select> <button type="button" class="button button-small" id="ms_attachment_add">Add Selected Attachment</button>';
echo '<ul id="ms_attachment_list" style="margin-top:10px;">';
foreach ($selectedAttachments as $name) {
echo '<li data-name="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . ' <button type="button" class="button-link-delete ms-att-remove">Remove</button></li>';
}
echo '</ul>';
echo '<input type="hidden" name="AttachmentNamesCsv" id="ms_attachment_csv" value="' . htmlspecialchars(implode(', ', $selectedAttachments), ENT_QUOTES) . '">';
echo '</td></tr>';
echo '<tr><th scope="row"><label for="ms_cc">CC</label></th><td><input class="regular-text" type="text" id="ms_cc" name="CC" value="' . htmlspecialchars($form['CC'], ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="ms_bcc">BCC</label></th><td><input class="regular-text" type="text" id="ms_bcc" name="BCC" value="' . htmlspecialchars($form['BCC'], ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="ms_reply">Reply-To</label></th><td><input class="regular-text" type="text" id="ms_reply" name="ReplyTo" value="' . htmlspecialchars($form['ReplyTo'], ENT_QUOTES) . '"></td></tr>';
echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update Mailshot' : 'Create Mailshot') . '</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">Discard Changes</a> ';
}
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">New</a></p>';
echo '</form>';
echo '</div></div>';
echo '<h2>Existing Mailshots</h2>';
echo '<table class="widefat striped"><thead><tr><th>Purpose</th><th>Data Source</th><th>Subject</th><th>Actions</th></tr></thead><tbody>';
foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0);
$editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $id);
echo '<tr>';
echo '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . htmlspecialchars((string) ($row['Purpose'] ?? '')) . '</td>';
echo '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . htmlspecialchars((string) ($row['DataSource'] ?? '')) . '</td>';
echo '<td style="max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . htmlspecialchars((string) ($row['Subject'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_delete">';
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this mailshot?\');">Delete</button>';
echo '</form></td>';
echo '</tr>';
}
if ($items === []) {
echo '<tr><td colspan="4">No mailshots found.</td></tr>';
}
echo '</tbody></table>';
echo '<script>
(function () {
var lastTarget = null;
var editorModal = document.getElementById("ms-editor-modal");
var openNew = document.getElementById("ms-open-new");
var closeEditor = document.getElementById("ms-close-editor");
var editor = document.getElementById("mailshot-editor");
if (!editor) { return; }
if (openNew && editorModal) {
openNew.addEventListener("click", function () {
var idInput = document.getElementById("ms-editor-id");
if (idInput) { idInput.value = ""; }
["ms_purpose","ms_ds","ms_subject","ms_message","ms_pdfa","ms_cc","ms_bcc","ms_reply","ms_pdfname"].forEach(function (id) {
var el = document.getElementById(id);
if (el) { el.value = ""; }
});
var list = document.getElementById("ms_attachment_list");
if (list) { list.innerHTML = ""; }
editorModal.style.display = "block";
});
}
if (closeEditor && editorModal) {
closeEditor.addEventListener("click", function () {
editorModal.style.display = "none";
});
}
var targets = ["ms_subject", "ms_message", "ms_pdfa"];
targets.forEach(function (id) {
var el = document.getElementById(id);
if (!el) { return; }
el.addEventListener("focus", function () { lastTarget = el; });
});
var tokenButtons = editor.querySelectorAll(".ms-token-btn");
tokenButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
var token = btn.getAttribute("data-token") || "";
if (!lastTarget) {
lastTarget = document.getElementById("ms_message");
}
if (!lastTarget) { return; }
var start = lastTarget.selectionStart || 0;
var end = lastTarget.selectionEnd || 0;
var value = lastTarget.value || "";
lastTarget.value = value.substring(0, start) + token + value.substring(end);
lastTarget.focus();
var next = start + token.length;
lastTarget.setSelectionRange(next, next);
});
});
var list = document.getElementById("ms_attachment_list");
var pick = document.getElementById("ms_attachment_pick");
var addBtn = document.getElementById("ms_attachment_add");
var hidden = document.getElementById("ms_attachment_csv");
if (list && pick && addBtn && hidden) {
var sync = function () {
var names = [];
list.querySelectorAll("li").forEach(function (li) {
var n = li.getAttribute("data-name") || "";
if (n && names.indexOf(n) === -1) { names.push(n); }
});
hidden.value = names.join(", ");
};
list.addEventListener("click", function (ev) {
if (ev.target && ev.target.classList.contains("ms-att-remove")) {
var li = ev.target.closest("li");
if (li) { li.remove(); sync(); }
}
});
addBtn.addEventListener("click", function () {
var value = pick.value || "";
if (!value) { return; }
var exists = false;
list.querySelectorAll("li").forEach(function (li) {
if ((li.getAttribute("data-name") || "") === value) { exists = true; }
});
if (exists) { return; }
var li = document.createElement("li");
li.setAttribute("data-name", value);
li.appendChild(document.createTextNode(value + " "));
var rm = document.createElement("button");
rm.type = "button";
rm.className = "button-link-delete ms-att-remove";
rm.textContent = "Remove";
li.appendChild(rm);
list.appendChild(li);
sync();
});
sync();
}
var hasEdit = ' . ($editId > 0 ? 'true' : 'false') . ';
if (hasEdit && editorModal) {
editorModal.style.display = "block";
}
})();
</script>';
echo '</div>';
}
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<string,mixed>|null */
private function result(): ?array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
return is_array($raw) ? $raw : null;
}
}

View File

@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\PdfAssetService;
use FecaMailshots\WordPress\WordPressFacade;
final class PdfAssetsAdminPage
{
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_pdf_assets_ui_result';
/** @var callable(): PdfAssetService */
private $serviceFactory;
private WordPressFacade $wp;
/** @param callable(): PdfAssetService $serviceFactory */
public function __construct(callable $serviceFactory, WordPressFacade $wp)
{
$this->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 '<div class="wrap"><h1>PDF Assets</h1>';
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 '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
}
echo '</div>';
}
echo '<p><button type="button" class="button button-primary" id="pdf-open-new">New PDF Asset</button></p>';
echo '<div id="pdf-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="pdf-close-editor">Close</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>';
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">';
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="pdf-editor-id">';
} else {
echo '<input type="hidden" name="id" value="" id="pdf-editor-id">';
}
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="pdf_name">Name</label></th><td><input class="regular-text" type="text" id="pdf_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_file_name">File Name</label></th><td><input class="regular-text" type="text" id="pdf_file_name" name="file_name" value="' . htmlspecialchars($fileName, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_mime">MIME Type</label></th><td><input class="regular-text" type="text" id="pdf_mime" name="mime_type" value="' . htmlspecialchars($mime, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_width">Width (mm)</label></th><td><input class="small-text" type="number" step="0.01" id="pdf_width" name="width_mm" value="' . htmlspecialchars($width, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_height">Height (mm)</label></th><td><input class="small-text" type="number" step="0.01" id="pdf_height" name="height_mm" value="' . htmlspecialchars($height, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_just">Justification</label></th><td><select id="pdf_just" name="justification">';
foreach (['in-place', 'left', 'right'] as $opt) {
$selected = $opt === $just ? ' selected' : '';
echo '<option value="' . htmlspecialchars($opt, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($opt) . '</option>';
}
echo '</select></td></tr>';
echo '<tr><th scope="row"><label for="pdf_file_upload">Upload PDF</label></th><td><input type="file" id="pdf_file_upload" name="file_upload" accept="application/pdf"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_base64">File Bytes (Base64)</label></th><td><textarea id="pdf_base64" name="file_bytes_base64" rows="4" class="large-text code"></textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update PDF Asset' : 'Create PDF Asset') . '</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets')) . '">Cancel Edit</a>';
}
echo '</p></form>';
echo '</div></div>';
echo '<h2>Existing PDF Assets</h2>';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>File</th><th>Size (mm)</th><th>Justification</th><th>Bytes</th><th>Actions</th></tr></thead><tbody>';
foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0);
$editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets&edit_id=' . $id);
echo '<tr>';
echo '<td>' . $id . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['name'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['file_name'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['width_mm'] ?? '')) . ' x ' . htmlspecialchars((string) ($row['height_mm'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['justification'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_delete">';
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this PDF asset?\');">Delete</button>';
echo '</form></td>';
echo '</tr>';
}
if ($items === []) {
echo '<tr><td colspan="7">No PDF assets found.</td></tr>';
}
echo '</tbody></table>';
echo '<script>(function(){var modal=document.getElementById("pdf-editor-modal");var openBtn=document.getElementById("pdf-open-new");var closeBtn=document.getElementById("pdf-close-editor");if(openBtn&&modal){openBtn.addEventListener("click",function(){var id=document.getElementById("pdf-editor-id");if(id){id.value="";}["pdf_name","pdf_file_name","pdf_mime","pdf_width","pdf_height","pdf_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}var j=document.getElementById("pdf_just");if(j){j.value="in-place";}modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';if(hasEdit&&modal){modal.style.display="block";}})();</script>';
echo '</div>';
}
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<string,mixed>|null */
private function result(): ?array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
return is_array($raw) ? $raw : null;
}
}

View File

@ -0,0 +1,445 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Repository\MailCredentialRepository;
use FecaMailshots\WordPress\WordPressFacade;
final class ProfileAdminPage
{
private const CAPABILITY = 'edit_pages';
private const TEST_RESULT_OPTION_PREFIX = 'feca_mailshots_profile_test_result_';
private WordPressFacade $wp;
/** @var callable(): MailCredentialRepository */
private $repoFactory;
/** @param callable(): MailCredentialRepository $repoFactory */
public function __construct(WordPressFacade $wp, callable $repoFactory)
{
$this->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 '<div class="wrap"><h1>Mailshot Profile</h1>';
if ($status !== '') {
echo '<div style="padding:10px;border:1px solid #8bc34a;background:#f1f8e9;margin:12px 0;">' . htmlspecialchars($status) . '</div>';
}
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 '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($test['messages']) && is_array($test['messages'])) {
echo '<ul>';
foreach ($test['messages'] as $line) {
echo '<li>' . htmlspecialchars((string) $line) . '</li>';
}
echo '</ul>';
}
echo '</div>';
}
echo '<p>Configure your personal SMTP and IMAP credentials for test/run operations.</p>';
echo '<p><em>Passwords are stored encrypted in the mailshots database.</em></p>';
echo '<div style="padding:10px;border:1px solid #c8d7e1;background:#f6fbff;margin:12px 0;">';
echo '<strong>Common configuration patterns</strong>';
echo '<ul style="margin:8px 0 0 18px;list-style:disc;">';
echo '<li>SMTP port 465 usually means <em>Require TLS</em> should be enabled (implicit TLS).</li>';
echo '<li>SMTP port 587 usually means <em>Require TLS</em> should be disabled so STARTTLS can be negotiated.</li>';
echo '<li>IMAP port 993 usually uses mailbox flags <code>/imap/ssl</code>.</li>';
echo '<li>IMAP port 143 usually uses non-SSL flags such as <code>/imap</code> (or your provider specific STARTTLS mode).</li>';
echo '</ul>';
echo '</div>';
echo '<form method="post" action="' . $action . '">';
echo '<h2>SMTP</h2>';
$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 '<table class="form-table" role="presentation"><tr><th scope="row">Require TLS</th><td><label><input type="checkbox" name="smtp_require_tls" value="1" ' . $tlsChecked . '> Use implicit TLS transport</label></td></tr></table>';
echo '<h2>IMAP Sent Copy</h2>';
$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 '<p>';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_profile_save">Save Profile</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_profile_test" formaction="' . $action . '" formmethod="post" title="Test SMTP login" onclick="this.form.test_kind.value=\'smtp\';">Test SMTP</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_profile_test" formaction="' . $action . '" formmethod="post" title="Test IMAP login" onclick="this.form.test_kind.value=\'imap\';">Test IMAP</button>';
echo '<input type="hidden" name="test_kind" value="">';
echo '</p>';
echo '</form></div>';
}
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 '<table class="form-table" role="presentation"><tr>';
echo '<th scope="row"><label for="' . htmlspecialchars($name) . '">' . htmlspecialchars($label) . '</label></th>';
echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">';
if ($hint !== '') {
echo '<p class="description">' . htmlspecialchars($hint) . '</p>';
}
echo '</td></tr></table>';
}
private function repo(): MailCredentialRepository
{
return ($this->repoFactory)();
}
/** @param array<string, mixed> $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<string, mixed> $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<string> */
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<string> */
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<int> $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<int> $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<string>}|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']),
];
}
}

View File

@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\MailshotRunService;
use FecaMailshots\Application\MailshotService;
use FecaMailshots\WordPress\WordPressFacade;
final class RunMailshotAdminPage
{
private const RESULT_OPTION_KEY = 'feca_mailshots_run_ui_result';
private const CAPABILITY = 'edit_pages';
/** @var callable(): MailshotRunService */
private $runServiceFactory;
/** @var callable(): MailshotService */
private $mailshotServiceFactory;
private WordPressFacade $wp;
/** @param callable(): MailshotRunService $runServiceFactory @param callable(): MailshotService $mailshotServiceFactory */
public function __construct(callable $runServiceFactory, callable $mailshotServiceFactory, WordPressFacade $wp)
{
$this->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 '<div class="wrap"><h1>Run Mailshot</h1>';
echo '<p>Execute full sends and retry failures from the current mailshot last-run rows.</p>';
if ($result !== null) {
$ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'Run action completed.' : 'Run action failed.') . '</strong>';
if ($ok) {
echo '<p>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)
. '</p>';
}
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
}
echo '<details style="margin-top:8px;"><summary>Show technical details</summary><pre style="margin-top:8px;">' . htmlspecialchars(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '</pre></details>';
echo '</div>';
}
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;">';
echo '<input type="hidden" name="page" value="feca-mailshots-run">';
echo '<label>Mailshot: <select name="mailshot_id">';
foreach ($mailshots as $m) {
$id = (int) ($m['id'] ?? 0);
$sel = $id === $selectedMailshotId ? ' selected' : '';
$label = trim((string) ($m['Purpose'] ?? ''));
if ($label === '') {
$label = 'Mailshot #' . $id;
}
echo '<option value="' . $id . '"' . $sel . '>' . htmlspecialchars($label) . '</option>';
}
echo '</select></label> <button class="button" type="submit">Load</button>';
echo '</form>';
echo '</div>';
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">2. Run Actions</h2>';
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<button class="button button-primary" type="submit" name="action" value="feca_mailshots_run_execute_ui" onclick="return confirm(\'Run mailshot now for the selected recipients?\');">Run Mailshot</button> ';
echo '<button class="button" type="submit" name="action" value="feca_mailshots_retry_failed_ui" onclick="return confirm(\'Retry all failed recipients for this mailshot?\');">Retry Failed Sends</button>';
echo '</form>';
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;">';
echo '<h2 style="margin-top:0;">3. Last Run Rows</h2>';
if ($lastRunRows === []) {
echo '<p>No rows in mailshot_last_run for this mailshot.</p>';
} else {
echo '<table class="widefat striped"><thead><tr><th>Recipient Key</th><th>Email</th><th>Status</th><th>Attempts</th><th>Error/Warning</th><th>Retry</th></tr></thead><tbody>';
foreach ($lastRunRows as $row) {
$recipientKey = (string) ($row['recipient_key'] ?? '');
$status = (string) ($row['status'] ?? '');
$msg = (string) (($row['error_message'] ?? '') ?: ($row['warning_message'] ?? ''));
echo '<tr>';
echo '<td>' . htmlspecialchars($recipientKey) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['recipient_email_last'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars($status) . '</td>';
echo '<td>' . (int) ($row['attempt_count'] ?? 0) . '</td>';
echo '<td>' . htmlspecialchars($msg) . '</td>';
echo '<td>';
if ($status === 'failed') {
echo '<form method="post" action="' . $action . '">';
echo '<input type="hidden" name="action" value="feca_mailshots_retry_recipient_ui">';
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<input type="hidden" name="recipient_key" value="' . htmlspecialchars($recipientKey, ENT_QUOTES) . '">';
echo '<button class="button" type="submit" onclick="return confirm(\'Retry this failed recipient now?\');">Retry</button>';
echo '</form>';
}
echo '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
echo '</div>';
echo '</div>';
}
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<string,mixed>|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)();
}
}

View File

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\WordPress\WordPressFacade;
final class SetupAdminPage
{
public const OPTION_KEY = 'feca_mailshots_db_settings';
public const TEST_RESULT_OPTION_KEY = 'feca_mailshots_db_test_result';
private WordPressFacade $wp;
public function __construct(WordPressFacade $wp)
{
$this->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 '<div class="wrap">';
echo '<h1>FECA Mailshots Setup</h1>';
if ($status !== '') {
echo '<div style="padding:10px;border:1px solid #8bc34a;background:#f1f8e9;margin:12px 0;">' . htmlspecialchars($status) . '</div>';
}
if ($test !== null) {
$ok = !empty($test['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'Connection test passed.' : 'Connection test failed.';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($test['messages']) && is_array($test['messages'])) {
echo '<ul>';
foreach ($test['messages'] as $line) {
echo '<li>' . htmlspecialchars((string) $line) . '</li>';
}
echo '</ul>';
}
echo '</div>';
}
echo '<p>Configure database credentials used by FECA Mailshots in this WordPress environment.</p>';
echo '<form method="post" action="' . $action . '">';
$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 '<p>';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_setup_save">Save Setup</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_setup_test">Test Connection</button>';
echo '</p>';
echo '</form>';
echo '</div>';
}
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<string, string> */
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 '<table class="form-table" role="presentation"><tr>';
echo '<th scope="row"><label for="' . htmlspecialchars($name) . '">' . htmlspecialchars($label) . '</label></th>';
echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '"></td>';
echo '</tr></table>';
}
/** @return array{ok:bool,messages:list<string>} */
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<string>}|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']),
];
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Repository\AttachmentRepository;
final class AttachmentService
{
private AttachmentRepository $repo;
public function __construct(AttachmentRepository $repo)
{
$this->repo = $repo;
}
/** @return list<array<string, mixed>> */
public function list(): array
{
return $this->repo->all();
}
/** @return array<string, mixed> */
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);
}
}

View File

@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseRouter;
use FecaMailshots\Repository\MailshotRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use PDO;
final class DataSourceService
{
private MailshotQueryRepository $queries;
private DatabaseRouter $router;
private DslParser $parser;
private DslValidator $validator;
private DslCompiler $compiler;
private SourceMetadataProvider $metadata;
private MailshotRepository $mailshots;
public function __construct(
MailshotQueryRepository $queries,
DatabaseRouter $router,
DslParser $parser,
DslValidator $validator,
DslCompiler $compiler,
SourceMetadataProvider $metadata,
MailshotRepository $mailshots
) {
$this->queries = $queries;
$this->router = $router;
$this->parser = $parser;
$this->validator = $validator;
$this->compiler = $compiler;
$this->metadata = $metadata;
$this->mailshots = $mailshots;
}
/** @return list<array<string, mixed>> */
public function list(): array
{
return $this->queries->all();
}
/** @return array<string, mixed>|null */
public function get(int $id): ?array
{
return $this->queries->find($id);
}
/** @return array<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, list<string>> */
public function sourceFields(): array
{
$result = [];
foreach ($this->metadata->allKnownSources() as $source) {
$result[$source] = $this->metadata->sourceFields($source);
}
return $result;
}
/** @return list<string> */
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<string> */
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<string> */
public function sourceFieldsForSource(string $source): array
{
$source = trim($source);
if ($source === '') {
return [];
}
return $this->metadata->sourceFields($source);
}
}

View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Domain\AppError;
final class DslCompiler
{
private SourceMetadataProvider $metadata;
public function __construct(SourceMetadataProvider $metadata)
{
$this->metadata = $metadata;
}
/**
* @param array{sources:list<string>, where:array<int, mixed>} $ast
* @return array{sql:string,params:list<mixed>}
*/
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<mixed> &$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 . '`';
}
}

View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Domain\AppError;
final class DslValidator
{
private SourceMetadataProvider $metadata;
/** @var array<string, list<string>> */
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<string>, where:array<int, mixed>} $ast
* @return array{errors:list<string>,warnings:list<string>,expected_fields:list<string>}
*/
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<string> $sources @param list<string> &$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<string> $sources @param list<string> &$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<string> $sources */
private function hasAnyEmailField(array $sources): bool
{
foreach ($sources as $source) {
if ($this->metadata->hasEmailField($source)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
interface ImapAppender
{
/** @param array<string, mixed> $credentials */
public function appendSent(array $credentials, string $rawMime, string $attemptId): void;
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
interface MailCredentialsProvider
{
/** @return array<string, mixed>|null */
public function credentials(): ?array;
}

View File

@ -0,0 +1,437 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
final class MailshotRunService
{
private MailshotRepository $mailshots;
private MailshotQueryRepository $queries;
private DataSourceService $dataSources;
private TemplateRenderer $renderer;
private SmtpSender $smtp;
private ImapAppender $imap;
private MailCredentialsProvider $credentials;
private LastRunRepository $lastRun;
public function __construct(
MailshotRepository $mailshots,
MailshotQueryRepository $queries,
DataSourceService $dataSources,
TemplateRenderer $renderer,
SmtpSender $smtp,
ImapAppender $imap,
MailCredentialsProvider $credentials,
LastRunRepository $lastRun
) {
$this->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<string, mixed> */
public function defaultTestEmail(): array
{
$raw = getenv('MAILSHOT_TEST_TO_DEFAULT');
return ['default_test_email' => is_string($raw) ? trim($raw) : ''];
}
/** @return array<string,mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string,mixed>,1:list<array<string,mixed>>} */
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<array<string,mixed>> $rows
* @param array<string,mixed> $mailshot
* @param array<string,mixed> $creds
* @return array<string,mixed>
*/
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<string,mixed> $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<array<string,mixed>> $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<string,mixed> $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<string> */
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));
}
}

View File

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Repository\AttachmentRepository;
use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
final class MailshotService
{
private MailshotRepository $mailshots;
private MailshotQueryRepository $queries;
private AttachmentRepository $attachments;
private LastRunRepository $lastRun;
private DataSourceService $dataSources;
public function __construct(
MailshotRepository $mailshots,
MailshotQueryRepository $queries,
AttachmentRepository $attachments,
LastRunRepository $lastRun,
DataSourceService $dataSources
) {
$this->mailshots = $mailshots;
$this->queries = $queries;
$this->attachments = $attachments;
$this->lastRun = $lastRun;
$this->dataSources = $dataSources;
}
/** @return list<array<string, mixed>> */
public function list(): array
{
return $this->mailshots->all();
}
/** @return list<string> */
public function dataSourceNames(): array
{
$rows = $this->queries->all();
return array_map(static fn(array $r): string => (string) $r['name'], $rows);
}
/** @return list<string> */
public function attachmentNames(): array
{
return $this->attachments->names();
}
/** @return array<string, mixed> */
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<array<string, mixed>> */
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<array<string, string>>, errors:list<string>} */
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<string> */
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, '_');
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Repository\PdfAssetRepository;
final class PdfAssetService
{
private PdfAssetRepository $repo;
public function __construct(PdfAssetRepository $repo)
{
$this->repo = $repo;
}
/** @return list<array<string, mixed>> */
public function list(): array
{
return $this->repo->all();
}
/** @return array<string, mixed> */
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);
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Repository\MailCredentialRepository;
use FecaMailshots\WordPress\WordPressFacade;
final class PerUserMailCredentialsProvider implements MailCredentialsProvider
{
private MailCredentialRepository $repo;
private WordPressFacade $wp;
public function __construct(MailCredentialRepository $repo, WordPressFacade $wp)
{
$this->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;
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
interface SecretKeyProvider
{
public function keyMaterial(): string;
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
interface SmtpSender
{
/**
* @param array<string, mixed> $credentials
* @param list<string> $to
* @param list<string> $cc
* @param list<string> $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;
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
interface SourceMetadataProvider
{
public function sourceExists(string $source): bool;
/** @return list<string> */
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<string> */
public function allKnownSources(): array;
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
final class TemplateRenderer
{
/**
* @param array<string, mixed> $context
* @return array{subject:string,message:string,pdf_attachment:string,warnings:list<string>}
*/
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<string, mixed> $row @return array<string, mixed> */
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;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Domain;
final class AppError extends \RuntimeException
{
private string $codeName;
/** @var array<string, mixed> */
private array $context;
/** @param array<string, mixed> $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<string, mixed> */
public function context(): array
{
return $this->context;
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Domain;
final class DslLexer
{
/** @return list<DslToken> */
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;
}
}

View File

@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Domain;
final class DslParser
{
/** @var list<DslToken> */
private array $tokens = [];
private int $pos = 0;
/**
* @return array{sources:list<string>, where:array<int, mixed>}
*/
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<string> */
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<int, mixed> */
private function parsePredicateExpr(): array
{
$predicates = [];
$predicates[] = $this->parsePredicateTerm();
while ($this->matchKw('and')) {
$predicates[] = $this->parsePredicateTerm();
}
return $predicates;
}
/** @return array<string, mixed> */
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<mixed> */
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;
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Domain;
final class DslToken
{
public string $type;
public string $value;
public int $offset;
public function __construct(string $type, string $value, int $offset)
{
$this->type = $type;
$this->value = $value;
$this->offset = $offset;
}
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use FecaMailshots\Application\SmtpSender;
final class BasicSmtpSender implements SmtpSender
{
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null): array
{
$host = (string) ($credentials['smtp_host'] ?? '');
$port = (int) ($credentials['smtp_port'] ?? 0);
$user = (string) ($credentials['smtp_user'] ?? '');
$pass = (string) ($credentials['smtp_password'] ?? '');
$from = (string) ($credentials['smtp_from_email'] ?? '');
$fromName = (string) ($credentials['smtp_from_name'] ?? '');
if ($host === '' || $port <= 0 || $user === '' || $pass === '' || $from === '') {
throw new \RuntimeException('Missing SMTP credentials.');
}
$transport = !empty($credentials['smtp_require_tls']) ? 'tls://' : '';
$fp = @stream_socket_client($transport . $host . ':' . $port, $errno, $errstr, 20);
if (!is_resource($fp)) {
throw new \RuntimeException('SMTP connect failed: ' . $errstr);
}
try {
$this->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<string> $to @param list<string> $cc @param list<string> $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<int> $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<int> $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;
}
}
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use PDO;
interface DatabaseRouter
{
public function mailshotsPdo(): PDO;
public function membersPdo(): PDO;
public function mailshotsDbName(): string;
public function membersDbName(): string;
}

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use FecaMailshots\Application\SourceMetadataProvider;
use PDO;
final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
{
private DatabaseRouter $router;
/** @var array<string, list<string>> */
private array $builtInFields;
/** @var array<string, array{left:string,right:string}> */
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'];
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
final class Env
{
/** @return array<string, string> */
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;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use FecaMailshots\Application\SecretKeyProvider;
final class EnvSecretKeyProvider implements SecretKeyProvider
{
private string $envKey;
public function __construct(string $envKey = 'MAILSHOTS_FIXTURE_SECRET_KEY')
{
$this->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);
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use PDO;
final class PdoDatabaseRouter implements DatabaseRouter
{
private PDO $mailshotsPdo;
private PDO $membersPdo;
private string $mailshotsDbName;
private string $membersDbName;
/** @param array<string, string> $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<string, string> $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];
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use FecaMailshots\Application\ImapAppender;
final class PhpImapAppender implements ImapAppender
{
/** @var array<string, bool> */
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);
}
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use FecaMailshots\Application\SecretKeyProvider;
final class WordPressSaltSecretKeyProvider implements SecretKeyProvider
{
public function keyMaterial(): string
{
$parts = [];
foreach (['AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY'] as $const) {
if (defined($const) && is_string(constant($const)) && constant($const) !== '') {
$parts[] = constant($const);
}
}
if ($parts === []) {
throw new \RuntimeException('WordPress salt constants are not available.');
}
return implode('|', $parts);
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace FecaMailshots;
use FecaMailshots\Admin\AttachmentsAdminPage;
use FecaMailshots\Admin\DataSourcesAdminPage;
use FecaMailshots\Admin\MailshotsAdminPage;
use FecaMailshots\Admin\MailshotTestAdminPage;
use FecaMailshots\Admin\PdfAssetsAdminPage;
use FecaMailshots\Admin\ProfileAdminPage;
use FecaMailshots\Admin\RunMailshotAdminPage;
use FecaMailshots\Admin\SetupAdminPage;
use FecaMailshots\Application\AttachmentService;
use FecaMailshots\Application\DataSourceService;
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Application\MailCredentialsProvider;
use FecaMailshots\Application\MailshotRunService;
use FecaMailshots\Application\MailshotService;
use FecaMailshots\Application\PerUserMailCredentialsProvider;
use FecaMailshots\Application\PdfAssetService;
use FecaMailshots\Application\SecretKeyProvider;
use FecaMailshots\Application\TemplateRenderer;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\BasicSmtpSender;
use FecaMailshots\Infrastructure\DatabaseRouter;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\PhpImapAppender;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Infrastructure\WordPressSaltSecretKeyProvider;
use FecaMailshots\Repository\AttachmentRepository;
use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\MailCredentialRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
use FecaMailshots\Repository\PdfAssetRepository;
use FecaMailshots\Support\Container;
use FecaMailshots\Support\ErrorLogLogger;
use FecaMailshots\WordPress\WordPressFacade;
use FecaMailshots\Application\SmtpSender;
use FecaMailshots\Application\ImapAppender;
final class Plugin
{
public static function buildContainer(array $dbConfig, WordPressFacade $wp): Container
{
$c = new Container();
$c->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;
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Repository;
use FecaMailshots\Infrastructure\DatabaseRouter;
use PDO;
final class AttachmentRepository
{
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
{
$this->router = $router;
}
/** @return list<array<string, mixed>> */
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<string, mixed> $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<string, mixed> $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<string> */
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);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Repository;
use FecaMailshots\Infrastructure\DatabaseRouter;
use PDO;
final class LastRunRepository
{
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
{
$this->router = $router;
}
/** @return list<array<string, mixed>> */
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<string, mixed> $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;
}
}

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Repository;
use FecaMailshots\Application\SecretKeyProvider;
use FecaMailshots\Infrastructure\DatabaseRouter;
use PDO;
final class MailCredentialRepository
{
private DatabaseRouter $router;
private SecretKeyProvider $keyProvider;
public function __construct(DatabaseRouter $router, SecretKeyProvider $keyProvider)
{
$this->router = $router;
$this->keyProvider = $keyProvider;
}
/** @return array<string, mixed>|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<string, mixed> $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;
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Repository;
use FecaMailshots\Infrastructure\DatabaseRouter;
use PDO;
final class MailshotQueryRepository
{
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
{
$this->router = $router;
}
/** @return list<array<string, mixed>> */
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]);
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Repository;
use FecaMailshots\Infrastructure\DatabaseRouter;
use PDO;
final class MailshotRepository
{
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
{
$this->router = $router;
}
/** @return list<array<string, mixed>> */
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<string, mixed> $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<string, mixed> $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<string, mixed> $row @return array<string, mixed> */
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;
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Repository;
use FecaMailshots\Infrastructure\DatabaseRouter;
use PDO;
final class PdfAssetRepository
{
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
{
$this->router = $router;
}
/** @return list<array<string, mixed>> */
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<string, mixed> $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<string, mixed> $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]);
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Support;
final class Container
{
/** @var array<string, callable(self): mixed> */
private array $factories = [];
/** @var array<string, mixed> */
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];
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Support;
final class ErrorLogLogger implements Logger
{
public function info(string $message, array $context = []): void
{
error_log($this->format('INFO', $message, $context));
}
public function error(string $message, array $context = []): void
{
error_log($this->format('ERROR', $message, $context));
}
/** @param array<string, mixed> $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);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Support;
interface Logger
{
/** @param array<string, mixed> $context */
public function info(string $message, array $context = []): void;
/** @param array<string, mixed> $context */
public function error(string $message, array $context = []): void;
}

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\WordPress;
final class FixtureWordPressFacade implements WordPressFacade
{
/** @var array<string, list<callable>> */
private array $actions = [];
/** @var array<string, mixed> */
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);
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\WordPress;
final class ProductionWordPressFacade implements WordPressFacade
{
public function addAction(string $hook, callable $callback): void
{
add_action($hook, $callback);
}
public function addMenuPage(string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void
{
add_menu_page($pageTitle, $menuTitle, $capability, $slug, $callback, 'dashicons-email-alt', 58);
}
public function addSubmenuPage(string $parentSlug, string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void
{
add_submenu_page($parentSlug, $pageTitle, $menuTitle, $capability, $slug, $callback);
}
public function currentUserCan(string $capability): bool
{
return current_user_can($capability);
}
public function verifyNonce(string $nonce, string $action): bool
{
return wp_verify_nonce($nonce, $action) === 1;
}
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
{
status_header($statusCode);
wp_send_json($payload);
}
public function adminUrl(string $path = ''): string
{
return admin_url($path);
}
public function currentUserId(): int
{
return (int) get_current_user_id();
}
public function getOption(string $name, $default = null)
{
return get_option($name, $default);
}
public function updateOption(string $name, $value): bool
{
return (bool) update_option($name, $value);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\WordPress;
interface WordPressFacade
{
public function addAction(string $hook, callable $callback): void;
public function addMenuPage(string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void;
public function addSubmenuPage(string $parentSlug, string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void;
public function currentUserCan(string $capability): bool;
public function verifyNonce(string $nonce, string $action): bool;
public function requestParam(string $name, ?string $default = null): ?string;
/** @param array<string, mixed> $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;
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
// Load Composer dependencies (Twig, Dompdf, etc.) when available.
$composerAutoload = dirname(__DIR__, 2) . '/vendor/autoload.php';
if (is_file($composerAutoload)) {
require_once $composerAutoload;
}
spl_autoload_register(static function (string $class): void {
$prefix = 'FecaMailshots\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$relative = substr($class, strlen($prefix));
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require_once $path;
}
});

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use FecaMailshots\Plugin;
use FecaMailshots\WordPress\ProductionWordPressFacade;
require_once __DIR__ . '/autoload.php';
$getOption = static function (string $key): string {
if (!function_exists('get_option')) {
return '';
}
$raw = get_option(\FecaMailshots\Admin\SetupAdminPage::OPTION_KEY, []);
if (!is_array($raw) || !isset($raw[$key])) {
return '';
}
return trim((string) $raw[$key]);
};
$dbConfig = [
'MYSQL_HOST' => $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();

View File

@ -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.

View File

@ -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.

View File

@ -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).

407
requirements/mailshot.md Normal file
View File

@ -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.<table>` 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: <asset_name>]`.
### 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.

View File

@ -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).

View File

@ -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.

86
requirements/top-level.md Normal file
View File

@ -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.

76
requirements/ui_design.md Normal file
View File

@ -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).

View File

@ -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 (`<60><>`) 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`

View File

@ -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 `<br>` 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 `"<value><br>"` 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 `<br>`, 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.

View File

@ -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

124
samples/invoicing.html Normal file
View File

@ -0,0 +1,124 @@
{% macro br(text) %}
{% if text %}
{{ text }}<br>
{% endif %}
{% endmacro %}
<style>
body {
font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
}
.feca-address {
font-size: small;
font-weight: bold;
}
.to-address {
margin-top: 20mm;
margin-bottom: 10mm;
font-size: medium;
}
.invoice-header {
background-color: {{ theme_colour }};
color: white;
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
font-weight: bold;
font-size: large;
display: flex;
justify-content: space-between;
padding: 2mm;
}
.enclosed {
margin-top: 10mm;
}
.due {
margin-top: 10mm;
margin-bottom: 10mm;
text-align: center;
font-weight: bold;
}
.thank-you {
margin-top: 15mm;
font-style: italic;
text-align: center;
}
.contact {
margin-top: 15mm;
margin-bottom: 10mm;
font-size: small;
text-align: center;
}
.registered-charity {
font-size: small;
text-align: center;
}
.bottom-area {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
}
</style>
<div class="feca-address">
Fen Edge Community Association {{ pdf_asset('FenEdgeLogoOrange') }}<br>
Community Office<br>
Cottenham Village College<br>
Cottenham<br>
CB24 8UA</div>
<div class="to-address">
{{ br(contact_name) }}
<b>{{ br(AdvertiserName) }}</b>
{{ br(address_1) }}
{{ br(address_2) }}
{{ br(town) }}
{{ br(post_code) }}
</div>
<div class="invoice-header">
<div>INVOICE: FEN{{ invoice_number }}</div>
<div>{{ invoice_date }}</div>
</div>
<p class="enclosed">Please find enclosed a copy of the latest issue of Fen Edge News feature your {{ invoice_size }} advertisement on {{ invoice_page }}.</p>
<p class="due">
An amount of £{{ price }} is now due.</p>
<p>
Please pay by:<br><br>
<u><strong>BACS</strong></u><br>
<strong>Fen Edge Community Association</strong><br>
Account No: 40349178<br>
Sort Code: 20-17-22<br>
Reference: FEN{{ invoice_number }}<br>
<br><br>
<u><strong>Cheque</strong></u><br>
made payable to Fen Edge Community Assocation and sent to the above address.<br>
Please write FEN{{ invoice_number }} on the back of your cheque.
</p>
<p class="thank-you">
Thank you for supporting the Fen Edge Community Association
</p>
<p class="contact">
Advertising: Bob Turner<br>
&nbsp;advertising@fenedge.co.uk<br>
📞 01954 250082
</p>
<div class="bottom-area">
<hr>
<div class="registered-charity">
Registered Charity No. 293020
</div></div>

301
samples/renewal_draft.html Normal file
View File

@ -0,0 +1,301 @@
<style>
* {
box-sizing: border-box;
}
body {
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 9.5pt;
line-height: 1.25;
margin: 0;
padding: 0;
}
.page {
width: 100%;
}
.logo {
margin-bottom: 3mm;
}
.header {
border-bottom: 1.5px solid #d97706;
padding-bottom: 2.5mm;
margin-bottom: 4mm;
}
.title {
font-size: 15pt;
font-weight: bold;
color: #0f172a;
margin: 0 0 1mm 0;
}
.subtitle {
font-size: 9pt;
color: #6b7280;
margin: 0;
}
.fee-banner {
margin: 6mm 0 3mm 0;
padding: 2mm 3mm;
background: #fff7ed;
border-left: 4px solid #d97706;
font-size: 10.5pt;
font-weight: bold;
color: #9a3412;
}
.note {
margin: 0 0 3mm 0;
color: #4b5563;
font-style: italic;
}
.account-name {
margin: 0 0 3mm 0;
font-size: 11.5pt;
font-weight: bold;
color: #111827;
}
.section-title {
font-size: 8.7pt;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #374151;
background: #f3f4f6;
padding: 1.6mm 2.5mm;
border: 1px solid #d1d5db;
border-bottom: none;
margin-top: 2.5mm;
}
table.details {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin: 0 0 2mm 0;
}
table.details td {
border: 1px solid #d1d5db;
padding: 1.8mm 2.5mm;
vertical-align: top;
}
table.details td.label {
width: 32%;
background: #f9fafb;
font-weight: bold;
color: #374151;
}
table.details td.value {
width: 68%;
color: #111827;
}
.instruction-box {
margin-top: 2.5mm;
padding: 2.5mm 3mm;
background: #f9fafb;
border: 1px solid #d1d5db;
color: #374151;
}
.signature-table {
width: 100%;
border-collapse: collapse;
margin-top: 3mm;
margin-bottom: 3mm;
}
.signature-table td {
width: 50%;
border: 1px solid #d1d5db;
padding: 2.5mm 3mm 2mm 3mm;
vertical-align: bottom;
}
.sig-label {
font-size: 8.5pt;
font-weight: bold;
color: #374151;
margin-bottom: 4mm;
}
.sig-line {
border-bottom: 1px solid #111827;
height: 4mm;
}
.payment-section {
margin-top: 10mm;
padding-top: 2.5mm;
}
.payment-title {
font-size: 9.5pt;
font-weight: bold;
color: #111827;
margin-bottom: 1.5mm;
}
.payment-option {
margin-bottom: 2mm;
}
.small {
font-size: 8.8pt;
color: #4b5563;
}
.strong {
font-weight: bold;
}
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: 80pt;
color: rgba(220, 38, 38, 0.15); /* soft transparent red */
font-weight: bold;
letter-spacing: 8px;
z-index: 0;
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.page {
position: relative;
z-index: 1;
}
</style>
<div class="watermark">DRAFT</div>
<div class="page">
<div class="logo">
{{ pdf_asset('FenEdgeLogoOrange') }}
</div>
<div class="header">
<div class="title">Membership Renewal 20262027</div>
<div class="subtitle">Fen Edge Community Association</div>
</div>
<div class="fee-banner">Group Membership Fee: £15</div>
<div class="note">Please amend any details that have changed before returning this form.</div>
<div class="account-name">{{ account_name }}</div>
<div class="section-title">Primary Contact Details</div>
<table class="details">
<tbody>
<tr>
<td class="label">Number of members</td>
<td class="value"><b>{{ account_no_of_members }}</b></td>
</tr>
<tr>
<td class="label">Contact name</td>
<td class="value"><b>{{ contact_1_name }}</b></td>
</tr>
<tr>
<td class="label">Position</td>
<td class="value"><b>{{ contact_1_position }}</b></td>
</tr>
<tr>
<td class="label">Address</td>
<td class="value"><b>{{ contact_1_address_1 }} {{ contact_1_address_2 }} {{ contact_1_town }} {{ contact_1_postcode }}</b></td>
</tr>
<tr>
<td class="label">Email</td>
<td class="value"><b>{{ contact_1_email }}</b></td>
</tr>
<tr>
<td class="label">Phone / Mobile</td>
<td class="value"><b>{{ contact_1_phone }} {{ contact_1_mobile }}</b></td>
</tr>
</tbody>
</table>
<div class="section-title">Second Contact</div>
<table class="details">
<tbody>
<tr>
<td class="label">Name</td>
<td class="value"><b>{{ contact_2_name }}</b></td>
</tr>
<tr>
<td class="label">Position</td>
<td class="value"><b>{{ contact_2_position }}</b></td>
</tr>
<tr>
<td class="label">Email</td>
<td class="value"><b>{{ contact_2_email }}</b></td>
</tr>
<tr>
<td class="label">Phone / Mobile</td>
<td class="value"><b>{{ contact_2_phone }} {{ contact_2_mobile }}</b></td>
</tr>
</tbody>
</table>
<div class="section-title">Fen Edge News Contact</div>
<table class="details">
<tbody>
<tr>
<td class="label">Name</td>
<td class="value"><b>{{ fen_contact_name }}</b></td>
</tr>
<tr>
<td class="label">Email</td>
<td class="value"><b>{{ fen_contact_email }}</b></td>
</tr>
</tbody>
</table>
<div class="instruction-box">
Please check your groups public contact details on the FECA website:
<b>www.fenedge.co.uk/full-members-list</b> and advise any changes.
</div>
<table class="signature-table">
<tbody>
<tr>
<td>
<div class="sig-label">Signed</div>
<div class="sig-line"></div>
</td>
<td>
<div class="sig-label">Date</div>
<div class="sig-line"></div>
</td>
</tr>
</tbody>
</table>
<div class="payment-section">
<div class="payment-title">Payment Options</div>
<div class="payment-option">
<span class="strong">1. Cheque:</span>
payable to <b>Fen Edge Community Association</b>, sent with this form to
<span class="small"><b>FECA Community Office, Cottenham Village College, Cottenham, CB24 8UA</b></span>
</div>
<div class="payment-option">
<span class="strong">2. BACS:</span>
<b>Sort Code 20-17-22</b>, <b>Account No. 40349178</b>;
then scan and email the form to <b>info@fenedge.co.uk</b>
</div>
</div>
</div>

186
scripts/deploy_remote.sh Executable file
View File

@ -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

118
scripts/mysql_tunnel.sh Executable file
View File

@ -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

73
scripts/run_fixture_server.sh Executable file
View File

@ -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

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/wp_shim.php';
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Plugin;
use FecaMailshots\WordPress\FixtureWordPressFacade;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$tunnelPort = getenv('MYSQL_TUNNEL_LOCAL_PORT');
$host = '127.0.0.1';
$port = is_string($tunnelPort) && $tunnelPort !== '' ? $tunnelPort : (string) Env::require('REMOTE_MYSQL_PORT');
$dbConfig = [
'MYSQL_HOST' => $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];

42
tests/fixture/index.php Normal file
View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FECA Mailshots Fixture</title>
<style>
body { font-family: Georgia, "Times New Roman", serif; background: #f7f6f0; color: #222; margin: 24px auto; max-width: 960px; padding: 0 16px; }
a { color: #0b5e8a; }
pre { background: #f3f1e6; border: 1px solid #ddd9c8; padding: 12px; overflow: auto; }
.card { background: #fffef9; border: 1px solid #d7d4c6; padding: 14px; margin-bottom: 12px; }
</style>
</head>
<body>
<h1>FECA Mailshots Fixture</h1>
<div class="card">
<p>This fixture harness runs plugin code without WordPress install.</p>
<ul>
<li><a href="mailshots_fixture.php">Mailshots Fixture UI</a></li>
<li><a href="router.php?hook=admin_post_feca_mailshots_data_sources_api&op=list">Data Sources API (list)</a></li>
<li><a href="router.php?hook=admin_post_feca_mailshots_mailshots_api&op=list">Mailshots API (list)</a></li>
<li><a href="router.php?hook=admin_post_feca_mailshots_attachments_api&op=list">Attachments API (list)</a></li>
<li><a href="router.php?hook=admin_post_feca_mailshots_pdf_assets_api&op=list">PDF Assets API (list)</a></li>
</ul>
</div>
<div class="card">
<p>Example API calls:</p>
<pre>POST /router.php?hook=admin_post_feca_mailshots_mailshots_api
op=save
Purpose=Renewal Reminder
DataSource=...existing datasource name...
Subject=Hello
Message=&lt;p&gt;Body&lt;/p&gt;
AttachmentNames=["terms.pdf"]
</pre>
</div>
</body>
</html>

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
function call_api(string $hook, array $query): array
{
$url = 'http://' . $_SERVER['HTTP_HOST'] . dirname($_SERVER['SCRIPT_NAME']) . '/router.php?hook=' . rawurlencode($hook);
$opts = [
'http' => [
'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']);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mailshots Fixture UI</title>
<style>
body { font-family: Georgia, "Times New Roman", serif; background: #f7f6f0; color: #222; margin: 24px auto; max-width: 1200px; padding: 0 16px 40px; }
.card { background: #fffef9; border: 1px solid #d7d4c6; padding: 14px; margin-bottom: 12px; }
input, textarea, select { width: 100%; box-sizing: border-box; margin-bottom: 8px; padding: 6px; }
textarea { min-height: 90px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd9c8; padding: 6px 8px; vertical-align: top; }
pre { background: #f3f1e6; border: 1px solid #ddd9c8; padding: 10px; overflow: auto; }
</style>
</head>
<body>
<h1>Mailshots Fixture UI</h1>
<?php if ($message !== null): ?>
<div class="card"><strong>API response</strong><pre><?php echo htmlspecialchars(json_encode($message, JSON_PRETTY_PRINT)); ?></pre></div>
<?php endif; ?>
<div class="card">
<h2>Create / Update Mailshot</h2>
<form method="post">
<input type="hidden" name="fixture_op" value="save_mailshot">
<label>ID (blank for create)</label>
<input name="id" value="">
<label>Purpose</label>
<input name="Purpose" value="">
<label>DataSource</label>
<select name="DataSource">
<option value="">-- select --</option>
<?php foreach (($dataSources['items'] ?? []) as $ds): ?>
<option value="<?php echo htmlspecialchars((string) $ds); ?>"><?php echo htmlspecialchars((string) $ds); ?></option>
<?php endforeach; ?>
</select>
<label>Subject</label>
<input name="Subject" value="">
<label>Message (HTML)</label>
<textarea name="Message"></textarea>
<label>PDF Attachment (HTML)</label>
<textarea name="PDFAttachment"></textarea>
<label>AttachmentNames JSON</label>
<input name="AttachmentNames" value="[]">
<label>PDFFilenameDerivedFrom</label>
<input name="PDFFilenameDerivedFrom" value="">
<label>ReplyTo</label>
<input name="ReplyTo" value="">
<button type="submit">Save mailshot</button>
</form>
</div>
<div class="card">
<h2>Current Mailshots</h2>
<table>
<thead><tr><th>ID</th><th>Purpose</th><th>DataSource</th><th>Subject</th><th>Delete</th></tr></thead>
<tbody>
<?php foreach (($mailshots['items'] ?? []) as $row): ?>
<tr>
<td><?php echo htmlspecialchars((string) ($row['id'] ?? '')); ?></td>
<td><?php echo htmlspecialchars((string) ($row['Purpose'] ?? '')); ?></td>
<td><?php echo htmlspecialchars((string) ($row['DataSource'] ?? '')); ?></td>
<td><?php echo htmlspecialchars((string) ($row['Subject'] ?? '')); ?></td>
<td>
<form method="post">
<input type="hidden" name="fixture_op" value="delete_mailshot">
<input type="hidden" name="id" value="<?php echo htmlspecialchars((string) ($row['id'] ?? '0')); ?>">
<button type="submit">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</body>
</html>

11
tests/fixture/router.php Normal file
View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
$ctx = require __DIR__ . '/bootstrap.php';
$wp = $ctx['wp'];
assert($wp instanceof FecaMailshots\WordPress\FixtureWordPressFacade);
$hook = $_GET['hook'] ?? 'admin_post_feca_mailshots_data_sources_api';
$wp->dispatch($hook);

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
$ctx = require __DIR__ . '/bootstrap.php';
$wp = $ctx['wp'];
assert($wp instanceof FecaMailshots\WordPress\FixtureWordPressFacade);
$wp->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";

80
tests/fixture/wp_shim.php Normal file
View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/');
}
if (!defined('AUTH_KEY')) {
define('AUTH_KEY', 'fixture-auth-key');
}
if (!defined('SECURE_AUTH_KEY')) {
define('SECURE_AUTH_KEY', 'fixture-secure-auth-key');
}
if (!defined('LOGGED_IN_KEY')) {
define('LOGGED_IN_KEY', 'fixture-logged-in-key');
}
if (!defined('NONCE_KEY')) {
define('NONCE_KEY', 'fixture-nonce-key');
}
$GLOBALS['feca_wp_actions'] = [];
if (!function_exists('add_action')) {
function add_action(string $hook, callable $callback): void
{
if (!isset($GLOBALS['feca_wp_actions'][$hook])) {
$GLOBALS['feca_wp_actions'][$hook] = [];
}
$GLOBALS['feca_wp_actions'][$hook][] = $callback;
}
}
if (!function_exists('add_menu_page')) {
function add_menu_page(...$args): void
{
}
}
if (!function_exists('add_submenu_page')) {
function add_submenu_page(...$args): void
{
}
}
if (!function_exists('current_user_can')) {
function current_user_can(string $capability): bool
{
return true;
}
}
if (!function_exists('wp_verify_nonce')) {
function wp_verify_nonce(string $nonce, string $action): int
{
return $nonce !== '' ? 1 : 0;
}
}
if (!function_exists('status_header')) {
function status_header(int $code): void
{
http_response_code($code);
}
}
if (!function_exists('wp_send_json')) {
function wp_send_json(array $payload): void
{
header('Content-Type: application/json');
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
}
}
if (!function_exists('admin_url')) {
function admin_url(string $path = ''): string
{
return '/wp-admin/' . ltrim($path, '/');
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\PerUserMailCredentialsProvider;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Infrastructure\WordPressSaltSecretKeyProvider;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Repository\MailCredentialRepository;
use FecaMailshots\WordPress\FixtureWordPressFacade;
if (!defined('AUTH_KEY')) define('AUTH_KEY', 'phase-auth');
if (!defined('SECURE_AUTH_KEY')) define('SECURE_AUTH_KEY', 'phase-secure');
if (!defined('LOGGED_IN_KEY')) define('LOGGED_IN_KEY', 'phase-logged');
if (!defined('NONCE_KEY')) define('NONCE_KEY', 'phase-nonce');
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);
$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) {}
}

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\DataSourceService;
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\MailshotRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$host = getenv('MYSQL_TUNNEL_HOST');
if ($host === false || $host === '') {
$host = '127.0.0.1';
}
$port = getenv('MYSQL_TUNNEL_LOCAL_PORT');
if ($port === false || $port === '') {
$port = '13306';
}
$dbConfig = [
'MYSQL_HOST' => (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");
}
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Plugin;
use FecaMailshots\WordPress\FixtureWordPressFacade;
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'),
];
$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";

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\AttachmentService;
use FecaMailshots\Application\DataSourceService;
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Application\MailshotService;
use FecaMailshots\Application\PdfAssetService;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\AttachmentRepository;
use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
use FecaMailshots\Repository\PdfAssetRepository;
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);
$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' => '<p>Hello</p>',
'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) {}
}
}

View File

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\DataSourceService;
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Application\ImapAppender;
use FecaMailshots\Application\MailCredentialsProvider;
use FecaMailshots\Application\MailshotRunService;
use FecaMailshots\Application\SmtpSender;
use FecaMailshots\Application\TemplateRenderer;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
final class FakeCreds implements MailCredentialsProvider {
public function credentials(): ?array {
return [
'smtp_host' => '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' => '<p>Hi {{ name|default(Name|default("there")) }}</p>',
'PDFAttachment' => '<p>PDF {{ id|default("none") }}</p>',
'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) {}
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Tests\Unit;
use FecaMailshots\Application\SourceMetadataProvider;
final class FakeMetadataProvider implements SourceMetadataProvider
{
/** @var array<string, list<string>> */
private array $fields;
/** @param array<string, list<string>> $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);
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
require_once __DIR__ . '/FakeMetadataProvider.php';
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
$metadata = new FakeMetadataProvider([
'contacts' => ['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";

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
require_once __DIR__ . '/FakeMetadataProvider.php';
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
$metadata = new FakeMetadataProvider([
'contacts' => ['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";

BIN
tools/composer Executable file

Binary file not shown.