#!/usr/bin/env php (int)$row[0], 'type' => $row[1], 'petition_id' => $row[2], 'email' => $row[3], 'reason' => $row[4] ?? '' ]; } } fclose($fp); return $ignored; } /** * Add entry to ignore list */ function addToIgnoreList(string $type, string $petitionId, string $email, string $reason): bool { $isNewFile = !file_exists(IGNORE_LIST); $fp = fopen(IGNORE_LIST, 'a'); if (!$fp) { return false; } if (flock($fp, LOCK_EX)) { if ($isNewFile) { fputcsv($fp, ['timestamp', 'type', 'petition_id', 'email', 'reason'], ',', '"', ''); } fputcsv($fp, [ time(), $type, $petitionId, $email, $reason ], ',', '"', ''); flock($fp, LOCK_UN); fclose($fp); return true; } fclose($fp); return false; } /** * Check if entry is ignored */ function isIgnored(array $ignoreList, string $type, string $petitionId, string $email): bool { $key = $type . '|' . $petitionId . '|' . strtolower($email); return isset($ignoreList[$key]); } /** * Read failed emails from SMTP log */ function getFailedEmails(bool $excludeIgnored = true): array { if (!file_exists(SMTP_LOG)) { return []; } $ignoreList = $excludeIgnored ? loadIgnoreList() : []; $failed = []; $fp = fopen(SMTP_LOG, 'r'); if (!$fp) { return []; } // Skip header fgetcsv($fp, null, ',', '"', ''); // CSV format: timestamp, type, petition_id, email, status, error_message while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) { if (isset($row[4]) && $row[4] === 'failed') { $entry = [ 'timestamp' => (int)$row[0], 'type' => $row[1], 'petition_id' => $row[2], 'email' => $row[3], 'error' => $row[5] ?? '' ]; // Skip if ignored if ($excludeIgnored && isIgnored($ignoreList, $entry['type'], $entry['petition_id'], $entry['email'])) { continue; } $failed[] = $entry; } } 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, bool $excludeIgnored = true): array { $csvPath = PETITIONS_DIR . '/' . $petitionId . '.csv'; if (!file_exists($csvPath)) { return []; } $ignoreList = $excludeIgnored ? loadIgnoreList() : []; $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]; $entry = [ '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 ]; // Skip if ignored if ($excludeIgnored && isIgnored($ignoreList, 'unconfirmed', $petitionId, $entry['email'])) { continue; } $unconfirmed[] = $entry; } } 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(bool $excludeIgnored = true): array { $all = []; foreach (getPetitionFiles() as $petitionId) { $all = array_merge($all, getUnconfirmedSignatures($petitionId, $excludeIgnored)); } // 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']; // Get petition-specific SMTP settings (allows separate SMTP account for better deliverability) $smtpHost = $config['petition']['host'] ?? $config['host']; $smtpPort = $config['petition']['port'] ?? $config['port']; $smtpUser = $config['petition']['username'] ?? $config['username']; $smtpPass = $config['petition']['password'] ?? $config['password']; // Pre-flight check $fp = @fsockopen($smtpHost, $smtpPort, $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($smtpHost); $mail->SetSMTPport($smtpPort); $mail->SetSMTPuser($smtpUser); $mail->SetSMTPpass($smtpPass); $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", 'cyan' => "\033[36m", '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) Marker oppforinger som ignorert\n"; echo " 6) 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'); } /** * Interactive ignore marking */ function markAsIgnored(): void { echo "\n"; printColor("Marker oppforinger som ignorert\n", 'cyan'); echo "\n"; echo "Velg liste:\n"; echo " 1) Mislykkede e-poster fra SMTP-logg\n"; echo " 2) Ubekreftede signaturer\n"; echo " 3) Tilbake\n"; echo "\n"; $choice = prompt('Valg: '); if ($choice === '1') { markFailedAsIgnored(); } elseif ($choice === '2') { markUnconfirmedAsIgnored(); } } /** * Mark failed emails as ignored (one by one) */ function markFailedAsIgnored(): void { $failed = getFailedEmails(); if (empty($failed)) { printColor("\nIngen mislykkede e-poster a markere.\n", 'green'); return; } echo "\n"; printColor("Ga gjennom mislykkede e-poster en etter en.\n", 'cyan'); echo "Trykk 'j' for a ignorere, 'n' for a beholde, 's' for a hoppe over resten.\n\n"; $ignored = 0; $kept = 0; foreach ($failed as $i => $entry) { $num = $i + 1; $total = count($failed); $date = formatDate($entry['timestamp']); $type = $entry['type'] === 'confirmation' ? 'Bekreftelse' : 'Takk'; echo str_repeat('-', 60) . "\n"; echo "[{$num}/{$total}]\n"; echo " E-post: {$entry['email']}\n"; echo " Type: {$type}\n"; echo " Kampanje: {$entry['petition_id']}\n"; echo " Dato: {$date}\n"; echo " Feil: " . ($entry['error'] ?: '(ukjent)') . "\n"; $response = strtolower(prompt("\nIgnorer denne? [j/n/s]: ")); if ($response === 's' || $response === 'stopp' || $response === 'stop') { echo "Avslutter gjennomgang.\n"; break; } if ($response === 'j' || $response === 'ja' || $response === 'y' || $response === 'yes') { $reason = prompt("Grunn (valgfritt): "); if (addToIgnoreList($entry['type'], $entry['petition_id'], $entry['email'], $reason)) { printColor("Markert som ignorert.\n", 'green'); $ignored++; } else { printColor("Kunne ikke lagre til ignorliste.\n", 'red'); } } else { echo "Beholdt.\n"; $kept++; } } echo "\n"; printColor("Ferdig: {$ignored} ignorert, {$kept} beholdt\n", 'green'); } /** * Mark unconfirmed signatures as ignored (one by one) */ function markUnconfirmedAsIgnored(): void { $unconfirmed = getAllUnconfirmedSignatures(); // Include expired for review $currentTime = time(); if (empty($unconfirmed)) { printColor("\nIngen ubekreftede signaturer a markere.\n", 'green'); return; } echo "\n"; printColor("Ga gjennom ubekreftede signaturer en etter en.\n", 'cyan'); echo "Trykk 'j' for a ignorere, 'n' for a beholde, 's' for a hoppe over resten.\n\n"; $ignored = 0; $kept = 0; foreach ($unconfirmed as $i => $sig) { $num = $i + 1; $total = count($unconfirmed); $date = formatDate($sig['timestamp']); $name = $sig['firstname'] . ' ' . $sig['surname']; $daysLeft = 30 - floor(($currentTime - $sig['token_created']) / 86400); $expired = $daysLeft < 0; echo str_repeat('-', 60) . "\n"; echo "[{$num}/{$total}]"; if ($expired) { printColor(" (UTLOPT)", 'red'); } echo "\n"; echo " E-post: {$sig['email']}\n"; echo " Navn: {$name}\n"; echo " Kampanje: {$sig['petition_id']}\n"; echo " Dato: {$date}\n"; if (!$expired) { echo " Utloper: om {$daysLeft} dager\n"; } $response = strtolower(prompt("\nIgnorer denne? [j/n/s]: ")); if ($response === 's' || $response === 'stopp' || $response === 'stop') { echo "Avslutter gjennomgang.\n"; break; } if ($response === 'j' || $response === 'ja' || $response === 'y' || $response === 'yes') { $reason = prompt("Grunn (valgfritt): "); if (addToIgnoreList('unconfirmed', $sig['petition_id'], $sig['email'], $reason)) { printColor("Markert som ignorert.\n", 'green'); $ignored++; } else { printColor("Kunne ikke lagre til ignorliste.\n", 'red'); } } else { echo "Beholdt.\n"; $kept++; } } echo "\n"; printColor("Ferdig: {$ignored} ignorert, {$kept} beholdt\n", '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': markAsIgnored(); break; case '6': case 'q': case 'quit': case 'exit': echo "Ha det!\n"; exit(0); default: printColor("Ugyldig valg. Velg 1-6.\n", 'red'); } } } // Run main();