diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/app/Controllers/View/EmailController.php b/app/Controllers/View/EmailController.php index 16dc51a..c9b4c4f 100644 --- a/app/Controllers/View/EmailController.php +++ b/app/Controllers/View/EmailController.php @@ -170,6 +170,163 @@ class EmailController extends Controller } } + /** + * Send one email with a single To recipient and many CC recipients. + * + * @param string $recipient + * @param string[] $ccRecipients + * @param string $subject + * @param string $htmlMessage + * @param string|null $profile + * @param string|null $replyToEmail + * @param string|null $replyToName + * @param array $attachments + */ + public function sendEmailWithCc( + string $recipient, + array $ccRecipients, + string $subject, + string $htmlMessage, + ?string $profile = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + array $attachments = [] + ): bool { + $ccRecipients = array_values(array_unique(array_filter(array_map(static function ($email) { + $email = trim((string) $email); + return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + }, $ccRecipients)))); + + if ($ccRecipients === []) { + return $this->sendEmail($recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments); + } + + $autoload = APPPATH . '../vendor/autoload.php'; + if (is_file($autoload)) { + require_once $autoload; + } + + $profile = $this->resolveProfile($profile); + $cfg = $this->getProfileConfig($profile); + + if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') { + log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*."); + return false; + } + + $debugEnabled = (bool) env('MAIL_DEBUG', false); + $timeout = (int) env('MAIL_TIMEOUT', 15); + $keepAlive = (bool) env('MAIL_KEEPALIVE', false); + $verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN); + + $targetHost = $cfg['host']; + $targetPort = (int) $cfg['port']; + + $resolved = @gethostbyname($targetHost); + if (!$resolved || $resolved === $targetHost) { + log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved"); + } + + $sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5); + if (!$sockOk) { + log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host."); + } else { + fclose($sockOk); + } + + $mail = new PHPMailer(true); + + try { + if ($debugEnabled) { + ob_start(); + } + + $mail->isSMTP(); + $mail->Host = $cfg['host']; + $mail->Port = $cfg['port']; + $mail->SMTPAuth = true; + $mail->Username = $cfg['user']; + $mail->Password = $cfg['pass']; + $mail->CharSet = 'UTF-8'; + $mail->Timeout = $timeout; + $mail->SMTPKeepAlive = $keepAlive; + $mail->SMTPAutoTLS = true; + + if ($cfg['encryption'] === 'ssl') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } else { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + + $mail->SMTPOptions = [ + 'ssl' => [ + 'verify_peer' => $verifyPeer, + 'verify_peer_name' => $verifyPeer, + 'allow_self_signed' => !$verifyPeer, + ], + ]; + + $mail->SMTPDebug = $debugEnabled ? 3 : 0; + $mail->Debugoutput = 'error_log'; + + $mail->setFrom($cfg['fromEmail'], $cfg['fromName']); + if (!empty($cfg['returnPath'])) { + $mail->Sender = $cfg['returnPath']; + } + + $mail->clearReplyTos(); + $rtEmail = env('MAIL_DEFAULT_REPLY_TO'); + $rtName = env('MAIL_DEFAULT_REPLY_TO_NAME'); + if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) { + $rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']); + } + if (!$rtName) { + $rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']); + } + $rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']); + if ($rtEmail) { + $mail->addReplyTo($rtEmail, $rtName); + } + + if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) { + $mail->DKIM_domain = $cfg['dkim']['domain']; + $mail->DKIM_private = $cfg['dkim']['private']; + $mail->DKIM_selector = $cfg['dkim']['selector']; + $mail->DKIM_identity = $cfg['fromEmail']; + } + + $mail->addAddress($recipient); + foreach ($ccRecipients as $ccRecipient) { + $mail->addCC($ccRecipient); + } + + foreach ($attachments as $att) { + if (!empty($att['path']) && is_file($att['path'])) { + $mail->addAttachment($att['path'], $att['name'] ?? ''); + } + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlMessage; + + $ok = $mail->send(); + + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + if ($ok) { + log_message('info', "[mail:$profile] Sent to {$recipient} with " . count($ccRecipients) . " CC recipient(s), subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}"); + return true; + } + + log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}"); + return false; + } catch (Exception $e) { + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}"); + return false; + } + } + /** Resolve null/alias profile names to a canonical env prefix. */ private function resolveProfile(?string $profile): string { diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index 234bf96..fafa6bc 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -12,8 +12,10 @@ use App\Models\EnrollmentModel; use App\Models\InvoiceModel; use App\Models\InvoiceEventModel; use App\Models\ClassSectionModel; +use App\Models\ParentModel; use App\Models\PaymentModel; use App\Models\CalendarModel; +use App\Services\EmailService; use Config\Database; #use ??? use App\Controllers\View\InvoiceController; @@ -32,6 +34,8 @@ class EventController extends ResourceController protected $studentClassModel; protected $classSectionModel; protected $paymentModel; + protected $parentModel; + protected $emailService; protected $schoolYear; protected $semester; protected $categories; @@ -52,6 +56,8 @@ class EventController extends ResourceController $this->classSectionModel = new ClassSectionModel(); $this->paymentModel = new PaymentModel(); $this->enrollmentModel = new EnrollmentModel(); + $this->parentModel = new ParentModel(); + $this->emailService = new EmailService(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); @@ -167,7 +173,13 @@ class EventController extends ResourceController } } - return redirect()->to('/administrator/events')->with('success', 'Event created successfully'); + $redirect = redirect()->to('/administrator/events')->with('success', 'Event created successfully'); + if ($eventId && $this->request->getPost('send_email_parent')) { + $emailStatus = $this->broadcastEventToParents((int) $eventId); + $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); + } + + return $redirect; } return view('administrator/events/create_event', [ @@ -208,7 +220,12 @@ class EventController extends ResourceController ]); if ($updated) { - return redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); + $redirect = redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); + if ($this->request->getPost('send_email_parent')) { + $emailStatus = $this->broadcastEventToParents((int) $id); + $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); + } + return $redirect; } else { log_message('debug', 'GET detected'); return redirect()->back()->with('error', 'Failed to update event'); @@ -221,6 +238,106 @@ class EventController extends ResourceController ]); } + private function broadcastEventToParents(int $eventId): array + { + $event = $this->eventModel->find($eventId); + if (!$event) { + return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because the event could not be loaded.']; + } + + $emails = $this->collectParentBroadcastEmails((string) ($event['school_year'] ?? '')); + if ($emails === []) { + return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because no parent email addresses were found.']; + } + + $subject = 'School Event: ' . trim((string) ($event['event_name'] ?? 'Upcoming Event')); + $innerBody = view('emails/event_broadcast', [ + 'event' => $event, + 'eventUrl' => base_url('parent/events'), + 'flyerUrl' => !empty($event['flyer']) ? base_url('uploads/' . ltrim((string) $event['flyer'], '/')) : null, + ]); + $body = view('emails/_wrap_layout', [ + 'title' => $subject, + 'subject' => $subject, + 'body_html' => $innerBody, + ]); + + $ok = $this->emailService->sendCc($emails, $subject, $body, 'general'); + $batchCount = $this->emailService->getLastCcBatchCount(); + + return [ + 'ok' => $ok, + 'count' => count($emails), + 'batches' => $batchCount, + 'message' => $ok + ? 'Event email was sent in ' . $batchCount . ' CC batch(es) for ' . count($emails) . ' recipient(s).' + : 'The event was saved, but sending the parent CC email failed.', + ]; + } + + private function collectParentBroadcastEmails(string $schoolYear = ''): array + { + $emails = []; + + $db = Database::connect(); + + $parentRole = $db->table('roles') + ->select('id') + ->where('LOWER(name)', 'parent') + ->get() + ->getRow(); + + if ($parentRole) { + $firstParentQuery = $db->table('users') + ->select('users.email') + ->join('user_roles', 'user_roles.user_id = users.id') + ->where('user_roles.role_id', (int) $parentRole->id) + ->where('users.email IS NOT NULL', null, false) + ->where('users.email !=', ''); + + if ($schoolYear !== '' && $db->fieldExists('school_year', 'users')) { + $firstParentQuery->where('users.school_year', $schoolYear); + } + + foreach ($firstParentQuery->get()->getResultArray() as $row) { + $email = trim((string) ($row['email'] ?? '')); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[strtolower($email)] = $email; + } + } + } + + $secondParentQuery = $db->table('parents') + ->select('secondparent_email') + ->where('secondparent_email IS NOT NULL', null, false) + ->where('secondparent_email !=', ''); + + if ($schoolYear !== '' && $db->fieldExists('school_year', 'parents')) { + $secondParentQuery->where('school_year', $schoolYear); + } + + foreach ($secondParentQuery->get()->getResultArray() as $row) { + $email = trim((string) ($row['secondparent_email'] ?? '')); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[strtolower($email)] = $email; + } + } + + return array_values($emails); + } + + private function applyEventEmailFlash($redirect, array $emailStatus) + { + if (!empty($emailStatus['message'])) { + return $redirect->with( + ($emailStatus['ok'] ?? false) ? 'email_status' : 'email_error', + $emailStatus['message'] + ); + } + + return $redirect; + } + public function delete($id = null) { diff --git a/app/Services/EmailService.php b/app/Services/EmailService.php index 3a06d43..c37ae2b 100644 --- a/app/Services/EmailService.php +++ b/app/Services/EmailService.php @@ -8,6 +8,7 @@ class EmailService { protected array $senders; protected EmailController $mailer; + protected int $lastCcBatchCount = 0; public function __construct() { @@ -73,6 +74,45 @@ class EmailService return $allOk; } + /** + * Send one email with all recipients in CC. + */ + public function sendCc(array $ccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool + { + $this->lastCcBatchCount = 0; + + if (empty($ccList)) { + return true; + } + + $sender = $this->getSenderDetails($fromKey); + $to = trim((string) ($sender['email'] ?? '')); + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { + $fallback = trim((string) (getenv('SMTP_USER') ?: '')); + $to = filter_var($fallback, FILTER_VALIDATE_EMAIL) ? $fallback : 'no-reply@alrahmaisgl.org'; + } + + $attachmentsPayload = $this->normalizeAttachments($attachments); + $chunks = array_chunk(array_values($ccList), 10); + $this->lastCcBatchCount = count($chunks); + $allOk = true; + + foreach ($chunks as $index => $chunk) { + $ok = $this->mailer->sendEmailWithCc($to, $chunk, $subject, $message, $fromKey, null, null, $attachmentsPayload); + if (!$ok) { + $allOk = false; + log_message('error', 'EmailService::sendCc failed for batch ' . ($index + 1) . ' of ' . count($chunks)); + } + } + + return $allOk; + } + + public function getLastCcBatchCount(): int + { + return $this->lastCcBatchCount; + } + /** * Normalize various attachment inputs to the format expected by EmailController. */ diff --git a/app/Views/administrator/events/create_event.php b/app/Views/administrator/events/create_event.php index c95312a..6e9bf3f 100644 --- a/app/Views/administrator/events/create_event.php +++ b/app/Views/administrator/events/create_event.php @@ -87,6 +87,19 @@ +
+
Parent Email Broadcast
+
+
+ + +
+
The email is sent once. The school sender address stays in To, and all parent emails are placed in CC.
+
+
+ Cancel diff --git a/app/Views/administrator/events/edit_event.php b/app/Views/administrator/events/edit_event.php index 7f544f0..98b59e0 100644 --- a/app/Views/administrator/events/edit_event.php +++ b/app/Views/administrator/events/edit_event.php @@ -70,6 +70,19 @@ +
+
Parent Email Broadcast
+
+
+ + +
+
Use this when you want to rebroadcast the updated event to all parents in a single email.
+
+
+ Cancel diff --git a/app/Views/administrator/events/event_list.php b/app/Views/administrator/events/event_list.php index 6cc4af1..189afb3 100644 --- a/app/Views/administrator/events/event_list.php +++ b/app/Views/administrator/events/event_list.php @@ -12,6 +12,14 @@
getFlashdata('error') ?>
+ getFlashdata('email_status')): ?> +
getFlashdata('email_status') ?>
+ + + getFlashdata('email_error')): ?> +
getFlashdata('email_error') ?>
+ +
No events found.
diff --git a/app/Views/emails/event_broadcast.php b/app/Views/emails/event_broadcast.php new file mode 100644 index 0000000..a424f56 --- /dev/null +++ b/app/Views/emails/event_broadcast.php @@ -0,0 +1,38 @@ + +
+

+

Date:

+ +

Category:

+ + +

Amount: $

+ + +

+ + +
+ Event flyer for <?= esc($eventName) ?> +
+

+ Flyer: + +

+ +

For more details, please contact the school office or review the parent event page.

+

+ +

+