Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions .github/workflows/test-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- 'feature/*'
- 'feat/*'
- 'fix/*'
pull_request:
branches:
- 'feat/*'
- 'feature/*'

jobs:
setup:
Expand Down Expand Up @@ -73,33 +77,61 @@
- name: Run Lefthook
run: pnpm lefthook run github-actions

php-matrix:
runs-on: ubuntu-latest
outputs:
php: ${{ steps.compute.outputs.php }}
min: ${{ steps.compute.outputs.min }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 1 }
- name: Compute PHP matrix from "Requires PHP" floor up to latest stable
id: compute
shell: bash
run: |
# Floor is the plugin's declared minimum in the bit_smtp.php "Requires PHP" header (currently 8.1).
floor="$(grep -ioP 'Requires PHP:\s*\K[0-9]+\.[0-9]+' bit_smtp.php | head -n1)"
if [ -z "$floor" ]; then echo "Could not parse 'Requires PHP' from bit_smtp.php" >&2; exit 1; fi
# Maintained list of stable PHP releases up to today's latest; starting at 8.1 also drops the legacy 8.0 leg.
all=(8.1 8.2 8.3 8.4)
selected=()
for v in "${all[@]}"; do
# keep v when floor <= v (version-sorted), i.e. floor is the smaller of the pair
if [ "$(printf '%s\n' "$floor" "$v" | sort -V | head -n1)" = "$floor" ]; then selected+=("$v"); fi
done
json="$(printf '%s\n' "${selected[@]}" | jq -R . | jq -cs .)"
echo "php=$json" >> "$GITHUB_OUTPUT"
echo "min=$floor" >> "$GITHUB_OUTPUT"
echo "PHP matrix: $json (floor $floor)"

