'required|string|max:255', 'type' => 'required|in:local_newsletter,general_newsletter,system_message', 'subjects' => 'required|array|min:1', 'subjects.*' => 'required|string|max:255', 'selectedPosts' => 'array', 'scheduledAt' => 'nullable|date|after:now', 'filterByProfileType' => 'boolean', 'selectedProfileTypes' => 'array', 'selectedProfileTypes.*' => 'string|in:User,Organization,Bank,Admin', '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|email', ]; 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) { // Admin access OK } 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 . '%'); }); }) ->when($this->typeFilter, function ($query) { $query->where('type', $this->typeFilter); }) ->when($this->statusFilter, function ($query) { $query->where('status', $this->statusFilter); }) ->orderBy($this->sortField, $this->sortDirection); $mailings = $mailingsQuery->paginate(15); return view('livewire.mailings.manage', [ 'mailings' => $mailings, 'availablePosts' => $this->getAvailablePosts(), ]); } 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; } public function openEditModal($mailingId) { $this->editingMailing = Mailing::findOrFail($mailingId); // Can only edit drafts if (!$this->editingMailing->canBeScheduled()) { $this->notification([ 'title' => __('Error'), 'description' => __('Only draft 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 location filter settings $this->filterByLocation = $this->editingMailing->filter_by_location ?? false; // Convert single IDs to arrays for multiple selection support $this->selectedCountryIds = $this->editingMailing->location_country_id ? [$this->editingMailing->location_country_id] : []; $this->selectedDivisionIds = $this->editingMailing->location_division_id ? [$this->editingMailing->location_division_id] : []; $this->selectedCityIds = $this->editingMailing->location_city_id ? [$this->editingMailing->location_city_id] : []; $this->selectedDistrictIds = $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'); // Send location data to LocationFilter component if ($this->filterByLocation) { $this->dispatch('loadLocationFilterData', [ 'countries' => $this->selectedCountryIds, 'divisions' => $this->selectedDivisionIds, 'cities' => $this->selectedCityIds, 'districts' => $this->selectedDistrictIds, ]); } $this->showEditModal = true; } 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->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->filterByProfileType = false; $this->selectedProfileTypes = []; $this->filterByLocation = false; $this->selectedCountryIds = []; $this->selectedDivisionIds = []; $this->selectedCityIds = []; $this->selectedDistrictIds = []; $this->estimatedRecipientCount = 0; // 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 updateEstimatedRecipients() { // If no type is selected, set count to 0 if (empty($this->type)) { $this->estimatedRecipientCount = 0; 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_location = $this->filterByLocation; $tempMailing->location_country_id = !empty($this->selectedCountryIds) ? $this->selectedCountryIds[0] : null; $tempMailing->location_division_id = !empty($this->selectedDivisionIds) ? $this->selectedDivisionIds[0] : null; $tempMailing->location_city_id = !empty($this->selectedCityIds) ? $this->selectedCityIds[0] : null; $tempMailing->location_district_id = !empty($this->selectedDistrictIds) ? $this->selectedDistrictIds[0] : null; $this->estimatedRecipientCount = $tempMailing->getRecipientsQuery()->count(); } // 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 updatedType() { $this->updateEstimatedRecipients(); } public function updateRecipientCount() { $tempMailing = new Mailing(); $tempMailing->type = $this->type; // Apply location filtering $query = $this->getFilteredRecipientsQuery($tempMailing); $this->estimatedRecipientCount = $query->count(); } private function getFilteredRecipientsQuery($mailing) { $baseQuery = $mailing->getRecipientsQuery(); if (!$this->filterByLocation) { return $baseQuery; } // Apply location filtering based on selected locations return $baseQuery->whereHas('locations', function ($query) { if (!empty($this->selectedDistrictIds)) { $query->whereIn('district_id', $this->selectedDistrictIds); } elseif (!empty($this->selectedCityIds)) { $query->whereIn('city_id', $this->selectedCityIds); } elseif (!empty($this->selectedDivisionIds)) { $query->whereIn('division_id', $this->selectedDivisionIds); } elseif (!empty($this->selectedCountryIds)) { $query->whereIn('country_id', $this->selectedCountryIds); } }); } public function updateAvailableLocales() { if (!empty($this->selectedPosts)) { // Create a temporary mailing object to get available locales $tempMailing = new \App\Models\Mailing(); $tempMailing->content_blocks = $this->prepareContentBlocks(); $this->availableLocales = $tempMailing->getAvailablePostLocales(); } else { // If no posts selected, show base language $this->availableLocales = [timebank_config('base_language', 'en')]; } // Ensure subjects array has all available locales foreach ($this->availableLocales as $locale) { if (!isset($this->subjects[$locale])) { $this->subjects[$locale] = ''; } } } /** * Get current recipient counts by locale based on form state */ 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_location = $this->filterByLocation; $tempMailing->content_blocks = $this->prepareContentBlocks(); // Apply location filtering if enabled if ($this->filterByLocation) { // Use the first selected ID for compatibility with existing model logic $tempMailing->location_country_id = !empty($this->selectedCountryIds) ? $this->selectedCountryIds[0] : null; $tempMailing->location_division_id = !empty($this->selectedDivisionIds) ? $this->selectedDivisionIds[0] : null; $tempMailing->location_city_id = !empty($this->selectedCityIds) ? $this->selectedCityIds[0] : null; $tempMailing->location_district_id = !empty($this->selectedDistrictIds) ? $this->selectedDistrictIds[0] : null; } return $tempMailing->getRecipientCountsByLocale(); } public function saveMailing() { // CRITICAL: Authorize admin access for saving mailing $this->authorizeAdminAccess(); // 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_location' => $this->filterByLocation, // For now, save the first selected ID to maintain compatibility with existing DB schema '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, '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->getRecipientsQuery()->count(); $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->getRecipientsQuery()->count(); $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(); $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 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(); if (!$this->mailingToSend) { return; } $this->mailingToSend->update([ 'status' => 'sending', 'updated_by_user_id' => Auth::guard('web')->id(), ]); // Dispatch locale-specific email jobs $this->mailingToSend->dispatchLocaleSpecificJobs(); // TODO: Replace with actual job dispatch in Phase 6 // This will dispatch separate jobs for each locale with filtered content $this->showSendConfirmModal = false; $this->mailingToSend = null; $this->notification([ 'title' => __('Success'), 'description' => __('Mailing is being sent. This process may take several minutes.'), 'icon' => 'success' ]); $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(); $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::findOrFail($mailingId); // Test mail is allowed for draft, scheduled, and even sending mailings if (!in_array($mailing->status, ['draft', 'scheduled', 'sending'])) { $this->notification([ 'title' => __('Error'), 'description' => __('Test mail can only be sent for draft, scheduled, or sending 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; } public function cancelMailing($mailingId) { $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->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() { $posts = Post::whereIn('id', $this->selectedPostIds) ->with('translations') ->get(); foreach ($posts as $index => $post) { if (!collect($this->selectedPosts)->firstWhere('post_id', $post->id)) { $this->selectedPosts[] = [ 'post_id' => $post->id, 'order' => count($this->selectedPosts) + 1, 'title' => $post->translations->first()->title ?? __('Untitled') ]; } } $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)) { // Validate custom email $this->validateOnly('customTestEmail'); $testEmails[] = $this->customTestEmail; } // Check if at least one email is selected if (empty($testEmails)) { $this->notification([ 'title' => __('Error'), 'description' => __('Please select at least one email address to send the test to.'), 'icon' => 'error' ]); 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']); } /** * Generate HTML preview of the mailing */ public function getMailingPreviewHtml() { if (!$this->mailingForTest) { return '

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

'; } // Get the base language for preview $locale = timebank_config('base_language', 'en'); // Generate content blocks for preview $contentBlocks = []; foreach ($this->mailingForTest->getContentBlocksForLocale($locale) as $block) { $post = \App\Models\Post::with(['translations', 'category'])->find($block['post_id']); if (!$post) { 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' => timebank_config("mailing.templates.{$postType}_block") ]; } // Render the email view return view('emails.newsletter.wrapper', [ 'subject' => $this->mailingForTest->getSubjectForLocale($locale), 'mailingTitle' => $this->mailingForTest->title, 'locale' => $locale, 'contentBlocks' => $contentBlocks, 'unsubscribeUrl' => '#preview-unsubscribe', 'isTestMail' => false, ])->render(); } /** * Determine post type for preview */ private function determinePostType($post) { // Check for ImagePost category type first if ($post->category && $post->category->type && str_starts_with($post->category->type, 'App\\Models\\ImagePost')) { return 'image'; } if ($post->category && $post->category->id) { $categoryMappings = [ 4 => 'event', // The Hague events 5 => 'event', // South-Holland events 6 => 'event', // The Netherlands events 7 => 'news', // The Hague news 8 => 'news', // General news 113 => 'article', // Article ]; return $categoryMappings[$post->category->id] ?? 'news'; } if ($post->meeting || (isset($post->from) && $post->from)) { return 'event'; } return 'news'; } /** * 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, ]; // 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; $data['address'] = $post->meeting->address; } // Add event date/time if ($translation->from) { $eventDate = \Carbon\Carbon::parse($translation->from); $data['event_date'] = $eventDate->locale($locale)->translatedFormat('F j'); $data['event_time'] = $eventDate->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; } }