Add customizable email delay for bulk operations Fix URL typo in confirmation email Update menu options and case handling
1308 lines
39 KiB
PHP
Executable file
1308 lines
39 KiB
PHP
Executable file
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* Petition Email CLI Tool
|
|
*
|
|
* Interactive tool for managing petition emails:
|
|
* - View failed email sends from SMTP log
|
|
* - View unconfirmed signatures from petition CSVs
|
|
* - Retry sending emails with rate limiting (250/hour max)
|
|
* - Mark entries as ignored (for malformed emails etc.)
|
|
*
|
|
* Usage: php custom/petition-cli.php
|
|
*/
|
|
|
|
// Ensure CLI only
|
|
if (php_sapi_name() !== 'cli') {
|
|
die("This script can only be run from the command line.\n");
|
|
}
|
|
|
|
define('BASE_DIR', __DIR__);
|
|
define('DATA_DIR', BASE_DIR . '/data');
|
|
define('PETITIONS_DIR', DATA_DIR . '/petitions');
|
|
define('SMTP_LOG', DATA_DIR . '/smtp-log.csv');
|
|
define('IGNORE_LIST', DATA_DIR . '/petition-ignore.csv');
|
|
|
|
// Rate limit: 250 emails/hour = 1 email per 14.4 seconds, using 15 seconds
|
|
define('EMAIL_DELAY_SECONDS', 15);
|
|
|
|
/**
|
|
* Load SMTP configuration
|
|
*/
|
|
function loadSmtpConfig(): ?array {
|
|
$configPath = BASE_DIR . '/smtp-config.php';
|
|
if (!file_exists($configPath)) {
|
|
return null;
|
|
}
|
|
return require $configPath;
|
|
}
|
|
|
|
/**
|
|
* Get all petition CSV files
|
|
*/
|
|
function getPetitionFiles(): array {
|
|
if (!is_dir(PETITIONS_DIR)) {
|
|
return [];
|
|
}
|
|
|
|
$files = glob(PETITIONS_DIR . '/*.csv');
|
|
return array_map(function($f) {
|
|
return basename($f, '.csv');
|
|
}, $files);
|
|
}
|
|
|
|
/**
|
|
* Load ignore list
|
|
*/
|
|
function loadIgnoreList(): array {
|
|
if (!file_exists(IGNORE_LIST)) {
|
|
return [];
|
|
}
|
|
|
|
$ignored = [];
|
|
$fp = fopen(IGNORE_LIST, 'r');
|
|
if (!$fp) {
|
|
return [];
|
|
}
|
|
|
|
// Skip header
|
|
fgetcsv($fp, null, ',', '"', '');
|
|
|
|
// CSV format: timestamp, type, petition_id, email, reason
|
|
while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) {
|
|
if (isset($row[3])) {
|
|
// Key format: type|petition_id|email
|
|
$key = ($row[1] ?? '') . '|' . ($row[2] ?? '') . '|' . strtolower($row[3]);
|
|
$ignored[$key] = [
|
|
'timestamp' => (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/underskriftskampanje/{$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) Send bekreftelse til spesifikke e-postadresser\n";
|
|
echo " 6) Marker oppforinger som ignorert\n";
|
|
echo " 7) Manuell bekreftelse av signaturer\n";
|
|
echo " 8) Slett signaturer\n";
|
|
echo " 9) 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;
|
|
}
|
|
|
|
// Ask for custom delay
|
|
$delaySeconds = EMAIL_DELAY_SECONDS;
|
|
$customDelay = prompt("\nForsinkelse mellom e-poster i sekunder [standard: " . EMAIL_DELAY_SECONDS . "]: ");
|
|
if (!empty($customDelay) && is_numeric($customDelay) && (int)$customDelay >= 0) {
|
|
$delaySeconds = (int)$customDelay;
|
|
}
|
|
|
|
$total = count($valid);
|
|
$estimatedMinutes = $delaySeconds > 0 ? ceil(($total * $delaySeconds) / 60) : 0;
|
|
|
|
echo "\n";
|
|
printColor("Sender med " . $delaySeconds . " sekunders mellomrom...\n", 'blue');
|
|
if ($estimatedMinutes > 0) {
|
|
echo "Estimert tid: ca. {$estimatedMinutes} minutter\n\n";
|
|
} else {
|
|
echo "\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 && $delaySeconds > 0) {
|
|
sleep($delaySeconds);
|
|
}
|
|
}
|
|
|
|
echo "\n";
|
|
printColor("Ferdig: {$success} vellykket, {$failures} mislykket\n", $failures > 0 ? 'yellow' : 'green');
|
|
}
|
|
|
|
/**
|
|
* Send confirmation to specific email addresses
|
|
*/
|
|
function sendToSpecificEmails(): void {
|
|
echo "\n";
|
|
printColor("Send bekreftelse til spesifikke e-postadresser\n", 'cyan');
|
|
echo "\n";
|
|
|
|
$input = prompt("Skriv inn e-postadresse(r) (kommaseparert): ");
|
|
if (empty(trim($input))) {
|
|
echo "Ingen e-postadresser oppgitt.\n";
|
|
return;
|
|
}
|
|
|
|
// Parse and clean email addresses
|
|
$emails = array_map('trim', explode(',', $input));
|
|
$emails = array_filter($emails, fn($e) => !empty($e));
|
|
$emails = array_map('strtolower', $emails);
|
|
|
|
if (empty($emails)) {
|
|
echo "Ingen gyldige e-postadresser oppgitt.\n";
|
|
return;
|
|
}
|
|
|
|
echo "\n";
|
|
echo "Soker etter " . count($emails) . " e-postadresse(r)...\n\n";
|
|
|
|
// Find matching unconfirmed signatures
|
|
$found = [];
|
|
$currentTime = time();
|
|
|
|
foreach (getPetitionFiles() as $petitionId) {
|
|
$signatures = getUnconfirmedSignatures($petitionId, false); // Include ignored
|
|
foreach ($signatures as $sig) {
|
|
if (in_array(strtolower($sig['email']), $emails)) {
|
|
$sig['expired'] = ($currentTime - $sig['token_created']) > 2592000;
|
|
$found[] = $sig;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine which emails were not found
|
|
$foundEmails = array_map(fn($f) => strtolower($f['email']), $found);
|
|
$notFound = [];
|
|
foreach ($emails as $email) {
|
|
if (!in_array($email, $foundEmails)) {
|
|
$notFound[] = $email;
|
|
}
|
|
}
|
|
|
|
if (!empty($notFound)) {
|
|
printColor("Ikke funnet (eller allerede bekreftet):\n", 'red');
|
|
foreach ($notFound as $email) {
|
|
echo " - {$email}\n";
|
|
}
|
|
echo "\n";
|
|
}
|
|
|
|
if (empty($found)) {
|
|
echo "Ingen ubekreftede signaturer a sende til.\n";
|
|
return;
|
|
}
|
|
|
|
// Show what will be sent
|
|
printColor("Fant folgende ubekreftede signaturer:\n", 'yellow');
|
|
foreach ($found as $entry) {
|
|
$date = formatDate($entry['timestamp']);
|
|
$name = $entry['firstname'] . ' ' . $entry['surname'];
|
|
$status = $entry['expired'] ? ' [UTLOPT]' : '';
|
|
echo " - {$entry['email']} ({$name}) - {$entry['petition_id']}{$status}\n";
|
|
}
|
|
|
|
// Filter out expired
|
|
$valid = array_filter($found, fn($f) => !$f['expired']);
|
|
$valid = array_values($valid);
|
|
$expiredCount = count($found) - count($valid);
|
|
|
|
if ($expiredCount > 0) {
|
|
echo "\n";
|
|
printColor("{$expiredCount} signatur(er) har utlopt token og vil bli hoppet over.\n", 'yellow');
|
|
}
|
|
|
|
if (empty($valid)) {
|
|
echo "Ingen gyldige signaturer a sende til.\n";
|
|
return;
|
|
}
|
|
|
|
echo "\n";
|
|
if (!confirm("Sende bekreftelse til " . count($valid) . " adresse(r)?")) {
|
|
echo "Avbrutt.\n";
|
|
return;
|
|
}
|
|
|
|
// Ask for custom delay
|
|
$delaySeconds = EMAIL_DELAY_SECONDS;
|
|
$customDelay = prompt("\nForsinkelse mellom e-poster i sekunder [standard: " . EMAIL_DELAY_SECONDS . "]: ");
|
|
if (!empty($customDelay) && is_numeric($customDelay) && (int)$customDelay >= 0) {
|
|
$delaySeconds = (int)$customDelay;
|
|
}
|
|
|
|
$total = count($valid);
|
|
echo "\n";
|
|
printColor("Sender...\n", 'blue');
|
|
echo "\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 && $delaySeconds > 0) {
|
|
sleep($delaySeconds);
|
|
}
|
|
}
|
|
|
|
echo "\n";
|
|
printColor("Ferdig: {$success} vellykket, {$failures} mislykket\n", $failures > 0 ? 'yellow' : 'green');
|
|
}
|
|
|
|
/**
|
|
* Atomically modify petition CSV file with a callback function.
|
|
* The callback receives the rows array (without header) by reference and can modify it.
|
|
* Returns the result from the callback, or null on file errors.
|
|
*
|
|
* @param string $petitionId The petition ID
|
|
* @param callable $callback Function that receives (&$rows, $petitionId) and returns a result
|
|
* @return mixed|null The callback's return value, or null on error
|
|
*/
|
|
function modifyPetitionFile(string $petitionId, callable $callback): mixed {
|
|
$csvPath = PETITIONS_DIR . '/' . $petitionId . '.csv';
|
|
if (!file_exists($csvPath)) {
|
|
return null;
|
|
}
|
|
|
|
// Open with r+ to allow reading and writing, acquire exclusive lock immediately
|
|
// This matches the pattern used in petition-form.php to prevent race conditions
|
|
$fp = fopen($csvPath, 'r+');
|
|
if (!$fp) {
|
|
printColor(" Advarsel: Kunne ikke apne {$petitionId}.csv\n", 'yellow');
|
|
return null;
|
|
}
|
|
|
|
if (!flock($fp, LOCK_EX)) {
|
|
fclose($fp);
|
|
printColor(" Advarsel: Kunne ikke lase {$petitionId}.csv\n", 'yellow');
|
|
return null;
|
|
}
|
|
|
|
// Read all rows while holding the lock
|
|
$header = fgetcsv($fp, null, ',', '"', '');
|
|
if ($header === false) {
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
return null;
|
|
}
|
|
|
|
$rows = [];
|
|
while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) {
|
|
$rows[] = $row;
|
|
}
|
|
|
|
// Call the callback to modify rows
|
|
$originalCount = count($rows);
|
|
$result = $callback($rows, $petitionId);
|
|
|
|
// Write back if rows were modified (count changed or callback signals modification)
|
|
$modified = (count($rows) !== $originalCount);
|
|
if (!$modified && is_array($result) && isset($result['modified'])) {
|
|
$modified = $result['modified'];
|
|
}
|
|
|
|
if ($modified) {
|
|
rewind($fp);
|
|
ftruncate($fp, 0);
|
|
fputcsv($fp, $header, ',', '"', '');
|
|
foreach ($rows as $row) {
|
|
fputcsv($fp, $row, ',', '"', '');
|
|
}
|
|
}
|
|
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Manually confirm signatures by email address
|
|
*/
|
|
function manuallyConfirmSignatures(): void {
|
|
echo "\n";
|
|
printColor("Manuell bekreftelse av signaturer\n", 'cyan');
|
|
echo "\n";
|
|
|
|
$input = prompt("Skriv inn e-postadresse(r) (kommaseparert): ");
|
|
if (empty(trim($input))) {
|
|
echo "Ingen e-postadresser oppgitt.\n";
|
|
return;
|
|
}
|
|
|
|
// Parse and clean email addresses
|
|
$emails = array_map('trim', explode(',', $input));
|
|
$emails = array_filter($emails, fn($e) => !empty($e));
|
|
$emails = array_map('strtolower', $emails);
|
|
|
|
if (empty($emails)) {
|
|
echo "Ingen gyldige e-postadresser oppgitt.\n";
|
|
return;
|
|
}
|
|
|
|
echo "\n";
|
|
echo "Soker etter " . count($emails) . " e-postadresse(r)...\n\n";
|
|
|
|
$found = [];
|
|
|
|
// Search through all petition files
|
|
foreach (getPetitionFiles() as $petitionId) {
|
|
$result = modifyPetitionFile($petitionId, function(&$rows, $petitionId) use ($emails) {
|
|
$found = [];
|
|
$modified = false;
|
|
|
|
foreach ($rows as &$row) {
|
|
if (!isset($row[1]) || !isset($row[6])) {
|
|
continue;
|
|
}
|
|
|
|
$rowEmail = strtolower($row[1]);
|
|
if (in_array($rowEmail, $emails)) {
|
|
if ($row[6] === 'pending') {
|
|
$row[6] = 'confirmed';
|
|
$modified = true;
|
|
$found[] = [
|
|
'email' => $row[1],
|
|
'name' => ($row[2] ?? '') . ' ' . ($row[3] ?? ''),
|
|
'petition_id' => $petitionId,
|
|
'was_pending' => true
|
|
];
|
|
} else {
|
|
$found[] = [
|
|
'email' => $row[1],
|
|
'name' => ($row[2] ?? '') . ' ' . ($row[3] ?? ''),
|
|
'petition_id' => $petitionId,
|
|
'was_pending' => false,
|
|
'status' => $row[6]
|
|
];
|
|
}
|
|
}
|
|
}
|
|
unset($row);
|
|
|
|
return ['found' => $found, 'modified' => $modified];
|
|
});
|
|
|
|
if ($result && !empty($result['found'])) {
|
|
$found = array_merge($found, $result['found']);
|
|
}
|
|
}
|
|
|
|
// Determine which emails were not found
|
|
$foundEmails = array_map(fn($f) => strtolower($f['email']), $found);
|
|
$notFound = [];
|
|
foreach ($emails as $email) {
|
|
if (!in_array($email, $foundEmails)) {
|
|
$notFound[] = $email;
|
|
}
|
|
}
|
|
|
|
// Display results
|
|
if (!empty($found)) {
|
|
printColor("Funnet:\n", 'green');
|
|
foreach ($found as $entry) {
|
|
$status = $entry['was_pending']
|
|
? "BEKREFTET"
|
|
: "allerede " . $entry['status'];
|
|
echo " - {$entry['email']} ({$entry['name']}) - {$entry['petition_id']}: ";
|
|
if ($entry['was_pending']) {
|
|
printColor("{$status}\n", 'green');
|
|
} else {
|
|
printColor("{$status}\n", 'yellow');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($notFound)) {
|
|
echo "\n";
|
|
printColor("Ikke funnet:\n", 'red');
|
|
foreach ($notFound as $email) {
|
|
echo " - {$email}\n";
|
|
}
|
|
}
|
|
|
|
$confirmedCount = count(array_filter($found, fn($f) => $f['was_pending']));
|
|
echo "\n";
|
|
printColor("Ferdig: {$confirmedCount} signatur(er) bekreftet\n", 'green');
|
|
}
|
|
|
|
/**
|
|
* Manually delete signatures by email address
|
|
*/
|
|
function manuallyDeleteSignatures(): void {
|
|
echo "\n";
|
|
printColor("Slett signaturer\n", 'cyan');
|
|
echo "\n";
|
|
|
|
$input = prompt("Skriv inn e-postadresse(r) (kommaseparert): ");
|
|
if (empty(trim($input))) {
|
|
echo "Ingen e-postadresser oppgitt.\n";
|
|
return;
|
|
}
|
|
|
|
// Parse and clean email addresses
|
|
$emails = array_map('trim', explode(',', $input));
|
|
$emails = array_filter($emails, fn($e) => !empty($e));
|
|
$emails = array_map('strtolower', $emails);
|
|
|
|
if (empty($emails)) {
|
|
echo "Ingen gyldige e-postadresser oppgitt.\n";
|
|
return;
|
|
}
|
|
|
|
echo "\n";
|
|
echo "Soker etter " . count($emails) . " e-postadresse(r)...\n\n";
|
|
|
|
$found = [];
|
|
|
|
// First pass: find all matching signatures (read-only)
|
|
foreach (getPetitionFiles() as $petitionId) {
|
|
$csvPath = PETITIONS_DIR . '/' . $petitionId . '.csv';
|
|
if (!file_exists($csvPath)) {
|
|
continue;
|
|
}
|
|
|
|
$fp = fopen($csvPath, 'r');
|
|
if (!$fp) {
|
|
continue;
|
|
}
|
|
|
|
if (!flock($fp, LOCK_SH)) {
|
|
fclose($fp);
|
|
continue;
|
|
}
|
|
|
|
fgetcsv($fp, null, ',', '"', ''); // Skip header
|
|
while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) {
|
|
if (!isset($row[1])) {
|
|
continue;
|
|
}
|
|
|
|
$rowEmail = strtolower($row[1]);
|
|
if (in_array($rowEmail, $emails)) {
|
|
$found[] = [
|
|
'email' => $row[1],
|
|
'name' => ($row[2] ?? '') . ' ' . ($row[3] ?? ''),
|
|
'petition_id' => $petitionId,
|
|
'status' => $row[6] ?? 'unknown',
|
|
'timestamp' => (int)($row[0] ?? 0)
|
|
];
|
|
}
|
|
}
|
|
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
}
|
|
|
|
// Determine which emails were not found
|
|
$foundEmails = array_map(fn($f) => strtolower($f['email']), $found);
|
|
$notFound = [];
|
|
foreach ($emails as $email) {
|
|
if (!in_array($email, $foundEmails)) {
|
|
$notFound[] = $email;
|
|
}
|
|
}
|
|
|
|
if (!empty($notFound)) {
|
|
printColor("Ikke funnet:\n", 'red');
|
|
foreach ($notFound as $email) {
|
|
echo " - {$email}\n";
|
|
}
|
|
echo "\n";
|
|
}
|
|
|
|
if (empty($found)) {
|
|
echo "Ingen signaturer a slette.\n";
|
|
return;
|
|
}
|
|
|
|
// Show what will be deleted
|
|
printColor("Folgende signaturer vil bli slettet:\n", 'yellow');
|
|
foreach ($found as $entry) {
|
|
$date = formatDate($entry['timestamp']);
|
|
echo " - {$entry['email']} ({$entry['name']}) - {$entry['petition_id']} [{$entry['status']}] {$date}\n";
|
|
}
|
|
|
|
echo "\n";
|
|
if (!confirm("Er du sikker pa at du vil slette " . count($found) . " signatur(er)?")) {
|
|
echo "Avbrutt.\n";
|
|
return;
|
|
}
|
|
|
|
// Second pass: delete the signatures
|
|
$deleted = 0;
|
|
$emailsToDelete = array_map(fn($f) => strtolower($f['email']), $found);
|
|
|
|
foreach (getPetitionFiles() as $petitionId) {
|
|
$result = modifyPetitionFile($petitionId, function(&$rows, $petitionId) use ($emailsToDelete) {
|
|
$deletedCount = 0;
|
|
$rows = array_filter($rows, function($row) use ($emailsToDelete, &$deletedCount) {
|
|
if (isset($row[1]) && in_array(strtolower($row[1]), $emailsToDelete)) {
|
|
$deletedCount++;
|
|
return false; // Remove this row
|
|
}
|
|
return true; // Keep this row
|
|
});
|
|
$rows = array_values($rows); // Re-index
|
|
return ['deleted' => $deletedCount];
|
|
});
|
|
|
|
if ($result && isset($result['deleted'])) {
|
|
$deleted += $result['deleted'];
|
|
}
|
|
}
|
|
|
|
echo "\n";
|
|
printColor("Ferdig: {$deleted} signatur(er) slettet\n", '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':
|
|
sendToSpecificEmails();
|
|
break;
|
|
case '6':
|
|
markAsIgnored();
|
|
break;
|
|
case '7':
|
|
manuallyConfirmSignatures();
|
|
break;
|
|
case '8':
|
|
manuallyDeleteSignatures();
|
|
break;
|
|
case '9':
|
|
case 'q':
|
|
case 'quit':
|
|
case 'exit':
|
|
echo "Ha det!\n";
|
|
exit(0);
|
|
default:
|
|
printColor("Ugyldig valg. Velg 1-9.\n", 'red');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run
|
|
main();
|