Repository navigation
feat(mail): multi-provider mail framework with connections, routing, delivery webhooks, and failure alerts - #37
Merged
abdul-kaioum merged 6 commits intoAug 20, 2026
Conversation
Replace the blind free-text box with a searchable AutoComplete listing the
wp_mail sender plugins detected at runtime, while still allowing an admin to
type a slug not yet detected. The saved rule value stays the plugin slug.
Backend adds an all-time distinct source read (MailAnalyticsRepository::
distinctSourcePlugins) excluding empty/unknown senders, a slug->name mapper
over get_plugins (MailSourceLabeler), and an admin-gated read endpoint
GET mail/routing/sources returning { sources: [{ value, label }] }.
Frontend threads the sources list RoutingRulesPage -> RoutingRuleRow ->
ConditionEditor via useMailSources, and resets condition.value when the field
switches into/out of source_plugin so no stale value leaks across modes.
Assisted-By: AI
Comment on lines
+77
to
+105
| runs-on: ubuntu-latest | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| php: ['8.0', '8.1', '8.3'] | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: { fetch-depth: 1 } | ||
| - name: Setup PHP ${{ matrix.php }} | ||
| uses: shivammathur/setup-php@v2 | ||
| with: | ||
| php-version: ${{ matrix.php }} | ||
| tools: composer:v2 | ||
| # phpunit 12 requires PHP >= 8.3, so 8.0/8.1 install with the platform check ignored and skip test:unit/analyse. | ||
| - name: Install deps (ignore platform for the 8.0/8.1 gate legs) | ||
| run: composer install --prefer-dist --no-progress ${{ matrix.php != '8.3' && '--ignore-platform-req=php' || '' }} | ||
| - name: Gate smoke (all versions) | ||
| run: php tests/gate/gate-smoke.php | ||
| - name: Bootstrap-chain 8.0 compat | ||
| run: composer compat:gate | ||
| - name: Backend 8.1 compat (8.1 and 8.3) | ||
| if: matrix.php != '8.0' | ||
| run: composer compat | ||
| - name: Full PHP unit suite incl. golden (8.3 only) | ||
| if: matrix.php == '8.3' | ||
| run: composer test:unit | ||
| - name: PHPStan (8.3 only) | ||
| if: matrix.php == '8.3' | ||
| run: composer analyse |
🔍 WordPress Plugin Check Report
📊 Report
❌ Errors (56)📁 backend/app/Mail/Providers/AbstractWebhookProvisioner.php (5 errors)
📁 backend/app/HTTP/Controllers/OAuthController.php (8 errors)
📁 backend/app/HTTP/Controllers/ConnectionController.php (1 error)
📁 languages/generatedString.php (4 errors)
📁 backend/app/Mail/Message/MimeBuilder.php (2 errors)
📁 backend/app/Mail/Providers/SendGrid/SendGridTransport.php (1 error)
📁 backend/app/Mail/Providers/ProviderRegistry.php (2 errors)
📁 backend/app/Mail/Providers/Mailgun/MailgunWebhookService.php (2 errors)
📁 backend/app/Mail/Connections/Connection.php (1 error)
📁 backend/app/Mail/OAuth/OAuth2TokenProvider.php (1 error)
📁 backend/app/Mail/Descriptor/ProviderDescriptor.php (1 error)
📁 backend/app/Mail/Descriptor/DescriptorApiTransport.php (3 errors)
📁 backend/app/Mail/Descriptor/DescriptorProvider.php (1 error)
📁 backend/app/Mail/Auth/AuthorizationResolver.php (2 errors)
📁 backend/app/Mail/Auth/AbstractAuthStrategy.php (1 error)
📁 backend/app/Mail/Auth/AwsSigV4Strategy.php (2 errors)
📁 backend/app/Mail/Support/AttachmentBuilder.php (3 errors)
📁 backend/app/Mail/Support/MultipartEncoder.php (1 error)
📁 backend/app/Mail/Support/AddressFormatter.php (1 error)
📁 backend/app/HTTP/Services/WebhookProvisioningService.php (2 errors)
📁 backend/db/Migrations/BitSmtpLogsTableMigration.php (5 errors)
📁 backend/app/Mail/Notifications/FailureNotificationMessage.php (1 error)
📁 backend/app/Mail/Notifications/Channels/SlackFailureNotificationChannel.php (1 error)
📁 backend/app/Mail/Aws/SigV4Signer.php (1 error)
📁 backend/app/Mail/Config/MailSettingsSanitizer.php (2 errors)
📁 backend/app/HTTP/OAuth/OAuthCallbackRouter.php (1 error)
📁 readme.txt (1 error)
|
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
174 |
WordPress.PHP.DevelopmentFunctions.error_log_error_log | error_log() found. Debug code should not normally be used in production. |
📁 backend/app/Mail/Config/MailSettingsSanitizer.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
479 |
WordPress.PHP.DevelopmentFunctions.error_log_error_log | error_log() found. Debug code should not normally be used in production. |
📁 backend/app/HTTP/OAuth/OAuthCallbackRouter.php (4 warnings)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
61 |
WordPress.Security.ValidatedSanitizedInput.MissingUnslash | $_SERVER['REQUEST_URI'] not unslashed before sanitization. Use wp_unslash() or similar |
61 |
WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | Detected usage of a non-sanitized input variable: $_SERVER['REQUEST_URI'] |
68 |
WordPress.Security.ValidatedSanitizedInput.MissingUnslash | $_SERVER['REQUEST_METHOD'] not unslashed before sanitization. Use wp_unslash() or similar |
68 |
WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | Detected usage of a non-sanitized input variable: $_SERVER['REQUEST_METHOD'] |
📁 readme.txt (3 warnings)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
0 |
mismatched_plugin_name | Plugin name "Bit SMTP – Easy SMTP Solution with Email Logs" is different from the name declared in plugin header "Bit SMTP". |
0 |
readme_parser_warnings_trimmed_section_description | The "Description" section is too long and was truncated. A maximum of 2500 characters is supported. |
0 |
readme_parser_warnings_trimmed_short_description | The "Short Description" section is too long and was truncated. A maximum of 150 characters is supported. |
📁 backend/app/Mail/Routing/MailSourceDetector.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
11 |
WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace | debug_backtrace() found. Debug code should not normally be used in production. |
📁 backend/app/HTTP/Services/MailConfigService.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
482 |
WordPress.PHP.DevelopmentFunctions.error_log_error_log | error_log() found. Debug code should not normally be used in production. |
📁 backend/app/HTTP/Webhook/WebhookRouter.php (6 warnings)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
56 |
WordPress.Security.ValidatedSanitizedInput.MissingUnslash | $_SERVER['REQUEST_URI'] not unslashed before sanitization. Use wp_unslash() or similar |
56 |
WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | Detected usage of a non-sanitized input variable: $_SERVER['REQUEST_URI'] |
66 |
WordPress.Security.ValidatedSanitizedInput.MissingUnslash | $_SERVER['REQUEST_METHOD'] not unslashed before sanitization. Use wp_unslash() or similar |
66 |
WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | Detected usage of a non-sanitized input variable: $_SERVER['REQUEST_METHOD'] |
74 |
WordPress.Security.ValidatedSanitizedInput.MissingUnslash | $_SERVER['CONTENT_LENGTH'] not unslashed before sanitization. Use wp_unslash() or similar |
74 |
WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | Detected usage of a non-sanitized input variable: $_SERVER['CONTENT_LENGTH'] |
🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check
abdul-kaioum
changed the base branch from
main
to
feat/multi-provider-mail-framework
August 20, 2026 07:29
Add a pull_request trigger on feat/*|feature/* bases so PRs targeting the multi-provider feature branch (not just pushes) run the PHP suite; the repo previously ran CI only on PRs to main, leaving feature-branch PRs unchecked. Replace the hardcoded php: ['8.0','8.1','8.3'] matrix with a php-matrix job that derives the floor from the "Requires PHP" header in bit_smtp.php and emits floor->latest-stable as JSON (8.1..8.4), dropping the legacy 8.0 leg. php-tests consumes it and preserves per-version behavior: legs below 8.3 install with --ignore-platform-req=php and skip test:unit/analyse (phpunit 12 needs 8.3+); compat:gate runs on the minimum leg; compat runs on all legs. Assisted-By: AI
abdul-kaioum
merged commit Aug 20, 2026
767de67
into
feat/multi-provider-mail-framework
10 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Email-routing feature (lives only on branch feat/multi-provider-mail-framework, NOT main): make the routing-rule condition whose field is 'Source plugin' offer a searchable picker of wp_mail sender plugins detected at runtime (WooCommerce, Bit Form, Contact Form 7, ...), replacing the blind free-text box, while STILL allowing an admin to type a plugin slug not yet detected. The saved rule value MUST remain the plugin slug string (routing matches on the slug), never the friendly label. Every OTHER field (recipient, from, subject) keeps the existing plain Input. Switching the field into or out of source_plugin resets condition.value to '' so no stale value leaks across modes.
Backend: MailAnalyticsRepository::distinctSourcePlugins() — all-time DISTINCT source_plugin read excluding NULL/empty/'unknown', ordered asc, static SQL, no interpolated input (reuses rows() convention). MailSourceLabeler maps slug->label via get_plugins() on the plugin dir prefix, falling back to raw slug, passing mu:/theme: through. MailSourceController::index returns { sources:[{value,label}] } mirroring ProviderController::index; route GET mail/routing/sources inside the existing cap:admin Route::group (admin-gated, read-only). Deliberate decision: did NOT add an AbilitySchemas output schema — that class is consumed only by AbilitiesProvider (WP abilities/MCP), not by plain REST controllers, so a schema here would be unconsumed dead code; ProviderController::index likewise has none.
Frontend: useMailSources hook (react-query + @common/helpers/request, GET mail/routing/sources) mirroring useProviders; MailSource {value,label}. ConditionEditor renders antd AutoComplete (searchable via filterOption on slug+label) for source_plugin, plain Input otherwise. sources threaded RoutingRulesPage->SortableRuleRow->RoutingRuleRow->ConditionEditor like the existing connections prop. Additive only.
Tests: ConditionEditor.test (AutoComplete vs Input, emits slug, allows custom slug, clears value on switch), RoutingRuleRow/RoutingRulesPage prop threading + detected-source surfacing, useMailSources.test, PHP unit MailSourceLabelerTest, PHP integration MailSourceControllerTest (distinct read, {sources} shape, admin-gated route). DB test lives in tests/Integration because composer test:unit only runs tests/Unit+tests/Golden.
Constraint: PR MUST target base feat/multi-provider-mail-framework, never main. Local gates already green: pnpm test/lint/build, composer test:unit/lint/compat/analyse.
What Changed
backend/app/Connectors/SmtpConfig.phpand the Homepage SMTP form) with a multi-provider Mail domain underbackend/app/Mail/: 14 providers (Amazon SES, Gmail, Microsoft 365, SendGrid, Mailgun, Brevo, Postmark, Resend, Mailjet, SparkPost, ZeptoMail, Cloudflare, Other SMTP, PHP sendmail) built on descriptor/transport/auth-strategy layers, AES-256-GCM credential encryption at rest, an OAuth2 connect flow, and a Connections management UI with drag-ordered fallback.wp_mailby source plugin, recipient, subject, or from (drag-reorderable rules), provider delivery-status webhooks with per-provider signature verification and event rollup surfaced on mail-log rows, failure-notification alerts across email/Slack/Telegram/webhook channels, and mail analytics exposed through WP abilities.wp_mailsender plugins detected at runtime — new admin-gatedGET mail/routing/sourcesroute backed byMailSourceController/MailSourceLabeler, with the saved rule value kept as the plugin slug while still allowing custom slugs — and add split PHPUnit unit/integration/golden suites, a PHPStan level-6 baseline, and a Playwright e2e suite wired into CI.Risk Assessment
✅ Low: Additive, well-bounded feature (searchable source-plugin picker) that fully conforms to the stated intent, uses static non-interpolated SQL backed by an existing index, keeps the saved value as the slug, and is covered by behavior-based FE and PHP tests with no correctness, security, or performance defect found.
Testing
Baseline setup (pnpm install, composer install) plus targeted automated tests all pass: 4 Routing vitest suites (25 tests) prove the AutoComplete-vs-Input switch, slug emission, custom-slug entry, value-reset on field-mode change, prop threading, and the mail/routing/sources hook; the PHP unit MailSourceLabelerTest (3 tests) proves slug->label mapping and the raw-slug fallbacks. For the UI-facing intent I rendered the real ConditionEditor in a browser via Vite + Playwright and captured two reviewer-visible screenshots showing the searchable picker (WooCommerce/Bit Form/Contact Form 7), label+slug filtering, an accepted custom typed slug, the saved value remaining the slug string, and the recipient field keeping its plain Input. The PHP integration test (DB DISTINCT read + admin-gated route) was not run because it depends on the project's docker composer test:integration harness (DB :3307 + WordPress core) absent from this isolated worktree and not gated by CI; that slice is owned by the docker path / outer pipeline. Temp demo files and the dev server were removed; the worktree is clean and evidence screenshots remain in the evidence directory./home/ar2/.no-mistakes/evidence/01M0EZ5MSSCGR9P58WXE7MA90E/picker-open-dropdown.png)/home/ar2/.no-mistakes/evidence/01M0EZ5MSSCGR9P58WXE7MA90E/picker-filtered-and-custom.png)Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
tests/Integration/MailSourceControllerTest.php:1- PHP integration test MailSourceControllerTest (backend DISTINCT source_plugin read excluding null/empty/'unknown' ordered asc, {sources:[{value,label}]} shape, and admin-gated GET mail/routing/sources route registration) was reviewed but not executed here: it requires the project's dockercomposer test:integrationharness (test DB on 127.0.0.1:3307, currently down) plus a WordPress core checkout (absent in this isolated worktree). No CI workflow runs test:integration, so this slice is owned by that docker path / outer pipeline. The user-facing picker intent and the labeler slug->label logic are fully covered by the frontend behavior tests, visual evidence, and the passing PHP unit test, so this does not block demonstrating the change.npx vitest --watch=false frontend/src/pages/Routing/ConditionEditor.test.tsx frontend/src/pages/Routing/RoutingRuleRow.test.tsx frontend/src/pages/Routing/RoutingRulesPage.test.tsx frontend/src/pages/Routing/data/useMailSources.test.tsx— 4 files, 25 tests passed./vendor/bin/phpunit -c phpunit.xml.dist --filter MailSourceLabeler— 3 tests passedManual visual: served the real ConditionEditor via a throwaway Vite entry and drove it with Playwright — confirmed source_plugin field renders an antd AutoComplete picker (value=''), recipient field renders the unchanged plain Input; captured screenshot picker-open-dropdown.pngManual visual: typed 'form' into the source_plugin picker — dropdown filtered to Bit Form + Contact Form 7 (WooCommerce dropped, matching on slug+label) while saved condition.value became the literal typed string 'form' (custom slug allowed, value is the slug not the label); captured screenshot picker-filtered-and-custom.pngReviewed tests/Integration/MailSourceControllerTest.php; confirmed it requires docker DB :3307 (down) + WP core (absent) so it runs only under composer test:integration🔧 **Document** - 1 issue found → auto-fixed ✅
AGENTS.md:47- AGENTS.md 'Example' lists 'Request Middleware in backend/app/HTTP/Requests', but middleware actually lives in backend/app/HTTP/Middleware/. Pre-existing factual error NOT surfaced by this change (no middleware added), so left untouched to keep scope tight; worth a small follow-up fix.🔧 Fix: correct stale middleware path in AGENTS.md example
✅ Re-checked - no issues remain.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.