Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
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)]);
}
}
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
76 changes: 76 additions & 0 deletions backend/app/Mail/Routing/MailSourceLabeler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace BitApps\SMTP\Mail\Routing;

/**
* Maps runtime-detected wp_mail source-plugin slugs to human labels for the routing picker.
*
* Slugs are the plugin directory names MailSourceDetector records; labels come from the plugin
* headers exposed by get_plugins(). mu:/theme: prefixed slugs and undetected plugins pass through.
*/
final class MailSourceLabeler
{
/**
* @var array<string,string> plugin directory slug => human name
*/
private array $directory;

/**
* @param null|array<string,array<string,mixed>> $plugins get_plugins() output; injected in tests
*/
public function __construct(?array $plugins = null)
{
$this->directory = $this->indexByDirectory($plugins ?? $this->loadPlugins());
}

/**
* Turns detected source slugs into picker options carrying a friendly label.
*
* @param array<int,string> $slugs
*
* @return array<int,array{value:string,label:string}>
*/
public function options(array $slugs): array
{
return array_map(function (string $slug): array {
return ['value' => $slug, 'label' => $this->directory[$slug] ?? $slug];
}, $slugs);
}

/**
* Indexes get_plugins() by the plugin directory slug MailSourceDetector emits.
*
* @param array<string,array<string,mixed>> $plugins
*
* @return array<string,string>
*/
private function indexByDirectory(array $plugins): array
{
$directory = [];
foreach ($plugins as $file => $data) {
$slash = strpos($file, '/');
$dir = $slash === false ? $file : substr($file, 0, $slash);
$name = isset($data['Name']) && $data['Name'] !== '' ? (string) $data['Name'] : $dir;

if (!isset($directory[$dir])) {
$directory[$dir] = $name;
}
}

return $directory;
}

/**
* @return array<string,array<string,mixed>>
*/
private function loadPlugins(): array
{
if (!\function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}

return get_plugins();
}
}
2 changes: 2 additions & 0 deletions backend/hooks/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use BitApps\SMTP\HTTP\Controllers\ConnectionController;
use BitApps\SMTP\HTTP\Controllers\LogController;
use BitApps\SMTP\HTTP\Controllers\MailSettingsController;
use BitApps\SMTP\HTTP\Controllers\MailSourceController;
use BitApps\SMTP\HTTP\Controllers\NotificationController;
use BitApps\SMTP\HTTP\Controllers\OAuthController;
use BitApps\SMTP\HTTP\Controllers\ProviderController;
Expand Down Expand Up @@ -35,6 +36,7 @@
Route::post('mail/settings/save', [MailSettingsController::class, 'save']);
Route::post('mail/notifications/test', [NotificationController::class, 'test']);
Route::get('mail/providers', [ProviderController::class, 'index']);
Route::get('mail/routing/sources', [MailSourceController::class, 'index']);
Route::post('mail/connections/save', [ConnectionController::class, 'save']);
Route::post('mail/connections/webhook/create', [ConnectionController::class, 'createWebhook']);
Route::post('mail/connections/delete', [ConnectionController::class, 'delete']);
Expand Down
132 changes: 125 additions & 7 deletions frontend/src/pages/Routing/ConditionEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type EditableRoutingCondition } from '@pages/Routing/types'
import { type EditableRoutingCondition, type MailSource } from '@pages/Routing/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
Expand All @@ -11,9 +11,16 @@ const condition: EditableRoutingCondition = {
value: 'a@b.com'
}

const sources: MailSource[] = [
{ value: 'woocommerce', label: 'WooCommerce' },
{ value: 'bit-form', label: 'Bit Form' }
]