php-tests:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium test

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
needs: [php-matrix]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.0', '8.1', '8.3']
php: ${{ fromJSON(needs.php-matrix.outputs.php) }}
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' || '' }}
# phpunit 12 requires PHP >= 8.3; legs below that install with the platform check ignored and skip test:unit/analyse.
- name: Install deps (ignore platform on legs < 8.3)
run: composer install --prefer-dist --no-progress ${{ fromJSON(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
- name: Bootstrap-chain compat (minimum supported version leg)
if: matrix.php == needs.php-matrix.outputs.min
run: composer compat:gate
- name: Backend 8.1 compat (8.1 and 8.3)
if: matrix.php != '8.0'
- name: Backend compat (all legs, >= 8.1)
run: composer compat
- name: Full PHP unit suite incl. golden (8.3 only)
if: matrix.php == '8.3'
- name: Full PHP unit suite incl. golden (>= 8.3)
if: ${{ fromJSON(matrix.php) >= 8.3 }}
run: composer test:unit
- name: PHPStan (8.3 only)
if: matrix.php == '8.3'
- name: PHPStan (>= 8.3)
if: ${{ fromJSON(matrix.php) >= 8.3 }}
run: composer analyse

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium test

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
25 changes: 20 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

- `frontend/src/`: React frontend code. Entry point: `main.tsx`.
- `backend/`: PHP backend code.
- `app/hooks/`: Route definitions.
- `hooks/`: Route definitions.
- `ajax.php`: AJAX routes.
- `api.php`: REST API routes.
- `app/HTTP/Controller/`: PHP controllers for handling requests.
- `app/HTTP/Controllers/`: PHP controllers for handling requests.

## Agent Workflow

Expand All @@ -22,6 +22,14 @@
- Ask for user feedback early to avoid unnecessary work and overuse of tokens.
- Adjust plan based on feedback before continuing.

## Testing

- PHP suites are split (see `phpunit.xml.dist` vs `phpunit.integration.xml`): `composer test:unit`
runs only `tests/Unit` + `tests/Golden` (no WordPress/DB, Brain\Monkey). Tests that touch `$wpdb`,
REST routes, or `get_plugins()` belong in `tests/Integration` (`composer test:integration`, needs
the docker `db`/`mailpit` services), not in the unit suite.
- Frontend tests are vitest, colocated as `*.test.tsx`. Gates: `pnpm test`, `pnpm lint`, `pnpm build`.

## Conventions

- Keep frontend and backend logic separated.
Expand All @@ -32,15 +40,22 @@
## Example

- For a new API endpoint:
- Add route in `backend/app/hooks/api.php`.
- Implement logic in a controller under `backend/app/HTTP/Controller/`.
- Add route in `backend/hooks/api.php`.
- Implement logic in a controller under `backend/app/HTTP/Controllers/`.
- Backend Configuration in `backend/app/Config.php`
- Request validator in `backend/app/HTTP/Requests`
- Request Middleware in `backend/app/HTTP/Requests`
- Request Middleware in `backend/app/HTTP/Middleware`
- Services in `backend/app/HTTP/Services`
- Update frontend in `frontend/src/` as needed.
- Frontend uses antd, typescript

---

**Always plan, implement, lint, check, and test in order. Use feedback to clarify and avoid wasted effort.**

## Maintaining this file

Keep this file for knowledge useful to almost every future agent session in this project.
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
Prefer rewriting or pruning existing entries over appending new ones.
When updating this file, preserve this bar for all agents and keep entries concise.
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<!-- Points Claude at AGENTS.md via import; edit AGENTS.md, not this file. -->

@AGENTS.md
1 change: 1 addition & 0 deletions backend/app/HTTP/Controllers/ConnectionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function save(ConnectionSaveRequest $request)
$provider = $data['provider'] ?? '';

if (!Plugin::instance()->providerRegistry()->has($provider)) {
// translators: %s: provider slug
return Response::error(\sprintf(__('Unknown provider: %s', 'bit-smtp'), $provider));
}

Expand Down
29 changes: 29 additions & 0 deletions backend/app/HTTP/Controllers/MailSourceController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace BitApps\SMTP\HTTP\Controllers;

use BitApps\SMTP\Deps\BitApps\WPKit\Http\Response;
use BitApps\SMTP\Mail\Analytics\MailAnalyticsRepository;
use BitApps\SMTP\Mail\Routing\MailSourceLabeler;
use WP_Error;

/**
* Exposes the distinct wp_mail source plugins detected at runtime for the routing condition picker.
*/
class MailSourceController
{
/**
* Returns detected source plugins as { sources: [{ value: <slug>, label: <name> }] }.
*/
public function index(): Response
{
$slugs = (new MailAnalyticsRepository())->distinctSourcePlugins();
if ($slugs instanceof WP_Error) {
return Response::error(__('Failed to load mail sources', 'bit-smtp'));
}

return Response::success(['sources' => (new MailSourceLabeler())->options($slugs)]);
}
}
43 changes: 23 additions & 20 deletions backend/app/HTTP/Controllers/OAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ protected function emit(string $html): void
header('Content-Type: text/html; charset=utf-8');
}

// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Trusted first-party page; the dynamic parts (heading, JSON payload) are escaped in page().
echo $html;

exit;
Expand Down Expand Up @@ -202,12 +203,14 @@ private function withTokens(Connection $connection, array $body): array
private function oauth2Transport(string $provider): OAuth2ProviderInterface
{
if ($provider === '' || !$this->registry()->has($provider)) {
throw new InvalidArgumentException(\sprintf(__('Unknown provider: %s', 'bit-smtp'), $provider));
// translators: %s: provider slug
throw new InvalidArgumentException(esc_html(\sprintf(__('Unknown provider: %s', 'bit-smtp'), $provider)));
}

$transport = $this->registry()->get($provider)->transport();
if (!$transport instanceof OAuth2ProviderInterface) {
throw new InvalidArgumentException(\sprintf(__('Provider does not support OAuth2: %s', 'bit-smtp'), $provider));
// translators: %s: provider slug
throw new InvalidArgumentException(esc_html(\sprintf(__('Provider does not support OAuth2: %s', 'bit-smtp'), $provider)));
}

return $transport;
Expand Down Expand Up @@ -239,24 +242,24 @@ private function page(string $heading, array $message): string
$payload = (string) json_encode($message, \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT);
$heading = htmlspecialchars($heading, \ENT_QUOTES, 'UTF-8');

return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Bit SMTP</title></head>
<body>
<p>{$heading}</p>
<script>
(function () {
var message = {$payload};
if (window.opener) {
window.opener.postMessage(message, window.location.origin);
}
window.close();
})();
</script>
</body>
</html>
HTML;
return implode("\n", [
'<!DOCTYPE html>',
'<html lang="en">',
'<head><meta charset="utf-8"><title>Bit SMTP</title></head>',
'<body>',
'<p>' . $heading . '</p>',
'<script>',
'(function () {',
' var message = ' . $payload . ';',
' if (window.opener) {',
' window.opener.postMessage(message, window.location.origin);',
' }',
' window.close();',
'})();',
'</script>',
'</body>',
'</html>',
]);
}

private function client(): ApiClient
Expand Down
2 changes: 1 addition & 1 deletion backend/app/HTTP/OAuth/OAuthCallbackRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@
public function dispatch(): void
{
$hasRequestUri = \array_key_exists('REQUEST_URI', $_SERVER);
$requestUri = $_SERVER['REQUEST_URI'] ?? null;

Check warning on line 61 in backend/app/HTTP/OAuth/OAuthCallbackRouter.php

View workflow job for this annotation

GitHub Actions / Plugin Check

WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

Detected usage of a non-sanitized input variable: $_SERVER['REQUEST_URI']

Check warning on line 61 in backend/app/HTTP/OAuth/OAuthCallbackRouter.php

View workflow job for this annotation

GitHub Actions / Plugin Check

WordPress.Security.ValidatedSanitizedInput.MissingUnslash

$_SERVER['REQUEST_URI'] not unslashed before sanitization. Use wp_unslash() or similar
$routerUri = self::staticRouterUri(\is_string($requestUri) ? $requestUri : null);

if (!self::isCallbackPath($routerUri)) {
return;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {

Check warning on line 68 in backend/app/HTTP/OAuth/OAuthCallbackRouter.php

View workflow job for this annotation

GitHub Actions / Plugin Check

WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

Detected usage of a non-sanitized input variable: $_SERVER['REQUEST_METHOD']

Check warning on line 68 in backend/app/HTTP/OAuth/OAuthCallbackRouter.php

View workflow job for this annotation

GitHub Actions / Plugin Check

WordPress.Security.ValidatedSanitizedInput.MissingUnslash

$_SERVER['REQUEST_METHOD'] not unslashed before sanitization. Use wp_unslash() or similar
status_header(405);
($this->terminate)();

Expand All @@ -92,7 +92,7 @@
return '';
}

$path = parse_url($requestUri, PHP_URL_PATH);
$path = wp_parse_url($requestUri, PHP_URL_PATH);

return \is_string($path) ? $path : '';
}
Expand Down
4 changes: 2 additions & 2 deletions backend/app/HTTP/Services/WebhookProvisioningService.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
$auth
);
if ($provisioner === null) {
throw new RuntimeException(__('This provider does not support automatic webhook creation.', 'bit-smtp'));
throw new RuntimeException(esc_html__('This provider does not support automatic webhook creation.', 'bit-smtp'));
}

$result = $provisioner->ensure($connection);
Expand Down Expand Up @@ -98,7 +98,7 @@
// provisioned unless the material persisted — throwing lets the next save retry instead of
// locking in an unverified webhook.
if (!$this->config->persistConnectionProvisioning($connection->getId(), $credentials, $settings)) {
throw new RuntimeException(__('The webhook was registered but its signature material could not be saved.', 'bit-smtp'));
throw new RuntimeException(esc_html__('The webhook was registered but its signature material could not be saved.', 'bit-smtp'));
}

return $result;
Expand Down Expand Up @@ -171,7 +171,7 @@
private function warning(Connection $connection, Throwable $e): array
{
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log -- best-effort provisioning: record the failure without failing the save
error_log('Bit SMTP webhook auto-provision failed: ' . $this->redact($e->getMessage(), $connection));

Check warning on line 174 in backend/app/HTTP/Services/WebhookProvisioningService.php

View workflow job for this annotation

GitHub Actions / Plugin Check

WordPress.PHP.DevelopmentFunctions.error_log_error_log

error_log() found. Debug code should not normally be used in production.

return [
'status' => 'warning',
Expand Down
20 changes: 20 additions & 0 deletions backend/app/Mail/Analytics/MailAnalyticsRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,26 @@ public function groups(AnalyticsQuery $query, string $dimension, int $limit)
}, $rows);
}

/**
* All-time distinct wp_mail source-plugin slugs, excluding empty and unknown senders.
*
* @return array<int,string>|WP_Error
*/
public function distinctSourcePlugins()
{
$sql = "SELECT DISTINCT source_plugin FROM `{$this->table}`
WHERE source_plugin IS NOT NULL AND source_plugin <> '' AND source_plugin <> 'unknown'
ORDER BY source_plugin ASC";
$rows = $this->rows($sql, []);
if ($rows instanceof WP_Error) {
return $rows;
}

return array_map(static function (array $row): string {
return (string) ($row['source_plugin'] ?? '');
}, $rows);
}

/**
* @return array<int,array{pattern:string,total:int}>|WP_Error
*/
Expand Down
2 changes: 1 addition & 1 deletion backend/app/Mail/Auth/AbstractAuthStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ protected function requireSecret(Connection $connection, string $key): string
$value = $this->secret($connection, $key);

if ($value === '') {
throw AuthConfigException::missing($key);
throw AuthConfigException::missing(esc_html($key));
}

return $value;
Expand Down
4 changes: 2 additions & 2 deletions backend/app/Mail/Auth/AuthorizationResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public function resolveFromConfig(array $authConfig): AuthStrategyInterface
return new AwsSigV4Strategy($this->signer, $params['service'] ?? '');

default:
throw new InvalidArgumentException("No HTTP auth strategy for type: {$type}");
throw new InvalidArgumentException(esc_html("No HTTP auth strategy for type: {$type}"));
}
}

