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 '
';
}
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;
}
}