describe('ConditionEditor', () => {
it('renders the field, operator and value bound to the condition', () => {
render(<ConditionEditor condition={condition} onChange={vi.fn()} onRemove={vi.fn()} />)
render(
<ConditionEditor condition={condition} sources={sources} onChange={vi.fn()} onRemove={vi.fn()} />
)

expect(screen.getByRole('combobox', { name: 'Field' })).toBeInTheDocument()
expect(screen.getByText('Recipient')).toBeInTheDocument()
Expand All @@ -24,17 +31,21 @@ describe('ConditionEditor', () => {

it('calls onChange with the updated field when a new field is selected', async () => {
const onChange = vi.fn()
render(<ConditionEditor condition={condition} onChange={onChange} onRemove={vi.fn()} />)
render(
<ConditionEditor condition={condition} sources={sources} onChange={onChange} onRemove={vi.fn()} />
)

await userEvent.click(screen.getByRole('combobox', { name: 'Field' }))
await userEvent.click(await screen.findByTitle('Subject'))

expect(onChange).toHaveBeenCalledWith({ ...condition, field: 'subject' })
expect(onChange).toHaveBeenCalledWith({ ...condition, field: 'subject', value: 'a@b.com' })
})

it('calls onChange with the updated operator when a new operator is selected', async () => {
const onChange = vi.fn()
render(<ConditionEditor condition={condition} onChange={onChange} onRemove={vi.fn()} />)
render(
<ConditionEditor condition={condition} sources={sources} onChange={onChange} onRemove={vi.fn()} />
)

await userEvent.click(screen.getByRole('combobox', { name: 'Operator' }))
await userEvent.click(await screen.findByTitle('Contains'))
Expand All @@ -44,7 +55,9 @@ describe('ConditionEditor', () => {

it('calls onChange with the updated value when the value input changes', async () => {
const onChange = vi.fn()
render(<ConditionEditor condition={condition} onChange={onChange} onRemove={vi.fn()} />)
render(
<ConditionEditor condition={condition} sources={sources} onChange={onChange} onRemove={vi.fn()} />
)

await userEvent.type(screen.getByLabelText('Value'), 'x')

Expand All @@ -53,10 +66,115 @@ describe('ConditionEditor', () => {

it('calls onRemove when the remove button is clicked', async () => {
const onRemove = vi.fn()
render(<ConditionEditor condition={condition} onChange={vi.fn()} onRemove={onRemove} />)
render(
<ConditionEditor condition={condition} sources={sources} onChange={vi.fn()} onRemove={onRemove} />
)

await userEvent.click(screen.getByRole('button', { name: 'Remove condition' }))

expect(onRemove).toHaveBeenCalled()
})

it('renders an autocomplete of detected sources when the field is source_plugin', async () => {
const sourceCondition: EditableRoutingCondition = {
id: 'condition-2',
field: 'source_plugin',
operator: 'equals',
value: ''
}
render(
<ConditionEditor
condition={sourceCondition}
sources={sources}
onChange={vi.fn()}
onRemove={vi.fn()}
/>
)

const value = screen.getByRole('combobox', { name: 'Value' })
await userEvent.click(value)

expect(await screen.findByTitle('WooCommerce')).toBeInTheDocument()
expect(screen.getByTitle('Bit Form')).toBeInTheDocument()
})

it('emits the plugin slug when a source is picked from the autocomplete', async () => {
const onChange = vi.fn()
const sourceCondition: EditableRoutingCondition = {
id: 'condition-2',
field: 'source_plugin',
operator: 'equals',
value: ''
}
render(
<ConditionEditor
condition={sourceCondition}
sources={sources}
onChange={onChange}
onRemove={vi.fn()}
/>
)

await userEvent.click(screen.getByRole('combobox', { name: 'Value' }))
await userEvent.click(await screen.findByTitle('WooCommerce'))

expect(onChange).toHaveBeenCalledWith({ ...sourceCondition, value: 'woocommerce' })
})

it('lets an admin type a custom slug not present in the detected sources', async () => {
const onChange = vi.fn()
const sourceCondition: EditableRoutingCondition = {
id: 'condition-2',
field: 'source_plugin',
operator: 'equals',
value: ''
}
render(
<ConditionEditor
condition={sourceCondition}
sources={sources}
onChange={onChange}
onRemove={vi.fn()}
/>
)

await userEvent.type(screen.getByRole('combobox', { name: 'Value' }), 'x')

expect(onChange).toHaveBeenCalledWith({ ...sourceCondition, value: 'x' })
})

it('clears the value when switching a source_plugin condition to another field', async () => {
const onChange = vi.fn()
const sourceCondition: EditableRoutingCondition = {
id: 'condition-2',
field: 'source_plugin',
operator: 'equals',
value: 'woocommerce'
}
render(
<ConditionEditor
condition={sourceCondition}
sources={sources}
onChange={onChange}
onRemove={vi.fn()}
/>
)

await userEvent.click(screen.getByRole('combobox', { name: 'Field' }))
await userEvent.click(await screen.findByTitle('Subject'))

expect(onChange).toHaveBeenCalledWith({ ...sourceCondition, field: 'subject', value: '' })
})

it('clears the value when switching another field into source_plugin', async () => {
const onChange = vi.fn()
render(
<ConditionEditor condition={condition} sources={sources} onChange={onChange} onRemove={vi.fn()} />
)

await userEvent.click(screen.getByRole('combobox', { name: 'Field' }))
await userEvent.click(await screen.findByTitle('Source plugin'))

expect(onChange).toHaveBeenCalledWith({ ...condition, field: 'source_plugin', value: '' })
})
})
Loading
Loading