Expand All @@ -70,7 +70,7 @@ private function resolveOAuth2(ProviderInterface $provider): AuthStrategyInterfa
$transport = $provider->transport();

if (!$transport instanceof OAuth2ProviderInterface) {
throw new InvalidArgumentException('Provider transport must implement OAuth2ProviderInterface for oauth2 auth type: ' . \get_class($transport));
throw new InvalidArgumentException(esc_html('Provider transport must implement OAuth2ProviderInterface for oauth2 auth type: ' . \get_class($transport)));
}

return new OAuth2Strategy($this->tokens, $transport);
Expand Down
4 changes: 2 additions & 2 deletions backend/app/Mail/Auth/AwsSigV4Strategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ public function apply(ApiRequest $request, Connection $connection): void
$region = (string) $connection->setting('region', '');

if (!preg_match(self::REGION_PATTERN, $region)) {
throw AuthConfigException::invalidRegion($region);
throw AuthConfigException::invalidRegion(esc_html($region));
}

$accessKey = (string) $connection->setting('access_key', '');
$secretKey = $this->secret($connection, 'secret_key');

$baseHeaders = [
'Host' => (string) parse_url($request->url, \PHP_URL_HOST),
'Host' => (string) wp_parse_url($request->url, \PHP_URL_HOST),
'Content-Type' => $request->contentType,
'X-Amz-Content-Sha256' => hash('sha256', $request->body),
];
Expand Down
2 changes: 1 addition & 1 deletion backend/app/Mail/Aws/SigV4Signer.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public static function sign(
$amzDate = $amzDate ?? gmdate('Ymd\THis\Z');
$dateStamp = substr($amzDate, 0, 8);

$urlParts = parse_url($url);
$urlParts = wp_parse_url($url);
$host = isset($headers['Host']) ? $headers['Host'] : ($urlParts['host'] ?? '');
$path = $urlParts['path'] ?? '/';
$query = $urlParts['query'] ?? '';
Expand Down
4 changes: 2 additions & 2 deletions backend/app/Mail/Config/MailSettingsSanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@
}

