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' => ['Create a mailshot before loading recipients.']];
if ($selectedMailshotId > 0) {
$preview = $this->runService()->previewRecipients($selectedMailshotId, 100);
}
$rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : [];
$selectedRecipientIndex = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
if ($selectedRecipientIndex < -1) {
$selectedRecipientIndex = 0;
}
$defaultEmail = $this->runService()->defaultTestEmail()['default_test_email'] ?? '';
$testEmail = (string) ($this->wp->requestParam('test_email', (string) $defaultEmail) ?? $defaultEmail);
$result = $this->result();
if ((string) ($this->wp->requestParam('render_test', '0') ?? '0') === '1') {
if ($selectedRecipientIndex < 0) {
$result = ['ok' => false, 'errors' => ['Choose a specific recipient row for Render Test.']];
} else {
try {
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
$result = $this->runService()->renderTest($selectedMailshotId, $selectedRecipientIndex);
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => ['Render test failed: ' . $e->getMessage()]];
}
}
$result['ui_action'] = 'render';
}
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
echo '
Mailshot Test
';
echo $this->renderAdminUiStyles();
echo '
Render with a selected recipient context, then optionally send one test email.
';
if ($result !== null) {
$ok = !empty($result['ok']);
$bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '
';
echo '
' . ($ok ? 'Test action succeeded.' : 'Test action failed.') . '';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '
' . htmlspecialchars(implode('; ', $result['errors'])) . '
';
}
if (!empty($result['warnings']) && is_array($result['warnings'])) {
echo '
Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '
';
}
if (!empty($result['sent_to'])) {
echo '
Sent to: ' . htmlspecialchars((string) $result['sent_to']) . '
';
}
if (!empty($result['sent_at'])) {
echo '
Sent at: ' . htmlspecialchars((string) $result['sent_at']) . '
';
}
if (($result['ui_action'] ?? '') === 'render' && !empty($result['ok']) && is_array($result['rendered'] ?? null)) {
echo '
';
}
echo '
';
}
echo '
';
echo '
1. Select Mailshot
';
echo '
';
echo '';
echo '
';
if (!empty($preview['errors'])) {
echo '
' . htmlspecialchars(implode('; ', $preview['errors'])) . '
';
}
echo '
';
echo '
';
echo '';
if ($rows !== []) {
$sampleColumns = $this->sampleColumns($rows, 4);
echo '
';
echo '
Recipient Sample (First 20)
';
echo '
';
echo '
';
}
if ($result !== null && ($result['ui_action'] ?? '') === 'render' && !empty($result['ok']) && is_array($result['rendered'] ?? null)) {
$rendered = $result['rendered'];
$recipient = $this->selectedRecipientSummary($rows, $selectedRecipientIndex);
$recipientText = $recipient['email'] ?? '';
if (($recipient['key'] ?? '') !== '' && strcasecmp((string) ($recipient['key'] ?? ''), (string) ($recipient['email'] ?? '')) !== 0) {
$recipientText .= ($recipientText !== '' ? ' | ' : '') . (string) $recipient['key'];
}
if ($recipientText === '') {
$recipientText = '(recipient unavailable)';
}
$subject = (string) ($rendered['subject'] ?? '');
$messageHtml = (string) ($rendered['message'] ?? '');
$pdfHtml = (string) ($rendered['pdf_attachment'] ?? '');
echo '
';
echo '
';
echo '
Render Preview
';
echo '
Recipient: ' . htmlspecialchars($recipientText) . '
';
echo '
Subject: ' . htmlspecialchars($subject) . '
';
echo '
';
echo '
Message (HTML)
';
echo '
PDF Attachment (HTML)
';
echo '
';
echo '
';
echo '
';
echo '';
}
echo '
';
}
public function handleApi(): void
{
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
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') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
if ($idx < 0) {
$this->wp->sendJson(['ok' => false, 'errors' => ['Choose a specific recipient row for Render Test.']]);
return;
}
$this->wp->sendJson($service->renderTest($mailshotId, $idx));
return;
}
if ($op === 'send_test') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
$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', '') ?? '');
if ($idx < 0) {
$this->wp->sendJson($service->sendTestAll($mailshotId, $to));
return;
}
$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->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
if ($idx < 0) {
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''), true);
return;
}
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''), true);
}
public function handleSendUi(): void
{
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
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', '') ?? '');
try {
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
if ($idx < 0) {
$result = $this->runService()->sendTestAll($mailshotId, $email);
} else {
$result = $this->runService()->sendTest($mailshotId, $idx, $email);
}
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
}
unset($result['rendered']);
$result['ui_action'] = 'send';
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId, $idx, $email);
}
private function redirect(int $mailshotId, int $recipientIndex, string $testEmail, bool $renderTest = false): void
{
$url = $this->wp->adminUrl('admin.php?page=feca-mailshots-test&mailshot_id=' . $mailshotId . '&recipient_index=' . $recipientIndex . '&test_email=' . rawurlencode($testEmail));
if ($renderTest) {
$url .= '&render_test=1';
}
if (!headers_sent()) {
header('Location: ' . $url, true, 302);
exit;
}
}
/** @return array|null */
private function result(): ?array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null;
}
private function maybeRaiseMemoryLimit(string $target): void
{
if ($target === '' || !function_exists('ini_get') || !function_exists('ini_set')) {
return;
}
$current = (string) ini_get('memory_limit');
$currentBytes = $this->memoryLimitToBytes($current);
$targetBytes = $this->memoryLimitToBytes($target);
if ($currentBytes < 0 || $targetBytes <= 0 || $currentBytes >= $targetBytes) {
return;
}
@ini_set('memory_limit', $target);
}
private function resolveDownloadMemoryLimitTarget(): string
{
$optionValue = $this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '');
if (is_string($optionValue)) {
$value = trim($optionValue);
if ($this->memoryLimitToBytes($value) > 0) {
return $value;
}
}
$envValue = getenv(self::DOWNLOAD_MEMORY_LIMIT_ENV);
if (is_string($envValue)) {
$value = trim($envValue);
if ($this->memoryLimitToBytes($value) > 0) {
return $value;
}
}
return '';
}
private function memoryLimitToBytes(string $limit): int
{
$value = trim($limit);
if ($value === '') {
return 0;
}
if ($value === '-1') {
return -1;
}
$unit = strtolower(substr($value, -1));
if (ctype_alpha($unit)) {
$number = (float) substr($value, 0, -1);
switch ($unit) {
case 'g':
return (int) ($number * 1024 * 1024 * 1024);
case 'm':
return (int) ($number * 1024 * 1024);
case 'k':
return (int) ($number * 1024);
default:
return (int) $number;
}
}
return (int) $value;
}
private function runService(): MailshotRunService
{
return ($this->runServiceFactory)();
}
private function mailshotService(): MailshotService
{
return ($this->mailshotServiceFactory)();
}
/**
* @param list> $rows
* @return list
*/
private function sampleColumns(array $rows, int $maxColumns): array
{
if ($rows === [] || $maxColumns <= 0) {
return [];
}
$firstRow = is_array($rows[0]['row'] ?? null) ? $rows[0]['row'] : [];
if ($firstRow === []) {
return [];
}
$firstRecipientKey = trim((string) ($rows[0]['recipient_key'] ?? ''));
$firstRecipientEmail = trim((string) ($rows[0]['recipient_email'] ?? ''));
$selected = [];
$seenValues = [];
foreach (array_keys($firstRow) as $key) {
$name = (string) $key;
if ($name === '') {
continue;
}
if ($this->isIdLikeField($name) || $this->isEmailLikeField($name) || $this->isKeyLikeField($name)) {
continue;
}
$sampleValue = trim($this->displayCellValue($firstRow[$name] ?? null));
if ($sampleValue !== '') {
$valueKey = strtolower($sampleValue);
if ($valueKey === strtolower($firstRecipientKey) || $valueKey === strtolower($firstRecipientEmail)) {
continue;
}
if (isset($seenValues[$valueKey])) {
continue;
}
$seenValues[$valueKey] = true;
}
$selected[] = $name;
if (count($selected) >= $maxColumns) {
break;
}
}
return $selected;
}
private function isIdLikeField(string $field): bool
{
$name = strtolower($field);
return $name === 'id'
|| str_ends_with($name, '.id')
|| str_ends_with($name, '_id')
|| str_contains($name, 'accountid');
}
private function isEmailLikeField(string $field): bool
{
$name = strtolower($field);
return $name === 'email' || str_ends_with($name, '.email') || str_contains($name, 'email');
}
private function isKeyLikeField(string $field): bool
{
$name = strtolower($field);
return $name === 'key'
|| str_ends_with($name, '.key')
|| str_contains($name, 'recipient_key')
|| str_contains($name, 'contact_key');
}
/** @param mixed $value */
private function displayCellValue($value): string
{
if ($value === null) {
return '';
}
if (is_scalar($value)) {
return trim((string) $value);
}
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
}
/**
* @param list> $rows
* @return array{index:int,key:string,email:string}|array{}
*/
private function selectedRecipientSummary(array $rows, int $recipientIndex): array
{
foreach ($rows as $row) {
$idx = (int) ($row['index'] ?? -1);
if ($idx !== $recipientIndex) {
continue;
}
return [
'index' => $idx,
'key' => trim((string) ($row['recipient_key'] ?? '')),
'email' => trim((string) ($row['recipient_email'] ?? '')),
];
}
return [];
}
}