Initial commit
This commit is contained in:
commit
d7e1995da3
|
|
@ -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
|
||||
|
|
@ -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`
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
```
|
||||
|
|
@ -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';
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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)();
|
||||
}
|
||||
}
|
||||
|
|
@ -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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 . '`';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FecaMailshots\Application;
|
||||
|
||||
interface MailCredentialsProvider
|
||||
{
|
||||
/** @return array<string, mixed>|null */
|
||||
public function credentials(): ?array;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '_');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FecaMailshots\Application;
|
||||
|
||||
interface SecretKeyProvider
|
||||
{
|
||||
public function keyMaterial(): string;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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`
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
✉ advertising@fenedge.co.uk<br>
|
||||
📞 01954 250082
|
||||
</p>
|
||||
|
||||
<div class="bottom-area">
|
||||
<hr>
|
||||
|
||||
<div class="registered-charity">
|
||||
Registered Charity No. 293020
|
||||
</div></div>
|
||||
|
|
@ -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 2026–2027</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 group’s 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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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];
|
||||
|
|
@ -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=<p>Body</p>
|
||||
AttachmentNames=["terms.pdf"]
|
||||
</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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";
|
||||
|
|
@ -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, '/');
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
Binary file not shown.
Loading…
Reference in New Issue