$url = isset($webhook['url']) && \is_scalar($webhook['url']) ? trim((string) $webhook['url']) : '';
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
$scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
if ($url !== '' && (
\strlen($url) > 2048
|| filter_var($url, FILTER_VALIDATE_URL) === false
Expand Down Expand Up @@ -349,7 +349,7 @@

private static function isSlackIncomingWebhookUrl(string $url): bool
{
$parts = parse_url($url);
$parts = wp_parse_url($url);
if (!\is_array($parts)) {
return false;
}
Expand Down Expand Up @@ -476,7 +476,7 @@
// Fail open (a not-yet-registered provider is benign) but stay loud: a known provider
// whose lookup throws would otherwise let a plaintext secret pass through settings untraced.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log -- surface without fataling the save
error_log('BIT SMTP: secret-key resolution failed: ' . $e->getMessage());

Check warning on line 479 in backend/app/Mail/Config/MailSettingsSanitizer.php

View workflow job for this annotation

GitHub Actions / Plugin Check

WordPress.PHP.DevelopmentFunctions.error_log_error_log

error_log() found. Debug code should not normally be used in production.

return [];
}
Expand Down
2 changes: 1 addition & 1 deletion backend/app/Mail/Connections/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public static function fromArray(array $data): self
{
foreach (['id', 'provider', 'kind'] as $required) {
if (!\array_key_exists($required, $data)) {
throw new InvalidArgumentException("Missing required key: {$required}");
throw new InvalidArgumentException(esc_html("Missing required key: {$required}"));
}
}

Expand Down
Loading
Loading