'required|string|max:255', 'type' => 'required|in:local_newsletter,general_newsletter,system_message,general_newsletter_only', 'subjects' => 'required|array|min:1', 'selectedPosts' => 'array', 'scheduledAt' => 'nullable|date|after:now', 'filterByProfileType' => 'boolean', 'selectedProfileTypes' => 'array', 'selectedProfileTypes.*' => 'string|in:User,Organization,Bank,Admin', 'filterByContacts' => 'boolean', 'selectedContactProfiles' => 'array', 'filterByLocation' => 'boolean', 'selectedCountryIds' => 'array', 'selectedCountryIds.*' => 'integer|exists:countries,id', 'selectedDivisionIds' => 'array', 'selectedDivisionIds.*' => 'integer|exists:divisions,id', 'selectedCityIds' => 'array', 'selectedCityIds.*' => 'integer|exists:cities,id', 'selectedDistrictIds' => 'array', 'selectedDistrictIds.*' => 'integer|exists:districts,id', 'customTestEmail' => 'nullable|string', ]; public function rules(): array { $supportedLocales = array_keys(config('laravellocalization.supportedLocales', [])); // Subjects are required for locales that have both content and recipients, // and optional (but validated if filled) for other content locales. $subjectRules = []; foreach ($this->availableLocales as $locale) { if (in_array($locale, $this->requiredLocales)) { $subjectRules["subjects.{$locale}"] = 'required|string|max:255'; } else { $subjectRules["subjects.{$locale}"] = 'nullable|string|max:255'; } } return array_merge($this->rules, $subjectRules, [ 'fallbackLocale' => ['nullable', 'string', 'max:10', 'in:' . implode(',', array_merge([''], $supportedLocales))], ]); } public function mount() { // Admin Authorization - Prevent IDOR attacks and cross-guard access $activeProfileType = session('activeProfileType'); $activeProfileId = session('activeProfileId'); if (!$activeProfileType || !$activeProfileId) { abort(403, __('No active profile selected')); } $profile = $activeProfileType::find($activeProfileId); if (!$profile) { abort(403, __('Active profile not found')); } // Validate profile ownership using ProfileAuthorizationHelper (prevents cross-guard attacks) \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Verify admin or central bank permissions if ($profile instanceof \App\Models\Admin) { // Require at least view or manage mailings permission $webUser = \Auth::guard('web')->user(); if (!$webUser || !$webUser->can('access mailings')) { abort(403, __('You do not have permission to access mailings.')); } } elseif ($profile instanceof \App\Models\Bank) { // Only central bank (level 0) can access mailing management if ($profile->level !== 0) { abort(403, __('Central bank access required for mailing management')); } } else { abort(403, __('Admin or central bank access required')); } // Log admin access for security monitoring \Log::info('Mailings management access', [ 'component' => 'Mailings\\Manage', 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'authenticated_guard' => \Auth::getDefaultDriver(), 'ip_address' => request()->ip(), ]); // Initialize estimated recipient count to 0 since no type is selected initially $this->estimatedRecipientCount = 0; } public function updatedPage() { $this->dispatch('scroll-to-top'); } public function render() { $mailingsQuery = Mailing::with(['updatedByUser:id,name,full_name,profile_photo_path']) ->when($this->search, function ($query) { $query->where(function ($q) { $q->where('title', 'like', '%' . $this->search . '%') ->orWhere('subject', 'like', '%' . $this->search . '%') ->orWhere('id', is_numeric($this->search) ? (int) $this->search : -1); }); }) ->when($this->typeFilter, function ($query) { $query->where('type', $this->typeFilter); }) ->when($this->statusFilter, function ($query) { $query->where('status', $this->statusFilter); }) ->orderBy( in_array($this->sortField, ['title', 'status', 'type', 'created_at', 'updated_at', 'id']) ? $this->sortField : 'created_at', $this->sortDirection === 'asc' ? 'asc' : 'desc' ); $mailings = $mailingsQuery->paginate(15); // Polls for only part of the members: the mailing reaches others too (security audit F8) $limitedPollPostIds = \App\Models\Poll::whereIn('post_id', collect($this->selectedPosts)->pluck('post_id')->all()) ->get() ->filter->hasLimitedAudience() ->pluck('post_id') ->all(); return view('livewire.mailings.manage', [ 'mailings' => $mailings, 'availablePosts' => $this->getAvailablePosts(), 'limitedPollPostIds' => $limitedPollPostIds, ]); } public function updatedSearch() { $this->resetPage(); } public function updatedTypeFilter() { $this->resetPage(); } public function updatedStatusFilter() { $this->resetPage(); } public function sortBy($field) { if ($this->sortField === $field) { $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; } else { $this->sortDirection = 'asc'; } $this->sortField = $field; } public function openCreateModal() { $this->resetForm(); $this->updateAvailableLocales(); // Initialize with base language $this->showCreateModal = true; $this->dispatch('flatpickr-set-date', value: null); } public function openEditModal($mailingId) { $this->editingMailing = Mailing::findOrFail($mailingId); // Can only edit drafts and scheduled mailings if (!$this->editingMailing->canBeEdited()) { $this->notification([ 'title' => __('Error'), 'description' => __('Only draft and scheduled mailings can be edited.'), 'icon' => 'error' ]); return; } $this->title = $this->editingMailing->title; $this->type = $this->editingMailing->type; $this->subjects = $this->editingMailing->getAllSubjects(); // Load content blocks and enrich with post titles $this->selectedPosts = $this->loadContentBlocksWithTitles($this->editingMailing->content_blocks ?? []); // Load profile type filter settings $this->filterByProfileType = $this->editingMailing->filter_by_profile_type ?? false; $this->selectedProfileTypes = $this->editingMailing->selected_profile_types ?? []; // Load contact filter settings $this->filterByContacts = $this->editingMailing->filter_by_contacts ?? false; $this->selectedContactProfiles = $this->resolveContactProfilesToDisplay($this->editingMailing->contact_profile_ids ?? []); // Load fallback locale setting (updateAvailableLocales called below will set fallbackEligibleLocales) $this->fallbackLocale = $this->editingMailing->fallback_locale ?? ''; // Load location filter settings $this->filterByLocation = $this->editingMailing->filter_by_location ?? false; // Load full multi-select arrays; fall back to singular column for old records $this->selectedCountryIds = !empty($this->editingMailing->location_country_ids) ? $this->editingMailing->location_country_ids : ($this->editingMailing->location_country_id ? [$this->editingMailing->location_country_id] : []); $this->selectedDivisionIds = !empty($this->editingMailing->location_division_ids) ? $this->editingMailing->location_division_ids : ($this->editingMailing->location_division_id ? [$this->editingMailing->location_division_id] : []); $this->selectedCityIds = !empty($this->editingMailing->location_city_ids) ? $this->editingMailing->location_city_ids : ($this->editingMailing->location_city_id ? [$this->editingMailing->location_city_id] : []); $this->selectedDistrictIds = !empty($this->editingMailing->location_district_ids) ? $this->editingMailing->location_district_ids : ($this->editingMailing->location_district_id ? [$this->editingMailing->location_district_id] : []); // Load available locales from posts $this->updateAvailableLocales(); $this->scheduledAt = $this->editingMailing->scheduled_at?->format('Y-m-d\TH:i'); $this->showEditModal = true; // Hydrate the location filter child component with saved data via event // (props on tags are ignored after initial mount when wire:ignore is used) if ($this->filterByLocation) { $this->dispatch('loadLocationFilterData', data: [ 'countries' => $this->selectedCountryIds, 'divisions' => $this->selectedDivisionIds, 'cities' => $this->selectedCityIds, 'districts' => $this->selectedDistrictIds, ]); } // Hydrate the flatpickr with the saved scheduled date $this->dispatch('flatpickr-set-date', value: $this->scheduledAt); } public function closeModals() { $this->showCreateModal = false; $this->showEditModal = false; $this->showPreviewModal = false; $this->showTestMailModal = false; $this->showTestEmailSelectionModal = false; $this->showBulkDeleteModal = false; $this->showSendConfirmModal = false; $this->showStopConfirmModal = false; $this->mailingToStop = null; $this->showReportModal = false; $this->mailingForReport = null; $this->showPostSelector = false; $this->resetForm(); } public function resetForm() { $this->title = ''; $this->type = ''; $this->subjects = []; $this->selectedPosts = []; $this->scheduledAt = null; $this->editingMailing = null; $this->selectedPostIds = []; $this->availableLocales = []; $this->requiredLocales = []; $this->filterByProfileType = false; $this->selectedProfileTypes = []; $this->filterByContacts = false; $this->selectedContactProfiles = []; $this->filterByLocation = false; $this->selectedCountryIds = []; $this->selectedDivisionIds = []; $this->selectedCityIds = []; $this->selectedDistrictIds = []; $this->estimatedRecipientCount = 0; $this->fallbackLocale = ''; $this->fallbackEligibleLocales = []; // Reset test email selection properties $this->sendToAuthUser = false; $this->sendToActiveProfile = false; $this->customTestEmail = ''; $this->mailingForTest = null; $this->availableTestEmails = []; $this->resetValidation(); } public function clearProfileTypes() { $this->selectedProfileTypes = []; $this->updateEstimatedRecipients(); } public function updatedFilterByContacts() { if (!$this->filterByContacts) { $this->selectedContactProfiles = []; } $this->updateEstimatedRecipients(); } #[On('selectedProfiles')] public function handleSelectedContactProfiles(array $profiles = []) { // Child dispatches a single profile object; parent appends and deduplicates if (empty($profiles)) { return; } $alreadySelected = collect($this->selectedContactProfiles)->contains( fn($p) => $p['id'] === $profiles['id'] && $p['type'] === $profiles['type'] ); if (!$alreadySelected) { $this->selectedContactProfiles[] = $profiles; } $this->updateEstimatedRecipients(); } public function removeContactProfile($index) { unset($this->selectedContactProfiles[$index]); $this->selectedContactProfiles = array_values($this->selectedContactProfiles); $this->updateEstimatedRecipients(); } /** * Re-hydrate contact profile display data (name, photo, description) from stored {id, type} pairs. */ private function resolveContactProfilesToDisplay(array $contactProfileIds): array { if (empty($contactProfileIds)) { return []; } $resolved = []; foreach ($contactProfileIds as $entry) { if (empty($entry['id']) || empty($entry['type'])) { continue; } $modelClass = $entry['type']; if (!class_exists($modelClass)) { continue; } $profile = $modelClass::find($entry['id']); if (!$profile) { continue; } $profile->loadMissing(['locations.city.translations', 'locations.district.translations', 'locations.division.translations']); $resolved[] = [ 'id' => $profile->id, 'type' => $modelClass, 'name' => $profile->name, 'description' => $this->profileTypeLabel($modelClass), 'location' => $profile->getLocationFirstShort(), 'profile_photo_path' => url(\Storage::url($profile->profile_photo_path)), ]; } return $resolved; } private function profileTypeLabel(string $class): string { return match ($class) { \App\Models\Organization::class => __('Organization'), \App\Models\Bank::class => __('Bank'), \App\Models\Admin::class => __('Admin'), default => '', }; } public function updateEstimatedRecipients() { // If no type is selected, set count to 0 if (empty($this->type)) { $this->estimatedRecipientCount = 0; $this->updateAvailableLocales(); return; } // Create a temporary mailing instance with current form data $tempMailing = new \App\Models\Mailing(); $tempMailing->type = $this->type; $tempMailing->filter_by_profile_type = $this->filterByProfileType; $tempMailing->selected_profile_types = $this->selectedProfileTypes; $tempMailing->filter_by_contacts = $this->filterByContacts; $tempMailing->contact_profile_ids = $this->filterByContacts ? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all() : []; $tempMailing->filter_by_location = $this->filterByLocation; $tempMailing->location_country_ids = $this->selectedCountryIds ?? []; $tempMailing->location_division_ids = $this->selectedDivisionIds ?? []; $tempMailing->location_city_ids = $this->selectedCityIds ?? []; $tempMailing->location_district_ids = $this->selectedDistrictIds ?? []; $this->estimatedRecipientCount = $tempMailing->getRecipientsCount(); $this->updateAvailableLocales(); } // Reactive property updates public function updatedFilterByProfileType() { $this->updateEstimatedRecipients(); } public function updatedSelectedProfileTypes() { $this->updateEstimatedRecipients(); } // Location filtering event listeners #[On('locationFilterUpdated')] public function handleLocationFilterUpdate($locationData) { // Store the data without triggering re-renders $this->selectedCountryIds = $locationData['countries'] ?? []; $this->selectedDivisionIds = $locationData['divisions'] ?? []; $this->selectedCityIds = $locationData['cities'] ?? []; $this->selectedDistrictIds = $locationData['districts'] ?? []; $this->updateRecipientCount(); // Don't emit any events that would cause the LocationFilter to re-render } public function updatedFilterByLocation() { if (!$this->filterByLocation) { $this->selectedCountryIds = []; $this->selectedDivisionIds = []; $this->selectedCityIds = []; $this->selectedDistrictIds = []; // Reset the LocationFilter component $this->dispatch('resetLocationFilter'); } $this->updateRecipientCount(); } public function updated($field) { $this->resetValidation($field); if (in_array($field, ['sendToAuthUser', 'sendToActiveProfile', 'customTestEmail'])) { $this->resetValidation('testRecipients'); } } public function updatedType() { $this->updateEstimatedRecipients(); } public function updateRecipientCount() { $this->updateEstimatedRecipients(); } public function updateAvailableLocales() { if (!empty($this->selectedPosts)) { $tempMailing = new \App\Models\Mailing(); $tempMailing->content_blocks = $this->prepareContentBlocks(); $tempMailing->fallback_locale = $this->fallbackLocale ?: null; $this->fallbackEligibleLocales = $tempMailing->getFallbackEligibleLocales(); // When a mailing type is selected, derive active locales from actual recipient groups // so that location/profile-type filters are reflected in which subjects are required. if (!empty($this->type)) { $tempMailing->type = $this->type; $tempMailing->filter_by_profile_type = $this->filterByProfileType; $tempMailing->selected_profile_types = $this->selectedProfileTypes; $tempMailing->filter_by_contacts = $this->filterByContacts; $tempMailing->contact_profile_ids = $this->filterByContacts ? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all() : []; $tempMailing->filter_by_location = $this->filterByLocation; $tempMailing->location_country_ids = $this->selectedCountryIds ?? []; $tempMailing->location_division_ids = $this->selectedDivisionIds ?? []; $tempMailing->location_city_ids = $this->selectedCityIds ?? []; // Show subject fields for all locales that have post content — // test emails are sent in all content locales and each needs its own subject. $this->availableLocales = $tempMailing->getAvailablePostLocales(); // Subjects are only required for locales that also have recipients $countsByLocale = $tempMailing->getRecipientCountsByLocale(); $this->requiredLocales = collect($countsByLocale) ->filter(fn($data) => $data['has_content']) ->keys() ->values() ->toArray(); // Always ensure base language is present if (empty($this->availableLocales)) { $this->availableLocales = [timebank_config('base_language', 'en')]; } if (empty($this->requiredLocales)) { $this->requiredLocales = $this->availableLocales; } } else { // No type selected yet — show all locales that have post translations // Required = available since we don't know recipient counts yet $this->availableLocales = $tempMailing->getAvailablePostLocales(); $this->requiredLocales = $this->availableLocales; } } else { $this->availableLocales = [timebank_config('base_language', 'en')]; $this->requiredLocales = [timebank_config('base_language', 'en')]; $this->fallbackEligibleLocales = []; } // Clear selected fallback if it's no longer eligible if ($this->fallbackLocale && !in_array($this->fallbackLocale, $this->fallbackEligibleLocales)) { $this->fallbackLocale = ''; } // Ensure subjects array has entries for all available locales foreach ($this->availableLocales as $locale) { if (!isset($this->subjects[$locale])) { $this->subjects[$locale] = ''; } } // Remove subject keys for locales no longer in use so they don't trigger validation foreach (array_keys($this->subjects) as $locale) { if (!in_array($locale, $this->availableLocales)) { unset($this->subjects[$locale]); } } } /** * Get current recipient counts by locale based on form state */ public function getLocationFilterName(): ?string { if (!$this->filterByLocation) { return null; } if (!empty($this->selectedDistrictIds)) { $locale = \App\Models\Locations\DistrictLocale::where('district_id', $this->selectedDistrictIds[0])->first(); return $locale?->name; } if (!empty($this->selectedCityIds)) { $locale = \App\Models\Locations\CityLocale::where('city_id', $this->selectedCityIds[0])->first(); return $locale?->name; } if (!empty($this->selectedDivisionIds)) { $locale = \App\Models\Locations\DivisionLocale::where('division_id', $this->selectedDivisionIds[0])->first(); return $locale?->name; } if (!empty($this->selectedCountryIds)) { $locale = \App\Models\Locations\CountryLocale::where('country_id', $this->selectedCountryIds[0])->first(); return $locale?->name; } return null; } public function getCurrentRecipientCountsByLocale() { // If no type is selected, return empty array if (empty($this->type)) { return []; } // Create a temporary mailing object with current form state $tempMailing = new \App\Models\Mailing(); $tempMailing->type = $this->type; $tempMailing->filter_by_profile_type = $this->filterByProfileType; $tempMailing->selected_profile_types = $this->selectedProfileTypes; $tempMailing->filter_by_contacts = $this->filterByContacts; $tempMailing->contact_profile_ids = $this->filterByContacts ? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all() : []; $tempMailing->filter_by_location = $this->filterByLocation; $tempMailing->content_blocks = $this->prepareContentBlocks(); $tempMailing->fallback_locale = $this->fallbackLocale ?: null; // Apply location filtering if enabled if ($this->filterByLocation) { $tempMailing->location_country_ids = $this->selectedCountryIds ?? []; $tempMailing->location_division_ids = $this->selectedDivisionIds ?? []; $tempMailing->location_city_ids = $this->selectedCityIds ?? []; $tempMailing->location_district_ids = $this->selectedDistrictIds ?? []; } return $tempMailing->getRecipientCountsByLocale(); } public function getCurrentRecipientCountsByLocaleAndLocation(): array { if (empty($this->type)) { return []; } $tempMailing = new \App\Models\Mailing(); $tempMailing->type = $this->type; $tempMailing->filter_by_profile_type = $this->filterByProfileType; $tempMailing->selected_profile_types = $this->selectedProfileTypes; $tempMailing->filter_by_contacts = $this->filterByContacts; $tempMailing->contact_profile_ids = $this->filterByContacts ? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all() : []; $tempMailing->filter_by_location = $this->filterByLocation; $tempMailing->content_blocks = $this->prepareContentBlocks(); $tempMailing->fallback_locale = $this->fallbackLocale ?: null; if ($this->filterByLocation) { $tempMailing->location_country_ids = $this->selectedCountryIds ?? []; $tempMailing->location_division_ids = $this->selectedDivisionIds ?? []; $tempMailing->location_city_ids = $this->selectedCityIds ?? []; $tempMailing->location_district_ids = $this->selectedDistrictIds ?? []; } return $tempMailing->getRecipientCountsByLocaleAndLocation(); } public function getCurrentSuppressedCount(): int { if (empty($this->type)) { return 0; } $tempMailing = new \App\Models\Mailing(); $tempMailing->type = $this->type; $tempMailing->filter_by_profile_type = $this->filterByProfileType; $tempMailing->selected_profile_types = $this->selectedProfileTypes; $tempMailing->filter_by_contacts = $this->filterByContacts; $tempMailing->contact_profile_ids = $this->filterByContacts ? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all() : []; $tempMailing->filter_by_location = $this->filterByLocation; if ($this->filterByLocation) { $tempMailing->location_country_ids = $this->selectedCountryIds ?? []; $tempMailing->location_division_ids = $this->selectedDivisionIds ?? []; $tempMailing->location_city_ids = $this->selectedCityIds ?? []; $tempMailing->location_district_ids = $this->selectedDistrictIds ?? []; } return $tempMailing->getSuppressedRecipientsCount(); } public function saveMailing() { // CRITICAL: Authorize admin access for saving mailing $this->authorizeAdminAccess(); $this->authorizePermission($this->editingMailing ? 'update mailings' : 'create mailings'); // Scheduling requires send permission (scheduling is equivalent to deferred sending) if ($this->scheduledAt) { $this->authorizePermission('send mailings'); } // Conditional validation: selectedPosts required if scheduling $rules = $this->rules(); if ($this->scheduledAt) { $rules['selectedPosts'] = 'required|array|min:1'; } $this->validate($rules); $data = [ 'title' => $this->title, 'type' => $this->type, 'subject' => $this->subjects, 'content_blocks' => $this->prepareContentBlocks(), 'scheduled_at' => $this->scheduledAt ? \Carbon\Carbon::parse($this->scheduledAt) : null, 'filter_by_profile_type' => $this->filterByProfileType, 'selected_profile_types' => $this->selectedProfileTypes, 'filter_by_contacts' => $this->filterByContacts, 'contact_profile_ids' => $this->filterByContacts ? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all() : [], 'filter_by_location' => $this->filterByLocation, 'location_country_ids' => $this->selectedCountryIds ?? [], 'location_division_ids' => $this->selectedDivisionIds ?? [], 'location_city_ids' => $this->selectedCityIds ?? [], 'location_district_ids' => $this->selectedDistrictIds ?? [], // Keep singular columns in sync with the first selected value for backwards compatibility 'location_country_id' => !empty($this->selectedCountryIds) ? $this->selectedCountryIds[0] : null, 'location_division_id' => !empty($this->selectedDivisionIds) ? $this->selectedDivisionIds[0] : null, 'location_city_id' => !empty($this->selectedCityIds) ? $this->selectedCityIds[0] : null, 'location_district_id' => !empty($this->selectedDistrictIds) ? $this->selectedDistrictIds[0] : null, 'fallback_locale' => $this->fallbackLocale ?: null, 'updated_by_user_id' => Auth::guard('web')->id(), ]; try { if ($this->editingMailing) { // Update existing mailing $data['status'] = $data['scheduled_at'] ? 'scheduled' : 'draft'; $this->editingMailing->update($data); $this->editingMailing->recipients_count = $this->editingMailing->getRecipientsCount(); $this->editingMailing->save(); $this->notification([ 'title' => __('Success'), 'description' => __('Mailing updated successfully.'), 'icon' => 'success' ]); } else { // Create new mailing $data['status'] = $data['scheduled_at'] ? 'scheduled' : 'draft'; $mailing = Mailing::create($data); $mailing->recipients_count = $mailing->getRecipientsCount(); $mailing->save(); $this->notification([ 'title' => __('Success'), 'description' => __('Mailing created successfully.'), 'icon' => 'success' ]); } $this->closeModals(); $this->dispatch('mailingCreated'); } catch (\Exception $e) { $this->notification([ 'title' => __('Error'), 'description' => __('Failed to save mailing: :error', ['error' => $e->getMessage()]), 'icon' => 'error' ]); } } public function deleteMailing($mailingId) { // CRITICAL: Authorize admin access for deleting mailing $this->authorizeAdminAccess(); $this->authorizePermission('delete mailings'); $mailing = Mailing::findOrFail($mailingId); if (!in_array($mailing->status, ['draft', 'scheduled'])) { $this->notification([ 'title' => __('Error'), 'description' => __('Cannot delete sent or sending mailings.'), 'icon' => 'error' ]); return; } $mailing->delete(); $this->notification([ 'title' => __('Success'), 'description' => __('Mailing deleted successfully.'), 'icon' => 'success' ]); $this->dispatch('mailingDeleted'); } public function openReportModal($mailingId) { $this->mailingForReport = Mailing::withTrashed()->findOrFail($mailingId); $this->showReportModal = true; } public function refreshReport() { if ($this->mailingForReport) { $this->mailingForReport = Mailing::withTrashed()->findOrFail($this->mailingForReport->id); } } public function getReportBounceStats(): array { if (!$this->mailingForReport) { return ['hard' => 0, 'soft' => 0, 'complaint' => 0, 'unknown' => 0, 'total' => 0]; } return $this->mailingForReport->getBounceStats(); } public function openSendConfirmModal($mailingId) { $this->mailingToSend = Mailing::findOrFail($mailingId); if (!$this->mailingToSend->canBeSent()) { $this->notification([ 'title' => __('Error'), 'description' => __('Mailing cannot be sent in its current status.'), 'icon' => 'error' ]); return; } // Note: getEffectiveRecipientsCount() now correctly applies location filtering $this->showSendConfirmModal = true; } public function sendMailing() { // CRITICAL: Authorize admin access for sending mailing $this->authorizeAdminAccess(); $this->authorizePermission('send mailings'); if (!$this->mailingToSend) { return; } $this->mailingToSend->update([ 'status' => 'sending', 'sending_started_at' => now(), 'updated_by_user_id' => Auth::guard('web')->id(), ]); // Dispatch locale-specific email jobs $this->mailingToSend->dispatchLocaleSpecificJobs(); $mailingId = $this->mailingToSend->id; $this->showSendConfirmModal = false; $this->mailingToSend = null; $this->openReportModal($mailingId); $this->dispatch('mailingUpdated'); } public function updatedBulkSelected() { $this->bulkDisabled = count($this->bulkSelected) === 0; } public function openBulkDeleteModal() { if (count($this->bulkSelected) === 0) { $this->notification([ 'title' => __('Error'), 'description' => __('Please select mailings to delete.'), 'icon' => 'error' ]); return; } $this->showBulkDeleteModal = true; } public function bulkDeleteMailings() { // CRITICAL: Authorize admin access for bulk deleting mailings $this->authorizeAdminAccess(); $this->authorizePermission('delete mailings'); $mailings = Mailing::whereIn('id', $this->bulkSelected)->get(); $deleted = 0; $errors = 0; foreach ($mailings as $mailing) { if (in_array($mailing->status, ['draft', 'scheduled'])) { $mailing->delete(); $deleted++; } else { $errors++; } } $this->showBulkDeleteModal = false; $this->bulkSelected = []; $this->bulkDisabled = true; if ($deleted > 0) { $this->notification([ 'title' => __('Success'), 'description' => ($errors > 0 ? __(':deleted mailing(s) deleted successfully. :errors mailing(s) could not be deleted due to status restrictions.', ['deleted' => $deleted, 'errors' => $errors]) : __(':deleted mailing(s) deleted successfully.', ['deleted' => $deleted])), 'icon' => 'success' ]); } else { $this->notification([ 'title' => __('Error'), 'description' => __('No mailings could be deleted. Only draft and scheduled mailings can be deleted.'), 'icon' => 'error' ]); } $this->dispatch('mailingDeleted'); } public function sendTestMail($mailingId) { // CRITICAL: Authorize admin access for sending test mail $this->authorizeAdminAccess(); $mailing = Mailing::withTrashed()->findOrFail($mailingId); // Test mail is allowed for draft, scheduled, sending, and sent mailings if (!in_array($mailing->status, ['draft', 'scheduled', 'sending', 'sent'])) { $this->notification([ 'title' => __('Error'), 'description' => __('Test mail can only be sent for draft, scheduled, sending, or sent mailings.'), 'icon' => 'error' ]); return; } // Check if mailing has content $availableLocales = collect(array_keys(config('laravellocalization.supportedLocales', []))) ->filter(function ($locale) use ($mailing) { $contentBlocks = $mailing->getContentBlocksForLocale($locale); return !empty($contentBlocks); }); if ($availableLocales->isEmpty()) { $this->notification([ 'title' => __('Error'), 'description' => __('No content available in any language for this mailing.'), 'icon' => 'error' ]); return; } // Prepare available test emails $this->prepareTestEmailOptions(); // Store mailing for test $this->mailingForTest = $mailing; // Show email selection modal $this->showTestEmailSelectionModal = true; $this->dispatch('load-preview', url: route('mailings.preview_render', $mailing->id)); } public function openStopConfirmModal($mailingId) { $this->authorizeAdminAccess(); $this->authorizePermission('send mailings'); $mailing = Mailing::findOrFail($mailingId); if ($mailing->status !== 'sending') { $this->notification([ 'title' => __('Error'), 'description' => __('This mailing cannot be stopped.'), 'icon' => 'error' ]); return; } $this->mailingToStop = $mailing; $this->showStopConfirmModal = true; } public function stopMailing() { $this->authorizeAdminAccess(); $this->authorizePermission('send mailings'); if (!$this->mailingToStop) { return; } $this->mailingToStop->update([ 'status' => 'aborted', 'sent_at' => now(), 'updated_by_user_id' => Auth::guard('web')->id(), ]); $this->showStopConfirmModal = false; $this->mailingToStop = null; $this->notification([ 'title' => __('Mailing stopped'), 'description' => __('The mailing has been stopped. In-progress batches will finish their current email and then halt.'), 'icon' => 'info' ]); $this->dispatch('mailingUpdated'); } public function cancelMailing($mailingId) { $this->authorizeAdminAccess(); $this->authorizePermission('send mailings'); $mailing = Mailing::findOrFail($mailingId); if (!$mailing->canBeCancelled()) { $this->notification([ 'title' => __('Error'), 'description' => __('This mailing cannot be unscheduled.'), 'icon' => 'error' ]); return; } // Remove the scheduled datetime and change status back to draft $mailing->update([ 'scheduled_at' => null, 'status' => 'draft' ]); $this->notification([ 'title' => __('Success'), 'description' => __('Mailing has been unscheduled successfully and can now be edited.'), 'icon' => 'success' ]); $this->dispatch('mailingUpdated'); } public function openPostSelector() { $this->selectedPostIds = collect($this->selectedPosts)->pluck('post_id')->map(fn($id) => (int) $id)->toArray(); $this->showPostSelector = true; } public function togglePostSelection($postId) { if (in_array($postId, $this->selectedPostIds)) { $this->selectedPostIds = array_filter($this->selectedPostIds, fn($id) => $id != $postId); } else { $this->selectedPostIds[] = $postId; } } public function addSelectedPosts() { // Remove posts that were unchecked in the selector $this->selectedPosts = collect($this->selectedPosts) ->filter(fn($p) => in_array((int) $p['post_id'], $this->selectedPostIds)) ->values() ->toArray(); // Add newly checked posts that aren't already in the list $existingIds = collect($this->selectedPosts)->pluck('post_id')->map(fn($id) => (int) $id)->toArray(); $newIds = array_diff($this->selectedPostIds, $existingIds); if (!empty($newIds)) { $posts = Post::whereIn('id', $newIds)->with('translations')->get(); foreach ($posts as $post) { $this->selectedPosts[] = [ 'post_id' => $post->id, 'order' => count($this->selectedPosts) + 1, 'title' => $post->translations->first()->title ?? __('Untitled') ]; } } // Re-index order foreach ($this->selectedPosts as $i => $post) { $this->selectedPosts[$i]['order'] = $i + 1; } $this->selectedPostIds = []; $this->showPostSelector = false; $this->postSearch = ''; // Update available locales when posts are added $this->updateAvailableLocales(); } public function removePost($index) { unset($this->selectedPosts[$index]); $this->selectedPosts = array_values($this->selectedPosts); // Reorder posts foreach ($this->selectedPosts as $i => $post) { $this->selectedPosts[$i]['order'] = $i + 1; } // Update available locales when posts are removed $this->updateAvailableLocales(); } public function movePostUp($index) { if ($index > 0) { $temp = $this->selectedPosts[$index]; $this->selectedPosts[$index] = $this->selectedPosts[$index - 1]; $this->selectedPosts[$index - 1] = $temp; // Update order numbers $this->selectedPosts[$index]['order'] = $index + 1; $this->selectedPosts[$index - 1]['order'] = $index; $this->updateAvailableLocales(); } } public function movePostDown($index) { if ($index < count($this->selectedPosts) - 1) { $temp = $this->selectedPosts[$index]; $this->selectedPosts[$index] = $this->selectedPosts[$index + 1]; $this->selectedPosts[$index + 1] = $temp; // Update order numbers $this->selectedPosts[$index]['order'] = $index + 1; $this->selectedPosts[$index + 1]['order'] = $index + 2; $this->updateAvailableLocales(); } } private function prepareContentBlocks() { return collect($this->selectedPosts)->map(function ($post, $index) { return [ 'post_id' => $post['post_id'], 'order' => $index + 1 ]; })->toArray(); } private function getAvailablePosts() { if (!$this->showPostSelector) { return collect(); } return Post::with(['translations', 'category.translations']) ->whereHas('translations', function ($query) { $query->where('status', 1) // Status 1 = published ->where('from', '<=', now()) ->where(function ($q) { $q->whereNull('till') ->orWhere('till', '>=', now()); }); if ($this->postSearch) { $query->where('title', 'like', '%' . $this->postSearch . '%'); } }) ->orderBy('updated_at', 'desc') ->limit(20) ->get(); } #[On('mailingCreated')] #[On('mailingUpdated')] #[On('mailingDeleted')] public function refreshMailings() { // This method will be called by listeners to refresh the mailings list // In Livewire 3, we don't need to call render() explicitly } /** * Load content blocks and enrich with post titles for editing */ private function loadContentBlocksWithTitles($contentBlocks) { if (empty($contentBlocks)) { return []; } $postIds = collect($contentBlocks)->pluck('post_id'); $posts = Post::whereIn('id', $postIds) ->with('translations') ->get() ->keyBy('id'); return collect($contentBlocks)->map(function ($block) use ($posts) { $post = $posts->get($block['post_id']); return [ 'post_id' => $block['post_id'], 'order' => $block['order'], 'title' => $post && $post->translations->first() ? $post->translations->first()->title : __('Post not found (ID: :id)', ['id' => $block['post_id']]) ]; })->toArray(); } /** * Get published translations with their language flags for a post */ public function getPublishedTranslationsWithFlags($post) { $now = now(); return $post->translations->filter(function ($translation) use ($now) { return $translation->status == 1 && $translation->from <= $now && ($translation->till === null || $translation->till >= $now); })->map(function ($translation) { $language = \App\Models\Language::where('lang_code', $translation->locale)->first(); return [ 'locale' => $translation->locale, 'flag' => $language ? $language->flag : '🏳️' ]; }); } /** * Prepare test email options for the modal */ public function prepareTestEmailOptions() { $this->availableTestEmails = []; // Option 1: Authenticated user email (underlying web user) $webUser = Auth::guard('web')->user(); if ($webUser && $webUser->email) { $this->availableTestEmails['auth_user'] = [ 'email' => $webUser->email, 'label' => __('Your user profile: :email', ['email' => $webUser->email]), 'available' => true ]; } // Option 2: Active profile email (if different from auth user) $activeProfile = getActiveProfile(); if ($activeProfile && $activeProfile->email && (!isset($this->availableTestEmails['auth_user']) || $activeProfile->email !== $this->availableTestEmails['auth_user']['email'])) { $this->availableTestEmails['active_profile'] = [ 'email' => $activeProfile->email, 'label' => __('Your current active profile: :email', ['email' => $activeProfile->email]), 'available' => true ]; } // Reset checkboxes $this->sendToAuthUser = false; $this->sendToActiveProfile = false; $this->customTestEmail = ''; } /** * Send test mailing mailing to selected email addresses */ public function sendTestMailToSelected() { // CRITICAL: Authorize admin access for sending test mail to selected recipients $this->authorizeAdminAccess(); if (!$this->mailingForTest) { $this->notification([ 'title' => __('Error'), 'description' => __('No mailing selected for testing.'), 'icon' => 'error' ]); return; } // Collect selected emails $testEmails = []; if ($this->sendToAuthUser && isset($this->availableTestEmails['auth_user'])) { $testEmails[] = $this->availableTestEmails['auth_user']['email']; } if ($this->sendToActiveProfile && isset($this->availableTestEmails['active_profile'])) { $testEmails[] = $this->availableTestEmails['active_profile']['email']; } if (!empty($this->customTestEmail)) { $customEmails = collect(explode(',', $this->customTestEmail)) ->map(fn($email) => trim($email)) ->filter() ->values(); $invalidEmails = $customEmails->reject(fn($email) => filter_var($email, FILTER_VALIDATE_EMAIL)); if ($invalidEmails->isNotEmpty()) { $this->addError('customTestEmail', __('Invalid email address(es): :emails', ['emails' => $invalidEmails->implode(', ')])); return; } $testEmails = array_merge($testEmails, $customEmails->all()); } // Check if at least one email is selected if (empty($testEmails)) { $this->addError('testRecipients', __('Please select at least one email address to send the test to.')); return; } // Remove duplicates $testEmails = array_unique($testEmails); // Get all available locales that have content $availableLocales = collect(array_keys(config('laravellocalization.supportedLocales', []))) ->filter(function ($locale) { $contentBlocks = $this->mailingForTest->getContentBlocksForLocale($locale); return !empty($contentBlocks); }); if ($availableLocales->isEmpty()) { $this->notification([ 'title' => __('Error'), 'description' => __('No content available in any language for this mailing.'), 'icon' => 'error' ]); return; } try { // Save current locale to restore it after sending test emails $originalLocale = \App::getLocale(); $totalEmailsSent = 0; // Send test mailing mail to each selected email in each available locale foreach ($testEmails as $email) { foreach ($availableLocales as $locale) { $testMail = new \App\Mail\TestNewsletterMail($this->mailingForTest, $locale); \Mail::to($email)->send($testMail); $totalEmailsSent++; // Add delay to avoid rate limiting (except for last email) if ($email !== end($testEmails) || $locale !== $availableLocales->last()) { sleep(2); // 2 second delay between emails } } } // Restore original locale before displaying success message \App::setLocale($originalLocale); $localeCount = $availableLocales->count(); $recipientCount = count($testEmails); // Close selection modal and show success modal $this->showTestEmailSelectionModal = false; // Show success modal with details $this->testMailMessage = __('Test emails sent successfully!') . "\n\n" . __('Recipients: :recipients', ['recipients' => implode(', ', $testEmails)]) . "\n\n" . __('Languages: :languages', ['languages' => $availableLocales->implode(', ')]) . "\n\n" . __('Total emails sent: :count', ['count' => $totalEmailsSent]); $this->showTestMailModal = true; } catch (\Exception $e) { // Restore original locale before displaying error message if (isset($originalLocale)) { \App::setLocale($originalLocale); } \Log::error('sendTestMailToSelected exception: ' . $e->getMessage()); \Log::error('Exception trace: ' . $e->getTraceAsString()); // Close selection modal and show error modal $this->showTestEmailSelectionModal = false; $this->testMailMessage = __('Test Email Error') . "\n\n" . __('Recipients: :recipients', ['recipients' => implode(', ', $testEmails)]) . "\n\n" . __('Error: :error', ['error' => $e->getMessage()]); $this->showTestMailModal = true; } } /** * Cancel test email selection and close modal */ public function cancelTestEmailSelection() { $this->showTestEmailSelectionModal = false; $this->mailingForTest = null; $this->sendToAuthUser = false; $this->sendToActiveProfile = false; $this->customTestEmail = ''; $this->availableTestEmails = []; $this->resetValidation(['customTestEmail']); } /** * Pick the best locale for preview: * 1. Current app locale (if it has content for the mailing) * 2. Locale with the most recipients that has content * 3. Any locale that has content for the selected posts */ public function resolvePreviewLocale(): string { if (!$this->mailingForTest) { return timebank_config('base_language', 'en'); } return $this->mailingForTest->resolvePreviewLocale(); } /** * Generate HTML preview of the mailing */ public function getMailingPreviewHtml() { if (!$this->mailingForTest) { return '

' . __('No mailing selected') . '

'; } $locale = $this->resolvePreviewLocale(); // Generate content blocks for preview $contentBlocks = []; foreach ($this->mailingForTest->getContentBlocksForLocale($locale) as $block) { if (!empty($block['post_missing'])) { $contentBlocks[] = [ 'type' => 'missing', 'data' => ['post_id' => $block['post_id']], 'template' => null, ]; continue; } $post = \App\Models\Post::withTrashed()->with(['translations', 'category', 'meeting.location.city.translations', 'poll'])->find($block['post_id']); if (!$post) { $contentBlocks[] = [ 'type' => 'missing', 'data' => ['post_id' => $block['post_id']], 'template' => null, ]; continue; } // Get translation for the locale $translation = $this->mailingForTest->getPostTranslationForLocale($post->id, $locale); if (!$translation) { continue; } // Determine post type $postType = $this->determinePostType($post); // Prepare post data $postData = $this->preparePostDataForPreview($post, $translation, $locale); $contentBlocks[] = [ 'type' => $postType, 'data' => $postData, 'template' => \App\Models\Mailing::blockTemplate($postType) ]; } // Render the email view return view('emails.newsletter.wrapper', [ 'subject' => $this->mailingForTest->getSubjectForLocale($locale), 'mailingTitle' => $this->mailingForTest->title, 'mailingId' => $this->mailingForTest->id, 'locale' => $locale, 'contentBlocks' => $contentBlocks, 'unsubscribeUrl' => '#preview-unsubscribe', 'isTestMail' => false, ])->render(); } /** * Determine post type for preview */ private function determinePostType($post) { return \App\Models\Mailing::determinePostType($post); } /** * Prepare post data for preview */ private function preparePostDataForPreview($post, $translation, $locale) { // Generate fully localized URL with translated route path for the preview locale $url = LaravelLocalization::getURLFromRouteNameTranslated( $locale, 'routes.post.show_by_slug', ['slug' => $translation->slug] ); $data = [ 'title' => $translation->title, 'excerpt' => $translation->excerpt, 'content' => $translation->content, 'url' => $url, 'date' => $post->updated_at->locale($locale)->translatedFormat('M j, Y'), 'author' => $post->author ? $post->author->name : null, 'show_button' => $this->mailingForTest->type !== 'system_message' && !str_starts_with($post->category->type ?? '', 'SiteContents\\'), ]; // Poll posts link to the poll page and show whether the poll is open $data = array_merge($data, \App\Models\Mailing::pollBlockData($post, $locale)); // Add category information if ($post->category) { $categoryTranslation = $post->category->translations()->where('locale', $locale)->first(); $data['category'] = $categoryTranslation ? $categoryTranslation->name : $post->category->translations()->first()->name; } // Add location prefix if ($post->category && $post->category->categoryable && $post->category->categoryable->translations) { $locationTranslation = $post->category->categoryable->translations->where('locale', $locale)->first(); if ($locationTranslation && $locationTranslation->name) { $data['location_prefix'] = strtoupper($locationTranslation->name); } } // Add event-specific data if ($post->meeting) { $data['venue'] = $post->meeting->venue; $cityTranslations = $post->meeting->location?->city?->translations; $data['city'] = $cityTranslations?->firstWhere('locale', $locale)?->name ?? $cityTranslations?->first()?->name ?? null; } // Add event date/time from meeting->from only; hide if not set $eventFromDate = $post->meeting?->from; if ($eventFromDate) { $eventDate = \Carbon\Carbon::parse($eventFromDate); $data['event_date'] = ucfirst($eventDate->locale($locale)->translatedFormat('j F')); $data['event_time'] = $eventDate->locale($locale)->translatedFormat('H:i'); if ($post->meeting->till) { $data['event_time_end'] = \Carbon\Carbon::parse($post->meeting->till)->locale($locale)->translatedFormat('H:i'); } } // Add image - use email conversion (resized without cropping) if ($post->getFirstMediaUrl('posts')) { $data['image'] = $post->getFirstMediaUrl('posts', 'email'); // Add media caption and owner for image posts $media = $post->getFirstMedia('posts'); if ($media) { $captionKey = 'caption-' . $locale; $data['media_caption'] = $media->getCustomProperty($captionKey, ''); $data['media_owner'] = $media->getCustomProperty('owner', ''); } } return $data; } }