diff --git a/custom/petition-cli.php b/custom/petition-cli.php new file mode 100755 index 0000000..83746b2 --- /dev/null +++ b/custom/petition-cli.php @@ -0,0 +1,583 @@ +#!/usr/bin/env php + (int)$row[0], + 'type' => $row[1], + 'petition_id' => $row[2], + 'email' => $row[3], + 'error' => $row[5] ?? '' + ]; + } + } + + fclose($fp); + + // Sort by timestamp descending (newest first) + usort($failed, fn($a, $b) => $b['timestamp'] - $a['timestamp']); + + // Remove duplicates (keep only latest failure per email+type+petition) + $unique = []; + $seen = []; + foreach ($failed as $entry) { + $key = $entry['email'] . '|' . $entry['type'] . '|' . $entry['petition_id']; + if (!isset($seen[$key])) { + $seen[$key] = true; + $unique[] = $entry; + } + } + + return $unique; +} + +/** + * Get unconfirmed signatures from a petition CSV + */ +function getUnconfirmedSignatures(string $petitionId): array { + $csvPath = PETITIONS_DIR . '/' . $petitionId . '.csv'; + if (!file_exists($csvPath)) { + return []; + } + + $unconfirmed = []; + $fp = fopen($csvPath, 'r'); + if (!$fp) { + return []; + } + + // Skip header + fgetcsv($fp, null, ',', '"', ''); + + // CSV format: timestamp, email, firstname, surname, region, display, status, token, token_created, ip_hash + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { + if (isset($row[6]) && $row[6] === 'pending') { + $tokenCreated = isset($row[8]) ? (int)$row[8] : (int)$row[0]; + $unconfirmed[] = [ + 'timestamp' => (int)$row[0], + 'email' => $row[1], + 'firstname' => $row[2], + 'surname' => $row[3], + 'region' => $row[4], + 'display' => $row[5], + 'token' => $row[7], + 'token_created' => $tokenCreated, + 'petition_id' => $petitionId + ]; + } + } + + fclose($fp); + + // Sort by timestamp descending + usort($unconfirmed, fn($a, $b) => $b['timestamp'] - $a['timestamp']); + + return $unconfirmed; +} + +/** + * Get all unconfirmed signatures from all petitions + */ +function getAllUnconfirmedSignatures(): array { + $all = []; + foreach (getPetitionFiles() as $petitionId) { + $all = array_merge($all, getUnconfirmedSignatures($petitionId)); + } + + // Sort by timestamp descending + usort($all, fn($a, $b) => $b['timestamp'] - $a['timestamp']); + + return $all; +} + +/** + * Send a single email using PHPMailer + */ +function sendEmail(string $to, string $toName, string $subject, string $body): array { + $config = loadSmtpConfig(); + if (!$config || !$config['enabled']) { + return ['success' => false, 'error' => 'SMTP ikke konfigurert eller deaktivert']; + } + + $fromEmail = $config['petition']['from_email'] ?? $config['from_email']; + $fromName = $config['petition']['from_name'] ?? $config['from_name']; + + // Pre-flight check + $fp = @fsockopen($config['host'], $config['port'], $errno, $errstr, 10); + if (!$fp) { + return ['success' => false, 'error' => "Tilkobling feilet: {$errno} - {$errstr}"]; + } + fclose($fp); + + try { + require_once BASE_DIR . '/vendor/PHPMailer.Lite.php'; + + $mail = new \codeworxtech\PHPMailerLite\PHPMailerLite(); + + $mail->SetSMTPhost($config['host']); + $mail->SetSMTPport($config['port']); + $mail->SetSMTPuser($config['username']); + $mail->SetSMTPpass($config['password']); + + $mail->SetSender([$fromEmail => $fromName]); + $mail->AddRecipient([$to => $toName]); + $mail->SetSubject($subject); + $mail->SetBodyText($body); + + ob_start(); + $sent = @$mail->Send('smtp'); + $output = ob_get_clean(); + + if (!$sent || stripos($output, 'error') !== false || stripos($output, '✗') !== false) { + return ['success' => false, 'error' => strip_tags($output) ?: 'Sending feilet']; + } + + return ['success' => true, 'error' => '']; + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } +} + +/** + * Build confirmation email body + */ +function buildConfirmationEmail(array $signature, string $petitionTitle): array { + $confirmUrl = "https://stopplidelsen.no/underskriftskampanjer/{$signature['petition_id']}/?confirm={$signature['token']}#sign-now"; + + $body = "Hei {$signature['firstname']}!\n\n"; + $body .= "Takk for at du signerte underskriftskampanjen \"{$petitionTitle}\".\n\n"; + $body .= "Klikk lenken under for a bekrefte signaturen din:\n"; + $body .= "{$confirmUrl}\n\n"; + $body .= "Lenken utloper om 30 dager.\n\n"; + $body .= "Hvis du ikke signerte denne kampanjen, kan du trygt ignorere denne e-posten.\n\n"; + $body .= "---\n\n"; + $body .= "Du kan trekke tilbake signaturen din nar som helst ved a folge lenken i bekreftelsese-posten.\n\n"; + $body .= "Med vennlig hilsen,\n"; + $body .= "Stopp lidelsen\n"; + + return [ + 'subject' => 'Bekreft signaturen din', + 'body' => $body + ]; +} + +/** + * Log email result to SMTP log + */ +function logEmailResult(string $type, string $petitionId, string $email, bool $success, string $error = ''): void { + $fp = fopen(SMTP_LOG, 'a'); + if (!$fp) { + return; + } + + if (flock($fp, LOCK_EX)) { + fputcsv($fp, [ + time(), + $type, + $petitionId, + $email, + $success ? 'success' : 'failed', + $error + ], ',', '"', ''); + flock($fp, LOCK_UN); + } + + fclose($fp); +} + +/** + * Format timestamp for display + */ +function formatDate(int $timestamp): string { + return date('Y-m-d H:i', $timestamp); +} + +/** + * Print colored output + */ +function printColor(string $text, string $color): void { + $colors = [ + 'red' => "\033[31m", + 'green' => "\033[32m", + 'yellow' => "\033[33m", + 'blue' => "\033[34m", + 'reset' => "\033[0m" + ]; + + echo ($colors[$color] ?? '') . $text . $colors['reset']; +} + +/** + * Read user input + */ +function prompt(string $message): string { + echo $message; + return trim(fgets(STDIN)); +} + +/** + * Confirm action + */ +function confirm(string $message): bool { + $response = strtolower(prompt($message . ' [j/n]: ')); + return $response === 'j' || $response === 'ja' || $response === 'y' || $response === 'yes'; +} + +/** + * Display main menu + */ +function showMenu(): void { + echo "\n"; + printColor("=== Underskriftskampanje E-post CLI ===\n", 'blue'); + echo "\n"; + echo "Velg handling:\n"; + echo " 1) Vis mislykkede e-poster fra SMTP-logg\n"; + echo " 2) Vis ubekreftede signaturer\n"; + echo " 3) Send e-post pa nytt til mislykkede\n"; + echo " 4) Send bekreftelse pa nytt til ubekreftede\n"; + echo " 5) Avslutt\n"; + echo "\n"; +} + +/** + * List failed emails + */ +function listFailedEmails(): void { + $failed = getFailedEmails(); + + if (empty($failed)) { + printColor("\nIngen mislykkede e-poster i loggen.\n", 'green'); + return; + } + + echo "\n"; + printColor("Mislykkede e-poster (" . count($failed) . " stk):\n", 'yellow'); + echo str_repeat('-', 80) . "\n"; + + foreach ($failed as $i => $entry) { + $num = $i + 1; + $date = formatDate($entry['timestamp']); + $type = $entry['type'] === 'confirmation' ? 'Bekreftelse' : 'Takk'; + echo sprintf( + "%3d. %-35s %-12s %-20s\n Feil: %s\n", + $num, + $entry['email'], + $type, + $date, + $entry['error'] ?: '(ukjent)' + ); + } + echo str_repeat('-', 80) . "\n"; +} + +/** + * List unconfirmed signatures + */ +function listUnconfirmedSignatures(): void { + $unconfirmed = getAllUnconfirmedSignatures(); + + if (empty($unconfirmed)) { + printColor("\nIngen ubekreftede signaturer.\n", 'green'); + return; + } + + // Filter out expired tokens (older than 30 days) + $currentTime = time(); + $valid = array_filter($unconfirmed, function($sig) use ($currentTime) { + return ($currentTime - $sig['token_created']) <= 2592000; // 30 days + }); + + $expired = count($unconfirmed) - count($valid); + + echo "\n"; + printColor("Ubekreftede signaturer (" . count($valid) . " gyldige", 'yellow'); + if ($expired > 0) { + echo ", {$expired} utlopte"; + } + echo "):\n"; + echo str_repeat('-', 80) . "\n"; + + foreach ($valid as $i => $sig) { + $num = $i + 1; + $date = formatDate($sig['timestamp']); + $name = $sig['firstname'] . ' ' . $sig['surname']; + $daysLeft = 30 - floor(($currentTime - $sig['token_created']) / 86400); + + echo sprintf( + "%3d. %-35s %-20s\n %s (%d dager igjen)\n", + $num, + $sig['email'], + $name, + $date, + $daysLeft + ); + } + echo str_repeat('-', 80) . "\n"; +} + +/** + * Retry sending failed emails + */ +function retryFailedEmails(): void { + $failed = getFailedEmails(); + + if (empty($failed)) { + printColor("\nIngen mislykkede e-poster a sende pa nytt.\n", 'green'); + return; + } + + listFailedEmails(); + + if (!confirm("\nSende pa nytt til alle " . count($failed) . " adresser?")) { + echo "Avbrutt.\n"; + return; + } + + $total = count($failed); + $estimatedMinutes = ceil(($total * EMAIL_DELAY_SECONDS) / 60); + + echo "\n"; + printColor("Sender med " . EMAIL_DELAY_SECONDS . " sekunders mellomrom (maks 250/time)...\n", 'blue'); + echo "Estimert tid: ca. {$estimatedMinutes} minutter\n\n"; + + $success = 0; + $failures = 0; + + foreach ($failed as $i => $entry) { + $num = $i + 1; + echo "[{$num}/{$total}] {$entry['email']} ... "; + + // For now, we can only retry confirmation emails properly + // Thank you emails need more context that we don't have + if ($entry['type'] !== 'confirmation') { + printColor("HOPPET OVER (type: {$entry['type']})\n", 'yellow'); + continue; + } + + // We need to get the signature data from the petition CSV + $signatures = getUnconfirmedSignatures($entry['petition_id']); + $signature = null; + foreach ($signatures as $sig) { + if (strtolower($sig['email']) === strtolower($entry['email'])) { + $signature = $sig; + break; + } + } + + if (!$signature) { + printColor("HOPPET OVER (signatur ikke funnet eller allerede bekreftet)\n", 'yellow'); + continue; + } + + // Check token expiry + if ((time() - $signature['token_created']) > 2592000) { + printColor("HOPPET OVER (token utlopt)\n", 'yellow'); + continue; + } + + $email = buildConfirmationEmail($signature, $entry['petition_id']); + $result = sendEmail( + $signature['email'], + $signature['firstname'] . ' ' . $signature['surname'], + $email['subject'], + $email['body'] + ); + + if ($result['success']) { + printColor("OK\n", 'green'); + logEmailResult('confirmation', $entry['petition_id'], $signature['email'], true); + $success++; + } else { + printColor("FEILET: {$result['error']}\n", 'red'); + logEmailResult('confirmation', $entry['petition_id'], $signature['email'], false, $result['error']); + $failures++; + } + + // Wait before next email (except for last one) + if ($i < $total - 1) { + sleep(EMAIL_DELAY_SECONDS); + } + } + + echo "\n"; + printColor("Ferdig: {$success} vellykket, {$failures} mislykket\n", $failures > 0 ? 'yellow' : 'green'); +} + +/** + * Resend confirmation to unconfirmed signatures + */ +function resendToUnconfirmed(): void { + $unconfirmed = getAllUnconfirmedSignatures(); + + // Filter out expired tokens + $currentTime = time(); + $valid = array_filter($unconfirmed, function($sig) use ($currentTime) { + return ($currentTime - $sig['token_created']) <= 2592000; + }); + $valid = array_values($valid); // Re-index + + if (empty($valid)) { + printColor("\nIngen gyldige ubekreftede signaturer a sende til.\n", 'green'); + return; + } + + listUnconfirmedSignatures(); + + if (!confirm("\nSende bekreftelse pa nytt til alle " . count($valid) . " adresser?")) { + echo "Avbrutt.\n"; + return; + } + + $total = count($valid); + $estimatedMinutes = ceil(($total * EMAIL_DELAY_SECONDS) / 60); + + echo "\n"; + printColor("Sender med " . EMAIL_DELAY_SECONDS . " sekunders mellomrom (maks 250/time)...\n", 'blue'); + echo "Estimert tid: ca. {$estimatedMinutes} minutter\n\n"; + + $success = 0; + $failures = 0; + + foreach ($valid as $i => $signature) { + $num = $i + 1; + echo "[{$num}/{$total}] {$signature['email']} ... "; + + $email = buildConfirmationEmail($signature, $signature['petition_id']); + $result = sendEmail( + $signature['email'], + $signature['firstname'] . ' ' . $signature['surname'], + $email['subject'], + $email['body'] + ); + + if ($result['success']) { + printColor("OK\n", 'green'); + logEmailResult('confirmation', $signature['petition_id'], $signature['email'], true); + $success++; + } else { + printColor("FEILET: {$result['error']}\n", 'red'); + logEmailResult('confirmation', $signature['petition_id'], $signature['email'], false, $result['error']); + $failures++; + } + + // Wait before next email (except for last one) + if ($i < $total - 1) { + sleep(EMAIL_DELAY_SECONDS); + } + } + + echo "\n"; + printColor("Ferdig: {$success} vellykket, {$failures} mislykket\n", $failures > 0 ? 'yellow' : 'green'); +} + +// Main loop +function main(): void { + // Check requirements + $config = loadSmtpConfig(); + if (!$config) { + printColor("Feil: SMTP-konfigurasjon ikke funnet (smtp-config.php)\n", 'red'); + exit(1); + } + + if (!$config['enabled']) { + printColor("Advarsel: SMTP er deaktivert i konfigurasjonen\n", 'yellow'); + } + + while (true) { + showMenu(); + $choice = prompt('Valg: '); + + switch ($choice) { + case '1': + listFailedEmails(); + break; + case '2': + listUnconfirmedSignatures(); + break; + case '3': + retryFailedEmails(); + break; + case '4': + resendToUnconfirmed(); + break; + case '5': + case 'q': + case 'quit': + case 'exit': + echo "Ha det!\n"; + exit(0); + default: + printColor("Ugyldig valg. Velg 1-5.\n", 'red'); + } + } +} + +// Run +main(); diff --git a/custom/plugins/page/petition-form.php b/custom/plugins/page/petition-form.php index b54f91b..6ece4dd 100644 --- a/custom/plugins/page/petition-form.php +++ b/custom/plugins/page/petition-form.php @@ -584,6 +584,45 @@ function petitionFormatDate(int $timestamp, Context $ctx): string { return "{$day}. {$month} {$year}, {$time}"; } +/** + * Log email send attempt to SMTP log file + * Used for tracking failed sends and enabling retry via CLI + */ +function petitionLogEmail(string $type, string $petitionId, string $email, bool $success, string $errorMessage = ''): void { + $logPath = dirname(__DIR__, 2) . '/data/smtp-log.csv'; + $dir = dirname($logPath); + + if (!is_dir($dir)) { + mkdir($dir, 0750, true); + } + + $isNewFile = !file_exists($logPath); + + $fp = fopen($logPath, 'a'); + if (!$fp) { + return; + } + + if (flock($fp, LOCK_EX)) { + if ($isNewFile) { + fputcsv($fp, ['timestamp', 'type', 'petition_id', 'email', 'status', 'error_message'], ',', '"', ''); + } + + fputcsv($fp, [ + time(), + $type, + $petitionId, + $email, + $success ? 'success' : 'failed', + $errorMessage + ], ',', '"', ''); + + flock($fp, LOCK_UN); + } + + fclose($fp); +} + /** * Retry wrapper for email sending with exponential backoff * Retries up to maxRetries times with increasing delays @@ -617,26 +656,33 @@ function petitionSendWithRetry(callable $sendFunction, int $maxRetries = 3): boo /** * Send confirmation email (with retry wrapper) */ -function petitionSendConfirmationEmail(array $data, string $confirmUrl, string $petitionTitle, Context $ctx): bool { - return petitionSendWithRetry(function() use ($data, $confirmUrl, $petitionTitle, $ctx) { - return petitionSendConfirmationEmailInternal($data, $confirmUrl, $petitionTitle, $ctx); +function petitionSendConfirmationEmail(array $data, string $confirmUrl, string $petitionTitle, string $petitionId, Context $ctx): bool { + $result = petitionSendWithRetry(function() use ($data, $confirmUrl, $petitionTitle, $ctx, &$errorMessage) { + return petitionSendConfirmationEmailInternal($data, $confirmUrl, $petitionTitle, $ctx, $errorMessage); }); + + // Log the final result + petitionLogEmail('confirmation', $petitionId, $data['email'], $result, $result ? '' : ($errorMessage ?? 'Unknown error')); + + return $result; } /** * Internal: Send confirmation email (single attempt) */ -function petitionSendConfirmationEmailInternal(array $data, string $confirmUrl, string $petitionTitle, Context $ctx): bool { +function petitionSendConfirmationEmailInternal(array $data, string $confirmUrl, string $petitionTitle, Context $ctx, ?string &$errorMessage = null): bool { // Decode HTML entities for plain-text email (e.g., – → –) $petitionTitle = html_entity_decode($petitionTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $smtpConfigPath = dirname(__DIR__, 2) . '/smtp-config.php'; if (!file_exists($smtpConfigPath)) { + $errorMessage = 'SMTP config not found'; return false; } $config = require $smtpConfigPath; if (!$config['enabled']) { + $errorMessage = 'SMTP disabled'; return false; } @@ -670,7 +716,7 @@ function petitionSendConfirmationEmailInternal(array $data, string $confirmUrl, // Pre-flight check $fp = @fsockopen($config['host'], $config['port'], $errno, $errstr, 10); if (!$fp) { - // Note: Logging SMTP config only, no user data + $errorMessage = "Connection failed: {$errno} - {$errstr}"; error_log("Petition SMTP pre-flight failed: {$config['host']}:{$config['port']} - {$errno} - {$errstr}"); return false; } @@ -697,14 +743,14 @@ function petitionSendConfirmationEmailInternal(array $data, string $confirmUrl, $output = ob_get_clean(); if (!$sent || stripos($output, 'error') !== false || stripos($output, '✗') !== false) { - // Note: Output is already sanitized with strip_tags, no user data exposed + $errorMessage = strip_tags($output) ?: 'Send returned false'; error_log("Petition email send failed: " . strip_tags($output)); return false; } return true; } catch (\Exception $e) { - // Note: Exception message doesn't contain user data + $errorMessage = $e->getMessage(); error_log("Petition email exception: " . $e->getMessage()); return false; } @@ -750,32 +796,43 @@ function petitionGetSignatureByToken(string $csvPath, string $token): ?array { /** * Send thank you email with delete link (with retry wrapper) */ -function petitionSendThankYouEmail(string $token, string $deleteUrl, string $petitionTitle, string $csvPath, Context $ctx): bool { - return petitionSendWithRetry(function() use ($token, $deleteUrl, $petitionTitle, $csvPath, $ctx) { - return petitionSendThankYouEmailInternal($token, $deleteUrl, $petitionTitle, $csvPath, $ctx); +function petitionSendThankYouEmail(string $token, string $deleteUrl, string $petitionTitle, string $petitionId, string $csvPath, Context $ctx): bool { + $signature = petitionGetSignatureByToken($csvPath, $token); + $email = $signature['email'] ?? 'unknown'; + + $result = petitionSendWithRetry(function() use ($token, $deleteUrl, $petitionTitle, $csvPath, $ctx, &$errorMessage) { + return petitionSendThankYouEmailInternal($token, $deleteUrl, $petitionTitle, $csvPath, $ctx, $errorMessage); }); + + // Log the final result + petitionLogEmail('thankyou', $petitionId, $email, $result, $result ? '' : ($errorMessage ?? 'Unknown error')); + + return $result; } /** * Internal: Send thank you email with delete link (single attempt) */ -function petitionSendThankYouEmailInternal(string $token, string $deleteUrl, string $petitionTitle, string $csvPath, Context $ctx): bool { +function petitionSendThankYouEmailInternal(string $token, string $deleteUrl, string $petitionTitle, string $csvPath, Context $ctx, ?string &$errorMessage = null): bool { // Decode HTML entities for plain-text email (e.g., – → –) $petitionTitle = html_entity_decode($petitionTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $signature = petitionGetSignatureByToken($csvPath, $token); if (!$signature) { + $errorMessage = 'Signature not found'; return false; } $smtpConfigPath = dirname(__DIR__, 2) . '/smtp-config.php'; if (!file_exists($smtpConfigPath)) { + $errorMessage = 'SMTP config not found'; return false; } $config = require $smtpConfigPath; if (!$config['enabled']) { + $errorMessage = 'SMTP disabled'; return false; } @@ -805,6 +862,7 @@ function petitionSendThankYouEmailInternal(string $token, string $deleteUrl, str // Pre-flight check $fp = @fsockopen($config['host'], $config['port'], $errno, $errstr, 10); if (!$fp) { + $errorMessage = "Connection failed: {$errno} - {$errstr}"; error_log("Petition SMTP pre-flight failed: {$config['host']}:{$config['port']} - {$errno} - {$errstr}"); return false; } @@ -831,12 +889,14 @@ function petitionSendThankYouEmailInternal(string $token, string $deleteUrl, str $output = ob_get_clean(); if (!$sent || stripos($output, 'error') !== false || stripos($output, '✗') !== false) { + $errorMessage = strip_tags($output) ?: 'Send returned false'; error_log("Petition thank you email send failed: " . strip_tags($output)); return false; } return true; } catch (\Exception $e) { + $errorMessage = $e->getMessage(); error_log("Petition thank you email exception: " . $e->getMessage()); return false; } @@ -1096,7 +1156,7 @@ function petitionGetPageData(?Context $ctx): ?array { $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST']; $deleteUrl = "{$protocol}://{$host}{$langPrefix}/{$currentPath}/?delete={$token}#sign-now"; - petitionSendThankYouEmail($token, $deleteUrl, $petitionTitle, $csvPath, $ctx); + petitionSendThankYouEmail($token, $deleteUrl, $petitionTitle, $petitionId, $csvPath, $ctx); break; case 'already': $confirmMessage = ['type' => 'info', 'text' => petitionT($ctx, 'petition', 'confirm_already')]; @@ -1245,7 +1305,7 @@ function petitionGetPageData(?Context $ctx): ?array { $confirmUrl = "{$protocol}://{$host}{$langPrefix}/{$currentPath}/?confirm={$token}#sign-now"; // Send confirmation email - if (petitionSendConfirmationEmail($signatureData, $confirmUrl, $petitionTitle, $ctx)) { + if (petitionSendConfirmationEmail($signatureData, $confirmUrl, $petitionTitle, $petitionId, $ctx)) { // Subscribe to newsletter if opted in (fire and forget - don't block petition) $newsletterOptIn = isset($_POST['newsletter']) && $_POST['newsletter'] === 'on'; error_log("Newsletter checkbox: " . ($newsletterOptIn ? 'checked' : 'not checked'));