view = new View(__DIR__ . '/views'); } public function subscribe(): Response { $email = trim((string)($_POST['email'] ?? '')); $name = trim((string)($_POST['name'] ?? '')); if ($email === '') { return new Response('Missing email', 400); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return new Response('Invalid email', 400); } $limitKey = sha1(strtolower($email) . '|' . $this->clientIp()); if (RateLimiter::tooMany('newsletter_subscribe', $limitKey, 8, 600)) { return new Response('Too many requests. Please wait 10 minutes and try again.', 429); } $db = Database::get(); if ($db instanceof PDO) { $stmt = $db->prepare(" INSERT INTO ac_newsletter_subscribers (email, name, status, source) VALUES (:email, :name, 'subscribed', 'form') ON DUPLICATE KEY UPDATE name = VALUES(name), status = 'subscribed', unsubscribed_at = NULL "); $stmt->execute([ ':email' => $email, ':name' => $name !== '' ? $name : null, ]); } $this->syncMailchimp($email, $name); return new Response('Subscribed', 200); } public function unsubscribeForm(): Response { $email = trim((string)($_GET['email'] ?? '')); return new Response($this->view->render('site/unsubscribe.php', [ 'title' => 'Unsubscribe', 'email' => $email, 'status' => '', ])); } public function unsubscribe(): Response { $email = trim((string)($_POST['email'] ?? '')); $status = 'Email is required.'; if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { $limitKey = sha1(strtolower($email) . '|' . $this->clientIp()); if (RateLimiter::tooMany('newsletter_unsubscribe', $limitKey, 8, 600)) { return new Response($this->view->render('site/unsubscribe.php', [ 'title' => 'Unsubscribe', 'email' => $email, 'status' => 'Too many unsubscribe attempts. Please wait 10 minutes.', ]), 429); } } $db = Database::get(); if ($db instanceof PDO && $email !== '') { $stmt = $db->prepare("UPDATE ac_newsletter_subscribers SET status = 'unsubscribed', unsubscribed_at = NOW() WHERE email = :email"); $stmt->execute([':email' => $email]); $status = 'You have been unsubscribed.'; } return new Response($this->view->render('site/unsubscribe.php', [ 'title' => 'Unsubscribe', 'email' => $email, 'status' => $status, ])); } private function clientIp(): string { foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) { $value = trim((string)($_SERVER[$key] ?? '')); if ($value === '') { continue; } if ($key === 'HTTP_X_FORWARDED_FOR') { $parts = array_map('trim', explode(',', $value)); $value = (string)($parts[0] ?? ''); } if ($value !== '') { return substr($value, 0, 64); } } return '0.0.0.0'; } public function adminIndex(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); $campaigns = []; if ($db instanceof PDO) { $stmt = $db->query("SELECT id, title, subject, status, sent_at, scheduled_at FROM ac_newsletter_campaigns ORDER BY created_at DESC"); $campaigns = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; } return new Response($this->view->render('admin/index.php', [ 'title' => 'Newsletter', 'campaigns' => $campaigns, ])); } public function adminSubscribers(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); $subscribers = []; if ($db instanceof PDO) { $stmt = $db->query("SELECT id, email, name, status, created_at FROM ac_newsletter_subscribers ORDER BY created_at DESC"); $subscribers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; } return new Response($this->view->render('admin/subscribers.php', [ 'title' => 'Newsletter', 'subscribers' => $subscribers, ])); } public function adminEdit(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; $campaign = [ 'id' => 0, 'title' => '', 'subject' => '', 'content_html' => '', 'status' => 'draft', 'scheduled_at' => '', ]; $db = Database::get(); if ($id > 0 && $db instanceof PDO) { $stmt = $db->prepare("SELECT * FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { $campaign = $row; } } return new Response($this->view->render('admin/edit.php', [ 'title' => $id > 0 ? 'Edit Campaign' : 'New Campaign', 'campaign' => $campaign, 'error' => '', ])); } public function adminSave(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/admin/newsletter']); } $id = (int)($_POST['id'] ?? 0); $title = trim((string)($_POST['title'] ?? '')); $subject = trim((string)($_POST['subject'] ?? '')); $content = (string)($_POST['content_html'] ?? ''); $scheduledAt = trim((string)($_POST['scheduled_at'] ?? '')); if ($title === '' || $subject === '') { return $this->renderEditError($id, $title, $subject, $content, 'Title and subject are required.'); } try { $status = $scheduledAt !== '' ? 'scheduled' : 'draft'; if ($id > 0) { $stmt = $db->prepare(" UPDATE ac_newsletter_campaigns SET title = :title, subject = :subject, content_html = :content, status = :status, scheduled_at = :scheduled_at WHERE id = :id "); $stmt->execute([ ':title' => $title, ':subject' => $subject, ':content' => $content, ':status' => $status, ':scheduled_at' => $scheduledAt !== '' ? $scheduledAt : null, ':id' => $id, ]); } else { $stmt = $db->prepare(" INSERT INTO ac_newsletter_campaigns (title, subject, content_html, status, scheduled_at) VALUES (:title, :subject, :content, :status, :scheduled_at) "); $stmt->execute([ ':title' => $title, ':subject' => $subject, ':content' => $content, ':status' => $status, ':scheduled_at' => $scheduledAt !== '' ? $scheduledAt : null, ]); } } catch (Throwable $e) { return $this->renderEditError($id, $title, $subject, $content, 'Unable to save campaign.'); } return new Response('', 302, ['Location' => '/admin/newsletter']); } public function adminSend(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/admin/newsletter']); } $id = (int)($_POST['id'] ?? 0); $stmt = $db->prepare("SELECT id, subject, content_html FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); $campaign = $stmt->fetch(PDO::FETCH_ASSOC); if (!$campaign) { return new Response('', 302, ['Location' => '/admin/newsletter']); } $subStmt = $db->query("SELECT id, email FROM ac_newsletter_subscribers WHERE status = 'subscribed'"); $subs = $subStmt ? $subStmt->fetchAll(PDO::FETCH_ASSOC) : []; $settings = [ 'smtp_host' => Settings::get('smtp_host'), 'smtp_port' => Settings::get('smtp_port'), 'smtp_user' => Settings::get('smtp_user'), 'smtp_pass' => Settings::get('smtp_pass'), 'smtp_encryption' => Settings::get('smtp_encryption'), 'smtp_from_email' => Settings::get('smtp_from_email'), 'smtp_from_name' => Settings::get('smtp_from_name'), ]; foreach ($subs as $sub) { $result = Mailer::send((string)$sub['email'], (string)$campaign['subject'], (string)$campaign['content_html'], $settings); $sendStmt = $db->prepare(" INSERT INTO ac_newsletter_sends (campaign_id, subscriber_id, status, sent_at, error_text) VALUES (:campaign_id, :subscriber_id, :status, NOW(), :error_text) "); $sendStmt->execute([ ':campaign_id' => (int)$campaign['id'], ':subscriber_id' => (int)$sub['id'], ':status' => $result['ok'] ? 'sent' : 'failed', ':error_text' => $result['ok'] ? null : (string)$result['error'], ]); } $db->prepare("UPDATE ac_newsletter_campaigns SET status = 'sent', sent_at = NOW() WHERE id = :id") ->execute([':id' => $id]); return new Response('', 302, ['Location' => '/admin/newsletter']); } public function adminTestSend(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/admin/newsletter']); } $id = (int)($_POST['id'] ?? 0); $email = trim((string)($_POST['test_email'] ?? '')); if ($id <= 0 || $email === '') { return new Response('', 302, ['Location' => '/admin/newsletter/campaigns/edit?id=' . $id]); } $stmt = $db->prepare("SELECT subject, content_html FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); $campaign = $stmt->fetch(PDO::FETCH_ASSOC); if (!$campaign) { return new Response('', 302, ['Location' => '/admin/newsletter']); } $settings = [ 'smtp_host' => Settings::get('smtp_host'), 'smtp_port' => Settings::get('smtp_port'), 'smtp_user' => Settings::get('smtp_user'), 'smtp_pass' => Settings::get('smtp_pass'), 'smtp_encryption' => Settings::get('smtp_encryption'), 'smtp_from_email' => Settings::get('smtp_from_email'), 'smtp_from_name' => Settings::get('smtp_from_name'), ]; Mailer::send($email, (string)$campaign['subject'], (string)$campaign['content_html'], $settings); return new Response('', 302, ['Location' => '/admin/newsletter/campaigns/edit?id=' . $id]); } public function adminProcessQueue(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/admin/newsletter']); } $stmt = $db->prepare(" SELECT id FROM ac_newsletter_campaigns WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= NOW() "); $stmt->execute(); $campaigns = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($campaigns as $campaign) { $_POST['id'] = (int)$campaign['id']; $this->adminSend(); } return new Response('', 302, ['Location' => '/admin/newsletter']); } public function adminDeleteSubscriber(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $id = (int)($_POST['id'] ?? 0); $db = Database::get(); if ($db instanceof PDO && $id > 0) { $db->prepare("DELETE FROM ac_newsletter_subscribers WHERE id = :id")->execute([':id' => $id]); } return new Response('', 302, ['Location' => '/admin/newsletter/subscribers']); } private function renderEditError(int $id, string $title, string $subject, string $content, string $error): Response { $campaign = [ 'id' => $id, 'title' => $title, 'subject' => $subject, 'content_html' => $content, ]; return new Response($this->view->render('admin/edit.php', [ 'title' => $id > 0 ? 'Edit Campaign' : 'New Campaign', 'campaign' => $campaign, 'error' => $error, ])); } private function guard(array $roles): ?Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole($roles)) { return new Response('', 302, ['Location' => '/admin']); } return null; } private function syncMailchimp(string $email, string $name): void { $apiKey = Settings::get('mailchimp_api_key'); $listId = Settings::get('mailchimp_list_id'); if ($apiKey === '' || $listId === '') { return; } $parts = explode('-', $apiKey); $dc = $parts[1] ?? ''; if ($dc === '') { return; } $subscriberHash = md5(strtolower($email)); $url = "https://{$dc}.api.mailchimp.com/3.0/lists/{$listId}/members/{$subscriberHash}"; $payload = json_encode([ 'email_address' => $email, 'status' => 'subscribed', 'merge_fields' => [ 'FNAME' => $name, ], ]); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_USERPWD, 'user:' . $apiKey); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_exec($ch); curl_close($ch); } }