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_download_pdf_ui', [$this, 'handleDownloadUi']); } public function registerMenu(): void { $this->wp->addSubmenuPage('feca-mailshot', 'Download PDF', 'Download PDF', self::CAPABILITY, self::PAGE_SLUG, [$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->consumeOptionArray(self::RESULT_OPTION_KEY); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); echo '

Download PDF

'; echo $this->renderAdminUiStyles(); echo '

Run the selected mailshot without sending any email, and download generated PDF attachments.

'; if ($result !== null) { $ok = !empty($result['ok']); $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error'; echo '
'; echo '' . ($ok ? 'PDF generation succeeded.' : 'PDF generation failed.') . ''; if (!empty($result['errors']) && is_array($result['errors'])) { echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; } else { echo '

Generated: ' . (int) ($result['generated_count'] ?? 0) . ', Recipients: ' . (int) ($result['recipient_count'] ?? 0) . ', Skipped: ' . (int) ($result['skipped_count'] ?? 0) . '

'; } echo '
'; } echo '
'; echo ''; echo $this->hiddenNonceField(self::NONCE_ACTION); echo ''; echo '
'; echo '
'; echo ''; echo '
'; echo '

'; echo ' '; echo ''; echo '

'; echo '
'; echo 'Generating PDFs... Please wait. This can take a few seconds for larger mailshots.'; echo '
'; echo '
'; echo ''; echo '
'; } public function handleDownloadUi(): void { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $mailshotId = $this->requestInt('mailshot_id', 0); $format = $this->requestString('download_format', 'merged'); if (!in_array($format, ['merged', 'zip'], true)) { $format = 'merged'; } $this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget()); $downloadBaseName = $this->downloadBaseNameForMailshot($mailshotId); if ($format === 'zip') { try { $result = $this->runService()->generatePdfZipToTemp($mailshotId); } catch (\Throwable $e) { $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: ' . $e->getMessage()]]); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); return; } if (empty($result['ok'])) { $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); return; } $zipPath = (string) ($result['zip_path'] ?? ''); if ($zipPath === '' || !is_file($zipPath)) { $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: archive file not found.']]); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); return; } try { $this->sendFileDownload($downloadBaseName . '_pdfs.zip', 'application/zip', $zipPath); } catch (\Throwable $e) { @unlink($zipPath); $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP download failed: ' . $e->getMessage()]]); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); } return; } try { $result = $this->runService()->generatePdfBatch($mailshotId, true, false); } catch (\Throwable $e) { $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]]); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); return; } if (empty($result['ok'])) { $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); return; } $bytes = (string) ($result['merged_pdf_bytes'] ?? ''); if ($bytes === '') { $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF bytes are empty.']]); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); return; } try { $this->sendBinaryDownload($downloadBaseName . '_merged.pdf', 'application/pdf', $bytes); } catch (\Throwable $e) { $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF download failed: ' . $e->getMessage()]]); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); return; } } private function sendBinaryDownload(string $filename, string $contentType, string $bytes): void { if (headers_sent()) { throw new \RuntimeException('Cannot send download: headers already sent.'); } header('Content-Type: ' . $contentType); header('Content-Disposition: attachment; filename="' . str_replace('"', '', $filename) . '"'); header('Content-Length: ' . strlen($bytes)); header('X-Content-Type-Options: nosniff'); echo $bytes; exit; } private function sendFileDownload(string $filename, string $contentType, string $path): void { if (headers_sent()) { throw new \RuntimeException('Cannot send download: headers already sent.'); } $size = @filesize($path); if (!is_int($size) || $size <= 0) { throw new \RuntimeException('Cannot send download: file is missing or empty.'); } header('Content-Type: ' . $contentType); header('Content-Disposition: attachment; filename="' . str_replace('"', '', $filename) . '"'); header('Content-Length: ' . (string) $size); header('X-Content-Type-Options: nosniff'); $in = @fopen($path, 'rb'); if (!is_resource($in)) { throw new \RuntimeException('Cannot read download file.'); } while (!feof($in)) { $chunk = fread($in, 8192); if ($chunk === false) { fclose($in); throw new \RuntimeException('Failed while reading download file.'); } echo $chunk; } fclose($in); @unlink($path); exit; } private function runService(): MailshotRunService { return ($this->runServiceFactory)(); } private function mailshotService(): MailshotService { return ($this->mailshotServiceFactory)(); } private function downloadBaseNameForMailshot(int $mailshotId): string { try { foreach ($this->mailshotService()->list() as $mailshot) { if ((int) ($mailshot['id'] ?? 0) !== $mailshotId) { continue; } $purpose = trim((string) ($mailshot['Purpose'] ?? '')); if ($purpose !== '') { return $this->safeDownloadBaseName($purpose); } } } catch (\Throwable $e) { // Fall back to a stable id-based name if the purpose cannot be loaded. } return 'mailshot_' . max(0, $mailshotId); } private function safeDownloadBaseName(string $name): string { $name = trim($name); if ($name === '') { return 'mailshot'; } if (function_exists('iconv')) { $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name); if (is_string($ascii) && trim($ascii) !== '') { $name = $ascii; } } $name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?? ''; $name = preg_replace('/_+/', '_', $name) ?? ''; $name = trim($name, '._-'); return $name !== '' ? $name : 'mailshot'; } private function maybeRaiseMemoryLimit(string $target): void { if (!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) { return; } if ($currentBytes >= $targetBytes) { return; } $old = $current; @ini_set('memory_limit', $target); $new = (string) ini_get('memory_limit'); if ($new === $old) { return; } } private function resolveDownloadMemoryLimitTarget(): string { $optionValue = $this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, ''); if (is_string($optionValue)) { $v = trim($optionValue); if ($this->memoryLimitToBytes($v) > 0) { return $v; } } $envValue = getenv(self::DOWNLOAD_MEMORY_LIMIT_ENV); if (is_string($envValue)) { $v = trim($envValue); if ($this->memoryLimitToBytes($v) > 0) { return $v; } } return self::DEFAULT_DOWNLOAD_MEMORY_LIMIT; } private function memoryLimitToBytes(string $limit): int { $v = trim($limit); if ($v === '') { return 0; } if ($v === '-1') { return -1; } $unit = strtolower(substr($v, -1)); if (ctype_alpha($unit)) { $num = (float) substr($v, 0, -1); switch ($unit) { case 'g': return (int) ($num * 1024 * 1024 * 1024); case 'm': return (int) ($num * 1024 * 1024); case 'k': return (int) ($num * 1024); default: return (int) $num; } } return (int) $v; } }