innhold/custom/petition-cli.php
Ruben 798bf268aa Add petition-specific SMTP configuration support
Allow separate SMTP account for petition emails
to improve deliverability through proper SPF/DKIM
configuration matching the from address
2026-02-01 20:10:10 +01:00

838 lines
24 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, '&#10007;') !== 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();